diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts index 8fec8e4e0..2cf725575 100644 --- a/src/app/api/ai/test/__tests__/route.test.ts +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -289,3 +289,72 @@ describe("POST /api/ai/test — dropdown-aware override", () => { expect(body.error).toBe("Anthropic API key not configured"); }); }); + +/** + * Cost ceiling. The 5/min bucket bounded burst rate but nothing cumulative — + * sustained, it permits ~7 200 probes a day, and with an empty body this route + * resolves the SAME provider chain generation uses, which can be the + * operator's own credential. So the surface could reach the operator's key + * indefinitely with no daily ceiling and no ledger entry at all. + */ +describe("POST /api/ai/test — daily ceiling + ledger", () => { + function okProvider() { + vi.mocked(resolveProviderForTest).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async () => ({ + content: '{"ok":true}', + providerType: "anthropic", + model: "claude-3-5-sonnet-latest", + tokensUsed: 5, + })), + } as never); + } + + it("refuses once the per-user daily probe limit is spent", async () => { + const { checkRateLimit } = await import("@/lib/rate-limit"); + okProvider(); + // Burst bucket fine, cumulative daily bucket exhausted. + vi.mocked(checkRateLimit) + .mockResolvedValueOnce({ allowed: true } as never) + .mockResolvedValueOnce({ allowed: false } as never); + + const response = await POST(emptyRequest() as never); + + expect(response.status).toBe(429); + // Refused before any provider egress. + expect(resolveProviderForTest).not.toHaveBeenCalled(); + }); + + it("refuses when the day's token budget is already exhausted", async () => { + const { reserveBudget } = await import("@/lib/ai/coach/budget"); + const generateCompletion = vi.fn(); + vi.mocked(resolveProviderForTest).mockResolvedValue({ + type: "admin-key", + generateCompletion, + } as never); + vi.mocked(reserveBudget).mockResolvedValueOnce({ + allowed: false, + reserved: 32, + totalAfter: 200_000, + } as never); + + const response = await POST(emptyRequest() as never); + + expect(response.status).toBe(429); + expect(generateCompletion).not.toHaveBeenCalled(); + }); + + it("records a successful probe on the ledger", async () => { + const { reconcileSpend } = await import("@/lib/ai/coach/budget"); + okProvider(); + + await POST(emptyRequest() as never); + + expect(reconcileSpend).toHaveBeenCalledWith( + "u-1", + 32, + expect.any(Number), + "2026-01-01", + ); + }); +}); diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index 39423d43b..d7f2c4299 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -220,3 +220,92 @@ describe("coach chat — snapshot sent once per conversation (C4)", () => { expect(prompt).toContain("Your BP looks stable."); }); }); + +/** + * The erosion guard. + * + * The re-ground condition used to be the open-ended `allTurns.length > + * TURN_CAP`, which is true for EVERY turn past the history cap — not just the + * one that crosses it. So a conversation that ran long re-shipped the full + * ~15k-token snapshot on turn 21, 22, 23, … permanently, reinstating exactly + * the per-turn cost the snapshot-once design removes, and paying it on the + * longest (most expensive) conversations. The intent was always to re-ground + * ONCE, at the boundary where the original snapshot scrolls out of the + * verbatim window. + */ +describe("coach chat — snapshot re-grounds at the elision boundary only", () => { + const TURN_CAP = 20; + + beforeEach(() => { + vi.clearAllMocks(); + runStreamingRawCompletionWithFallback.mockResolvedValue({ + result: { content: "Your BP looks stable.", tokensUsed: 42, model: "m" }, + workingProvider: { providerType: "admin-openai" }, + }); + }); + + /** Drive one turn against a conversation that already has `n` turns on disk. */ + async function turnWithPriorCount(n: number): Promise { + fetchConversationWithMessages.mockResolvedValue({ + id: "c1", + summary: "earlier summary", + messages: Array.from({ length: n }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: `turn ${i}`, + })), + }); + await postAndDrain({ conversationId: "c1", message: "next?" }); + return lastUserPrompt(); + } + + it("re-grounds on the turn that crosses the cap", async () => { + expect(await turnWithPriorCount(TURN_CAP)).toContain(SNAPSHOT_JSON); + }); + + it("does NOT re-ship the snapshot on every turn past the cap", async () => { + // Well past the boundary — these are the turns that used to pay the full + // snapshot cost forever. + for (const prior of [TURN_CAP + 2, TURN_CAP + 6, TURN_CAP + 20, 58]) { + expect(await turnWithPriorCount(prior)).not.toContain(SNAPSHOT_JSON); + } + }); + + it("ships the full snapshot only twice across a 60-turn conversation", async () => { + let shipped = 0; + const shippedAt: number[] = []; + + // Turn 1 starts a fresh conversation (no prior turns on disk); every later + // turn loads the growing history. A successful turn persists both the user + // and the assistant message, so the count advances by 2. + fetchConversationWithMessages.mockResolvedValue(null); + await postAndDrain({ message: "How is my BP?" }); + if (lastUserPrompt().includes(SNAPSHOT_JSON)) { + shipped++; + shippedAt.push(0); + } + + for (let prior = 2; prior <= 60; prior += 2) { + if ((await turnWithPriorCount(prior)).includes(SNAPSHOT_JSON)) { + shipped++; + shippedAt.push(prior); + } + } + + // Turn 1 (grounding) + the single crossing at the cap. Under the old + // predicate this was 1 + every turn from 20 to 60 — 21 full snapshots. + expect(shippedAt).toEqual([0, TURN_CAP]); + expect(shipped).toBe(2); + }); + + /** + * The turn count does not advance in fixed steps: the user message is + * persisted before the provider call, the assistant message only on success, + * so a turn that lost its reply advances the count by 1 instead of 2. An + * exact `=== TURN_CAP` equality would be stepped straight over by such a + * conversation and would then never re-ground at all. The window must catch + * the crossing from either parity. + */ + it("still re-grounds when a lost reply shifts the turn parity", async () => { + expect(await turnWithPriorCount(TURN_CAP + 1)).toContain(SNAPSHOT_JSON); + }); +}); diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts new file mode 100644 index 000000000..9e5bd760d --- /dev/null +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -0,0 +1,196 @@ +/** + * Cost accounting for the status/reference provider chokepoint. + * + * `runStatusCompletion` is the single provider entry for every status and + * reference generator — the specialised status cards, the generic metric + * cards, biomarker cards, the batched assessment, the derived scores, the + * period narrative, and the off-request Coach memory workers. It previously + * ran with NO budget accounting whatsoever: nothing reserved, nothing + * recorded, no ceiling. Everything behind it spent invisibly, and on an + * operator-key account that was unmetered operator spend. + * + * These tests drive the REAL budget module (`reserveBudget` / `reconcileSpend` + * / `resolveDailyCap`) against a stateful in-memory stand-in for the + * `coach_usage` row, so they exercise the actual atomic-upsert path rather + * than merely asserting that a mock was called. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** In-memory stand-in for the day's `coach_usage.total_tokens`. */ +let ledgerTotal = 0; + +vi.mock("@/lib/db", () => ({ + prisma: { + // `reserveBudget`'s atomic upsert-increment. Tagged-template call shape: + // (strings, userId, dateKey, reserved, reserved). + $queryRaw: vi.fn( + async (_strings: TemplateStringsArray, ...v: unknown[]) => { + ledgerTotal += Number(v[2] ?? 0); + return [{ total_tokens: ledgerTotal }]; + }, + ), + // `reconcileSpend` (+delta) and `refundReservation` (-reserved). + $executeRaw: vi.fn( + async (strings: TemplateStringsArray, ...v: unknown[]) => { + const sql = strings.join("?"); + const amount = Number(v[0] ?? 0); + if (sql.includes("total_tokens + ")) ledgerTotal += amount; + else if (sql.includes("total_tokens - ")) ledgerTotal -= amount; + ledgerTotal = Math.max(0, ledgerTotal); + return 1; + }, + ), + user: { + findUnique: vi.fn(async () => ({ aiResponseTimeoutSeconds: null })), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +const { resolveProviderChain, resolveProvider } = vi.hoisted(() => ({ + resolveProviderChain: vi.fn(), + resolveProvider: vi.fn(), +})); +vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain, resolveProvider })); + +const { runRawCompletionWithFallback } = vi.hoisted(() => ({ + runRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback, +})); + +// Consent gate is exercised by its own suite; keep it open here so the budget +// behaviour is what these tests isolate. +vi.mock("@/lib/ai/consent-guard", () => ({ + chainRequiresServerManagedConsent: vi.fn(() => false), + hasActiveConsentForSurface: vi.fn(async () => true), +})); + +import { runStatusCompletion } from "../status-provider"; +import { annotate } from "@/lib/logging/context"; +import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget"; + +const OPERATOR_CHAIN = [{ providerType: "admin-openai", instance: {} }]; +const BYOK_CHAIN = [{ providerType: "anthropic", instance: {} }]; + +function completionArgs(overrides: Record = {}) { + return { + userId: "u1", + cacheAction: "status:test", + systemPrompt: "system", + userPrompt: "user", + consentSurface: "insights" as const, + ...overrides, + }; +} + +/** A successful provider reply reporting `tokensUsed`. */ +function mockProviderReply(tokensUsed: number | null) { + runRawCompletionWithFallback.mockResolvedValue({ + result: { content: '{"summary":"ok"}', model: "m", tokensUsed }, + workingProvider: { providerType: "admin-openai" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + ledgerTotal = 0; + resolveProviderChain.mockResolvedValue(OPERATOR_CHAIN); + resolveProvider.mockResolvedValue({ type: "none" }); +}); + +describe("runStatusCompletion — ledger accounting", () => { + it("lands a successful generation on the day's ledger", async () => { + mockProviderReply(1234); + + const result = await runStatusCompletion(completionArgs()); + + expect(result.kind).toBe("ok"); + // Reserved an estimate, then reconciled down to what the provider + // actually reported. Either way the spend is now ON the ledger — before + // this wiring existed the total stayed at zero forever. + expect(ledgerTotal).toBe(1234); + }); + + it("charges the reservation when the provider reports no token count", async () => { + mockProviderReply(null); + + await runStatusCompletion(completionArgs()); + + // An unreported generation must not bill as free. + expect(ledgerTotal).toBeGreaterThan(0); + }); + + it("refuses a generation once the day's cap is already spent", async () => { + ledgerTotal = OPERATOR_COST_CAP; + mockProviderReply(500); + + const result = await runStatusCompletion(completionArgs()); + + // Refused BEFORE any provider egress, and reported as a transient miss + // (`error`) rather than `none` — callers cache `none` as the settled + // "no provider configured" assessment, which a budget refusal is not. + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(result.kind).toBe("error"); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "insights.status.budget_exceeded" }, + }), + ); + // The refused reservation was refunded, not left on the row. + expect(ledgerTotal).toBe(OPERATOR_COST_CAP); + }); + + it("refunds the reservation when the provider errors", async () => { + runRawCompletionWithFallback.mockRejectedValue(new Error("upstream down")); + + const result = await runStatusCompletion(completionArgs()); + + expect(result.kind).toBe("error"); + expect(ledgerTotal).toBe(0); + }); +}); + +describe("runStatusCompletion — cost owner decides the ceiling", () => { + it("measures an operator-key chain against the operator ceiling", async () => { + // Just under the operator ceiling: an operator-key user is refused here. + ledgerTotal = OPERATOR_COST_CAP; + mockProviderReply(100); + + const result = await runStatusCompletion(completionArgs()); + + expect(result.kind).toBe("error"); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + }); + + it("measures a BYOK chain against the user-plan ceiling, not the operator's", async () => { + // Same spend that locked the operator-key user out above. A self-hoster on + // their own key pays their own bill, so the operator's ceiling is a + // category error for them — they must still be served. + resolveProviderChain.mockResolvedValue(BYOK_CHAIN); + ledgerTotal = OPERATOR_COST_CAP; + runRawCompletionWithFallback.mockResolvedValue({ + result: { content: '{"summary":"ok"}', model: "m", tokensUsed: 100 }, + workingProvider: { providerType: "anthropic" }, + }); + + const result = await runStatusCompletion(completionArgs()); + + expect(result.kind).toBe("ok"); + expect(runRawCompletionWithFallback).toHaveBeenCalled(); + }); + + it("still bounds a BYOK chain at the user-plan ceiling", async () => { + resolveProviderChain.mockResolvedValue(BYOK_CHAIN); + ledgerTotal = USER_PLAN_CAP; + mockProviderReply(100); + + const result = await runStatusCompletion(completionArgs()); + + expect(result.kind).toBe("error"); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + }); +});