fix(insights): let "Re-run analysis" reach a benched provider

The on-demand briefing (POST /api/insights/generate) runs through the
briefing chokepoint, which named its chain walk "job" for the background
share of the day's ceiling. Since background work leaves out a provider the
health ledger benched, a person pressing "Re-run analysis" got no request at
all: the call failed with a connection refusal that never happened, and a
refused credential was never reported back.

The chain runners now take the walk apart from the budget surface: a call
can be rationed as a job and still have a person waiting on it
(interactive). runBriefingCompletion takes the flag and the on-demand
route's calls (the first pass and the grounding retry) pass it, so they keep
the job share and walk a benched provider at the tail, as background-bench
promises for interactive walks. Every other job-surface caller is a worker
or is enqueued; the status, narrative and warm routes only read and queue,
and document reads and lab scans resolve a single provider without a chain.

Refs #1279
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 00:24:51 +02:00
1 parent b90980f2a2
commit bdfcea5e7c
7 files changed
+110 -42

No files matched your search

@@ -71,6 +71,9 @@ vi.mock("@/lib/db", () => ({
consentReceipt: {
findFirst: vi.fn(async () => ({ id: "receipt-1" })),
},
// The provider health ledger the chain walk reads. Nothing benched by
// default; the bench case below seeds a row.
providerHealth: { findMany: vi.fn(async () => []) },
},
}));
@@ -284,6 +287,35 @@ describe("POST /api/insights/generate — daily token ceiling", () => {
});
});
describe("POST /api/insights/generate — a provider the health ledger benched", () => {
// #1279 — "Re-run analysis" is a person waiting on the answer. Background
// work leaves a benched provider out; this call walks it at the tail of
// the chain, so it reaches the provider and reports what the provider
// said, not a connection failure that never happened.
it("still asks the benched provider and reports its own refusal", async () => {
vi.mocked(prisma.providerHealth.findMany).mockResolvedValueOnce([
{
providerType: "admin-openai",
lastResult: "auth_failed",
consecutiveFailures: 1,
nextRetryAt: new Date(Date.now() + 60 * 60 * 1000),
},
] as never);
const err = Object.assign(new Error("OpenAI request failed (401)"), {
httpStatus: 401,
});
makeProviderThatThrows(err);
const provider = await vi.mocked(resolveProvider)("u-1");
const res = await POST(jsonRequest({ force: true }) as never);
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
expect(res.status).toBe(422);
const body = (await res.json()) as ApiErrorEnvelope;
expect(body.error).toMatch(/AI provider rejected/i);
});
});
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(