mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user