Merge branch 'worktree-agent-aeb38510a52ce89ac' into release/v1.30.28

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 05:22:51 +02:00
commit 395d59fd30
11 files changed
+859 -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",
@@ -167,6 +173,11 @@ beforeEach(() => {
// need a fresh cache so the previous test's success doesn't reorder
// the chain on the next request.
clearLastWorkingProviderCache();
// `clearAllMocks` does not undo a `mockImplementation`, so the ledger stub is
// re-seeded per test — otherwise the over-cap case below leaks into the rest.
vi.mocked(prisma.$queryRaw).mockImplementation((async () => [
{ total_tokens: 0 },
]) as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
@@ -222,6 +233,33 @@ function makeProviderThatThrows(
} as unknown as Awaited<ReturnType<typeof resolveProvider>>);
}
describe("POST /api/insights/generate — daily token ceiling", () => {
it("refuses over-cap with 429 before any provider egress", async () => {
// The reservation reports a prior total already at the ceiling. The hourly
// rate limit bounds how OFTEN this route runs; the ledger bounds what it
// costs. Before this wiring the route had only the former, so ten
// full-price briefings an hour was the real ceiling.
// Targeted at the ledger's upsert only — other raw reads on this path
// (the plateau / derived-signal probes) must keep their own shape.
vi.mocked(prisma.$queryRaw).mockImplementation((async (
strings: TemplateStringsArray,
) =>
String(strings[0]).includes("coach_usage")
? [{ total_tokens: 10_000_000 }]
: [{ total_tokens: 0 }]) as never);
makeWorkingProvider();
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(429);
const body = (await res.json()) as ApiErrorEnvelope;
expect(body.error).toContain("Daily AI token budget");
// Not a provider failure: no cache row written, so the last good briefing
// stays intact and the card does not blank.
expect(prisma.user.update).not.toHaveBeenCalled();
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
});
});
describe("POST /api/insights/generate — provider error mapping", () => {
it("rejects a body over the 16 KB cap with 413 before parsing", async () => {
const res = await POST(
+60 -53
View File
@@ -41,11 +41,7 @@ import {
} from "@/lib/ai/coach/about-me";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
insightResultSchema,
singleUserTurn,
type InsightResult,
} from "@/lib/ai/types";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets";
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
import {
@@ -53,10 +49,11 @@ import {
readBriefingFailure,
} from "@/lib/insights/briefing-failure-marker";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { AllProvidersFailedError } from "@/lib/ai/provider-runner";
import {
AllProvidersFailedError,
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
BriefingBudgetExceededError,
runBriefingCompletion,
} from "@/lib/insights/briefing-provider";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import { screenInsightPayloadProse } from "@/lib/ai/safety/insight-payload-screen";
import {
@@ -650,42 +647,50 @@ export const POST = apiHandler((request: NextRequest) =>
let workingProviderType: string;
let fallbackHopCount = 0;
try {
const fallback = await runRawCompletionWithFallback({
const fallback = await runBriefingCompletion({
userId,
providers: chain,
params: singleUserTurn({
// The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8
// — emit a top-level `dailyBriefing` block when the snapshot has
// analysable signal — plus the trendAnnotations and
// storyboardAnnotations rules. The legacy `getInsightsSystemPrompt`
// returned the v1.4.5 `{changed, stable, drivers, …}` shape and
// never asked the model for dailyBriefing, so the hero strip's
// "Re-run analysis" button kept producing a payload with no
// briefing block (Issue 1, v1.4.20 post-deploy). The route already
// tolerates the strict shape via `insightResultSchema.safeParse`'s
// soft fallback to `parsed` and `passthrough()` on the strict
// schema, so switching the prompt does not break legacy callers.
system: buildSystemPromptWithReferences(locale, referenceMetrics),
user: userPrompt,
temperature: 0.3,
// v1.28.28 (#470) — env-tunable output ceiling (INSIGHTS_MAX_TOKENS,
// default 2500). The old fixed 1500 truncated verbose models'
// briefing JSON mid-string → generic invalid-JSON 422.
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
// generation is not aborted at the client's 60 s default on large
// accounts (which returned an empty briefing + trend narrative).
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
// The reply is parsed with `JSON.parse` below, so opt the OpenAI /
// Codex chains into their strict JSON mode (gated on this flag).
responseFormat: "json",
}),
chain,
// The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8
// — emit a top-level `dailyBriefing` block when the snapshot has
// analysable signal — plus the trendAnnotations and
// storyboardAnnotations rules. The legacy `getInsightsSystemPrompt`
// returned the v1.4.5 `{changed, stable, drivers, …}` shape and
// never asked the model for dailyBriefing, so the hero strip's
// "Re-run analysis" button kept producing a payload with no
// briefing block (Issue 1, v1.4.20 post-deploy). The route already
// tolerates the strict shape via `insightResultSchema.safeParse`'s
// soft fallback to `parsed` and `passthrough()` on the strict
// schema, so switching the prompt does not break legacy callers.
systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics),
userPrompt,
temperature: 0.3,
// v1.28.28 (#470) — env-tunable output ceiling (INSIGHTS_MAX_TOKENS,
// default 2500). The old fixed 1500 truncated verbose models'
// briefing JSON mid-string → generic invalid-JSON 422.
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
// generation is not aborted at the client's 60 s default on large
// accounts (which returned an empty briefing + trend narrative).
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
stage: "generate",
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
fallbackHopCount = fallback.fallbackHops.length;
} catch (e) {
// The day's token ceiling refused the reservation — no provider was
// contacted, so this is NOT a provider failure and must not record a
// briefing-failure marker (which would make the read path claim the
// refresh broke). The hourly rate limit bounds request frequency; this
// bounds cost. 429 with a distinct code so the client can say so.
if (e instanceof BriefingBudgetExceededError) {
return apiError(
"Daily AI token budget reached — insights will be available again tomorrow.",
429,
{ errorCode: "insights.generate.budgetExceeded" },
);
}
// v1.25 — record a dated failure marker (no cache row is written on this
// path, so the last good briefing stays intact). The read path pairs the
// preserved text with a discreet "couldn't refresh" hint, or shows a
@@ -849,21 +854,21 @@ export const POST = apiHandler((request: NextRequest) =>
meta: { ungroundedCount: ungrounded.length },
});
try {
const retry = await runRawCompletionWithFallback({
const retry = await runBriefingCompletion({
userId,
providers: chain,
params: singleUserTurn({
system: buildSystemPromptWithReferences(locale, referenceMetrics),
user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: 0.3,
// v1.28.28 (#470) — same env-tunable ceiling as the first call.
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget; see the first generation call.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
// Parsed with `JSON.parse` below — opt OpenAI / Codex into JSON mode.
responseFormat: "json",
}),
chain,
systemPrompt: buildSystemPromptWithReferences(
locale,
referenceMetrics,
),
userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: 0.3,
// v1.28.28 (#470) — same env-tunable ceiling as the first call.
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget; see the first generation call.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
stage: "grounding-retry",
});
const parsedRetry = JSON.parse(retry.result.content);
const validatedRetry = insightResultSchema.safeParse(parsedRetry);
@@ -881,7 +886,9 @@ export const POST = apiHandler((request: NextRequest) =>
workingProviderType = retry.workingProvider.providerType;
}
} catch {
// Retry failed to produce parseable JSON — fall through to the strip.
// Retry failed to produce parseable JSON, or the day's ceiling
// refused the correction pass — either way fall through to the
// strip. An ungrounded figure is never persisted.
}
}
// Still ungrounded after the retry: strip the briefing rather than ship a