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
58 changes: 58 additions & 0 deletions apps/api/src/lib/mode-split.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { z } from "zod";

import { sql } from "@llmgateway/db";

import type { AnyColumn } from "@llmgateway/db";

/**
* Aggregated credits-vs-BYOK ("api-keys") split for any rollup table carrying
* the per-mode measure columns. The blended `cost`/`requestCount` columns on
* those tables include both modes; these fields let clients present a
* Total / Credits / BYOK view without extra queries.
*/
export function modeSplitFields(table: {
creditsRequestCount: AnyColumn;
apiKeysRequestCount: AnyColumn;
creditsCost: AnyColumn;
apiKeysCost: AnyColumn;
}) {
return {
creditsRequestCount:
sql<number>`COALESCE(SUM(${table.creditsRequestCount}), 0)`.as(
"creditsRequestCount",
),
apiKeysRequestCount:
sql<number>`COALESCE(SUM(${table.apiKeysRequestCount}), 0)`.as(
"apiKeysRequestCount",
),
creditsCost: sql<number>`COALESCE(SUM(${table.creditsCost}), 0)`.as(
"creditsCost",
),
apiKeysCost: sql<number>`COALESCE(SUM(${table.apiKeysCost}), 0)`.as(
"apiKeysCost",
),
};
}

export const modeSplitSchema = {
creditsRequestCount: z.number(),
apiKeysRequestCount: z.number(),
creditsCost: z.number(),
apiKeysCost: z.number(),
};

export interface ModeSplitRow {
creditsRequestCount: number | string | null;
apiKeysRequestCount: number | string | null;
creditsCost: number | string | null;
apiKeysCost: number | string | null;
}

export function mapModeSplit(row: ModeSplitRow) {
return {
creditsRequestCount: Number(row.creditsRequestCount),
apiKeysRequestCount: Number(row.apiKeysRequestCount),
creditsCost: Number(row.creditsCost),
apiKeysCost: Number(row.apiKeysCost),
};
}
7 changes: 7 additions & 0 deletions apps/api/src/lib/user-usage-breakdown.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mapModeSplit, modeSplitFields } from "@/lib/mode-split.js";
import { bucketDate } from "@/utils/timezone.js";

import {
Expand All @@ -23,6 +24,10 @@ export interface UserUsageRow {
outputTokens: number;
totalTokens: number;
cost: number;
creditsRequestCount: number;
apiKeysRequestCount: number;
creditsCost: number;
apiKeysCost: number;
}

/**
Expand Down Expand Up @@ -78,6 +83,7 @@ export async function getUserUsageBreakdown(options: {
"totalTokens",
),
cost: sql<number>`COALESCE(SUM(${apiKeyHourlyStats.cost}), 0)`.as("cost"),
...modeSplitFields(apiKeyHourlyStats),
})
.from(apiKeyHourlyStats)
.innerJoin(tables.apiKey, eq(tables.apiKey.id, apiKeyHourlyStats.apiKeyId))
Expand Down Expand Up @@ -117,5 +123,6 @@ export async function getUserUsageBreakdown(options: {
outputTokens: Number(row.outputTokens),
totalTokens: Number(row.totalTokens),
cost: Number(row.cost),
...mapModeSplit(row),
}));
}
143 changes: 143 additions & 0 deletions apps/api/src/routes/activity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2338,4 +2338,147 @@ describe("activity endpoint", () => {
expect(res.status).toBe(403);
});
});

describe("credits vs BYOK mode split", () => {
beforeEach(async () => {
const now = new Date();
await db.insert(tables.log).values([
{
id: "mode-log-credits",
requestId: "mode-log-credits",
createdAt: now,
updatedAt: now,
organizationId: "test-org-id",
projectId: "test-project-id",
apiKeyId: "test-api-key-id",
duration: 100,
requestedModel: "mode-model",
requestedProvider: "openai",
usedModel: "mode-model",
usedProvider: "openai",
responseSize: 100,
promptTokens: "10",
completionTokens: "10",
totalTokens: "20",
messages: JSON.stringify([{ role: "user", content: "credits" }]),
mode: "hybrid",
usedMode: "credits",
cost: 1.5,
source: "test-agent",
},
{
id: "mode-log-byok",
requestId: "mode-log-byok",
createdAt: now,
updatedAt: now,
organizationId: "test-org-id",
projectId: "test-project-id",
apiKeyId: "test-api-key-id",
duration: 100,
requestedModel: "mode-model",
requestedProvider: "openai",
usedModel: "mode-model",
usedProvider: "openai",
responseSize: 100,
promptTokens: "10",
completionTokens: "10",
totalTokens: "20",
messages: JSON.stringify([{ role: "user", content: "byok" }]),
mode: "hybrid",
usedMode: "api-keys",
cost: 2.5,
source: "test-agent",
},
]);
await aggregateLogsForTesting();
});

test("splits day totals and model breakdown by usedMode", async () => {
const res = await app.request(
"/activity?days=7&projectId=test-project-id",
{ headers: { Cookie: token } },
);
expect(res.status).toBe(200);
const data = await res.json();

const day = data.activity.find(
(d: { creditsCost: number }) => d.creditsCost > 0,
);
expect(day).toBeDefined();
expect(day.creditsCost).toBeCloseTo(1.5, 5);
expect(day.apiKeysCost).toBeCloseTo(2.5, 5);
expect(day.creditsRequestCount).toBe(1);
// The two seeded api-keys logs from the outer beforeEach plus the BYOK
// log above.
expect(day.apiKeysRequestCount).toBe(3);
expect(day.cost).toBeCloseTo(4, 5);

const model = day.modelBreakdown.find(
(m: { id: string }) => m.id === "mode-model",
);
expect(model).toBeDefined();
expect(model.creditsCost).toBeCloseTo(1.5, 5);
expect(model.apiKeysCost).toBeCloseTo(2.5, 5);
expect(model.creditsRequestCount).toBe(1);
expect(model.apiKeysRequestCount).toBe(1);
});

test("splits the api key breakdown by usedMode", async () => {
const res = await app.request(
"/activity?days=7&projectId=test-project-id&groupBy=apiKey",
{ headers: { Cookie: token } },
);
expect(res.status).toBe(200);
const data = await res.json();

const entries = data.activity.flatMap(
(d: { apiKeyBreakdown: { id: string; creditsCost: number }[] }) =>
d.apiKeyBreakdown,
);
const entry = entries.find(
(e: { id: string; creditsCost: number }) =>
e.id === "test-api-key-id" && e.creditsCost > 0,
);
expect(entry).toBeDefined();
expect(entry.creditsCost).toBeCloseTo(1.5, 5);
expect(entry.apiKeysCost).toBeCloseTo(2.5, 5);
expect(entry.creditsRequestCount).toBe(1);
expect(entry.apiKeysRequestCount).toBe(3);
});

test("splits the source aggregation by usedMode", async () => {
// aggregateLogsForTesting does not cover the source rollup, so seed it
// directly like the other /activity/sources tests do.
await db.insert(tables.projectHourlySourceStats).values({
projectId: "test-project-id",
hourTimestamp: new Date(),
source: "test-agent",
requestCount: 2,
inputTokens: "20",
outputTokens: "20",
totalTokens: "40",
cost: 4,
creditsRequestCount: 1,
apiKeysRequestCount: 1,
creditsCost: 1.5,
apiKeysCost: 2.5,
});

const res = await app.request(
"/activity/sources?projectId=test-project-id",
{ headers: { Cookie: token } },
);
expect(res.status).toBe(200);
const data = await res.json();

const source = data.sources.find(
(s: { source: string }) => s.source === "test-agent",
);
expect(source).toBeDefined();
expect(source.creditsCost).toBeCloseTo(1.5, 5);
expect(source.apiKeysCost).toBeCloseTo(2.5, 5);
expect(source.creditsRequestCount).toBe(1);
expect(source.apiKeysRequestCount).toBe(1);
});
});
});
23 changes: 23 additions & 0 deletions apps/api/src/routes/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { HTTPException } from "hono/http-exception";
import { z } from "zod";

import { apiKeyScopeFilter } from "@/lib/api-key-scope-filter.js";
import {
mapModeSplit,
modeSplitFields,
modeSplitSchema,
} from "@/lib/mode-split.js";
import { requireEnterpriseAdmin } from "@/lib/require-enterprise-admin.js";
import { getUserUsageBreakdown } from "@/lib/user-usage-breakdown.js";
import {
Expand Down Expand Up @@ -49,6 +54,7 @@ const modelUsageSchema = z.object({
outputTokens: z.number(),
totalTokens: z.number(),
cost: z.number(),
...modeSplitSchema,
});

// Define the response schema for api-key-specific usage
Expand All @@ -60,6 +66,7 @@ const apiKeyUsageSchema = z.object({
outputTokens: z.number(),
totalTokens: z.number(),
cost: z.number(),
...modeSplitSchema,
});

// Define the response schema for per-member usage. Requests are attributed to
Expand All @@ -73,6 +80,7 @@ const userUsageSchema = z.object({
outputTokens: z.number(),
totalTokens: z.number(),
cost: z.number(),
...modeSplitSchema,
});

// Define the response schema for daily activity
Expand Down Expand Up @@ -496,6 +504,7 @@ activity.openapi(getActivity, async (c) => {
cost: sql<number>`COALESCE(SUM(${apiKeyHourlyModelStats.cost}), 0)`.as(
"cost",
),
...modeSplitFields(apiKeyHourlyModelStats),
})
.from(apiKeyHourlyModelStats)
.where(
Expand Down Expand Up @@ -531,6 +540,7 @@ activity.openapi(getActivity, async (c) => {
outputTokens: Number(breakdown.outputTokens),
totalTokens: Number(breakdown.totalTokens),
cost: Number(breakdown.cost),
...mapModeSplit(breakdown),
});
}

Expand Down Expand Up @@ -569,6 +579,7 @@ activity.openapi(getActivity, async (c) => {
cost: sql<number>`COALESCE(SUM(${apiKeyHourlyStats.cost}), 0)`.as(
"cost",
),
...modeSplitFields(apiKeyHourlyStats),
})
.from(apiKeyHourlyStats)
.leftJoin(apiKey, eq(apiKey.id, apiKeyHourlyStats.apiKeyId))
Expand Down Expand Up @@ -600,6 +611,7 @@ activity.openapi(getActivity, async (c) => {
outputTokens: Number(breakdown.outputTokens),
totalTokens: Number(breakdown.totalTokens),
cost: Number(breakdown.cost),
...mapModeSplit(breakdown),
});
}
}
Expand Down Expand Up @@ -854,6 +866,7 @@ activity.openapi(getActivity, async (c) => {
cost: sql<number>`COALESCE(SUM(${projectHourlyModelStats.cost}), 0)`.as(
"cost",
),
...modeSplitFields(projectHourlyModelStats),
})
.from(projectHourlyModelStats)
.where(
Expand All @@ -880,6 +893,7 @@ activity.openapi(getActivity, async (c) => {
outputTokens: Number(breakdown.outputTokens),
totalTokens: Number(breakdown.totalTokens),
cost: Number(breakdown.cost),
...mapModeSplit(breakdown),
});
}
}
Expand Down Expand Up @@ -918,6 +932,7 @@ activity.openapi(getActivity, async (c) => {
cost: sql<number>`COALESCE(SUM(${apiKeyHourlyStats.cost}), 0)`.as(
"cost",
),
...modeSplitFields(apiKeyHourlyStats),
})
.from(apiKeyHourlyStats)
.leftJoin(apiKey, eq(apiKey.id, apiKeyHourlyStats.apiKeyId))
Expand All @@ -944,6 +959,7 @@ activity.openapi(getActivity, async (c) => {
outputTokens: Number(breakdown.outputTokens),
totalTokens: Number(breakdown.totalTokens),
cost: Number(breakdown.cost),
...mapModeSplit(breakdown),
});
}
}
Expand Down Expand Up @@ -974,6 +990,10 @@ activity.openapi(getActivity, async (c) => {
outputTokens: breakdown.outputTokens,
totalTokens: breakdown.totalTokens,
cost: breakdown.cost,
creditsRequestCount: breakdown.creditsRequestCount,
apiKeysRequestCount: breakdown.apiKeysRequestCount,
creditsCost: breakdown.creditsCost,
apiKeysCost: breakdown.apiKeysCost,
});
}
}
Expand Down Expand Up @@ -1069,6 +1089,7 @@ const sourceUsageSchema = z.object({
outputTokens: z.number(),
totalTokens: z.number(),
cost: z.number(),
...modeSplitSchema,
lastUsedAt: z.string().nullable(),
});

Expand Down Expand Up @@ -1183,6 +1204,7 @@ activity.openapi(getSourceActivity, async (c) => {
cost: sql<number>`COALESCE(SUM(${projectHourlySourceStats.cost}), 0)`.as(
"cost",
),
...modeSplitFields(projectHourlySourceStats),
lastUsedAt: sql<
string | null
>`to_char(MAX(${projectHourlySourceStats.hourTimestamp}), 'YYYY-MM-DD"T"HH24:MI:SS')`.as(
Expand All @@ -1208,6 +1230,7 @@ activity.openapi(getSourceActivity, async (c) => {
outputTokens: Number(r.outputTokens),
totalTokens: Number(r.totalTokens),
cost: Number(r.cost),
...mapModeSplit(r),
lastUsedAt: r.lastUsedAt
? new Date(r.lastUsedAt + "Z").toISOString()
: null,
Expand Down
Loading
Loading