From 94b9c8abebb0413c21bf1abdb1206b4d6eef2a1a Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Sun, 26 Jul 2026 19:23:18 +0100 Subject: [PATCH] fix: bill storage cost in all modes and show it Data retention storage cost was recorded on every log but only ever deducted for api-keys mode logs with a nonzero inference cost. Credits mode (including dev/chat plan pools), wallet-backed traffic and zero-inference-cost logs (unbilled refusals) never paid it. Deduct storage for every log with a storage cost, independent of mode and inference cost. Also surface it: the admin org/project usage metrics now return and display a Storage Cost tile, and the dashboard's Total Spend headline includes storage (with the existing breakdown subtitle). Co-Authored-By: Claude Fable 5 --- apps/api/src/routes/admin.ts | 16 ++++ .../components/dashboard/dashboard-client.tsx | 18 ++-- apps/worker/src/log-processing.spec.ts | 92 +++++++++++++++++++ apps/worker/src/worker.ts | 61 ++++++------ .../app/organizations/[orgId]/org-metrics.tsx | 8 ++ .../projects/[projectId]/project-metrics.tsx | 8 ++ 6 files changed, 169 insertions(+), 34 deletions(-) diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 2b5ce4161d..809e65e78a 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -414,6 +414,7 @@ const orgMetricsSchema = z.object({ cachedCost: z.number(), cacheWriteTokens: z.number(), cacheWriteCost: z.number(), + dataStorageCost: z.number(), mostUsedModel: z.string().nullable(), mostUsedProvider: z.string().nullable(), mostUsedModelCost: z.number(), @@ -2602,6 +2603,7 @@ admin.openapi(getOrganizationMetrics, async (c) => { let cachedCost = 0; let cacheWriteTokens = 0; let cacheWriteCost = 0; + let dataStorageCost = 0; let discountSavings = 0; let mostUsedModel: string | null = null; let mostUsedProvider: string | null = null; @@ -2659,6 +2661,10 @@ admin.openapi(getOrganizationMetrics, async (c) => { sql`COALESCE(SUM(${projectHourlyStats.cacheWriteInputCost}), 0)`.as( "cacheWriteInputCost", ), + dataStorageCost: + sql`COALESCE(SUM(${projectHourlyStats.dataStorageCost}), 0)`.as( + "dataStorageCost", + ), }) .from(projectHourlyStats) .where( @@ -2685,6 +2691,7 @@ admin.openapi(getOrganizationMetrics, async (c) => { cachedCost = Number(totals.cachedInputCost) || 0; cacheWriteTokens = Number(totals.cacheWriteTokens) || 0; cacheWriteCost = Number(totals.cacheWriteInputCost) || 0; + dataStorageCost = Number(totals.dataStorageCost) || 0; discountSavings = Number(totals.discountSavings) || 0; } @@ -2758,6 +2765,7 @@ admin.openapi(getOrganizationMetrics, async (c) => { cachedCost, cacheWriteTokens, cacheWriteCost, + dataStorageCost, mostUsedModel, mostUsedProvider, mostUsedModelCost, @@ -3112,6 +3120,7 @@ const projectMetricsSchema = z.object({ cachedCost: z.number(), cacheWriteTokens: z.number(), cacheWriteCost: z.number(), + dataStorageCost: z.number(), mostUsedModel: z.string().nullable(), mostUsedProvider: z.string().nullable(), mostUsedModelCost: z.number(), @@ -3194,6 +3203,7 @@ admin.openapi(getProjectMetrics, async (c) => { let cachedCost = 0; let cacheWriteTokens = 0; let cacheWriteCost = 0; + let dataStorageCost = 0; let discountSavings = 0; let mostUsedModel: string | null = null; let mostUsedProvider: string | null = null; @@ -3249,6 +3259,10 @@ admin.openapi(getProjectMetrics, async (c) => { sql`COALESCE(SUM(${projectHourlyStats.cacheWriteInputCost}), 0)`.as( "cacheWriteInputCost", ), + dataStorageCost: + sql`COALESCE(SUM(${projectHourlyStats.dataStorageCost}), 0)`.as( + "dataStorageCost", + ), }) .from(projectHourlyStats) .where( @@ -3275,6 +3289,7 @@ admin.openapi(getProjectMetrics, async (c) => { cachedCost = Number(totals.cachedInputCost) || 0; cacheWriteTokens = Number(totals.cacheWriteTokens) || 0; cacheWriteCost = Number(totals.cacheWriteInputCost) || 0; + dataStorageCost = Number(totals.dataStorageCost) || 0; discountSavings = Number(totals.discountSavings) || 0; } @@ -3337,6 +3352,7 @@ admin.openapi(getProjectMetrics, async (c) => { cachedCost, cacheWriteTokens, cacheWriteCost, + dataStorageCost, mostUsedModel, mostUsedProvider, mostUsedModelCost, diff --git a/apps/ui/src/components/dashboard/dashboard-client.tsx b/apps/ui/src/components/dashboard/dashboard-client.tsx index 3c619884f6..bb899c76bb 100644 --- a/apps/ui/src/components/dashboard/dashboard-client.tsx +++ b/apps/ui/src/components/dashboard/dashboard-client.tsx @@ -358,7 +358,10 @@ export function DashboardClient({ initialActivityData }: DashboardClientProps) { (sum, day) => sum + day.requestCount, 0, ); - const prevCost = prevActivityData.reduce((sum, day) => sum + day.cost, 0); + const prevCost = prevActivityData.reduce( + (sum, day) => sum + day.cost + day.dataStorageCost, + 0, + ); const prevSavings = prevActivityData.reduce( (sum, day) => sum + day.discountSavings, 0, @@ -366,7 +369,10 @@ export function DashboardClient({ initialActivityData }: DashboardClientProps) { const cacheHitRate = totalRequests > 0 ? (totalCached / totalRequests) * 100 : 0; - const avgCostPerRequest = totalRequests > 0 ? totalCost / totalRequests : 0; + // Data retention storage is billed on top of inference costs, so the + // headline spend includes it. + const totalSpend = totalCost + totalDataStorageCost; + const avgCostPerRequest = totalRequests > 0 ? totalSpend / totalRequests : 0; // Day-by-day series for the KPI sparklines, with missing days filled as 0. const { requestsTrend, costTrend } = (() => { @@ -376,7 +382,7 @@ export function DashboardClient({ initialActivityData }: DashboardClientProps) { ); return { requestsTrend: sorted.map((day) => day.requestCount), - costTrend: sorted.map((day) => day.cost), + costTrend: sorted.map((day) => day.cost + day.dataStorageCost), }; } const byDate = new Map(activityData.map((day) => [day.date, day])); @@ -385,7 +391,7 @@ export function DashboardClient({ initialActivityData }: DashboardClientProps) { for (let i = 0; i < rangeDays; i++) { const day = byDate.get(format(addDays(from, i), "yyyy-MM-dd")); requests.push(day?.requestCount ?? 0); - costs.push(day?.cost ?? 0); + costs.push(day ? day.cost + day.dataStorageCost : 0); } return { requestsTrend: requests, costTrend: costs }; })(); @@ -550,7 +556,7 @@ export function DashboardClient({ initialActivityData }: DashboardClientProps) { ? "BYOK Usage" : "Total Spend" } - value={`$${totalCost.toFixed(2)}`} + value={`$${totalSpend.toFixed(2)}`} subtitle={ usageMode === "total" && totalCreditsCost > 0 && @@ -572,7 +578,7 @@ export function DashboardClient({ initialActivityData }: DashboardClientProps) { } icon={} accent="blue" - delta={pctChange(totalCost, prevCost)} + delta={pctChange(totalSpend, prevCost)} trend={costTrend} isLoading={isLoading} /> diff --git a/apps/worker/src/log-processing.spec.ts b/apps/worker/src/log-processing.spec.ts index 8d0c2aa59e..1df19501ae 100644 --- a/apps/worker/src/log-processing.spec.ts +++ b/apps/worker/src/log-processing.spec.ts @@ -354,6 +354,98 @@ describe("Log Processing", () => { expect(Number(updatedOrg!.credits)).toBe(initialCredits); }); + test("should deduct storage cost on top of inference cost for credits mode logs", async () => { + const initialCredits = Number(testOrg.credits); + + await db.insert(log).values({ + requestId: "test-request-credits-storage", + organizationId: testOrg.id, + projectId: testProject.id, + apiKeyId: testApiKey.id, + cost: 0.01, + dataStorageCost: "0.002", + cached: false, + usedMode: "credits", + duration: 2000, + requestedModel: "openai/gpt-4o-mini", + requestedProvider: "openai", + usedModel: "gpt-4o-mini", + usedProvider: "openai", + responseSize: 150, + mode: "credits", + }); + + await batchProcessLogs(); + + const updatedOrg = await db.query.organization.findFirst({ + where: { id: { eq: testOrg.id } }, + }); + + expect(Number(updatedOrg!.credits)).toBe(initialCredits - 0.012); + }); + + test("should deduct only storage cost for api-keys mode logs", async () => { + const initialCredits = Number(testOrg.credits); + + await db.insert(log).values({ + requestId: "test-request-api-keys-storage", + organizationId: testOrg.id, + projectId: testProject.id, + apiKeyId: testApiKey.id, + cost: 0.01, + dataStorageCost: "0.002", + cached: false, + usedMode: "api-keys", + duration: 2000, + requestedModel: "openai/gpt-4o-mini", + requestedProvider: "openai", + usedModel: "gpt-4o-mini", + usedProvider: "openai", + responseSize: 150, + mode: "api-keys", + }); + + await batchProcessLogs(); + + const updatedOrg = await db.query.organization.findFirst({ + where: { id: { eq: testOrg.id } }, + }); + + expect(Number(updatedOrg!.credits)).toBe(initialCredits - 0.002); + }); + + test("should deduct storage cost even when inference cost is zero", async () => { + const initialCredits = Number(testOrg.credits); + + // Unbilled refusals zero the inference cost but keep the storage cost + // (retention is billed separately from inference). + await db.insert(log).values({ + requestId: "test-request-zero-cost-storage", + organizationId: testOrg.id, + projectId: testProject.id, + apiKeyId: testApiKey.id, + cost: 0, + dataStorageCost: "0.003", + cached: false, + usedMode: "credits", + duration: 2000, + requestedModel: "openai/gpt-4o-mini", + requestedProvider: "openai", + usedModel: "gpt-4o-mini", + usedProvider: "openai", + responseSize: 150, + mode: "credits", + }); + + await batchProcessLogs(); + + const updatedOrg = await db.query.organization.findFirst({ + where: { id: { eq: testOrg.id } }, + }); + + expect(Number(updatedOrg!.credits)).toBe(initialCredits - 0.003); + }); + test("should update API key usage for all non-cached logs with cost", async () => { const initialUsage = Number(testApiKey.usage); diff --git a/apps/worker/src/worker.ts b/apps/worker/src/worker.ts index 78f6a5708f..259487aae8 100644 --- a/apps/worker/src/worker.ts +++ b/apps/worker/src/worker.ts @@ -1147,6 +1147,36 @@ export async function batchProcessLogs(): Promise { ); } + const sourceBucket = isChatSource(row.source) ? "chat" : "other"; + + const addToBucket = (amount: Decimal, premium: boolean) => { + const existing = orgCosts.get(row.organization_id) ?? { + chat: new Decimal(0), + other: new Decimal(0), + chatPremium: new Decimal(0), + otherPremium: new Decimal(0), + }; + existing[sourceBucket] = existing[sourceBucket].plus(amount); + if (premium) { + const premiumBucket = + sourceBucket === "chat" ? "chatPremium" : "otherPremium"; + existing[premiumBucket] = existing[premiumBucket].plus(amount); + } + orgCosts.set(row.organization_id, existing); + }; + + // Data retention storage is billed separately from inference (log.cost + // never includes it), so it is deducted from org credits for every + // mode: credits, api-keys (BYOK) and wallet-backed end-user traffic + // alike — and also when inference itself was free or zeroed (e.g. + // unbilled refusals keep their storage cost). + if (row.data_storage_cost) { + const storageCost = new Decimal(row.data_storage_cost); + if (storageCost.greaterThan(0)) { + addToBucket(storageCost, false); + } + } + // Prefer the exact decimal billingCost (realtime and other // decimal-billed rows) over the legacy float cost column. const effectiveCost = @@ -1189,39 +1219,14 @@ export async function batchProcessLogs(): Promise { continue; } - const sourceBucket = isChatSource(row.source) ? "chat" : "other"; - - const addToBucket = (amount: Decimal, premium: boolean) => { - const existing = orgCosts.get(row.organization_id) ?? { - chat: new Decimal(0), - other: new Decimal(0), - chatPremium: new Decimal(0), - otherPremium: new Decimal(0), - }; - existing[sourceBucket] = existing[sourceBucket].plus(amount); - if (premium) { - const premiumBucket = - sourceBucket === "chat" ? "chatPremium" : "otherPremium"; - existing[premiumBucket] = existing[premiumBucket].plus(amount); - } - orgCosts.set(row.organization_id, existing); - }; - - // Deduct organization credits based on mode: - // - Credits mode: deduct full cost (includes request cost + storage cost) - // - API keys mode: only deduct storage cost (data retention billing) + // Inference cost: credits mode deducts the full cost from org + // credits; api-keys mode pays the provider directly (BYOK), so + // only the storage cost above is billed. if (row.used_mode === "credits") { addToBucket( apiKeyCost, Boolean(row.used_model && isPremiumUsedModel(row.used_model)), ); - } else if (row.used_mode === "api-keys") { - if (row.data_storage_cost) { - const storageCost = new Decimal(row.data_storage_cost); - if (storageCost.greaterThan(0)) { - addToBucket(storageCost, false); - } - } } } diff --git a/ee/admin/src/app/organizations/[orgId]/org-metrics.tsx b/ee/admin/src/app/organizations/[orgId]/org-metrics.tsx index 025c80f953..68c33c38fa 100644 --- a/ee/admin/src/app/organizations/[orgId]/org-metrics.tsx +++ b/ee/admin/src/app/organizations/[orgId]/org-metrics.tsx @@ -3,6 +3,7 @@ import { Activity, CircleDollarSign, + Database, Hash, Loader2, Server, @@ -318,6 +319,13 @@ export function OrgMetricsSection({ orgId }: { orgId: string }) { icon={} accent="purple" /> + } + accent="purple" + /> } accent="purple" /> + } + accent="purple" + />