Merge pull request #552 from MBombeck/release/v1.30.28

release/v1.30.28 — the daily briefing counts its spend too
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-19 05:52:23 +02:00
commit e6e2210314
15 files changed
+869 -120

No files matched your search

+7
View File
@@ -2,6 +2,13 @@
## [Unreleased]
## [1.30.28] — 2026-07-19
The daily briefing counts its spend too.
- **Generating the daily briefing did not count against any budget.** It is the one remaining path that a sync can set off on its own: new sleep data arriving in the morning triggers a fresh briefing, and each attempt — including its correction passes — could reach a provider without being recorded. All of them now reserve before and settle after, against the same ceiling as everything else, so a self-hoster on their own key is measured against their own limit.
- Reaching the ceiling now skips the run rather than reporting a failure, because a failure schedules a retry — and retrying against a limit that does not move until the next day would loop.
## [1.30.27] — 2026-07-19
Three quiet miscounts.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.27
version: 1.30.28
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.27",
"version": "1.30.28",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.30.27";
/* @sw-version-fallback */ "v1.30.28";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -33,6 +33,12 @@ vi.mock("@/lib/feature-flags", () => ({
vi.mock("@/lib/db", () => ({
prisma: {
// The briefing path now reserves against the day's token ledger before
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: vi.fn(async () => ({
insightsPrivacyMode: "aggregated",
@@ -167,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,
@@ -222,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(
+60 -53
View File
@@ -41,11 +41,7 @@ import {
} from "@/lib/ai/coach/about-me";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
insightResultSchema,
singleUserTurn,
type InsightResult,
} from "@/lib/ai/types";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets";
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
import {
@@ -53,10 +49,11 @@ import {
readBriefingFailure,
} from "@/lib/insights/briefing-failure-marker";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { AllProvidersFailedError } from "@/lib/ai/provider-runner";
import {
AllProvidersFailedError,
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
BriefingBudgetExceededError,
runBriefingCompletion,
} from "@/lib/insights/briefing-provider";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import { screenInsightPayloadProse } from "@/lib/ai/safety/insight-payload-screen";
import {
@@ -650,42 +647,50 @@ export const POST = apiHandler((request: NextRequest) =>
let workingProviderType: string;
let fallbackHopCount = 0;
try {
const fallback = await runRawCompletionWithFallback({
const fallback = await runBriefingCompletion({
userId,
providers: chain,
params: singleUserTurn({
// The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8
// — emit a top-level `dailyBriefing` block when the snapshot has
// analysable signal — plus the trendAnnotations and
// storyboardAnnotations rules. The legacy `getInsightsSystemPrompt`
// returned the v1.4.5 `{changed, stable, drivers, …}` shape and
// never asked the model for dailyBriefing, so the hero strip's
// "Re-run analysis" button kept producing a payload with no
// briefing block (Issue 1, v1.4.20 post-deploy). The route already
// tolerates the strict shape via `insightResultSchema.safeParse`'s
// soft fallback to `parsed` and `passthrough()` on the strict
// schema, so switching the prompt does not break legacy callers.
system: buildSystemPromptWithReferences(locale, referenceMetrics),
user: userPrompt,
temperature: 0.3,
// v1.28.28 (#470) — env-tunable output ceiling (INSIGHTS_MAX_TOKENS,
// default 2500). The old fixed 1500 truncated verbose models'
// briefing JSON mid-string → generic invalid-JSON 422.
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
// generation is not aborted at the client's 60 s default on large
// accounts (which returned an empty briefing + trend narrative).
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
// The reply is parsed with `JSON.parse` below, so opt the OpenAI /
// Codex chains into their strict JSON mode (gated on this flag).
responseFormat: "json",
}),
chain,
// The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8
// — emit a top-level `dailyBriefing` block when the snapshot has
// analysable signal — plus the trendAnnotations and
// storyboardAnnotations rules. The legacy `getInsightsSystemPrompt`
// returned the v1.4.5 `{changed, stable, drivers, …}` shape and
// never asked the model for dailyBriefing, so the hero strip's
// "Re-run analysis" button kept producing a payload with no
// briefing block (Issue 1, v1.4.20 post-deploy). The route already
// tolerates the strict shape via `insightResultSchema.safeParse`'s
// soft fallback to `parsed` and `passthrough()` on the strict
// schema, so switching the prompt does not break legacy callers.
systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics),
userPrompt,
temperature: 0.3,
// v1.28.28 (#470) — env-tunable output ceiling (INSIGHTS_MAX_TOKENS,
// default 2500). The old fixed 1500 truncated verbose models'
// briefing JSON mid-string → generic invalid-JSON 422.
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
// generation is not aborted at the client's 60 s default on large
// accounts (which returned an empty briefing + trend narrative).
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
stage: "generate",
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
fallbackHopCount = fallback.fallbackHops.length;
} catch (e) {
// The day's token ceiling refused the reservation — no provider was
// contacted, so this is NOT a provider failure and must not record a
// briefing-failure marker (which would make the read path claim the
// refresh broke). The hourly rate limit bounds request frequency; this
// bounds cost. 429 with a distinct code so the client can say so.
if (e instanceof BriefingBudgetExceededError) {
return apiError(
"Daily AI token budget reached — insights will be available again tomorrow.",
429,
{ errorCode: "insights.generate.budgetExceeded" },
);
}
// v1.25 — record a dated failure marker (no cache row is written on this
// path, so the last good briefing stays intact). The read path pairs the
// preserved text with a discreet "couldn't refresh" hint, or shows a
@@ -849,21 +854,21 @@ export const POST = apiHandler((request: NextRequest) =>
meta: { ungroundedCount: ungrounded.length },
});
try {
const retry = await runRawCompletionWithFallback({
const retry = await runBriefingCompletion({
userId,
providers: chain,
params: singleUserTurn({
system: buildSystemPromptWithReferences(locale, referenceMetrics),
user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: 0.3,
// v1.28.28 (#470) — same env-tunable ceiling as the first call.
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget; see the first generation call.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
// Parsed with `JSON.parse` below — opt OpenAI / Codex into JSON mode.
responseFormat: "json",
}),
chain,
systemPrompt: buildSystemPromptWithReferences(
locale,
referenceMetrics,
),
userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: 0.3,
// v1.28.28 (#470) — same env-tunable ceiling as the first call.
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget; see the first generation call.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
stage: "grounding-retry",
});
const parsedRetry = JSON.parse(retry.result.content);
const validatedRetry = insightResultSchema.safeParse(parsedRetry);
@@ -881,7 +886,9 @@ export const POST = apiHandler((request: NextRequest) =>
workingProviderType = retry.workingProvider.providerType;
}
} catch {
// Retry failed to produce parseable JSON — fall through to the strip.
// Retry failed to produce parseable JSON, or the day's ceiling
// refused the correction pass — either way fall through to the
// strip. An ungrounded figure is never persisted.
}
}
// Still ungrounded after the retry: strip the briefing rather than ship a
@@ -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();
});
});
@@ -20,6 +20,12 @@ const invalidateUserInsights = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
// The briefing path now reserves against the day's token ledger before
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
@@ -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);
});
});
@@ -27,6 +27,12 @@ const extractFeatures = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
// The briefing path now reserves against the day's token ledger before
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
@@ -18,6 +18,12 @@ const extractFeatures = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
// The briefing path now reserves against the day's token ledger before
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
@@ -22,6 +22,12 @@ const recordBriefingFailure = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
// The briefing path now reserves against the day's token ledger before
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
@@ -24,6 +24,12 @@ const annotate = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
// The briefing path now reserves against the day's token ledger before
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
+178
View File
@@ -0,0 +1,178 @@
/**
* Metered provider chokepoint for the comprehensive-briefing tier.
*
* The status/reference tier funnels every generation through
* `runStatusCompletion`, which reserves against the day's token ledger before
* egress and reconciles after. The comprehensive briefing did not: both the
* shared generator (`comprehensive-generate.ts`) and the on-demand
* `POST /api/insights/generate` route called `runRawCompletionWithFallback`
* directly, so none of that spend reached `coach_usage` and no daily ceiling
* applied to it. The briefing is the single most expensive generation in the
* product — a full feature snapshot in, a multi-section JSON payload out — and
* it retries up to twice per run (JSON-shape retry, grounding-correction
* retry), so an unmetered run could cost three full generations.
*
* This is the same accounting the status tier uses, not a second mechanism:
* `reserveBudget` (one atomic upsert-increment, no read-then-write window) +
* `resolveDailyCap(chain)` for the cost owner + `reconcileSpend` against the
* provider's reported count. Every provider call on the briefing path routes
* through here, so a retry is reserved and charged like any other call — a
* user at the ceiling does not get a free correction pass.
*
* 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,
* their own ChatGPT plan, or a local model is measured against the generous
* user-plan ceiling, so their hardware is never rationed by the operator's
* bill.
*
* Callers own the consent gate and MUST apply it before calling in — a
* consent-blocked user must never have budget reserved against them, because
* nothing is ever sent upstream on their behalf.
*/
import {
runRawCompletionWithFallback,
type ProviderChainResolved,
type RunRawWithFallbackResult,
} from "@/lib/ai/provider-runner";
import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { singleUserTurn } from "@/lib/ai/types";
import { annotate } from "@/lib/logging/context";
/**
* Which call on the briefing path this was. Threaded onto the refusal
* annotation so an operator can tell a first-pass refusal (the whole briefing
* is missing) apart from a retry refusal (the first pass landed, the
* correction did not).
*/
export type BriefingCompletionStage =
"generate" | "json-retry" | "grounding-retry" | "reroll";
/**
* Thrown when the reservation would push the user past the day's ceiling.
*
* Deliberately a distinct type rather than a generic provider error: the
* callers map it to their own honest outcome (a typed `skipped` for the
* off-request generator, a 429 for the on-demand route) instead of reporting
* an upstream failure that never happened. No provider was contacted.
*/
export class BriefingBudgetExceededError extends Error {
constructor(
readonly stage: BriefingCompletionStage,
readonly totalAfter: number,
) {
super("insights.briefing.budget_exceeded");
this.name = "BriefingBudgetExceededError";
}
}
export interface RunBriefingCompletionArgs {
userId: string;
/** The resolved provider chain. Its primary decides the daily cap. */
chain: ProviderChainResolved[];
systemPrompt: string;
userPrompt: string;
temperature: number;
maxTokens: number;
/** Upstream per-request timeout, already resolved from the user's setting. */
timeoutMs: number;
stage: BriefingCompletionStage;
/**
* Deterministic seed. Omitted by the daily-briefing re-roll on purpose —
* a pinned seed would reproduce the phrasing the re-roll exists to vary.
*/
seed?: number;
/** UTC day-key override; defaults to today. Tests pin it. */
dateKey?: string;
}
/**
* Reserve, run the chain, reconcile. Returns the runner's result untouched so
* every existing caller keeps its own parsing, grounding, and error mapping.
*
* Failure semantics:
* - over the ceiling → `BriefingBudgetExceededError`, nothing reserved
* (the reservation is refunded inside `reserveBudget`), no egress.
* - provider threw → the reservation is refunded IN FULL and the original
* error re-thrown, so `AllProvidersFailedError` still reaches the callers
* that map it to a user-facing status.
* - provider returned → charged the reported count, net of cached input
* tokens; falls back to the reservation when the provider reports no
* count, so an unreported generation is never billed as free.
*/
export async function runBriefingCompletion(
args: RunBriefingCompletionArgs,
): Promise<RunRawWithFallbackResult> {
const dateKey = args.dateKey ?? buildDateKey();
// Reserve an ESTIMATE up front — the output ceiling plus a ~4-chars-per-token
// approximation of the prompt about to be sent — and reconcile against the
// provider's reported count afterwards. Same shape as the status tier.
const estimatedTokens =
args.maxTokens +
Math.ceil((args.systemPrompt.length + args.userPrompt.length) / 4);
const reservation = await reserveBudget(
args.userId,
estimatedTokens,
dateKey,
resolveDailyCap(args.chain),
);
if (!reservation.allowed) {
annotate({
action: { name: "insights.briefing.budget_exceeded" },
meta: { stage: args.stage, totalAfter: reservation.totalAfter },
});
throw new BriefingBudgetExceededError(args.stage, reservation.totalAfter);
}
let outcome: RunRawWithFallbackResult;
try {
outcome = await runRawCompletionWithFallback({
userId: args.userId,
providers: args.chain,
params: singleUserTurn({
system: args.systemPrompt,
user: args.userPrompt,
temperature: args.temperature,
maxTokens: args.maxTokens,
seed: args.seed,
timeoutMs: args.timeoutMs,
// Every briefing call parses its reply with `JSON.parse`, so opt the
// chains with a native JSON mode into it.
responseFormat: "json",
}),
});
} catch (err) {
// No usable reply: refund the whole reservation rather than bill an
// invented figure. A partially-burned upstream call is possible here, but
// we have no reported count to charge, and over-charging a failed
// generation would ration the retry the user is entitled to.
await reconcileSpend(args.userId, reservation.reserved, 0, dateKey).catch(
() => {
// Best-effort: a failed refund leaves the conservative reservation in
// place (never an undercount) and must not mask the provider error.
},
);
throw err;
}
const actualTokens = outcome.result.tokensUsed ?? reservation.reserved;
await reconcileSpend(
args.userId,
reservation.reserved,
actualTokens,
dateKey,
outcome.result.cachedInputTokens ?? 0,
).catch(() => {
// Ledger reconcile is best-effort; a failure leaves the reservation in
// place and never breaks a generation that already succeeded.
});
return outcome;
}
+93 -64
View File
@@ -54,7 +54,6 @@ import {
buildBriefingPersonalisationBlock,
} from "@/lib/ai/prompts/insight-generator";
import { buildRetryCorrectionMessage } from "@/lib/ai/generate-insight";
import { singleUserTurn } from "@/lib/ai/types";
import {
buildAboutMeInsightBlock,
getSelfContextTextForUser,
@@ -74,10 +73,11 @@ import {
import type { MeasurementType } from "@/generated/prisma/client";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { AllProvidersFailedError } from "@/lib/ai/provider-runner";
import {
AllProvidersFailedError,
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
BriefingBudgetExceededError,
runBriefingCompletion,
} from "@/lib/insights/briefing-provider";
import {
chainRequiresServerManagedConsent,
hasActiveConsentForSurface,
@@ -126,7 +126,13 @@ export type GenerateOutcome =
* temperature for phrasing variety. At most one re-roll per calendar day.
*/
| { status: "rerolled"; providerType: string }
| { status: "skipped"; reason: "no-provider" | "no-consent" }
/**
* `budget` — the day's token ceiling refused the reservation, so no
* provider was contacted. Grouped with the other `skipped` reasons rather
* than with `failed` on purpose: there is no upstream fault to retry
* against, so the caller must not enqueue the provider-failure retry.
*/
| { status: "skipped"; reason: "no-provider" | "no-consent" | "budget" }
| { status: "failed"; reason: string };
/** Higher, seedless temperature for the daily-briefing phrasing re-roll. */
@@ -196,26 +202,27 @@ async function rerollBriefingParagraph(args: {
let result;
let providerType: string;
try {
const fallback = await runRawCompletionWithFallback({
const fallback = await runBriefingCompletion({
userId: args.userId,
providers: args.chain,
params: singleUserTurn({
system: args.systemPrompt,
user: args.userPrompt,
// Seedless on purpose: the seed would pin the same phrasing, which is
// exactly what we are varying. Higher temperature for prose variety.
temperature: BRIEFING_REROLL_TEMPERATURE,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
// generation is not clipped at the client's 60 s default mid-stream.
// v1.25 — honours the per-user response-timeout setting (see caller).
timeoutMs: args.effectiveTimeoutMs,
responseFormat: "json",
}),
chain: args.chain,
systemPrompt: args.systemPrompt,
userPrompt: args.userPrompt,
// Seedless on purpose: the seed would pin the same phrasing, which is
// exactly what we are varying. Higher temperature for prose variety.
temperature: BRIEFING_REROLL_TEMPERATURE,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
// generation is not clipped at the client's 60 s default mid-stream.
// v1.25 — honours the per-user response-timeout setting (see caller).
timeoutMs: args.effectiveTimeoutMs,
stage: "reroll",
});
result = fallback.result;
providerType = fallback.workingProvider.providerType;
} catch {
// Includes `BriefingBudgetExceededError`: a user at the day's ceiling
// simply does not get the phrasing re-roll. The caller falls through to
// the plain timestamp refresh and keeps the cached paragraph.
return null;
}
@@ -817,27 +824,35 @@ export async function generateComprehensiveInsight(
let result;
let workingProviderType: string;
try {
const fallback = await runRawCompletionWithFallback({
const fallback = await runBriefingCompletion({
userId,
providers: chain,
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
// generation is not clipped at the client's 60 s default mid-stream.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
// v1.18.7 — structured surface: opt the non-OpenAI chains into their
// strongest JSON mode (Ollama `format`, Anthropic `{` prefill) so a
// first-pass JSON miss is rarer; stripJsonFences stays the net.
responseFormat: "json",
}),
chain,
systemPrompt,
userPrompt,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
// generation is not clipped at the client's 60 s default mid-stream.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
stage: "generate",
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
} catch (e) {
// Over the day's token ceiling — no provider was contacted. Typed as
// `skipped`, not `failed`: there is no upstream fault to retry against,
// so the caller must not enqueue the 45-minute provider-failure retry or
// hold the day provisional waiting for a briefing that will not come
// until the ledger rolls over. No failure marker either — the ceiling is
// an accounting outcome, not a broken generation.
if (e instanceof BriefingBudgetExceededError) {
annotate({
action: { name: "insights.generate.budget_exceeded" },
meta: { locale, stage: e.stage, totalAfter: e.totalAfter },
});
return { status: "skipped", reason: "budget" };
}
// v1.21.5 — make the failed generation queryable. This path used to return
// a `failed` outcome with NO annotation, so a provider that consistently
// failed (e.g. a codex briefing aborted by the 60 s timeout on a large
@@ -890,26 +905,38 @@ export async function generateComprehensiveInsight(
meta: { locale },
});
try {
const retry = await runRawCompletionWithFallback({
const retry = await runBriefingCompletion({
userId,
providers: chain,
params: singleUserTurn({
system: systemPrompt,
user: `${userPrompt}\n\n${buildRetryCorrectionMessage(
"Response was not valid JSON",
"The previous reply could not be parsed as a JSON object.",
)}`,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget; see the first generation call.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
responseFormat: "json",
}),
chain,
systemPrompt,
userPrompt: `${userPrompt}\n\n${buildRetryCorrectionMessage(
"Response was not valid JSON",
"The previous reply could not be parsed as a JSON object.",
)}`,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget; see the first generation call.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
stage: "json-retry",
});
result = retry.result;
workingProviderType = retry.workingProvider.providerType;
} catch (retryErr) {
// The first pass already landed and was charged; the correction pass is
// what the ceiling refused. Same `skipped` treatment as the first call —
// a budget refusal is not a provider fault to retry against.
if (retryErr instanceof BriefingBudgetExceededError) {
annotate({
action: { name: "insights.generate.budget_exceeded" },
meta: {
locale,
stage: retryErr.stage,
totalAfter: retryErr.totalAfter,
},
});
return { status: "skipped", reason: "budget" };
}
// v1.28.30 — annotate alongside the marker so an invalid-JSON night
// is greppable under the same action as every other failure class.
annotate({
@@ -973,19 +1000,17 @@ export async function generateComprehensiveInsight(
meta: { locale, ungroundedCount: ungrounded.length },
});
try {
const retry = await runRawCompletionWithFallback({
const retry = await runBriefingCompletion({
userId,
providers: chain,
params: singleUserTurn({
system: systemPrompt,
user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget; see the first generation call.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
responseFormat: "json",
}),
chain,
systemPrompt,
userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget; see the first generation call.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
stage: "grounding-retry",
});
const retryInsights = parseComprehensiveResult(retry.result.content);
const retryUngrounded = findUngroundedBriefingNumbers(
@@ -1003,7 +1028,11 @@ export async function generateComprehensiveInsight(
}
} catch {
// Retry failed on TRANSPORT (provider outage / timeout), not on
// content — the model never got its correction chance.
// content — the model never got its correction chance. A
// `BriefingBudgetExceededError` lands here too and is treated the same
// way on purpose: the model never got its correction chance either, so
// preferring the previously-cached (already-grounded) briefing over a
// hole is the right call. The ungrounded text is still never persisted.
retryTransportFailed = true;
}
}