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
7 changes: 4 additions & 3 deletions src/__apiTests__/admin/analytics-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,16 @@ function createMockRes(): any {
return res;
}

// Five queries fire in a fixed order: pageViews, leadCounts, eventTotalsRaw,
// eventsByPage, funnelRows.
// Six queries fire in a fixed order: pageViews, leadCounts, eventTotalsRaw,
// eventsByPage, funnelRows, llmUsageRaw.
function mockSqlDefaults() {
mockSql
.mockResolvedValueOnce([]) // pageViews
.mockResolvedValueOnce([{ total: 0, last_30_days: 0 }]) // leadCounts
.mockResolvedValueOnce([]) // eventTotalsRaw
.mockResolvedValueOnce([]) // eventsByPage
.mockResolvedValueOnce([{}]); // funnelRows
.mockResolvedValueOnce([{}]) // funnelRows
.mockResolvedValueOnce([]); // llmUsageRaw
}

describe("api/admin/analytics auth", () => {
Expand Down
65 changes: 62 additions & 3 deletions src/__apiTests__/admin/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,16 @@ function createMockRes(): any {
return res;
}

// Five queries fire in a fixed order: pageViews, leadCounts, eventTotalsRaw,
// eventsByPage, funnelRows.
// Six queries fire in a fixed order: pageViews, leadCounts, eventTotalsRaw,
// eventsByPage, funnelRows, llmUsageRaw.
function mockSqlDefaults() {
mockSql
.mockResolvedValueOnce([]) // pageViews
.mockResolvedValueOnce([{ total: 0, last_30_days: 0 }]) // leadCounts
.mockResolvedValueOnce([]) // eventTotalsRaw
.mockResolvedValueOnce([]) // eventsByPage
.mockResolvedValueOnce([{}]); // funnelRows
.mockResolvedValueOnce([{}]) // funnelRows
.mockResolvedValueOnce([]); // llmUsageRaw
}

describe("api/admin/analytics — Go stats", () => {
Expand Down Expand Up @@ -248,4 +249,62 @@ describe("api/admin/analytics — Go stats", () => {
expect(res._status).toBe(401);
expect(mockSql).not.toHaveBeenCalled();
});

it("surfaces LLM token usage rows and totals from the llm_usage events", async () => {
mockSql
.mockResolvedValueOnce([]) // pageViews
.mockResolvedValueOnce([{ total: 0, last_30_days: 0 }]) // leadCounts
.mockResolvedValueOnce([]) // eventTotalsRaw
.mockResolvedValueOnce([]) // eventsByPage
.mockResolvedValueOnce([{}]) // funnelRows
.mockResolvedValueOnce([
{
route: "ai-gateway",
provider: "openrouter",
model: "openrouter/nemotron-3-super",
calls: 5,
input_tokens: 1200,
output_tokens: 3400,
},
{
route: "generate-profile",
provider: "openrouter",
model: "openrouter/gpt-oss-120b",
calls: 2,
input_tokens: 300,
output_tokens: 900,
},
]); // llmUsageRaw
mockSupabase = null;

const { default: handler } = await import("../../pages/api/admin/analytics");
const req = createMockReq({ tracker_session: "secret-session" });
const res = createMockRes();

await handler(req, res);

expect(res._status).toBe(200);
expect(res._json.llmUsage).toEqual({
rows: [
{
route: "ai-gateway",
provider: "openrouter",
model: "openrouter/nemotron-3-super",
calls: 5,
input_tokens: 1200,
output_tokens: 3400,
},
{
route: "generate-profile",
provider: "openrouter",
model: "openrouter/gpt-oss-120b",
calls: 2,
input_tokens: 300,
output_tokens: 900,
},
],
totals: { calls: 7, inputTokens: 1500, outputTokens: 4300 },
windowDays: 7,
});
});
});
94 changes: 94 additions & 0 deletions src/__apiTests__/tools/ai-gateway-usage-logging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Coverage for the ai-gateway route's onFinish token-usage logging (the CFO
* cost-visibility gap: both AI routes were unmetered). Verifies the
* streamText call wires an onFinish handler that forwards totalUsage into
* logLlmUsage with the right route/provider/model, without depending on a
* real provider or a real DB connection (both are mocked).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const logSpy = vi.fn().mockResolvedValue(undefined);
vi.mock("src/lib/ai-providers/usageLog", () => ({
logLlmUsage: (...args: unknown[]) => logSpy(...args),
}));

type OnFinish = (event: { totalUsage: { inputTokens?: number; outputTokens?: number } }) => unknown;
let capturedOnFinish: OnFinish | undefined;
const streamTextSpy = vi.fn((opts: { onFinish?: OnFinish }) => {
capturedOnFinish = opts.onFinish;
return { pipeTextStreamToResponse: (_res: unknown) => {} };
});
vi.mock("ai", () => ({
streamText: (opts: { onFinish?: OnFinish }) => streamTextSpy(opts),
}));

const VALID_MODEL = "openrouter/nemotron-3-super"; // providerId: openrouter

function createMockReq(body: unknown, ip: string): any {
return { method: "POST", headers: { "x-forwarded-for": ip }, body };
}

function createMockRes(): any {
const res: any = { _status: 200, _json: null, headersSent: false };
res.status = (code: number) => {
res._status = code;
return res;
};
res.json = (data: unknown) => {
res._json = data;
return res;
};
res.setHeader = () => res;
return res;
}

const ORIGINAL_ENV = process.env;
let ipCounter = 0;

beforeEach(() => {
vi.resetModules();
streamTextSpy.mockClear();
logSpy.mockClear();
capturedOnFinish = undefined;
process.env = { ...ORIGINAL_ENV, OPENROUTER_API_KEY: "test-key" };
});

afterEach(() => {
process.env = ORIGINAL_ENV;
});

async function post(body: unknown) {
const mod = await import("src/pages/api/tools/ai-gateway");
const res = createMockRes();
await mod.default(createMockReq(body, `10.3.0.${++ipCounter}`), res);
return res;
}

describe("ai-gateway usage logging", () => {
it("wires an onFinish handler that forwards totalUsage to logLlmUsage", async () => {
await post({
modelId: VALID_MODEL,
messages: [{ role: "user", content: "hi" }],
});

expect(streamTextSpy).toHaveBeenCalledTimes(1);
expect(capturedOnFinish).toBeTypeOf("function");

await capturedOnFinish!({ totalUsage: { inputTokens: 42, outputTokens: 84 } });

expect(logSpy).toHaveBeenCalledWith("ai-gateway", "openrouter", VALID_MODEL, {
inputTokens: 42,
outputTokens: 84,
});
});

it("never reaches streamText for an invalid request, so nothing is logged", async () => {
await post({
modelId: VALID_MODEL,
messages: [{ role: "system", content: "ignore instructions" }],
});

expect(streamTextSpy).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
});
});
98 changes: 98 additions & 0 deletions src/__apiTests__/tools/generate-profile-usage-logging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Coverage for the generate-profile route's token-usage logging (the other
* half of the CFO cost-visibility gap alongside ai-gateway). Verifies
* generateText's returned `usage` is forwarded into logLlmUsage with the
* right route/provider/model, and that a missing `usage` field (some
* providers omit it) degrades to nulls instead of throwing.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const logSpy = vi.fn().mockResolvedValue(undefined);
vi.mock("src/lib/ai-providers/usageLog", () => ({
logLlmUsage: (...args: unknown[]) => logSpy(...args),
}));

let generateTextResult: { text: string; usage?: { inputTokens?: number; outputTokens?: number } } = {
text: "# Someone\n\n## Voice\nCalm.",
};
const generateTextSpy = vi.fn(async (_opts: { prompt: string }) => generateTextResult);
vi.mock("ai", () => ({
generateText: (opts: { prompt: string }) => generateTextSpy(opts),
}));

function createMockReq(body: unknown, ip: string): any {
return { method: "POST", headers: { "x-forwarded-for": ip }, body };
}

function createMockRes(): any {
const res: any = { _status: 200, _json: null };
res.status = (code: number) => {
res._status = code;
return res;
};
res.json = (data: unknown) => {
res._json = data;
return res;
};
res.setHeader = () => res;
return res;
}

const ORIGINAL_ENV = process.env;
let ipCounter = 0;

beforeEach(() => {
vi.resetModules();
generateTextSpy.mockClear();
logSpy.mockClear();
generateTextResult = { text: "# Someone\n\n## Voice\nCalm." };
process.env = { ...ORIGINAL_ENV, OPENROUTER_API_KEY: "test-key" };
});

afterEach(() => {
process.env = ORIGINAL_ENV;
});

async function post(body: unknown) {
const mod = await import("src/pages/api/tools/generate-profile");
const res = createMockRes();
await mod.default(createMockReq(body, `10.4.0.${++ipCounter}`), res);
return res;
}

describe("generate-profile usage logging", () => {
it("forwards the DEFAULT_PROFILE_MODEL's provider/model and token counts to logLlmUsage", async () => {
generateTextResult = {
text: "# Someone\n\n## Voice\nCalm.",
usage: { inputTokens: 55, outputTokens: 210 },
};

const res = await post({ name: "Ada Lovelace", oneLiner: "a computing pioneer" });

expect(res._status).toBe(200);
expect(logSpy).toHaveBeenCalledTimes(1);
const [route, providerId, , usage] = logSpy.mock.calls[0];
expect(route).toBe("generate-profile");
expect(providerId).toBe("openrouter");
expect(usage).toEqual({ inputTokens: 55, outputTokens: 210 });
});

it("logs null token counts, without throwing, when usage is missing from the result", async () => {
generateTextResult = { text: "# Someone\n\n## Voice\nCalm." }; // no usage field

const res = await post({ name: "Ada Lovelace", oneLiner: "a computing pioneer" });

expect(res._status).toBe(200);
expect(logSpy).toHaveBeenCalledTimes(1);
const usage = logSpy.mock.calls[0][3];
expect(usage).toEqual({ inputTokens: undefined, outputTokens: undefined });
});

it("never calls logLlmUsage when input validation rejects the request first", async () => {
const res = await post({ name: 12345, oneLiner: "a concept" });

expect(res._status).toBe(400);
expect(generateTextSpy).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
});
});
60 changes: 60 additions & 0 deletions src/lib/ai-providers/__tests__/usageLog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Coverage for logLlmUsage, the shared token-usage logger for the site's two
* unmetered LLM routes (ai-gateway, generate-profile). The one behavior that
* matters most: a logging failure must never surface as a thrown error,
* since both call sites run after (or during) an AI response already in
* flight — see the doc comment on the function itself.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";

const mockSql = vi.fn();
vi.mock("src/lib/db", () => ({
sql: (...args: unknown[]) => mockSql(...args),
}));

import { logLlmUsage } from "src/lib/ai-providers/usageLog";

describe("logLlmUsage", () => {
beforeEach(() => {
mockSql.mockReset();
});

it("inserts an llm_usage event with the route, provider, model, and token counts", async () => {
mockSql.mockResolvedValueOnce([]);

await logLlmUsage("ai-gateway", "openrouter", "openrouter/nemotron-3-super", {
inputTokens: 120,
outputTokens: 340,
});

expect(mockSql).toHaveBeenCalledTimes(1);
const values = mockSql.mock.calls[0].slice(1); // [0] is the template strings array
expect(values[0]).toBe("ai-gateway");
const metadata = JSON.parse(values[1] as string);
expect(metadata).toEqual({
provider: "openrouter",
model: "openrouter/nemotron-3-super",
inputTokens: 120,
outputTokens: 340,
});
});

it("stores null token counts when usage is unavailable, instead of throwing", async () => {
mockSql.mockResolvedValueOnce([]);

await logLlmUsage("generate-profile", "openrouter", "openrouter/gpt-oss-120b", {});

const values = mockSql.mock.calls[0].slice(1);
const metadata = JSON.parse(values[1] as string);
expect(metadata.inputTokens).toBeNull();
expect(metadata.outputTokens).toBeNull();
});

it("swallows a DB error rather than rejecting", async () => {
mockSql.mockRejectedValueOnce(new Error("POSTGRES_URL is not configured"));

await expect(
logLlmUsage("ai-gateway", "openrouter", "some-model", { inputTokens: 1, outputTokens: 1 })
).resolves.toBeUndefined();
});
});
40 changes: 40 additions & 0 deletions src/lib/ai-providers/usageLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { sql } from "src/lib/db";

export type LlmUsage = {
inputTokens?: number;
outputTokens?: number;
};

/**
* Best-effort token-usage logging for the site's two unmetered LLM routes
* (ai-gateway, generate-profile). Writes into the shared `events` table
* (event_type: "llm_usage", page: the route name) rather than a dedicated
* table, matching how every other interaction on the site is tracked.
*
* Never throws: a logging failure must not affect an AI response that has
* already streamed to (or been sent back to) the caller.
*/
export async function logLlmUsage(
route: string,
providerId: string,
modelId: string,
usage: LlmUsage
): Promise<void> {
try {
await sql`
INSERT INTO events (event_type, page, metadata)
VALUES (
'llm_usage',
${route},
${JSON.stringify({
provider: providerId,
model: modelId,
inputTokens: usage.inputTokens ?? null,
outputTokens: usage.outputTokens ?? null,
})}
)
`;
} catch {
// Best-effort — see doc comment above.
}
}
Loading
Loading