From de273378dffb80d58a311b75e55cdf7a2a796721 Mon Sep 17 00:00:00 2001 From: Rick Date: Fri, 12 Jun 2026 14:45:13 -0500 Subject: [PATCH] feat: add projected api pricing to dashboard --- .../migrations/0003_model_pricing_cache.sql | 15 + apps/dashboard/src/db/schema.ts | 17 + apps/dashboard/src/lib/dashboard-projects.ts | 3 + apps/dashboard/src/lib/dashboard-timeframe.ts | 4 + apps/dashboard/src/lib/openai-usage.ts | 84 +++- .../dashboard/src/lib/public-model-pricing.ts | 373 ++++++++++++++++++ apps/dashboard/src/lib/runtime.ts | 2 + apps/dashboard/src/lib/token-analytics.ts | 84 +++- apps/dashboard/src/routes/index.tsx | 82 +++- 9 files changed, 638 insertions(+), 26 deletions(-) create mode 100644 apps/dashboard/migrations/0003_model_pricing_cache.sql create mode 100644 apps/dashboard/src/lib/public-model-pricing.ts diff --git a/apps/dashboard/migrations/0003_model_pricing_cache.sql b/apps/dashboard/migrations/0003_model_pricing_cache.sql new file mode 100644 index 0000000..1d30fe9 --- /dev/null +++ b/apps/dashboard/migrations/0003_model_pricing_cache.sql @@ -0,0 +1,15 @@ +CREATE TABLE model_pricing_cache ( + match_key TEXT PRIMARY KEY NOT NULL, + requested_model TEXT NOT NULL, + source_model TEXT, + source_provider TEXT, + input_cost_per_token REAL, + output_cost_per_token REAL, + cache_read_input_cost_per_token REAL, + resolved INTEGER NOT NULL DEFAULT 0, + fetched_at INTEGER NOT NULL, + source_url TEXT NOT NULL +); + +CREATE INDEX model_pricing_cache_fetched_at_idx + ON model_pricing_cache (fetched_at); diff --git a/apps/dashboard/src/db/schema.ts b/apps/dashboard/src/db/schema.ts index 034f44f..36f5677 100644 --- a/apps/dashboard/src/db/schema.ts +++ b/apps/dashboard/src/db/schema.ts @@ -85,3 +85,20 @@ export const issueEvents = sqliteTable( index('issue_events_severity_idx').on(table.severity), ], ) + +export const modelPricingCache = sqliteTable( + 'model_pricing_cache', + { + matchKey: text('match_key').primaryKey(), + requestedModel: text('requested_model').notNull(), + sourceModel: text('source_model'), + sourceProvider: text('source_provider'), + inputCostPerToken: real('input_cost_per_token'), + outputCostPerToken: real('output_cost_per_token'), + cacheReadInputCostPerToken: real('cache_read_input_cost_per_token'), + resolved: integer('resolved').notNull().default(0), + fetchedAt: integer('fetched_at').notNull(), + sourceUrl: text('source_url').notNull(), + }, + (table) => [index('model_pricing_cache_fetched_at_idx').on(table.fetchedAt)], +) diff --git a/apps/dashboard/src/lib/dashboard-projects.ts b/apps/dashboard/src/lib/dashboard-projects.ts index 52c7a65..a514869 100644 --- a/apps/dashboard/src/lib/dashboard-projects.ts +++ b/apps/dashboard/src/lib/dashboard-projects.ts @@ -45,6 +45,7 @@ export function filterSnapshotByProjects(snapshot: DashboardSnapshot, selectedPr rangeLabel: snapshot.headline.rangeLabel, selectedProjectIds: filteredProjectIds, sourceLabel: snapshot.headline.sourceLabel, + pricingStatus: snapshot.headline.pricing, statusNote: snapshot.headline.summary, workspaceName: summarizeProjectSelection(snapshot.projects.available, filteredProjectIds), }) @@ -75,6 +76,7 @@ function summarizeModels(modelRows: DashboardModelDailyUsage[]): DashboardModelS const current = modelMap.get(key) if (current) { current.cost += row.cost + current.projectedCost = (current.projectedCost || 0) + (row.projectedCost || 0) current.requests += row.requests current.tokens += row.tokens continue @@ -83,6 +85,7 @@ function summarizeModels(modelRows: DashboardModelDailyUsage[]): DashboardModelS modelMap.set(key, { cost: row.cost, model: row.model, + projectedCost: row.projectedCost || 0, provider: row.provider, requests: row.requests, tokens: row.tokens, diff --git a/apps/dashboard/src/lib/dashboard-timeframe.ts b/apps/dashboard/src/lib/dashboard-timeframe.ts index 6173b19..5f90fd2 100644 --- a/apps/dashboard/src/lib/dashboard-timeframe.ts +++ b/apps/dashboard/src/lib/dashboard-timeframe.ts @@ -53,6 +53,7 @@ export function filterSnapshotByTimeframe(snapshot: DashboardSnapshot, selection rangeLabel: resolved.rangeLabel, selectedProjectIds: snapshot.filters.selectedProjectIds, sourceLabel: snapshot.headline.sourceLabel, + pricingStatus: snapshot.headline.pricing, statusNote: snapshot.headline.summary, workspaceName: snapshot.headline.workspace, }) @@ -81,6 +82,7 @@ export function filterSnapshotByTimeframe(snapshot: DashboardSnapshot, selection rangeLabel: resolved.rangeLabel, selectedProjectIds: snapshot.filters.selectedProjectIds, sourceLabel: snapshot.headline.sourceLabel, + pricingStatus: snapshot.headline.pricing, statusNote: snapshot.headline.summary, workspaceName: snapshot.headline.workspace, }) @@ -125,6 +127,7 @@ function summarizeModels(modelRows: DashboardModelDailyUsage[]): DashboardModelS const current = modelMap.get(key) if (current) { current.cost += row.cost + current.projectedCost = (current.projectedCost || 0) + (row.projectedCost || 0) current.requests += row.requests current.tokens += row.tokens continue @@ -133,6 +136,7 @@ function summarizeModels(modelRows: DashboardModelDailyUsage[]): DashboardModelS modelMap.set(key, { cost: row.cost, model: row.model, + projectedCost: row.projectedCost || 0, provider: row.provider, requests: row.requests, tokens: row.tokens, diff --git a/apps/dashboard/src/lib/openai-usage.ts b/apps/dashboard/src/lib/openai-usage.ts index a03ebaa..2109265 100644 --- a/apps/dashboard/src/lib/openai-usage.ts +++ b/apps/dashboard/src/lib/openai-usage.ts @@ -1,4 +1,10 @@ import { formatHoustonDay, formatHoustonTimestamp } from '#/lib/dashboard-timezone' +import { + ensureModelPricingForReferences, + estimateProjectedCostUsd, + getModelPricingLookupKey, +} from '#/lib/public-model-pricing' +import type { ModelPricingLookupRow } from '#/lib/public-model-pricing' import type { CloudflareAppEnv } from '#/lib/runtime' import type { DashboardIssueByDay, @@ -27,6 +33,7 @@ type DailyRollupRow = DashboardProjectOption & { type ModelSummaryRow = { cost: number model: string + projectedCost?: number provider: string requests: number tokens: number @@ -527,10 +534,24 @@ async function loadSnapshotFromD1( dailyModelRowsByDay.length > 0 ? dailyModelRowsByDay : aggregateModelRowsByDay(hourlyModelRowsByDay) - const models = + const pricingResult = await ensureModelPricingForReferences( + env, + resolvedModelRowsByDay.map((row) => ({ + model: row.model, + provider: row.provider, + tokens: row.tokens, + })), + ) + const enrichedModelRowsByDay = applyProjectedPricingToModelRows( + resolvedDailyRows, + resolvedModelRowsByDay, + pricingResult.lookup, + ) + const enrichedHourlyModelRowsByDay = hourlyModelRowsByDay.length > 0 - ? summarizeModelRows(resolvedModelRowsByDay) - : await loadModelSummary(env.DB, workspaceIds, firstDay, lastDay) + ? applyProjectedPricingToModelRows(hourlyRows, hourlyModelRowsByDay, pricingResult.lookup) + : undefined + const models = summarizeModelRows(enrichedModelRowsByDay) return buildSnapshotFromRollups({ availableProjects, @@ -538,12 +559,15 @@ async function loadSnapshotFromD1( environment: rows[rows.length - 1].environment, generatedAt: new Date(latestCreatedAt).toISOString(), hourlyModelRowsByDay: - hourlyModelRowsByDay.length > 0 ? hourlyModelRowsByDay : undefined, + enrichedHourlyModelRowsByDay && enrichedHourlyModelRowsByDay.length > 0 + ? enrichedHourlyModelRowsByDay + : undefined, hourlyRows: hourlyRows.length > 0 ? hourlyRows : undefined, issues: summarizeIssues(issuesByDay), issuesByDay, models, - modelRowsByDay: resolvedModelRowsByDay, + modelRowsByDay: enrichedModelRowsByDay, + pricingStatus: pricingResult.status, selectedProjectIds: availableProjects.map((project) => project.projectId), sourceLabel, statusNote: buildCombinedStatusNote(selections), @@ -716,7 +740,7 @@ async function loadDailyRollups( return result.results } -async function loadModelSummary( +export async function loadModelSummary( db: D1Database, workspaceIds: string[], startDay: string, @@ -871,6 +895,7 @@ function aggregateModelRowsByDay(rows: DashboardModelDailyUsage[]) { const current = rowMap.get(key) if (current) { current.cost += row.cost + current.projectedCost = roundCurrency((current.projectedCost || 0) + (row.projectedCost || 0)) current.requests += row.requests current.tokens += row.tokens continue @@ -885,6 +910,51 @@ function aggregateModelRowsByDay(rows: DashboardModelDailyUsage[]) { ) } +function applyProjectedPricingToModelRows( + rollupRows: DailyRollupRow[], + modelRows: DashboardModelDailyUsage[], + pricingLookup: Map, +) { + const rollupMap = new Map() + for (const row of rollupRows) { + rollupMap.set(`${row.projectId}:${row.day}`, row) + } + + return modelRows.map((row) => { + const rollup = rollupMap.get(`${row.projectId}:${row.day}`) + if (!rollup || rollup.totalTokens <= 0) { + return { ...row, projectedCost: 0 } + } + + const estimatedInputTokens = resolveProjectedTokenSlice(rollup.inputTokens, rollup.totalTokens, row.tokens) + const estimatedOutputTokens = resolveProjectedTokenSlice(rollup.outputTokens, rollup.totalTokens, row.tokens) + const estimatedCachedTokens = resolveProjectedTokenSlice(rollup.cachedTokens, rollup.totalTokens, row.tokens) + const pricing = pricingLookup.get(getModelPricingLookupKey(row.model)) + + return { + ...row, + projectedCost: estimateProjectedCostUsd({ + cacheReadInputTokens: estimatedCachedTokens, + inputTokens: estimatedInputTokens, + outputTokens: estimatedOutputTokens, + pricing, + }), + } + }) +} + +function resolveProjectedTokenSlice( + bucketTokens: number, + bucketTotalTokens: number, + modelTokens: number, +) { + if (bucketTokens <= 0 || bucketTotalTokens <= 0 || modelTokens <= 0) { + return 0 + } + + return Math.max(0, Math.round(modelTokens * Math.min(1, bucketTokens / bucketTotalTokens))) +} + function summarizeModelRows(rows: DashboardModelDailyUsage[]) { const modelMap = new Map() @@ -893,6 +963,7 @@ function summarizeModelRows(rows: DashboardModelDailyUsage[]) { const current = modelMap.get(key) if (current) { current.cost += row.cost + current.projectedCost = roundCurrency((current.projectedCost || 0) + (row.projectedCost || 0)) current.requests += row.requests current.tokens += row.tokens continue @@ -901,6 +972,7 @@ function summarizeModelRows(rows: DashboardModelDailyUsage[]) { modelMap.set(key, { cost: row.cost, model: row.model, + projectedCost: row.projectedCost || 0, provider: row.provider, requests: row.requests, tokens: row.tokens, diff --git a/apps/dashboard/src/lib/public-model-pricing.ts b/apps/dashboard/src/lib/public-model-pricing.ts new file mode 100644 index 0000000..f184edd --- /dev/null +++ b/apps/dashboard/src/lib/public-model-pricing.ts @@ -0,0 +1,373 @@ +import type { CloudflareAppEnv } from '#/lib/runtime' + +export type ModelPricingLookupRow = { + cacheReadInputCostPerToken: number + fetchedAt: number + inputCostPerToken: number + matchKey: string + requestedModel: string + resolved: boolean + sourceModel: string | null + sourceProvider: string | null + sourceUrl: string + outputCostPerToken: number +} + +export type ModelPricingReference = { + model: string + provider?: string + tokens?: number +} + +export type DashboardPricingStatus = { + coverageRatio: number + coveredModelCount: number + coveredTokens: number + lastRefreshedAt: string | null + sourceLabel: string + sourceUrl: string + totalModelCount: number + totalTokens: number +} + +export type PricingLookupResult = { + lookup: Map + status: DashboardPricingStatus +} + +type RemotePricingEntry = { + cache_read_input_token_cost?: number + input_cost_per_token?: number + litellm_provider?: string + output_cost_per_token?: number +} + +const DEFAULT_SOURCE_URL = + 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json' +const DEFAULT_SOURCE_LABEL = 'Public API pricing' +const DEFAULT_REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000 + +export async function ensureModelPricingForReferences( + env: CloudflareAppEnv, + references: ModelPricingReference[], +): Promise { + const normalizedRefs = dedupeReferences(references) + const sourceUrl = env.PUBLIC_MODEL_PRICING_SOURCE_URL || DEFAULT_SOURCE_URL + const refreshIntervalMs = getRefreshIntervalMs(env) + + if (normalizedRefs.length === 0) { + return { + lookup: new Map(), + status: buildPricingStatus([], sourceUrl), + } + } + + const cachedRows = await loadCachedRows(env.DB, normalizedRefs) + const staleOrMissingRefs = normalizedRefs.filter((reference) => { + const cached = cachedRows.get(reference.matchKey) + if (!cached) { + return true + } + return Date.now() - cached.fetchedAt > refreshIntervalMs + }) + + if (staleOrMissingRefs.length > 0) { + const remoteCatalog = await fetchRemoteCatalog(sourceUrl) + const refreshedRows = staleOrMissingRefs.map((reference) => + buildLookupRow(reference, remoteCatalog, sourceUrl), + ) + await persistLookupRows(env.DB, refreshedRows) + + for (const row of refreshedRows) { + cachedRows.set(row.matchKey, row) + } + } + + return { + lookup: cachedRows, + status: buildPricingStatus(normalizedRefs, sourceUrl, cachedRows), + } +} + +export function estimateProjectedCostUsd(input: { + cacheReadInputTokens?: number + inputTokens: number + outputTokens: number + pricing: ModelPricingLookupRow | undefined +}) { + if (!input.pricing?.resolved) { + return 0 + } + + return roundCurrency( + input.inputTokens * input.pricing.inputCostPerToken + + input.outputTokens * input.pricing.outputCostPerToken + + (input.cacheReadInputTokens || 0) * input.pricing.cacheReadInputCostPerToken, + ) +} + +export function getModelPricingLookupKey(model: string) { + return normalizeModelKey(model) +} + +async function loadCachedRows( + db: D1Database, + references: Array, +) { + const placeholders = references.map(() => '?').join(', ') + const result = await db + .prepare( + `SELECT match_key as matchKey, + requested_model as requestedModel, + source_model as sourceModel, + source_provider as sourceProvider, + input_cost_per_token as inputCostPerToken, + output_cost_per_token as outputCostPerToken, + cache_read_input_cost_per_token as cacheReadInputCostPerToken, + resolved as resolved, + fetched_at as fetchedAt, + source_url as sourceUrl + FROM model_pricing_cache + WHERE match_key IN (${placeholders})`, + ) + .bind(...references.map((reference) => reference.matchKey)) + .all() + + return new Map( + (result.results || []).map((row) => [ + row.matchKey, + { + ...row, + cacheReadInputCostPerToken: toFiniteNumber(row.cacheReadInputCostPerToken), + fetchedAt: toFiniteNumber(row.fetchedAt), + inputCostPerToken: toFiniteNumber(row.inputCostPerToken), + outputCostPerToken: toFiniteNumber(row.outputCostPerToken), + resolved: Boolean(row.resolved), + }, + ]), + ) +} + +async function fetchRemoteCatalog(sourceUrl: string) { + const response = await fetch(sourceUrl, { + headers: { + accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Pricing refresh failed with ${response.status} from ${sourceUrl}`) + } + + const json = (await response.json()) as Record + return Object.entries(json).map(([model, value]) => ({ + inputCostPerToken: toFiniteNumber(value.input_cost_per_token), + normalizedKey: normalizeModelKey(model), + outputCostPerToken: toFiniteNumber(value.output_cost_per_token), + provider: value.litellm_provider || null, + rawKey: model, + resolved: + Number.isFinite(value.input_cost_per_token) || Number.isFinite(value.output_cost_per_token), + cacheReadInputCostPerToken: toFiniteNumber(value.cache_read_input_token_cost), + })) +} + +function buildLookupRow( + reference: ModelPricingReference & { matchKey: string }, + catalog: Awaited>, + sourceUrl: string, +): ModelPricingLookupRow { + const matchedEntry = matchCatalogEntry(reference, catalog) + const fetchedAt = Date.now() + + return { + cacheReadInputCostPerToken: matchedEntry?.cacheReadInputCostPerToken || 0, + fetchedAt, + inputCostPerToken: matchedEntry?.inputCostPerToken || 0, + matchKey: reference.matchKey, + outputCostPerToken: matchedEntry?.outputCostPerToken || 0, + requestedModel: reference.model, + resolved: Boolean(matchedEntry?.resolved), + sourceModel: matchedEntry?.rawKey || null, + sourceProvider: matchedEntry?.provider || null, + sourceUrl, + } +} + +function matchCatalogEntry( + reference: ModelPricingReference & { matchKey: string }, + catalog: Awaited>, +) { + const exactMatch = catalog.find((entry) => entry.normalizedKey === reference.matchKey) + if (exactMatch) { + return exactMatch + } + + const providerHint = inferProviderHint(reference) + const partialMatches = catalog + .filter( + (entry) => + entry.normalizedKey.includes(reference.matchKey) || + reference.matchKey.includes(entry.normalizedKey), + ) + .sort((left, right) => + scoreCatalogEntry(right, reference.matchKey, providerHint) - + scoreCatalogEntry(left, reference.matchKey, providerHint), + ) + + return partialMatches[0] +} + +function scoreCatalogEntry( + entry: Awaited>[number], + requestedKey: string, + providerHint: string | null, +) { + let score = 0 + + if (entry.normalizedKey.startsWith(requestedKey)) { + score += 60 + } + + if (entry.normalizedKey.includes(requestedKey)) { + score += 30 + } + + if (providerHint && entry.provider === providerHint) { + score += 20 + } + + score -= Math.max(0, entry.normalizedKey.length - requestedKey.length) + return score +} + +function inferProviderHint(reference: ModelPricingReference) { + const combined = `${reference.provider || ''} ${reference.model}`.toLowerCase() + if (combined.includes('claude')) { + return 'anthropic' + } + if (combined.includes('gpt') || combined.includes('o1') || combined.includes('o3') || combined.includes('o4')) { + return 'openai' + } + return null +} + +async function persistLookupRows(db: D1Database, rows: ModelPricingLookupRow[]) { + if (rows.length === 0) { + return + } + + await db.batch( + rows.map((row) => + db + .prepare( + `INSERT INTO model_pricing_cache ( + match_key, + requested_model, + source_model, + source_provider, + input_cost_per_token, + output_cost_per_token, + cache_read_input_cost_per_token, + resolved, + fetched_at, + source_url + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(match_key) DO UPDATE SET + requested_model = excluded.requested_model, + source_model = excluded.source_model, + source_provider = excluded.source_provider, + input_cost_per_token = excluded.input_cost_per_token, + output_cost_per_token = excluded.output_cost_per_token, + cache_read_input_cost_per_token = excluded.cache_read_input_cost_per_token, + resolved = excluded.resolved, + fetched_at = excluded.fetched_at, + source_url = excluded.source_url`, + ) + .bind( + row.matchKey, + row.requestedModel, + row.sourceModel, + row.sourceProvider, + row.inputCostPerToken, + row.outputCostPerToken, + row.cacheReadInputCostPerToken, + row.resolved ? 1 : 0, + row.fetchedAt, + row.sourceUrl, + ), + ), + ) +} + +function dedupeReferences(references: ModelPricingReference[]) { + const seen = new Set() + const normalized: Array = [] + + for (const reference of references) { + const matchKey = normalizeModelKey(reference.model) + if (!matchKey || seen.has(matchKey)) { + continue + } + seen.add(matchKey) + normalized.push({ ...reference, matchKey }) + } + + return normalized +} + +function buildPricingStatus( + references: Array, + sourceUrl: string, + cachedRows?: Map, +): DashboardPricingStatus { + const totalTokens = references.reduce((sum, reference) => sum + (reference.tokens || 0), 0) + const coveredReferences = references.filter((reference) => cachedRows?.get(reference.matchKey)?.resolved) + const coveredTokens = coveredReferences.reduce( + (sum, reference) => sum + (reference.tokens || 0), + 0, + ) + const lastRefreshedAt = cachedRows + ? [...cachedRows.values()].reduce((latest, row) => { + if (!row.fetchedAt) { + return latest + } + return latest === null ? row.fetchedAt : Math.max(latest, row.fetchedAt) + }, null) + : null + + return { + coverageRatio: totalTokens > 0 ? coveredTokens / totalTokens : 0, + coveredModelCount: coveredReferences.length, + coveredTokens, + lastRefreshedAt: lastRefreshedAt ? new Date(lastRefreshedAt).toISOString() : null, + sourceLabel: DEFAULT_SOURCE_LABEL, + sourceUrl, + totalModelCount: references.length, + totalTokens, + } +} + +function normalizeModelKey(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function getRefreshIntervalMs(env: CloudflareAppEnv) { + const hours = Number(env.PUBLIC_MODEL_PRICING_REFRESH_HOURS) + if (Number.isFinite(hours) && hours > 0) { + return hours * 60 * 60 * 1000 + } + return DEFAULT_REFRESH_INTERVAL_MS +} + +function toFiniteNumber(value: unknown) { + const numericValue = typeof value === 'number' ? value : Number(value) + return Number.isFinite(numericValue) ? numericValue : 0 +} + +function roundCurrency(value: number) { + return Math.round(value * 100) / 100 +} diff --git a/apps/dashboard/src/lib/runtime.ts b/apps/dashboard/src/lib/runtime.ts index 87d0c35..67e9469 100644 --- a/apps/dashboard/src/lib/runtime.ts +++ b/apps/dashboard/src/lib/runtime.ts @@ -9,6 +9,8 @@ export type CloudflareAppEnv = { OPENAI_USAGE_ENVIRONMENT?: string OPENAI_USAGE_WORKSPACE_NAME?: string OPENAI_USAGE_WORKSPACE_SLUG?: string + PUBLIC_MODEL_PRICING_REFRESH_HOURS?: string + PUBLIC_MODEL_PRICING_SOURCE_URL?: string } export type AppRequestContext = { diff --git a/apps/dashboard/src/lib/token-analytics.ts b/apps/dashboard/src/lib/token-analytics.ts index d3423f2..b493c55 100644 --- a/apps/dashboard/src/lib/token-analytics.ts +++ b/apps/dashboard/src/lib/token-analytics.ts @@ -1,3 +1,5 @@ +import type { DashboardPricingStatus } from '#/lib/public-model-pricing' + export type DashboardProjectOption = { latestGeneratedAt?: string latestRollupDay?: string | null @@ -42,6 +44,7 @@ export type DashboardIssueByDay = DashboardIssue & export type DashboardModelSummary = { cost: number model: string + projectedCost?: number provider?: string requests: number tokens: number @@ -51,6 +54,7 @@ export type DashboardModelDailyUsage = DashboardProjectOption & { cost: number day: string model: string + projectedCost?: number provider: string requests: number tokens: number @@ -70,6 +74,7 @@ type SnapshotBuildInput = { issuesByDay?: DashboardIssueByDay[] models: DashboardModelSummary[] modelRowsByDay?: DashboardModelDailyUsage[] + pricingStatus?: DashboardPricingStatus rangeLabel?: string selectedProjectIds?: string[] sourceLabel: string @@ -91,12 +96,26 @@ export function buildSnapshotFromRollups(input: SnapshotBuildInput): DashboardSn input.bucketWindowStart, input.bucketWindowEnd, ) + const modelRowsByDay = + input.modelRowsByDay || + input.models.map((model) => ({ + ...EMPTY_PROJECT, + cost: model.cost, + day: aggregatedDailyRows.at(-1)?.day || '', + model: model.model, + projectedCost: model.projectedCost, + provider: model.provider || 'Unknown', + requests: model.requests, + tokens: model.tokens, + })) + const projectedCostByDay = summarizeProjectedCostByDay(modelRowsByDay) const totals = aggregatedDailyRows.reduce( (accumulator, row) => { accumulator.cachedTokens += row.cachedTokens accumulator.cost += row.cost accumulator.inputTokens += row.inputTokens accumulator.outputTokens += row.outputTokens + accumulator.projectedCost += projectedCostByDay.get(row.day) || 0 accumulator.requests += row.requests accumulator.totalTokens += row.totalTokens return accumulator @@ -106,6 +125,7 @@ export function buildSnapshotFromRollups(input: SnapshotBuildInput): DashboardSn cost: 0, inputTokens: 0, outputTokens: 0, + projectedCost: 0, requests: 0, totalTokens: 0, }, @@ -116,6 +136,7 @@ export function buildSnapshotFromRollups(input: SnapshotBuildInput): DashboardSn const modelRows = input.models.map((model, index) => ({ ...model, color: MODEL_COLORS[index % MODEL_COLORS.length], + projectedCost: model.projectedCost || 0, provider: model.provider || 'Unknown', })) const topModel = modelRows.at(0) @@ -128,6 +149,10 @@ export function buildSnapshotFromRollups(input: SnapshotBuildInput): DashboardSn const resolvedSelectedProjectIds = resolveSelectedProjectIds(availableProjects, input.selectedProjectIds) const projectBreakdown = summarizeProjects(input.dailyRows, availableProjects, resolvedSelectedProjectIds) const workspaceLabel = input.workspaceName || summarizeProjectSelection(availableProjects, resolvedSelectedProjectIds) + const pricingSummary = + input.pricingStatus && input.pricingStatus.totalModelCount > 0 + ? `${Math.round(input.pricingStatus.coverageRatio * 100)}% of model tokens matched public API pricing.` + : 'Projected API pricing becomes available once a model price match is found.' return { callouts: [ @@ -137,11 +162,13 @@ export function buildSnapshotFromRollups(input: SnapshotBuildInput): DashboardSn topDayByTokens ? `${formatDay(topDayByTokens.day)} was the busiest ${bucketLabel} at ${formatCompactNumber(topDayByTokens.totalTokens)} total tokens.` : `No ${bucketLabel}-level token data was returned for this window.`, - projectBreakdown.length > 1 - ? `${projectBreakdown[0].projectName} led the selected project set with ${formatCompactNumber(projectBreakdown[0].totalTokens)} tokens.` - : topDayByCost && topDayByCost.cost > 0 - ? `${formatDay(topDayByCost.day)} carried the highest tracked cost for any ${bucketLabel} at $${topDayByCost.cost.toFixed(2)}.` - : `Source label: ${input.sourceLabel}.`, + totals.projectedCost > 0 + ? `Projected API pricing for the selected window is $${totals.projectedCost.toFixed(2)} versus $${totals.cost.toFixed(2)} tracked actual cost.` + : projectBreakdown.length > 1 + ? `${projectBreakdown[0].projectName} led the selected project set with ${formatCompactNumber(projectBreakdown[0].totalTokens)} tokens.` + : topDayByCost && topDayByCost.cost > 0 + ? `${formatDay(topDayByCost.day)} carried the highest tracked cost for any ${bucketLabel} at $${topDayByCost.cost.toFixed(2)}.` + : `Source label: ${input.sourceLabel}.`, ], charts: { costByDay: aggregatedDailyRows.map((row) => ({ @@ -178,23 +205,14 @@ export function buildSnapshotFromRollups(input: SnapshotBuildInput): DashboardSn hourlyModelRowsByDay: input.hourlyModelRowsByDay, hourlyRows: input.hourlyRows, issuesByDay: input.issuesByDay || input.issues.map((issue) => ({ ...issue, ...EMPTY_PROJECT, day: availableEndDay })), - modelRowsByDay: - input.modelRowsByDay || - input.models.map((model) => ({ - ...EMPTY_PROJECT, - cost: model.cost, - day: availableEndDay, - model: model.model, - provider: model.provider || 'Unknown', - requests: model.requests, - tokens: model.tokens, - })), + modelRowsByDay, selectedProjectIds: resolvedSelectedProjectIds, }, headline: { environment: input.environment, generatedAt: input.generatedAt, granularity, + pricing: input.pricingStatus, rangeLabel: input.rangeLabel || `Last ${aggregatedDailyRows.length} ${granularity === 'hour' ? 'hours' : 'days'}`, sourceLabel: input.sourceLabel, summary: @@ -215,21 +233,31 @@ export function buildSnapshotFromRollups(input: SnapshotBuildInput): DashboardSn kpis: [ { label: 'Total Tokens', + note: pricingSummary, tone: 'neutral', value: formatCompactNumber(totals.totalTokens), }, { label: 'Tracked Cost', + note: input.sourceLabel, tone: totals.cost > 0 ? 'warning' : 'neutral', value: `$${totals.cost.toFixed(2)}`, }, + { + label: 'Projected API Cost', + note: pricingSummary, + tone: totals.projectedCost > totals.cost ? 'warning' : totals.projectedCost > 0 ? 'positive' : 'neutral', + value: `$${totals.projectedCost.toFixed(2)}`, + }, { label: 'API Calls', + note: pricingSummary, tone: 'neutral', value: totals.requests.toLocaleString('en-US'), }, { label: 'Cached Input Share', + note: pricingSummary, tone: cacheRate >= 0.2 ? 'positive' : cacheRate >= 0.05 ? 'warning' : 'negative', value: `${(cacheRate * 100).toFixed(1)}%`, }, @@ -242,10 +270,12 @@ export function buildSnapshotFromRollups(input: SnapshotBuildInput): DashboardSn table: aggregatedDailyRows.map((row) => ({ cachedShare: calculateCachedShare(row), cost: row.cost, + costDelta: roundCurrency((projectedCostByDay.get(row.day) || 0) - row.cost), day: row.day, hasData: row.hasData !== false, inputTokens: resolveTotalInputTokens(row), outputTokens: row.outputTokens, + projectedCost: projectedCostByDay.get(row.day) || 0, requests: row.requests, totalTokens: row.totalTokens, traceId: normalizeTraceId(row.day), @@ -447,6 +477,19 @@ function summarizeRowsByBucket(rows: DashboardDailyRow[]) { return [...dayMap.values()].sort((left, right) => left.day.localeCompare(right.day)) } +function summarizeProjectedCostByDay(rows: DashboardModelDailyUsage[]) { + const dayMap = new Map() + + for (const row of rows) { + if (!row.projectedCost) { + continue + } + dayMap.set(row.day, roundCurrency((dayMap.get(row.day) || 0) + row.projectedCost)) + } + + return dayMap +} + function fillMissingDailyBuckets(rows: DashboardDailyRow[], windowStart?: string, windowEnd?: string) { if (rows.length === 0) { return rows @@ -682,6 +725,10 @@ function normalizeTraceId(value: string) { return value.replace(/[^0-9A-Za-z]/g, '').slice(-12) || 'window' } +function roundCurrency(value: number) { + return Math.round(value * 100) / 100 +} + export type DashboardSnapshot = { callouts: string[] charts: { @@ -700,6 +747,7 @@ export type DashboardSnapshot = { color: string cost: number model: string + projectedCost?: number provider: string requests: number tokens: number @@ -733,6 +781,7 @@ export type DashboardSnapshot = { environment: string generatedAt: string granularity: DashboardBucketGranularity + pricing?: DashboardPricingStatus rangeLabel: string sourceLabel: string summary: string @@ -741,6 +790,7 @@ export type DashboardSnapshot = { issues: DashboardIssue[] kpis: Array<{ label: string + note?: string tone: 'positive' | 'warning' | 'neutral' | 'negative' value: string }> @@ -752,10 +802,12 @@ export type DashboardSnapshot = { table: Array<{ cachedShare: number cost: number + costDelta?: number day: string hasData?: boolean inputTokens: number outputTokens: number + projectedCost?: number requests: number totalTokens: number traceId: string diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx index ff33b83..6301105 100644 --- a/apps/dashboard/src/routes/index.tsx +++ b/apps/dashboard/src/routes/index.tsx @@ -229,9 +229,15 @@ function Home() { { accent: 'var(--chart-magenta)', key: 'cost-total', - label: 'Total cost', + label: 'Tracked cost', value: formatCurrency(activeSnapshot.charts.costByDay.reduce((sum, item) => sum + item.cost, 0)), }, + { + accent: 'var(--chart-violet)', + key: 'projected-total', + label: 'Projected API cost', + value: formatCurrency(activeSnapshot.charts.models.reduce((sum, item) => sum + (item.projectedCost || 0), 0)), + }, { accent: 'var(--chart-magenta)', key: 'cost-peak', @@ -239,8 +245,23 @@ function Home() { value: formatCurrency(Math.max(...activeSnapshot.charts.costByDay.map((item) => item.cost), 0)), }, ], + [activeSnapshot.charts.costByDay, activeSnapshot.charts.models], + ) + const trackedCostTotal = useMemo( + () => activeSnapshot.charts.costByDay.reduce((sum, item) => sum + item.cost, 0), [activeSnapshot.charts.costByDay], ) + const projectedCostTotal = useMemo( + () => activeSnapshot.charts.models.reduce((sum, item) => sum + (item.projectedCost || 0), 0), + [activeSnapshot.charts.models], + ) + const projectedCostDelta = projectedCostTotal - trackedCostTotal + const pricingCoverageLabel = activeSnapshot.headline.pricing + ? `${activeSnapshot.headline.pricing.coveredModelCount}/${activeSnapshot.headline.pricing.totalModelCount} models, ${(activeSnapshot.headline.pricing.coverageRatio * 100).toFixed(0)}% token coverage` + : null + const pricingRefreshLabel = activeSnapshot.headline.pricing?.lastRefreshedAt + ? `${formatRefreshBasisLabel(activeSnapshot.headline.pricing.lastRefreshedAt)} pricing refresh` + : 'No public pricing snapshot yet' return (
@@ -398,7 +419,7 @@ function Home() { {kpi.value}

- {activeSnapshot.headline.sourceLabel} + {kpi.note || activeSnapshot.headline.sourceLabel}

@@ -754,6 +775,42 @@ function Home() { + + +
+ API pricing projection +

+ Compare tracked cost with a public API-priced estimate so zero-cost account traffic is still visible in dollar terms. +

+
+ + + {pricingRefreshLabel} + +
+ +
+

Tracked cost

+

{formatCurrency(trackedCostTotal)}

+

Recorded from the imported rollups in this window.

+
+
+

Projected API cost

+

{formatCurrency(projectedCostTotal)}

+

{pricingCoverageLabel || 'Waiting on public pricing coverage.'}

+
+
+

Delta

+

{formatSignedCurrency(projectedCostDelta)}

+

Positive means current public API pricing is above tracked cost, negative means the opposite.

+
+
+
+ @@ -781,7 +838,9 @@ function Home() { Cached % - Cost + Tracked + Projected + Delta @@ -803,6 +862,12 @@ function Home() { {formatCurrency(row.cost)} + + {formatCurrency(row.projectedCost || 0)} + + + {formatSignedCurrency((row.projectedCost || 0) - row.cost)} + ))} @@ -879,7 +944,9 @@ function ModelUsageBreakdownCard({ models }: ModelUsageBreakdownCardProps) { {formatCompact(item.tokens)} tokens - {formatCurrency(item.cost)} + {formatCurrency(item.cost)} tracked + + {formatCurrency(item.projectedCost || 0)} projected @@ -1704,6 +1771,11 @@ function formatCurrency(value: number) { return `$${value.toFixed(2)}` } +function formatSignedCurrency(value: number) { + const prefix = value > 0 ? '+' : '' + return `${prefix}${formatCurrency(value)}` +} + function formatModelLabel(model: string, provider: string) { return provider ? `${provider} · ${model}` : model } @@ -1995,6 +2067,7 @@ type ModelUsageBreakdownCardProps = { color: string cost: number model: string + projectedCost?: number provider: string requests: number tokens: number @@ -2042,6 +2115,7 @@ type ModelBarsProps = { color: string cost: number model: string + projectedCost?: number provider: string requests: number tokens: number