Cover the briefing tier's ledger accounting

Pin the four guarantees at the new chokepoint and at both call sites: spend
lands on the ledger, over-cap is refused before any egress, a BYOK chain is
measured against the user-plan ceiling rather than the operator's, and a
failed call refunds in full. Also pin the ordering the consent gate depends
on — a consent-blocked user is never charged.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 05:15:30 +02:00
1 parent 8a1d7502d0
commit 79922631ce
3 files changed
+492

No files matched your search

@@ -173,6 +173,11 @@ beforeEach(() => {
// need a fresh cache so the previous test's success doesn't reorder
// the chain on the next request.
clearLastWorkingProviderCache();
// `clearAllMocks` does not undo a `mockImplementation`, so the ledger stub is
// re-seeded per test — otherwise the over-cap case below leaks into the rest.
vi.mocked(prisma.$queryRaw).mockImplementation((async () => [
{ total_tokens: 0 },
]) as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
@@ -228,6 +233,33 @@ function makeProviderThatThrows(
} as unknown as Awaited<ReturnType<typeof resolveProvider>>);
}
describe("POST /api/insights/generate — daily token ceiling", () => {
it("refuses over-cap with 429 before any provider egress", async () => {
// The reservation reports a prior total already at the ceiling. The hourly
// rate limit bounds how OFTEN this route runs; the ledger bounds what it
// costs. Before this wiring the route had only the former, so ten
// full-price briefings an hour was the real ceiling.
// Targeted at the ledger's upsert only — other raw reads on this path
// (the plateau / derived-signal probes) must keep their own shape.
vi.mocked(prisma.$queryRaw).mockImplementation((async (
strings: TemplateStringsArray,
) =>
String(strings[0]).includes("coach_usage")
? [{ total_tokens: 10_000_000 }]
: [{ total_tokens: 0 }]) as never);
makeWorkingProvider();
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(429);
const body = (await res.json()) as ApiErrorEnvelope;
expect(body.error).toContain("Daily AI token budget");
// Not a provider failure: no cache row written, so the last good briefing
// stays intact and the card does not blank.
expect(prisma.user.update).not.toHaveBeenCalled();
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
});
});
describe("POST /api/insights/generate — provider error mapping", () => {
it("rejects a body over the 16 KB cap with 413 before parsing", async () => {
const res = await POST(
@@ -0,0 +1,236 @@
/**
* Cost accounting for the comprehensive-briefing provider chokepoint.
*
* `runBriefingCompletion` is the single provider entry for the briefing tier —
* the shared generator's first pass, its JSON-shape retry, its
* grounding-correction retry, the daily paragraph re-roll, and both calls in
* the on-demand `POST /api/insights/generate` route. That tier previously ran
* with NO budget accounting: nothing reserved, nothing recorded, no ceiling.
* The briefing is the most expensive generation in the product and retries up
* to twice, so an unmetered run could cost three full generations — 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. Mirrors the status tier's
* `status-provider-budget.test.ts` deliberately: one accounting mechanism,
* one set of guarantees, asserted the same way at both chokepoints.
*/
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;
},
),
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const { runRawCompletionWithFallback } = vi.hoisted(() => ({
runRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runRawCompletionWithFallback,
}));
import {
runBriefingCompletion,
BriefingBudgetExceededError,
} from "../briefing-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" as const, instance: {} as never },
];
const BYOK_CHAIN = [
{ providerType: "anthropic" as const, instance: {} as never },
];
function completionArgs(overrides: Record<string, unknown> = {}) {
return {
userId: "u1",
chain: OPERATOR_CHAIN,
systemPrompt: "system",
userPrompt: "user",
temperature: 0.3,
maxTokens: 2500,
timeoutMs: 60_000,
stage: "generate" as const,
...overrides,
};
}
/** A successful provider reply reporting `tokensUsed`. */
function mockProviderReply(
tokensUsed: number | null,
cachedInputTokens?: number,
) {
runRawCompletionWithFallback.mockResolvedValue({
result: {
content: '{"dailyBriefing":{"paragraph":"ok"}}',
model: "m",
tokensUsed,
cachedInputTokens,
},
workingProvider: { providerType: "admin-openai" },
fallbackHops: [],
});
}
beforeEach(() => {
vi.clearAllMocks();
ledgerTotal = 0;
});
describe("runBriefingCompletion — ledger accounting", () => {
it("lands a successful generation on the day's ledger", async () => {
mockProviderReply(1234);
const outcome = await runBriefingCompletion(completionArgs());
expect(outcome.result.tokensUsed).toBe(1234);
// 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, no matter how many briefings
// the account generated.
expect(ledgerTotal).toBe(1234);
});
it("charges the reservation when the provider reports no token count", async () => {
mockProviderReply(null);
await runBriefingCompletion(completionArgs());
// An unreported generation must not bill as free.
expect(ledgerTotal).toBeGreaterThan(0);
});
it("bills net of provider-cached input tokens", async () => {
mockProviderReply(1000, 400);
await runBriefingCompletion(completionArgs());
// The gross count still includes input the prompt cache served cheaply;
// charging the user's meter for input they did not re-pay for is an
// over-charge.
expect(ledgerTotal).toBe(600);
});
it("refuses a generation once the day's cap is already spent", async () => {
ledgerTotal = OPERATOR_COST_CAP;
mockProviderReply(500);
await expect(
runBriefingCompletion(completionArgs()),
).rejects.toBeInstanceOf(BriefingBudgetExceededError);
// Refused BEFORE any provider egress — the point of the whole exercise.
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
expect(annotate).toHaveBeenCalledWith(
expect.objectContaining({
action: { name: "insights.briefing.budget_exceeded" },
}),
);
// The refused reservation was refunded, not left on the row.
expect(ledgerTotal).toBe(OPERATOR_COST_CAP);
});
it("refunds the reservation in full when the provider errors", async () => {
runRawCompletionWithFallback.mockRejectedValue(new Error("upstream down"));
await expect(runBriefingCompletion(completionArgs())).rejects.toThrow(
"upstream down",
);
// A failed generation must not leave an estimate parked on the ledger,
// which would ration the retry the user is entitled to.
expect(ledgerTotal).toBe(0);
});
it("re-throws the provider error unchanged so callers keep their mapping", async () => {
class AllProvidersFailedError extends Error {}
runRawCompletionWithFallback.mockRejectedValue(
new AllProvidersFailedError("chain exhausted"),
);
// The route maps `AllProvidersFailedError` to specific user-facing
// statuses; swallowing or re-wrapping it here would silently degrade every
// one of those branches to the generic error.
await expect(
runBriefingCompletion(completionArgs()),
).rejects.toBeInstanceOf(AllProvidersFailedError);
});
it("meters each retry stage separately", async () => {
mockProviderReply(1000);
await runBriefingCompletion(completionArgs({ stage: "generate" }));
await runBriefingCompletion(completionArgs({ stage: "json-retry" }));
await runBriefingCompletion(completionArgs({ stage: "grounding-retry" }));
// A correction pass is real spend. If retries rode the first pass's
// reservation, a user at the ceiling would get free retries.
expect(ledgerTotal).toBe(3000);
});
});
describe("runBriefingCompletion — cost owner decides the ceiling", () => {
it("measures an operator-key chain against the operator ceiling", async () => {
ledgerTotal = OPERATOR_COST_CAP;
mockProviderReply(100);
await expect(
runBriefingCompletion(completionArgs()),
).rejects.toBeInstanceOf(BriefingBudgetExceededError);
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.
ledgerTotal = OPERATOR_COST_CAP;
mockProviderReply(100);
const outcome = await runBriefingCompletion(
completionArgs({ chain: BYOK_CHAIN }),
);
expect(outcome.result.tokensUsed).toBe(100);
expect(runRawCompletionWithFallback).toHaveBeenCalled();
});
it("still bounds a BYOK chain at the user-plan ceiling", async () => {
ledgerTotal = USER_PLAN_CAP;
mockProviderReply(100);
await expect(
runBriefingCompletion(completionArgs({ chain: BYOK_CHAIN })),
).rejects.toBeInstanceOf(BriefingBudgetExceededError);
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,224 @@
/**
* The briefing generator's behaviour when the day's token ceiling refuses it.
*
* `briefing-provider-budget.test.ts` pins the accounting itself. This pins the
* WIRING: that `generateComprehensiveInsight` actually routes through the
* metered chokepoint, and that an over-cap run is reported honestly.
*
* Load-bearing distinction: an over-cap run is `skipped`, NOT `failed`. No
* provider was contacted, so there is no upstream fault to retry against. The
* morning-refresh worker enqueues its 45-minute provider-failure retry on a
* `failed` outcome and holds the day provisional — doing that for a ceiling
* that will not move until the ledger rolls over would be a retry loop against
* an accounting outcome.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
let ledgerTotal = 0;
const findUnique = vi.fn();
const userUpdate = vi.fn();
const resolveProviderChain = vi.fn();
const resolveProvider = vi.fn();
const runRawCompletionWithFallback = vi.fn();
const extractFeatures = vi.fn();
const recordBriefingFailure = vi.fn();
const hasActiveConsentForSurface = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(
async (_strings: TemplateStringsArray, ...v: unknown[]) => {
ledgerTotal += Number(v[2] ?? 0);
return [{ total_tokens: ledgerTotal }];
},
),
$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: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
},
auditLog: { deleteMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runRawCompletionWithFallback: (...a: unknown[]) =>
runRawCompletionWithFallback(...a),
}));
vi.mock("@/lib/insights/features", () => ({
FeaturesPayloadTooLargeError: class extends Error {
sizeBytes = 0;
},
extractFeatures: (...a: unknown[]) => extractFeatures(...a),
BRIEFING_FEATURE_WINDOW_DAYS: 400,
}));
vi.mock("@/lib/insights/briefing-failure-marker", () => ({
recordBriefingFailure: (...a: unknown[]) => recordBriefingFailure(...a),
readBriefingFailure: vi.fn(async () => null),
}));
vi.mock("@/lib/insights/illness-cycle-briefing", () => ({
buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null),
buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""),
}));
vi.mock("@/lib/insights/glp1-plateau", () => ({
detectGlp1Plateau: vi.fn(async () => null),
buildGlp1PlateauPrompt: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
buildAboutMeInsightBlock: vi.fn(() => ""),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserInsights: vi.fn(),
}));
// Modelled truthfully rather than stubbed open: only an operator-credential
// chain needs a receipt, which is what lets the consent-ordering test below
// assert the real gate rather than a bypass.
vi.mock("@/lib/ai/consent-guard", () => ({
chainRequiresServerManagedConsent: (chain: { providerType: string }[] = []) =>
chain.some((c) => c.providerType.startsWith("admin-")),
hasActiveConsentForSurface: (...a: unknown[]) =>
hasActiveConsentForSurface(...a),
}));
import { generateComprehensiveInsight } from "../comprehensive-generate";
import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget";
const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } });
beforeEach(() => {
vi.clearAllMocks();
ledgerTotal = 0;
resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: {} },
]);
resolveProvider.mockResolvedValue({ type: "none" });
extractFeatures.mockResolvedValue({
weight: { count: 12, latest: 81.4, mean30: 82.1 },
});
userUpdate.mockResolvedValue({});
findUnique.mockResolvedValue({
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
insightsExcludeMetrics: [],
insightsSnapshotHash: null,
insightsBriefingRerollDate: null,
});
hasActiveConsentForSurface.mockResolvedValue(true);
runRawCompletionWithFallback.mockResolvedValue({
result: { content: VALID, tokensUsed: 900, model: "m" },
workingProvider: { providerType: "admin-openai" },
fallbackHops: [],
});
});
describe("generateComprehensiveInsight — daily token ceiling", () => {
it("lands the briefing's spend on the ledger", async () => {
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome.status).toBe("generated");
// The whole point: this path used to spend invisibly.
expect(ledgerTotal).toBe(900);
});
it("refuses over-cap as `skipped: budget` without contacting a provider", async () => {
ledgerTotal = OPERATOR_COST_CAP;
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({ status: "skipped", reason: "budget" });
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
// Not a provider failure: no marker, so the read path never claims the
// refresh broke, and the morning-refresh worker does not enqueue its
// provider-failure retry against a ceiling.
expect(recordBriefingFailure).not.toHaveBeenCalled();
// No cache row written, so the last good briefing stays intact.
expect(userUpdate).not.toHaveBeenCalled();
expect(ledgerTotal).toBe(OPERATOR_COST_CAP);
});
it("serves a BYOK account at the same spend that refuses an operator-key one", async () => {
// A self-hoster on their own key pays their own bill; the operator's
// ceiling is a category error for them.
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
ledgerTotal = OPERATOR_COST_CAP;
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome.status).toBe("generated");
expect(runRawCompletionWithFallback).toHaveBeenCalled();
});
it("still bounds a BYOK account at the user-plan ceiling", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
ledgerTotal = USER_PLAN_CAP;
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({ status: "skipped", reason: "budget" });
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
it("refunds in full when the provider fails, leaving no estimate parked", async () => {
runRawCompletionWithFallback.mockRejectedValue(new Error("upstream down"));
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome.status).toBe("failed");
expect(ledgerTotal).toBe(0);
});
it("never charges a consent-blocked user", async () => {
// Ordering guarantee: the reservation lands AFTER the consent gate.
// Reserving first would bill an operator-key user for a snapshot that is
// never sent anywhere, and could lock them out of a tier they are not
// even permitted to use.
hasActiveConsentForSurface.mockResolvedValue(false);
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({ status: "skipped", reason: "no-consent" });
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
expect(ledgerTotal).toBe(0);
});
it("charges the JSON-shape retry as its own generation", async () => {
runRawCompletionWithFallback
.mockResolvedValueOnce({
result: { content: "not json", tokensUsed: 400, model: "m" },
workingProvider: { providerType: "admin-openai" },
fallbackHops: [],
})
.mockResolvedValueOnce({
result: { content: VALID, tokensUsed: 900, model: "m" },
workingProvider: { providerType: "admin-openai" },
fallbackHops: [],
});
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome.status).toBe("generated");
// Both passes billed. A retry that rode the first reservation would let a
// user at the ceiling generate for free.
expect(ledgerTotal).toBe(1300);
});
});