From 91b64f8f6c5a3524494d128e065da4418a737778 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 15:55:44 +0000 Subject: [PATCH] feat(analytics): log LLM token usage on ai-gateway + generate-profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both routes have been unmetered since launch — the CFO cost-visibility gap first flagged 08-28 and re-flagged 09-04 with no engineering path forward. logLlmUsage() writes provider/model/token counts into the existing events table (event_type: llm_usage) after each call; a new llm_usage query in admin/analytics.ts surfaces 7-day totals and a per-route/provider/model breakdown as a new section on /admin/analytics. Logging is best-effort and never affects the AI response in flight or already sent. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UgHaXtpMZHY1pi3sGYv6zz --- src/__apiTests__/admin/analytics-auth.test.ts | 7 +- src/__apiTests__/admin/analytics.test.ts | 65 +++++++++- .../tools/ai-gateway-usage-logging.test.ts | 94 ++++++++++++++ .../generate-profile-usage-logging.test.ts | 98 +++++++++++++++ .../ai-providers/__tests__/usageLog.test.ts | 60 +++++++++ src/lib/ai-providers/usageLog.ts | 40 ++++++ src/pages/admin/analytics.tsx | 115 ++++++++++++++++++ src/pages/api/admin/analytics.ts | 40 +++++- src/pages/api/tools/ai-gateway.ts | 6 + src/pages/api/tools/generate-profile.ts | 8 +- 10 files changed, 525 insertions(+), 8 deletions(-) create mode 100644 src/__apiTests__/tools/ai-gateway-usage-logging.test.ts create mode 100644 src/__apiTests__/tools/generate-profile-usage-logging.test.ts create mode 100644 src/lib/ai-providers/__tests__/usageLog.test.ts create mode 100644 src/lib/ai-providers/usageLog.ts diff --git a/src/__apiTests__/admin/analytics-auth.test.ts b/src/__apiTests__/admin/analytics-auth.test.ts index 73c4bf6..240e694 100644 --- a/src/__apiTests__/admin/analytics-auth.test.ts +++ b/src/__apiTests__/admin/analytics-auth.test.ts @@ -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", () => { diff --git a/src/__apiTests__/admin/analytics.test.ts b/src/__apiTests__/admin/analytics.test.ts index cdf288b..5fa5913 100644 --- a/src/__apiTests__/admin/analytics.test.ts +++ b/src/__apiTests__/admin/analytics.test.ts @@ -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", () => { @@ -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, + }); + }); }); diff --git a/src/__apiTests__/tools/ai-gateway-usage-logging.test.ts b/src/__apiTests__/tools/ai-gateway-usage-logging.test.ts new file mode 100644 index 0000000..b214b93 --- /dev/null +++ b/src/__apiTests__/tools/ai-gateway-usage-logging.test.ts @@ -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(); + }); +}); diff --git a/src/__apiTests__/tools/generate-profile-usage-logging.test.ts b/src/__apiTests__/tools/generate-profile-usage-logging.test.ts new file mode 100644 index 0000000..d234041 --- /dev/null +++ b/src/__apiTests__/tools/generate-profile-usage-logging.test.ts @@ -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(); + }); +}); diff --git a/src/lib/ai-providers/__tests__/usageLog.test.ts b/src/lib/ai-providers/__tests__/usageLog.test.ts new file mode 100644 index 0000000..c1828f9 --- /dev/null +++ b/src/lib/ai-providers/__tests__/usageLog.test.ts @@ -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(); + }); +}); diff --git a/src/lib/ai-providers/usageLog.ts b/src/lib/ai-providers/usageLog.ts new file mode 100644 index 0000000..a8b9ef6 --- /dev/null +++ b/src/lib/ai-providers/usageLog.ts @@ -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 { + 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. + } +} diff --git a/src/pages/admin/analytics.tsx b/src/pages/admin/analytics.tsx index 4ccae67..0f36494 100644 --- a/src/pages/admin/analytics.tsx +++ b/src/pages/admin/analytics.tsx @@ -49,6 +49,21 @@ type SupabaseStats = { puzzleCalibration?: { referenceElo: number; worst: CalibrationRow[] }; }; +type LlmUsageRow = { + route: string; + provider: string | null; + model: string | null; + calls: number; + input_tokens: number; + output_tokens: number; +}; + +type LlmUsage = { + rows: LlmUsageRow[]; + totals: { calls: number; inputTokens: number; outputTokens: number }; + windowDays: number; +}; + type AnalyticsResponse = { pageViews: PageViewRow[]; leads: { total: number; last_30_days: number }; @@ -57,6 +72,7 @@ type AnalyticsResponse = { funnel?: FunnelStep[]; supabaseStats?: SupabaseStats | null; priorityEvents?: string[]; + llmUsage?: LlmUsage; _meta?: { windowDays?: number }; }; @@ -563,6 +579,105 @@ export default function AdminAnalyticsPage() { )} )} + + {data?.llmUsage && ( +
+

+ LLM usage ({data.llmUsage.windowDays}d) +

+

+ Token volume on the site's two unmetered AI routes — + Chat Sandbox / Model Arena (ai-gateway) + and the Chat Sandbox character generator ( + generate-profile). Token + counts only, not dollars — pricing varies per provider/model + and both routes let the caller pick either. +

+ + {data.llmUsage.rows.length === 0 ? ( +
+ No LLM usage logged in the last {data.llmUsage.windowDays}{" "} + days yet. +
+ ) : ( + <> +
+
+
+ Calls +
+
+ {data.llmUsage.totals.calls.toLocaleString()} +
+
+
+
+ Input tokens +
+
+ {data.llmUsage.totals.inputTokens.toLocaleString()} +
+
+
+
+ Output tokens +
+
+ {data.llmUsage.totals.outputTokens.toLocaleString()} +
+
+
+
+ + + + + + + + + + + + + {data.llmUsage.rows.map((row) => ( + + + + + + + + + ))} + +
RouteProviderModel + Calls + + Input tok. + + Output tok. +
+ {row.route} + + {row.provider ?? "—"} + + {row.model ?? "—"} + + {row.calls.toLocaleString()} + + {row.input_tokens.toLocaleString()} + + {row.output_tokens.toLocaleString()} +
+
+ + )} +
+ )} diff --git a/src/pages/api/admin/analytics.ts b/src/pages/api/admin/analytics.ts index af4b0dc..9827c6d 100644 --- a/src/pages/api/admin/analytics.ts +++ b/src/pages/api/admin/analytics.ts @@ -29,6 +29,15 @@ const PRIORITY_EVENTS = [ type EventTotalRow = { event_type: string; count: number; sessions: number }; +type LlmUsageRow = { + route: string; + provider: string | null; + model: string | null; + calls: number; + input_tokens: number; + output_tokens: number; +}; + type CalibrationRow = { id: string; rating: number; @@ -205,7 +214,7 @@ export default async function handler( } try { - const [pageViews, leadCounts, eventTotalsRaw, eventsByPage, funnelRows] = await Promise.all([ + const [pageViews, leadCounts, eventTotalsRaw, eventsByPage, funnelRows, llmUsageRaw] = await Promise.all([ sql` SELECT COALESCE(page, metadata->>'path', '(unknown)') AS path, @@ -263,6 +272,24 @@ export default async function handler( FROM events WHERE created_at > NOW() - INTERVAL '30 days' `, + // Token usage for the site's two unmetered LLM routes (ai-gateway, + // generate-profile), logged by src/lib/ai-providers/usageLog.ts. + // Token counts only — no dollar conversion, since pricing varies per + // provider/model and both routes let the caller pick either. + sql` + SELECT + COALESCE(page, '(unknown)') AS route, + metadata->>'provider' AS provider, + metadata->>'model' AS model, + COUNT(*)::int AS calls, + COALESCE(SUM((metadata->>'inputTokens')::int), 0)::int AS input_tokens, + COALESCE(SUM((metadata->>'outputTokens')::int), 0)::int AS output_tokens + FROM events + WHERE event_type = 'llm_usage' + AND created_at > NOW() - INTERVAL '7 days' + GROUP BY 1, 2, 3 + ORDER BY (input_tokens + output_tokens) DESC + `, ]); // Guarantee the monetization-signal events appear even at zero, then float @@ -298,6 +325,16 @@ export default async function handler( // so it can't throw the request into the 503 catch below. const supabaseStats = await readSupabaseStats(); + const llmUsage = llmUsageRaw as LlmUsageRow[]; + const llmUsageTotals = llmUsage.reduce( + (acc, row) => ({ + calls: acc.calls + row.calls, + inputTokens: acc.inputTokens + row.input_tokens, + outputTokens: acc.outputTokens + row.output_tokens, + }), + { calls: 0, inputTokens: 0, outputTokens: 0 } + ); + return res.status(200).json({ pageViews, leads: leadCounts[0] ?? { total: 0, last_30_days: 0 }, @@ -306,6 +343,7 @@ export default async function handler( funnel, supabaseStats, priorityEvents: PRIORITY_EVENTS, + llmUsage: { rows: llmUsage, totals: llmUsageTotals, windowDays: 7 }, _meta: { windowDays: 30 }, }); } catch (e: unknown) { diff --git a/src/pages/api/tools/ai-gateway.ts b/src/pages/api/tools/ai-gateway.ts index daa339a..549e094 100644 --- a/src/pages/api/tools/ai-gateway.ts +++ b/src/pages/api/tools/ai-gateway.ts @@ -11,6 +11,7 @@ import { sanitizeAssistantMessage, } from "src/lib/ai-providers/sanitize"; import { createRateLimiter } from "src/lib/rate-limit"; +import { logLlmUsage } from "src/lib/ai-providers/usageLog"; const limiter = createRateLimiter(30, 60 * 60 * 1000); // 30 per hour @@ -54,6 +55,11 @@ async function tryStream( messages, maxOutputTokens: maxTokens, temperature, + onFinish: ({ totalUsage }) => + logLlmUsage("ai-gateway", provider.id, model.id, { + inputTokens: totalUsage.inputTokens, + outputTokens: totalUsage.outputTokens, + }), }); result.pipeTextStreamToResponse(res); return true; diff --git a/src/pages/api/tools/generate-profile.ts b/src/pages/api/tools/generate-profile.ts index 2ba1d0f..b90a33d 100644 --- a/src/pages/api/tools/generate-profile.ts +++ b/src/pages/api/tools/generate-profile.ts @@ -8,6 +8,7 @@ import { getProvider } from "src/lib/ai-providers/providers"; import { resolveKey } from "src/lib/ai-providers/keys"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createRateLimiter } from "src/lib/rate-limit"; +import { logLlmUsage } from "src/lib/ai-providers/usageLog"; const limiter = createRateLimiter(10, 60 * 60 * 1000); // 10 per hour @@ -58,13 +59,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) : createOpenAICompatible({ name: provider.id, baseURL: provider.baseUrl, apiKey }); try { - const { text } = await generateText({ + const { text, usage } = await generateText({ model: client(model.providerModelId), prompt: buildProfilePrompt(cleanName.cleaned, sanitized.cleaned), maxOutputTokens: 1500, temperature: 0.9, }); + await logLlmUsage("generate-profile", provider.id, model.id, { + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, + }); + const profileCheck = sanitizeProfile(text); if (!profileCheck.ok) { return res.status(500).json({ error: "Generated profile failed safety check. Try again." });