Meter the comprehensive briefing tier against the daily token ledger

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 05:10:12 +02:00
1 parent 813ad3ec5a
commit 8a1d7502d0
9 files changed
+367 -117

No files matched your search

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