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; } }