From 8a1d7502d0a87942a1ef651081a7a626e5ca59b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 05:10:12 +0200 Subject: [PATCH 1/4] Meter the comprehensive briefing tier against the daily token ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status tier funnels every generation through runStatusCompletion, which reserves against the day's ledger before egress and reconciles after. The comprehensive briefing did not: the shared generator and the on-demand POST /api/insights/generate route both called the provider chain directly, so none of that spend reached coach_usage and no daily ceiling applied to it. Route every briefing provider call — first pass, JSON-shape retry, grounding-correction retry, and the daily paragraph re-roll — through one chokepoint that reuses reserveBudget / reconcileSpend and resolveDailyCap. The reservation lands after the consent gate, so a consent-blocked user is never charged, and a failed or timed-out call is refunded in full. Over-cap is a typed skipped outcome for the off-request generator (no upstream fault to retry against, so no failure marker and no retry enqueue) and a 429 for the on-demand route. --- .../insights/generate/__tests__/route.test.ts | 6 + src/app/api/insights/generate/route.ts | 113 +++++------ .../comprehensive-briefing-reroll.test.ts | 6 + .../comprehensive-generate-force.test.ts | 6 + .../comprehensive-json-retry.test.ts | 6 + .../comprehensive-timeout-honored.test.ts | 6 + .../comprehensive-warm-timeout.test.ts | 6 + src/lib/insights/briefing-provider.ts | 178 ++++++++++++++++++ src/lib/insights/comprehensive-generate.ts | 157 ++++++++------- 9 files changed, 367 insertions(+), 117 deletions(-) create mode 100644 src/lib/insights/briefing-provider.ts diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index fb955dcee..12a88e790 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -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", diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index fcf76ae74..fb2d59a8f 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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 diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 94e45fa29..17fce6442 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -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), diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 92654eb4c..b952be436 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -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), diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index 6e724fcd9..cf1fb723b 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -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), diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index 6da5f93a6..e9c65af4a 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -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), diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index e90976081..c7b385097 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -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), diff --git a/src/lib/insights/briefing-provider.ts b/src/lib/insights/briefing-provider.ts new file mode 100644 index 000000000..bb013840f --- /dev/null +++ b/src/lib/insights/briefing-provider.ts @@ -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 { + 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; +} diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index ecbb94c6c..8e2b1cfcc 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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; } } From 79922631ce60c751ab6fd40f4210103e4ad56d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 05:15:30 +0200 Subject: [PATCH 2/4] Cover the briefing tier's ledger accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../insights/generate/__tests__/route.test.ts | 32 +++ .../briefing-provider-budget.test.ts | 236 ++++++++++++++++++ .../comprehensive-budget-refusal.test.ts | 224 +++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 src/lib/insights/__tests__/briefing-provider-budget.test.ts create mode 100644 src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 12a88e790..f30c72fed 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -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>); } +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( diff --git a/src/lib/insights/__tests__/briefing-provider-budget.test.ts b/src/lib/insights/__tests__/briefing-provider-budget.test.ts new file mode 100644 index 000000000..b79a4a7ff --- /dev/null +++ b/src/lib/insights/__tests__/briefing-provider-budget.test.ts @@ -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 = {}) { + 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(); + }); +}); diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts new file mode 100644 index 000000000..205fca7b1 --- /dev/null +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -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); + }); +}); From a3be2d7c923ee915b1a0d6c270e6f64c3737ec1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 05:26:16 +0200 Subject: [PATCH 3/4] =?UTF-8?q?chore(release):=20v1.30.28=20=E2=80=94=20me?= =?UTF-8?q?ter=20the=20briefing=20path,=20the=20last=20provider=20entry=20?= =?UTF-8?q?a=20sync=20can=20reach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e62f84f..2e67e26bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [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. + +No breaking changes. + ## [1.30.26] — 2026-07-19 Text that comes out of your documents is treated as data, not as instructions. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4ef14337e..2f3b39b2c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.26 + version: 1.30.28 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 94bacebd7..7f30d6795 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.26", + "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", diff --git a/public/sw.js b/public/sw.js index 1a5b761b9..9536f54db 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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.26"; + /* @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/*` From f61483d19be4bba958514959de5b82f4fa445e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 05:37:23 +0200 Subject: [PATCH 4/4] fix(sw): repair the service-worker version fallback after the release merge The changelog merge left conflict markers inside the CACHE_VERSION assignment. The version generator only rewrites the version string, so it could not repair the broken structure, and every service-worker test failed while both branches were green on their own. --- public/sw.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/public/sw.js b/public/sw.js index a034b4c9a..9536f54db 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,11 +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__) || -<<<<<<< HEAD /* @sw-version-fallback */ "v1.30.28"; -======= - /* @sw-version-fallback */ "v1.30.27"; ->>>>>>> main 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/*`