Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2659,6 +2661,10 @@ admin.openapi(getOrganizationMetrics, async (c) => {
sql<number>`COALESCE(SUM(${projectHourlyStats.cacheWriteInputCost}), 0)`.as(
"cacheWriteInputCost",
),
dataStorageCost:
sql<number>`COALESCE(SUM(${projectHourlyStats.dataStorageCost}), 0)`.as(
"dataStorageCost",
),
})
.from(projectHourlyStats)
.where(
Expand All @@ -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;
}

Expand Down Expand Up @@ -2758,6 +2765,7 @@ admin.openapi(getOrganizationMetrics, async (c) => {
cachedCost,
cacheWriteTokens,
cacheWriteCost,
dataStorageCost,
mostUsedModel,
mostUsedProvider,
mostUsedModelCost,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -3249,6 +3259,10 @@ admin.openapi(getProjectMetrics, async (c) => {
sql<number>`COALESCE(SUM(${projectHourlyStats.cacheWriteInputCost}), 0)`.as(
"cacheWriteInputCost",
),
dataStorageCost:
sql<number>`COALESCE(SUM(${projectHourlyStats.dataStorageCost}), 0)`.as(
"dataStorageCost",
),
})
.from(projectHourlyStats)
.where(
Expand All @@ -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;
}

Expand Down Expand Up @@ -3337,6 +3352,7 @@ admin.openapi(getProjectMetrics, async (c) => {
cachedCost,
cacheWriteTokens,
cacheWriteCost,
dataStorageCost,
mostUsedModel,
mostUsedProvider,
mostUsedModelCost,
Expand Down
18 changes: 12 additions & 6 deletions apps/ui/src/components/dashboard/dashboard-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,15 +358,21 @@ 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,
);

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 } = (() => {
Expand All @@ -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]));
Expand All @@ -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 };
})();
Expand Down Expand Up @@ -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 &&
Expand All @@ -572,7 +578,7 @@ export function DashboardClient({ initialActivityData }: DashboardClientProps) {
}
icon={<CircleDollarSign className="h-4 w-4" />}
accent="blue"
delta={pctChange(totalCost, prevCost)}
delta={pctChange(totalSpend, prevCost)}
trend={costTrend}
isLoading={isLoading}
/>
Expand Down
92 changes: 92 additions & 0 deletions apps/worker/src/log-processing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
61 changes: 33 additions & 28 deletions apps/worker/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,36 @@ export async function batchProcessLogs(): Promise<number> {
);
}

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 =
Expand Down Expand Up @@ -1189,39 +1219,14 @@ export async function batchProcessLogs(): Promise<number> {
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);
}
}
}
}

Expand Down
8 changes: 8 additions & 0 deletions ee/admin/src/app/organizations/[orgId]/org-metrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {
Activity,
CircleDollarSign,
Database,
Hash,
Loader2,
Server,
Expand Down Expand Up @@ -318,6 +319,13 @@ export function OrgMetricsSection({ orgId }: { orgId: string }) {
icon={<CircleDollarSign className="h-4 w-4" />}
accent="purple"
/>
<MetricCard
label="Storage Cost"
value={currencyFormatter.format(safeNumber(metrics.dataStorageCost))}
subtitle="Data retention billing, charged on top of usage costs"
icon={<Database className="h-4 w-4" />}
accent="purple"
/>
<MetricCard
label="Total Savings"
value={currencyFormatter.format(safeNumber(metrics.discountSavings))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {
Activity,
CircleDollarSign,
Database,
Hash,
Loader2,
Server,
Expand Down Expand Up @@ -284,6 +285,13 @@ export function ProjectMetricsSection({
icon={<CircleDollarSign className="h-4 w-4" />}
accent="purple"
/>
<MetricCard
label="Storage Cost"
value={currencyFormatter.format(safeNumber(metrics.dataStorageCost))}
subtitle="Data retention billing, charged on top of usage costs"
icon={<Database className="h-4 w-4" />}
accent="purple"
/>
<MetricCard
label="Total Savings"
value={currencyFormatter.format(safeNumber(metrics.discountSavings))}
Expand Down
Loading