diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts index e983939cb..8fec8e4e0 100644 --- a/src/app/api/ai/test/__tests__/route.test.ts +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -24,6 +24,17 @@ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(async () => ({ allowed: true })), })); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: () => "2026-01-01", + reserveBudget: vi.fn(async () => ({ + allowed: true, + reserved: 32, + totalAfter: 32, + })), + reconcileSpend: vi.fn(async () => undefined), + resolveDailyCap: () => 200_000, +})); + vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 14718f4b7..cd7e8a12b 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -10,6 +10,32 @@ import { singleUserTurn } from "@/lib/ai/types"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; +import { + buildDateKey, + reconcileSpend, + reserveBudget, + resolveDailyCap, +} from "@/lib/ai/coach/budget"; + +/** + * Output ceiling of the probe below. Mirrors the `maxTokens` on the completion + * so the ledger reserves what the call can actually spend. + */ +const AI_TEST_MAX_TOKENS = 32; + +/** + * Daily ceiling on connection tests per user. + * + * The 5/min bucket alone bounded burst rate but nothing cumulative: sustained, + * it permits ~7 200 probes/day, and with an empty body this route resolves the + * SAME chain generation uses — which can be the operator's own credential. That + * is unmetered operator spend on a surface that exists to answer "are my + * settings right?". A person checks that a handful of times after editing a + * provider; 50 is far above real use and far below anything that matters on the + * bill. + */ +const AI_TEST_DAILY_LIMIT = 50; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; export const dynamic = "force-dynamic"; @@ -34,6 +60,17 @@ export const POST = apiHandler(async (request: NextRequest) => { const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000); if (!rl.allowed) return apiError("Too many test requests", 429); + // Cumulative ceiling on top of the burst bucket — see AI_TEST_DAILY_LIMIT. + const daily = await checkRateLimit( + `ai-test-daily:${user.id}`, + AI_TEST_DAILY_LIMIT, + ONE_DAY_MS, + ); + if (!daily.allowed) { + annotate({ action: { name: "ai.test.daily_limit" } }); + return apiError("Too many test requests today", 429); + } + // Body is optional. Empty body → behaves like before (test the saved // config). Non-empty body → tests the unsaved selection without // mutating the user row. Plaintext keys never persist. @@ -65,19 +102,47 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("No AI provider configured", 422); } + // Meter the probe on the same daily ledger every other AI surface writes to, + // so operator-key spend from this route is visible rather than invisible. + // `admin-key` is the operator's own credential and draws the operator + // ceiling; an override names the caller's own key, and every other resolved + // type is the user's own egress, so those draw the user-plan ceiling. + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + user.id, + AI_TEST_MAX_TOKENS, + dateKey, + resolveDailyCap([ + { + providerType: provider.type === "admin-key" ? "admin-openai" : "local", + }, + ]), + ); + if (!reservation.allowed) { + annotate({ action: { name: "ai.test.budget_exceeded" } }); + return apiError("Daily AI budget exhausted", 429); + } + try { const result = await provider.generateCompletion( singleUserTurn({ system: "You are a connection-test responder.", user: 'Reply with the JSON object {"ok": true} and nothing else.', temperature: 0, - maxTokens: 32, + maxTokens: AI_TEST_MAX_TOKENS, // The probe asks for a JSON object — keep the OpenAI / Codex strict // JSON mode it relied on before the response_format gate landed. responseFormat: "json", }), ); + await reconcileSpend( + user.id, + reservation.reserved, + result.tokensUsed ?? reservation.reserved, + dateKey, + ); + return apiSuccess({ ok: true, providerType: result.providerType, @@ -91,6 +156,10 @@ export const POST = apiHandler(async (request: NextRequest) => { // Log full details server-side for the operator and respond with a // categorised, generic reason. const err = e as Error & { httpStatus?: number; bodyExcerpt?: string }; + // A failed probe produced no reported usage — refund the reservation so a + // provider that is simply misconfigured doesn't burn the caller's ledger + // while they fix it. + await reconcileSpend(user.id, reservation.reserved, 0, dateKey); annotate({ meta: { ai_test_error: err.message.slice(0, 500), diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index ed809c26d..0ef841abd 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -503,15 +503,33 @@ async function handleChatRequest(request: NextRequest): Promise { // figures stay in-context without re-paying for them. We therefore include // the full block only when: // - this is the first turn (no prior turns on disk), OR - // - the history window has begun eliding turns (`allTurns.length > TURN_CAP`) - // — once the oldest turns are folded into the rolling summary the original - // snapshot may have scrolled out of the verbatim window, so we re-ground. + // - the history window has JUST begun eliding turns — once the oldest turns + // are folded into the rolling summary the original snapshot may have + // scrolled out of the verbatim window, so we re-ground exactly once at + // that boundary. // On the cheap path (a follow-up inside the verbatim window) we send a short // pointer instead of the figures, preserving grounding + cross-metric // correlation quality at a fraction of the wire cost. + // + // The elision test is a narrow WINDOW, not the open-ended `allTurns.length > + // TURN_CAP` it replaced. That predicate is true for every turn past the cap, + // not just the one that crosses it, so a long conversation re-shipped the + // whole ~15k-token snapshot on turn 21, 22, 23 … — reinstating the exact + // per-turn cost this design removed, and paying it on the LONGEST + // conversations. A 60-turn conversation paid it ~40 times instead of twice. + // + // Width 2 rather than an exact `=== TURN_CAP` equality, because the turn + // count does not advance in fixed steps: the user turn is persisted before + // the provider call, the assistant turn only on success, so a failed turn + // advances the count by 1 and a successful one by 2. An exact-equality check + // would be stepped straight over by a conversation that ever lost a reply, + // and would then NEVER re-ground. A width-2 window cannot be stepped over by + // steps of 1 or 2, so the crossing is always caught, and it can match at most + // twice — bounded either way. const isFirstTurn = priorTurns.length === 0; - const historyEliding = allTurns.length > TURN_CAP; - const includeFullSnapshot = isFirstTurn || historyEliding; + const historyElisionCrossing = + priorTurns.length >= TURN_CAP && priorTurns.length <= TURN_CAP + 1; + const includeFullSnapshot = isFirstTurn || historyElisionCrossing; const transcript = window .map((t) => `${t.role.toUpperCase()}: ${t.content}`) .join("\n\n"); diff --git a/src/lib/ai/coach/coach-memory-refresh-worker.ts b/src/lib/ai/coach/coach-memory-refresh-worker.ts index 3867844f8..c303e49f0 100644 --- a/src/lib/ai/coach/coach-memory-refresh-worker.ts +++ b/src/lib/ai/coach/coach-memory-refresh-worker.ts @@ -2,11 +2,18 @@ * v1.11.1 — worker pipeline for the combined Coach memory-refresh queue. * * Runs both background generators for one conversation, sequentially so they - * share the wake-up but each pays its own budget check inside - * `runStatusCompletion`: the rolling conversation summary first, then durable - * fact extraction. Each step is fault-isolated — a failure or no-provider in - * one never sinks the other or the job. Kept out of the route bundle (the - * route imports only `enqueueCoachMemoryRefresh` from `coach-memory-shared`). + * share the wake-up: the rolling conversation summary first, then durable fact + * extraction. Each step is fault-isolated — a failure or no-provider in one + * never sinks the other or the job. Kept out of the route bundle (the route + * imports only `enqueueCoachMemoryRefresh` from `coach-memory-shared`). + * + * Both steps reserve and reconcile against the caller's daily token ledger + * inside `runStatusCompletion`, so this off-request work is metered on the same + * `coach_usage` row as the chat turn that triggered it, against the ceiling + * that matches the chain's cost owner. This comment previously asserted the + * same guarantee while no such accounting existed anywhere on the path — the + * chokepoint did no budget work at all, so every generator behind it, not just + * these two, spent unmetered. */ import { annotate } from "@/lib/logging/context"; diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 107a48466..8f36e8a1d 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -10,6 +10,12 @@ import { type ConsentSurface, } from "@/lib/ai/consent-guard"; import { annotate } from "@/lib/logging/context"; +import { + buildDateKey, + reconcileSpend, + reserveBudget, + resolveDailyCap, +} from "@/lib/ai/coach/budget"; import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets"; import { singleUserTurn } from "@/lib/ai/types"; import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout"; @@ -189,6 +195,49 @@ export async function runStatusCompletion( STATUS_PROVIDER_TIMEOUT_MS, ); + const maxTokens = args.maxTokens ?? AI_BUDGETS.status.maxTokens; + + // The day's token ledger. Until now this chokepoint — the provider entry for + // EVERY status/reference family (the specialised cards, the generic metric + // cards, biomarker cards, the batched assessment, the derived scores, the + // period narrative, and the off-request Coach memory workers: rolling + // summary, fact extraction, plan proposals) — ran with no accounting at all, + // so none of that spend appeared in `coach_usage` and no ceiling applied. + // + // The reservation is atomic (single upsert-increment), matching the Coach and + // document paths: a read-then-write check would let concurrent generations + // each observe a sub-cap total and all proceed. We reserve an ESTIMATE up + // front — the output ceiling plus a ~4-chars-per-token approximation of the + // prompt we are about to send — and reconcile against the provider's reported + // count afterwards, refunding in full when nothing was generated. + // + // The cap follows the COST OWNER, not the surface: `resolveDailyCap` charges + // the operator ceiling only when the chain's primary is the operator's own + // credential (`admin-openai` / `admin-codex`). A self-hoster on their own key + // or a local model is measured against the generous user-plan ceiling, so + // their own hardware/plan is never rationed by the operator's bill. + const dateKey = buildDateKey(); + const estimatedTokens = + maxTokens + Math.ceil((systemPrompt.length + userPrompt.length) / 4); + const reservation = await reserveBudget( + userId, + estimatedTokens, + dateKey, + resolveDailyCap(chain), + ); + if (!reservation.allowed) { + // Over the day's ceiling. Reported as `error` — a TRANSIENT miss the caller + // serves the fallback for without persisting it — deliberately NOT `none`, + // which callers cache as the settled "no provider configured" assessment. + // The distinct annotation keeps the refusal observable even though the + // result shape is shared. + annotate({ + action: { name: "insights.status.budget_exceeded" }, + meta: { cacheAction, totalAfter: reservation.totalAfter }, + }); + return { kind: "error" }; + } + const raced = await withTimeout( () => runRawCompletionWithFallback({ @@ -198,7 +247,7 @@ export async function runStatusCompletion( system: systemPrompt, user: userPrompt, temperature: args.temperature ?? AI_BUDGETS.status.temperature, - maxTokens: args.maxTokens ?? AI_BUDGETS.status.maxTokens, + maxTokens, // v1.18.7 — status/reference output is reproducible: pin the // deterministic seed unless a caller overrides it. seed: args.seed ?? REFERENCE_AI_SEED, @@ -213,13 +262,26 @@ export async function runStatusCompletion( ); if (raced.timedOut) { + // A timed-out generation may still have burned upstream tokens, but we have + // no reported count to charge — refund the reservation rather than bill an + // invented figure. + await reconcileSpend(userId, reservation.reserved, 0, dateKey); return { kind: "timeout" }; } if (raced.errored || raced.value === null) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); return { kind: "error" }; } const { result, workingProvider } = raced.value; + // Reconcile against what the provider actually reported. This runs for the + // empty-content branch too: those tokens were burned upstream even though the + // reply was unusable, so they stay on the ledger rather than being refunded + // into a free retry loop. Falls back to the reservation when the provider + // reports no count, so an unreported generation is never billed as zero. + const actualTokens = result.tokensUsed ?? reservation.reserved; + await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey); + const content = result.content; if (typeof content !== "string" || content.trim().length === 0) { annotate({ diff --git a/src/lib/jobs/__tests__/insight-status-generate.test.ts b/src/lib/jobs/__tests__/insight-status-generate.test.ts index f536ec70c..fe4a9b116 100644 --- a/src/lib/jobs/__tests__/insight-status-generate.test.ts +++ b/src/lib/jobs/__tests__/insight-status-generate.test.ts @@ -108,7 +108,7 @@ describe("enqueueStatusGeneration", () => { ).resolves.toBeUndefined(); }); - it("sends with a per-(user,metric,locale) singletonKey when boss exists", async () => { + it("sends with a per-(user,metric) singletonKey when boss exists", async () => { const send = vi.fn().mockResolvedValue("job-id"); getGlobalBoss.mockReturnValue({ send }); await enqueueStatusGeneration({ @@ -119,10 +119,34 @@ describe("enqueueStatusGeneration", () => { expect(send).toHaveBeenCalledWith( INSIGHT_STATUS_GENERATE_QUEUE, { userId: "u1", metric: "mood", locale: "en" }, - expect.objectContaining({ singletonKey: "u1:mood:en" }), + expect.objectContaining({ singletonKey: "u1:mood" }), ); }); + /** + * Cost guard. The locale on a status read is client-chosen (`?locale=`, or + * the `healthlog-locale` cookie), so when it was part of the singleton key + * each of the six supported locales opened its own de-dupe slot and one + * legitimate generation pass could be multiplied into six. The key must + * collapse them: same user, same metric, one queued generation regardless of + * how many locales ask. + */ + it("collapses every locale for one metric onto a single singletonKey", async () => { + const send = vi.fn().mockResolvedValue("job-id"); + getGlobalBoss.mockReturnValue({ send }); + + for (const locale of ["de", "en", "fr", "es", "it", "pl"] as const) { + await enqueueStatusGeneration({ userId: "u1", metric: "mood", locale }); + } + + const keys = new Set( + send.mock.calls.map( + (call) => (call[2] as { singletonKey: string }).singletonKey, + ), + ); + expect(keys).toEqual(new Set(["u1:mood"])); + }); + it("swallows a boss.send rejection (best-effort enqueue)", async () => { const send = vi.fn().mockRejectedValue(new Error("boom")); getGlobalBoss.mockReturnValue({ send }); diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index b1b4ae5e5..0228bcf4f 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -95,12 +95,29 @@ export interface InsightStatusGeneratePayload { /** * Fire-and-forget enqueue used by the read-only status route on a cache - * miss. A `singletonKey` per `(user, metric, locale)` collapses repeated - * polls into one queued job, so a client polling every few seconds while - * the provider works can't pile up duplicate generations. No-ops cleanly - * when the global boss instance is not available (e.g. the web process - * runs without an embedded worker) — the nightly pre-generate cron and - * the daily status crons remain the catch-net. + * miss. A `singletonKey` per `(user, metric)` collapses repeated polls into + * one queued job, so a client polling every few seconds while the provider + * works can't pile up duplicate generations. No-ops cleanly when the global + * boss instance is not available (e.g. the web process runs without an + * embedded worker) — the nightly pre-generate cron and the daily status + * crons remain the catch-net. + * + * The key deliberately EXCLUDES `locale`. It used to be + * `(user, metric, locale)`, and the locale on a status read is client-chosen + * — the `?locale=` query param, or the `healthlog-locale` cookie, both of + * which a caller sets freely. Each distinct locale therefore opened its own + * singleton slot, so walking the metric registry across the six supported + * locales multiplied one legitimate generation pass into six, none of which + * the de-dupe could collapse. Keying on `(user, metric)` makes the locale a + * property of the queued payload instead of part of its identity: one + * generation per metric per window, whichever locale asked first. + * + * A legitimate language switch still works. The cache stays keyed by + * `(user, metric, locale)`, so the new locale is a genuine cache miss and + * enqueues normally; the only change is that a switch made WHILE a generation + * for the same metric is already in flight waits out the 120 s window instead + * of racing a second one. The next poll re-enqueues and the card converges to + * the requested language. */ export async function enqueueStatusGeneration( payload: InsightStatusGeneratePayload, @@ -115,7 +132,7 @@ export async function enqueueStatusGeneration( } try { await boss.send(INSIGHT_STATUS_GENERATE_QUEUE, payload, { - singletonKey: `${payload.userId}:${payload.metric}:${payload.locale}`, + singletonKey: `${payload.userId}:${payload.metric}`, // De-dupe within a short window so a polling client doesn't enqueue // a fresh job on every tick while a generation is already running. singletonSeconds: 120,