mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
15 files changed
+869
-120
No files matched your search
@@ -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,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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user