fix(ai): an empty provider chain means no provider

Eight call sites (Coach turn, status texts, briefing, comprehensive
insights, medication extraction, about-me questions, both lab OCR paths,
the saved-config connection test) answered an empty chain by calling the
single-provider resolver, which ignores the chain's switches. A person who
kept only the local model on and lost its address got the ChatGPT sign-in
or the operator key instead, and every copy tagged that provider as the
operator key, so consent and cost attribution saw the wrong owner.

The rule now lives once in `resolveProviderChain`, and the presence probe
reads the same rule: a saved chain is the person's word, so an empty one
stays empty. Only a chain never saved falls back, to the OpenAI-compatible
gateway picked under Settings, AI, because the default chain has no entry
for it; that entry carries its real type. The callers drop their copies,
and the presence helpers that restated the old fallback go with them.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:29:11 +01:00
1 parent de8eadbf2f
commit e098fc882f
18 files changed
+227 -682

No files matched your search

@@ -4715,7 +4715,7 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
}
`;
exports[`coach chat golden transcripts > 16a coach.provider.none via an empty chain and no legacy provider 1`] = `
exports[`coach chat golden transcripts > 16a coach.provider.none via an empty chain 1`] = `
{
"body": null,
"calls": [
@@ -4757,342 +4757,6 @@ exports[`coach chat golden transcripts > 16a coach.provider.none via an empty ch
}
`;
exports[`coach chat golden transcripts > 16b an empty chain served by the legacy provider 1`] = `
{
"body": null,
"calls": [
{
"call": "createConversation",
},
{
"call": "appendMessage",
"content": "How is my blood pressure?",
"metricSource": null,
"model": null,
"providerType": null,
"role": "user",
"tokensUsed": null,
},
{
"call": "storeDeterministicFacts",
},
{
"call": "enqueueCoachMemoryRefresh",
},
{
"call": "assertConsentForChain",
"chain": [
"admin-openai",
],
},
{
"amount": 600,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
"owner": "user",
"surface": "coach",
},
{
"call": "runStreamingRawCompletionWithFallback",
"maxTokens": 600,
"messages": [
"user:70927bca288b:747",
],
"providers": [
"admin-openai",
],
"signal": true,
"surface": "coach",
"system": "af01480eff5e:1404",
"temperature": 0.4,
"timeoutMs": 180000,
},
{
"call": "annotate",
"metaKeys": [
"deltas",
],
"name": "coach.stream.deltas",
},
{
"actual": 42,
"attribution": {
"reservedOwner": "user",
"servedBy": "local",
},
"cached": 5,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
"reserved": 600,
},
{
"call": "appendMessage",
"content": "Your systolic averaged 128 mmHg over the window.",
"metricSource": {
"activity": [
{
"count": 1,
"durationMs": 0,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "done",
"stepRef": "s1",
},
{
"durationMs": 0,
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "done",
},
{
"id": "a3",
"label": "Writing the answer…",
"labelKey": "insights.coach.activity.answer",
"phase": "answer",
"round": 1,
"status": "done",
},
],
"groundedFigures": [
128,
82,
46,
105,
35.9375,
56.09756097560976,
],
"metrics": [
"bp",
],
"steps": [
{
"count": 1,
"domain": "snapshot",
"id": "s1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"status": "done",
"tool": "snapshot",
},
],
"windows": [
"last30days",
],
},
"model": "m-local",
"providerType": "local",
"role": "assistant",
"tokensUsed": 42,
},
{
"call": "annotate",
"metaKeys": [
"activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
"promptChars",
"promptVersion",
"provider",
"snapshotSent",
"stop",
"tokens",
"toolMode",
"toolsCalled",
"trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
{
"step": {
"count": 1,
"domain": "snapshot",
"id": "s1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"status": "done",
"tool": "snapshot",
},
"type": "step",
},
{
"activity": {
"count": 1,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "running",
"stepRef": "s1",
},
"type": "activity",
},
{
"activity": {
"count": 1,
"durationMs": 0,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "done",
"stepRef": "s1",
},
"type": "activity",
},
{
"activity": {
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "running",
},
"type": "activity",
},
{
"activity": {
"durationMs": 0,
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "done",
},
"type": "activity",
},
{
"activity": {
"id": "a3",
"label": "Writing the answer…",
"labelKey": "insights.coach.activity.answer",
"phase": "answer",
"round": 1,
"status": "running",
},
"type": "activity",
},
{
"token": "Your ",
"type": "token",
},
{
"token": "systolic ",
"type": "token",
},
{
"token": "averaged ",
"type": "token",
},
{
"token": "128 ",
"type": "token",
},
{
"token": "mmHg ",
"type": "token",
},
{
"token": "over ",
"type": "token",
},
{
"token": "the ",
"type": "token",
},
{
"token": "window.",
"type": "token",
},
{
"metricSource": {
"activity": [
{
"count": 1,
"durationMs": 0,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "done",
"stepRef": "s1",
},
{
"durationMs": 0,
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "done",
},
{
"id": "a3",
"label": "Writing the answer…",
"labelKey": "insights.coach.activity.answer",
"phase": "answer",
"round": 1,
"status": "done",
},
],
"groundedFigures": [
128,
82,
46,
105,
35.9375,
56.09756097560976,
],
"metrics": [
"bp",
],
"steps": [
{
"count": 1,
"domain": "snapshot",
"id": "s1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"status": "done",
"tool": "snapshot",
},
],
"windows": [
"last30days",
],
},
"type": "provenance",
},
{
"conversationId": "c-new",
"messageId": "m2",
"type": "done",
"usage": {
"model": "m-local",
"totalTokens": 42,
},
},
],
"status": 200,
"thrown": null,
}
`;
exports[`coach chat golden transcripts > 16c coach.provider.none from the capability gate 1`] = `
{
"body": null,
@@ -119,7 +119,6 @@ export const m = {
resolveServerLocale: vi.fn(),
runStreamingRawCompletionWithFallback: vi.fn(),
resolveProviderChain: vi.fn(),
resolveProvider: vi.fn(),
assertConsentForChain: vi.fn(),
appendMessage: vi.fn(),
createConversation: vi.fn(),
@@ -201,7 +200,6 @@ export function resetGolden(): void {
m.resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
m.resolveProvider.mockResolvedValue({ type: "none" });
m.assertConsentForChain.mockImplementation(
async (args: { chain: Array<{ providerType: string }> }) => {
record("assertConsentForChain", {
@@ -81,7 +81,6 @@ vi.mock("@/lib/ai/provider-runner", () => ({
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: h.m.resolveProviderChain,
resolveProvider: h.m.resolveProvider,
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: h.m.assertConsentForChain,
@@ -416,21 +415,8 @@ describe("coach chat golden transcripts", () => {
});
// 16 ── no provider ──────────────────────────────────────────────────────
it("16a coach.provider.none via an empty chain and no legacy provider", async () => {
it("16a coach.provider.none via an empty chain", async () => {
m.resolveProviderChain.mockResolvedValue([]);
m.resolveProvider.mockResolvedValue({ type: "none" });
await golden({ message: "How is my blood pressure?" });
});
it("16b an empty chain served by the legacy provider", async () => {
m.resolveProviderChain.mockResolvedValue([]);
m.resolveProvider.mockResolvedValue({
type: "openai",
supportsTools: false,
});
m.runStreamingRawCompletionWithFallback.mockImplementation(
streamingResult("Your systolic averaged 128 mmHg over the window."),
);
await golden({ message: "How is my blood pressure?" });
});
@@ -93,11 +93,7 @@ vi.mock("@/lib/ai/provider", async () => {
);
return {
...actual,
resolveProvider: vi.fn(),
// v1.4.16: the route consults the multi-provider chain
// first; the existing single-provider mocks return an empty chain
// so the route falls through to `resolveProvider()` and the legacy
// test fixtures keep behaving exactly as before.
// Empty unless a test hands the route a provider (`serveFrom`).
resolveProviderChain: vi.fn(async () => []),
};
});
@@ -167,7 +163,7 @@ import { NextRequest } from "next/server";
import { GET, POST, resolveInsightsRateLimit } from "../route";
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
import { resolveProvider } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
@@ -214,8 +210,17 @@ beforeEach(() => {
});
});
type ChainEntries = Awaited<ReturnType<typeof resolveProviderChain>>;
/** The chain the route resolves: one operator-key entry serving `instance`. */
function serveFrom(instance: unknown) {
vi.mocked(resolveProviderChain).mockResolvedValue([
{ providerType: "admin-openai", instance },
] as unknown as ChainEntries);
}
function makeWorkingProvider() {
vi.mocked(resolveProvider).mockResolvedValue({
serveFrom({
type: "openai",
generateCompletion: vi.fn(async () => ({
content: JSON.stringify({
@@ -230,7 +235,7 @@ function makeWorkingProvider() {
providerType: "openai",
model: "gpt-4",
})),
} as unknown as Awaited<ReturnType<typeof resolveProvider>>);
});
}
interface ApiErrorEnvelope {
@@ -253,13 +258,12 @@ function jsonRequest(body: unknown = {}): Request {
function makeProviderThatThrows(
err: Error & { httpStatus?: number; bodyExcerpt?: string },
) {
vi.mocked(resolveProvider).mockResolvedValue({
serveFrom({
type: "openai",
generateCompletion: vi.fn(async () => {
throw err;
}),
// The route only calls generateCompletion; pad the type for TS.
} as unknown as Awaited<ReturnType<typeof resolveProvider>>);
});
}
describe("POST /api/insights/generate — daily token ceiling", () => {
@@ -314,7 +318,8 @@ describe("POST /api/insights/generate — a provider the health ledger benched",
httpStatus: 401,
});
makeProviderThatThrows(err);
const provider = await vi.mocked(resolveProvider)("u-1");
const [{ instance: provider }] =
await vi.mocked(resolveProviderChain)("u-1");
const body = JSON.stringify({ force: true });
const request = new NextRequest("http://localhost/api/insights/generate", {
method: "POST",
@@ -894,7 +899,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
expect(body.data.cached).toBe(true);
expect(body.data.insights).toEqual(cached);
// No completion is ever run on the read path.
expect(resolveProvider).not.toHaveBeenCalled();
expect(resolveProviderChain).not.toHaveBeenCalled();
// A fresh cache (just now) does not trigger a warm — and the payload
// says so, so the client never starts a convergence poll.
expect(enqueueForceWarm).not.toHaveBeenCalled();
@@ -919,7 +924,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
userId: "u-1",
locale: "en",
});
expect(resolveProvider).not.toHaveBeenCalled();
expect(resolveProviderChain).not.toHaveBeenCalled();
// The stale serve is honest: `revalidating: true` rides on the
// payload so the client polls (bounded) until the warm lands.
const body = (await res.json()) as { data: { revalidating: boolean } };
@@ -953,7 +958,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
userId: "u-1",
locale: "en",
});
expect(resolveProvider).not.toHaveBeenCalled();
expect(resolveProviderChain).not.toHaveBeenCalled();
});
it("serves a fresh cache whose tag matches the reader's language", async () => {
@@ -51,7 +51,6 @@ vi.mock("@/lib/ai/coach/budget", () => ({
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(async () => ({ type: "none" })),
resolveProviderChain: vi.fn(async () => []),
}));
@@ -82,7 +81,7 @@ import {
reconcileSpend,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import {
runRawCompletionWithFallback,
AllProvidersFailedError,
@@ -382,9 +381,6 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
it("returns 503 when no provider is configured for the user", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(resolveProviderChain).mockResolvedValueOnce([]);
vi.mocked(resolveProvider).mockResolvedValueOnce({
type: "none",
} as never);
// The status stays 503 (a shipped client may branch on it) and gains the
// code that says what it means.
+8 -12
View File
@@ -72,7 +72,7 @@ import {
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { spentOnFailure } from "@/lib/ai/reasoning/answerless";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import {
@@ -198,17 +198,13 @@ async function handleExtract(request: NextRequest): Promise<Response> {
// surface a structured 503 — the wizard hides the overlay.
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({ action: { name: "medications.extract.no-provider" } });
throw new AiUnavailableError(
"medicationExtract",
"no_provider",
null,
MEDICATION_EXTRACT_NO_PROVIDER,
);
}
chain.push({ providerType: "admin-openai", instance: legacy });
annotate({ action: { name: "medications.extract.no-provider" } });
throw new AiUnavailableError(
"medicationExtract",
"no_provider",
null,
MEDICATION_EXTRACT_NO_PROVIDER,
);
}
// The wire. The runner may cascade through every entry, so each one is