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
+32 -3
View File
@@ -204,21 +204,50 @@ describe("probeProviderChain", () => {
expect(presence.managedBy).toBe("server");
});
it("falls back to the legacy single provider only for an empty chain", async () => {
// A chain whose only entry is switched off resolves empty.
it("finds nothing on a saved chain whose entries are all switched off", async () => {
// The person picked Anthropic, then switched it off in their chain, and
// the operator has a key: neither may serve behind the switch.
load(
row({
aiProviderChain: [
{ providerType: "anthropic", enabled: false, priority: 0 },
{ providerType: "admin-openai", enabled: false, priority: 1 },
],
aiProvider: "ANTHROPIC",
aiAnthropicKeyEncrypted: "enc",
}),
settings({ adminAiKeyEncrypted: "enc" }),
);
const presence = await probeProviderChain("u1", OWNER);
expect(presence.entries).toEqual([]);
expect(presence.managedBy).toBeNull();
});
it("serves the picked gateway on a chain never saved, under its own name", async () => {
// The default chain has no gateway entry, so the gateway the person picked
// is what serves them.
const gateway = {
aiProvider: "OPENAI_COMPATIBLE",
aiCompatBaseUrl: "https://gateway.example/v1",
aiCompatModel: "llama-3",
};
load(row(gateway));
const presence = await probeProviderChain("u1", OWNER);
expect(presence.entries.map((e) => e.providerType)).toEqual([
"admin-openai",
"openai-compatible",
]);
expect(presence.managedBy).toBe("user");
// Once the person saved a chain without it, the chain is their word.
load(
row({
...gateway,
aiProviderChain: [
{ providerType: "local", enabled: true, priority: 1 },
],
}),
);
expect((await probeProviderChain("u1", OWNER)).entries).toEqual([]);
});
it("carries the person's in-browser OCR opt-in", async () => {
@@ -168,4 +168,66 @@ describe("resolveProviderChain", () => {
const chain = await resolveProviderChain("user-1");
expect(chain).toEqual([]);
});
it("stays empty on a saved chain with every entry switched off", async () => {
// Only the local model on, its address missing; the person's own key and
// the operator's key are both switched off. Nothing may serve behind those
// switches, however the record's single provider field reads.
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
aiProvider: "OPENAI",
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: "enc-openai",
aiProviderChain: [
{ providerType: "local", priority: 1, enabled: true },
{ providerType: "openai", priority: 2, enabled: false },
{ providerType: "admin-openai", priority: 3, enabled: false },
],
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: "enc-admin",
adminAiModel: "gpt-4o-mini",
adminAiBaseUrl: "https://api.openai.com/v1",
} as never);
expect(await resolveProviderChain("user-1")).toEqual([]);
});
it("serves the picked gateway, under its own name, on a chain never saved", async () => {
const gatewayRow = {
aiProvider: "OPENAI_COMPATIBLE",
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiCompatBaseUrl: "https://gateway.example/v1",
aiCompatKeyEncrypted: null,
aiCompatModel: "llama-3",
role: "USER",
};
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({ ...gatewayRow, aiProviderChain: null } as never)
.mockResolvedValueOnce({
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
codexTokenExpiresAt: null,
codexConnectionStatus: "disconnected",
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: null,
} as never);
const chain = await resolveProviderChain("user-1");
expect(chain.map((c) => c.providerType)).toEqual(["openai-compatible"]);
// A saved chain without the gateway is the person's word.
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
...gatewayRow,
aiProviderChain: [{ providerType: "local", priority: 1, enabled: true }],
} as never);
expect(await resolveProviderChain("user-1")).toEqual([]);
});
});
+1 -1
View File
@@ -58,7 +58,7 @@ export interface ProviderEntryPresence {
* operator's key alone.
*/
export interface ProviderPresence {
/** Usable entries in chain order, the central Codex and legacy fallback included. */
/** Usable entries in chain order, the central Codex and the picked gateway included. */
entries: readonly ProviderEntryPresence[];
/** The person's in-browser OCR opt-in, which lets a text-only provider read a document. */
localOcrEnabled: boolean;
@@ -214,9 +214,9 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
expect(provider.generateCompletion).not.toHaveBeenCalled();
});
it("refuses when the legacy single-provider fallback serves the request", async () => {
// Empty chain → `resolveProvider` fallback, which is tagged `admin-openai`
// because an admin-key `OpenAIClient` is indistinguishable from a BYOK one.
it("sends nothing on an empty chain, even where the single-provider resolver has one", async () => {
// The chain is the person's switches; an empty one means no provider. The
// single-provider resolver ignores those switches, so it must not be asked.
providerMocks.resolveProviderChain.mockResolvedValue([]);
providerMocks.resolveProvider.mockResolvedValue(provider);
+2 -25
View File
@@ -32,10 +32,8 @@
*/
import {
hasAnyConfiguredProvider,
resolveProvider,
resolveProviderChain,
} from "@/lib/ai/provider";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
import { singleUserTurn } from "@/lib/ai/types";
import {
@@ -154,27 +152,6 @@ export function buildPrompts(
return { systemPrompt, userPrompt };
}
/**
* Resolve the chain that will serve this generation, so the consent gate and
* the daily cap both see the SAME provider set the call will actually use.
* Mirrors `resolveStatusChain` in `@/lib/insights/status-provider`: the chain
* first, then the legacy single provider tagged `admin-openai` — the
* conservative tag, because `resolveProvider`'s admin fallback returns a bare
* `OpenAIClient` that is indistinguishable from a BYOK key at the instance
* level. Tagging it as server-managed makes the gate fail closed. Returns null
* when nothing can serve.
*/
async function resolveQuestionsChain(
userId: string,
): Promise<ProviderChainResolved[] | null> {
const chain = await resolveProviderChain(userId);
if (chain.length > 0) return chain;
const legacy = await resolveProvider(userId);
if (legacy.type === "none") return null;
return [{ providerType: "admin-openai", instance: legacy }];
}
/**
* Derive the pending questions for a freshly saved self-context.
* Never throws — every failure path lands on the deterministic
@@ -208,8 +185,8 @@ export async function deriveClarifyingQuestions(
try {
if (!(await hasAnyConfiguredProvider(userId))) return fallback();
const chain = await resolveQuestionsChain(userId);
if (chain === null) return fallback();
const chain = await resolveProviderChain(userId);
if (chain.length === 0) return fallback();
// The wire re-check — BEFORE the snapshot is built, let alone sent. The
// capability table's `aboutMeQuestions` row decides which receipts cover
+11 -15
View File
@@ -3,7 +3,7 @@
* and decide between tool retrieval and the legacy snapshot path.
*/
import { annotate } from "@/lib/logging/context";
import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import { streamProviderError } from "./sse";
@@ -20,20 +20,16 @@ export async function resolveTurnChain(
// snapshot-stuffing path.
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({
action: { name: "insights.coach.noProvider" },
});
return {
ok: false,
response: streamProviderError({
code: "coach.provider.none",
reason: "no_provider",
}),
};
}
chain.push({ providerType: "admin-openai", instance: legacy });
annotate({
action: { name: "insights.coach.noProvider" },
});
return {
ok: false,
response: streamProviderError({
code: "coach.provider.none",
reason: "no_provider",
}),
};
}
// v1.12.1 — consent gate before any server-managed external egress. When
+54 -185
View File
@@ -25,6 +25,7 @@ import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
import {
parseProviderChain,
PROVIDER_CHAIN_DEFAULT,
PROVIDER_CHAIN_TYPES,
type ProviderChainType,
} from "./provider-chain";
@@ -810,15 +811,10 @@ async function readBoundSettings(userId: string): Promise<{
* from the user's saved credentials. Drop entries that have no
* usable credential (e.g. chain says `anthropic` but
* `aiAnthropicKeyEncrypted` is null).
* 3. Returns the surviving array — possibly empty if the user has
* no configured providers anywhere. Caller raises 422 in that
* case.
*
* Reused by the regular insight-generate route (B5b) AND the v1.4.17
* feedback-attribution path (B5e) — both need the same resolution
* semantics. `resolveProvider()` keeps its single-result shape for
* the legacy `weight-status.ts` / `mood-status.ts` / etc. consumers
* that have not migrated to the chain runner yet.
* 3. Append the operator's central Codex behind the person's opt-in.
* 4. Still empty: the picked gateway on a chain never saved
* (`servesPickedGateway`), else nothing. Every caller treats an
* empty result as "no provider"; none falls back on its own.
*/
export async function resolveProviderChain(
userId: string,
@@ -896,102 +892,17 @@ export async function resolveProviderChain(
}
}
return resolved;
}
/**
* Credential-presence subset of the `User` row that
* `userRowHasProviderCredential` evaluates. Mirrors the columns
* `resolveProviderChain` / `resolveProvider` read, but presence-only —
* no decrypt, no client construction, no token refresh.
*/
export interface ProviderCredentialRow {
aiProvider: string | null;
aiProviderChain: unknown;
aiAnthropicKeyEncrypted: string | null;
aiLocalKeyEncrypted: string | null;
aiOpenaiKeyEncrypted: string | null;
aiBaseUrl: string | null;
aiModel: string | null;
aiCompatBaseUrl: string | null;
aiCompatModel: string | null;
codexConnectionStatus: string | null;
codexAccessTokenEncrypted: string | null;
codexRefreshTokenEncrypted: string | null;
}
/**
* Cheap, synchronous "is any provider configured?" check over an already
* loaded credential row. Mirrors the resolution semantics of
* `resolveProviderChain` + the legacy `resolveProvider` fallback (the
* exact pair `generateComprehensiveInsight` uses to decide
* `skipped: no-provider`), but evaluates credential PRESENCE only —
* it never decrypts a key, constructs a client, or refreshes a Codex
* token. A `true` here can still resolve to a dead provider at call
* time (revoked key, unreachable local host); callers use it to decide
* whether a generation is worth attempting at all, not as a liveness
* probe.
*
* `adminKeyConfigured` is the presence of `appSettings.adminAiKeyEncrypted`
* — passed in so batch callers can read it once for a whole cohort.
*/
export function userRowHasProviderCredential(
row: ProviderCredentialRow,
adminKeyConfigured: boolean,
policy: ProviderCredentialPolicy = "personal",
): boolean {
if (policy === "deny") return false;
if (policy === "operator-default") return adminKeyConfigured;
const codexConnected =
row.codexConnectionStatus === "connected" &&
!!row.codexAccessTokenEncrypted &&
!!row.codexRefreshTokenEncrypted;
const chain = parseProviderChain(row.aiProviderChain ?? null).filter(
(e) => e.enabled,
);
for (const entry of chain) {
switch (entry.providerType) {
case "codex":
if (codexConnected) return true;
break;
case "openai":
if (row.aiOpenaiKeyEncrypted) return true;
break;
case "anthropic":
if (row.aiAnthropicKeyEncrypted) return true;
break;
case "local":
if (row.aiBaseUrl) return true;
break;
case "openai-compatible":
// Presence mirrors `buildCompatProvider`: a gateway needs an address
// AND a model name; the key is optional.
if (row.aiCompatBaseUrl && (row.aiCompatModel || row.aiModel)) {
return true;
}
break;
case "admin-openai":
if (adminKeyConfigured) return true;
break;
if (resolved.length === 0 && userRow && servesPickedGateway(userRow)) {
const gateway = buildCompatProvider(userRow);
if (gateway) {
resolved.push({
providerType: "openai-compatible",
instance: bind(gateway),
});
}
}
// Legacy `resolveProvider()` fallback — only reached when the chain
// resolves empty. Mirrors buildUserProvider → codex → admin in order.
const choice = row.aiProvider?.toUpperCase();
if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return true;
if (choice === "LOCAL" && row.aiBaseUrl) return true;
if (
choice === "OPENAI_COMPATIBLE" &&
row.aiCompatBaseUrl &&
(row.aiCompatModel || row.aiModel)
) {
return true;
}
if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return true;
if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return true;
return adminKeyConfigured;
return resolved;
}
/**
@@ -1038,71 +949,39 @@ function appSettingsCentralCodexConnected(
* the Coach works server-side yet the client saw `null`.
* - null — nothing configured anywhere; no provider can serve.
*
* Presence-only, mirroring `userRowHasProviderCredential`: it never
* decrypts a key, builds a client, or probes liveness.
* Read off the first entry of the presence probe: it never decrypts a key,
* builds a client, or probes liveness.
*/
export type ProviderManagedBy = "user" | "local" | "server";
function resolveManagedByFromRow(
row: ProviderCredentialRow,
adminKeyConfigured: boolean,
policy: ProviderCredentialPolicy = "personal",
): ProviderManagedBy | null {
if (policy === "deny") return null;
if (policy === "operator-default") {
return adminKeyConfigured ? "server" : null;
/** Where the credential behind one chain entry comes from. */
function providerOrigin(providerType: string): ProviderManagedBy {
if (providerType === "local") return "local";
if (providerType === "admin-openai" || providerType === "admin-codex") {
return "server";
}
const codexConnected =
row.codexConnectionStatus === "connected" &&
!!row.codexAccessTokenEncrypted &&
!!row.codexRefreshTokenEncrypted;
return "user";
}
const chain = parseProviderChain(row.aiProviderChain ?? null).filter(
(e) => e.enabled,
/**
* What a chain that resolves empty falls back to. A saved chain is the
* person's own word: an entry they switched off or removed stays out, so an
* empty saved chain means no provider. A chain they never saved is the
* default one (`parseProviderChain` hands back `PROVIDER_CHAIN_DEFAULT`
* itself), which holds every provider a person can pick except the
* OpenAI-compatible gateway; so the gateway they picked under Settings, AI
* serves when nothing in the default chain does. Resolution and the presence
* probe both read this, so they cannot disagree about it.
*/
function servesPickedGateway(row: {
aiProvider: string | null;
aiProviderChain: unknown;
}): boolean {
return (
parseProviderChain(row.aiProviderChain ?? null) ===
PROVIDER_CHAIN_DEFAULT &&
row.aiProvider?.toUpperCase() === "OPENAI_COMPATIBLE"
);
for (const entry of chain) {
switch (entry.providerType) {
case "codex":
if (codexConnected) return "user";
break;
case "openai":
if (row.aiOpenaiKeyEncrypted) return "user";
break;
case "anthropic":
if (row.aiAnthropicKeyEncrypted) return "user";
break;
case "local":
if (row.aiBaseUrl) return "local";
break;
case "openai-compatible":
// The gateway is the user's own egress to an endpoint they chose,
// like a BYO key — not the operator's, and not presumed on-host
// (LiteLLM / OpenRouter are usually remote).
if (row.aiCompatBaseUrl && (row.aiCompatModel || row.aiModel)) {
return "user";
}
break;
case "admin-openai":
if (adminKeyConfigured) return "server";
break;
}
}
// Legacy `resolveProvider()` fallback — only reached when the chain
// resolves empty. Mirrors buildUserProvider → codex → admin in order.
const choice = row.aiProvider?.toUpperCase();
if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return "user";
if (choice === "LOCAL" && row.aiBaseUrl) return "local";
if (
choice === "OPENAI_COMPATIBLE" &&
row.aiCompatBaseUrl &&
(row.aiCompatModel || row.aiModel)
) {
return "user";
}
if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return "user";
if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return "user";
return adminKeyConfigured ? "server" : null;
}
/**
@@ -1176,8 +1055,8 @@ function entryVision(
* `true` can still meet a dead key at call time; the generation path reports
* that as a failed generation. It follows the chain resolution
* (`resolveProviderChain`) entry by entry, appends the operator's central
* Codex only behind the person's opt-in, falls back to the legacy single
* provider only for a chain that resolves empty, and applies the credential
* Codex only behind the person's opt-in, takes the same empty-chain fallback
* (`servesPickedGateway`), and applies the credential
* policy of the provider-work authority: a delegate's chain is empty and a
* guardian's is the operator's key alone.
*
@@ -1265,24 +1144,19 @@ export function probeProviderChain(
const availableTypes: string[] = PROVIDER_CHAIN_TYPES.filter((type) =>
type === "admin-codex" ? centralCodex : credentialPresent(type),
);
if (entries.length === 0 && userRowHasProviderCredential(row, adminKey)) {
// The legacy single-provider fallback, tagged as the operator's slot
// the way every consumer of it tags it (the consent gate included).
entries.push(vision("admin-openai"));
if (
entries.length === 0 &&
servesPickedGateway(row) &&
credentialPresent("openai-compatible")
) {
entries.push(vision("openai-compatible"));
}
let managedBy = resolveManagedByFromRow(row, adminKey, policy);
if (
managedBy === null &&
row.useCentralCodex &&
appSettingsCentralCodexConnected(settings)
) {
managedBy = "server";
}
return {
entries,
localOcrEnabled: row.labsLocalOcrEnabled,
managedBy: entries.length > 0 ? managedBy : null,
managedBy:
entries.length > 0 ? providerOrigin(entries[0].providerType) : null,
availableTypes,
responseTimeoutSeconds: row.aiResponseTimeoutSeconds,
operatorKeyBaseUrl: settings?.adminAiBaseUrl ?? null,
@@ -1481,18 +1355,13 @@ async function resolveProviderForTestUnbound(
// Empty selection ("test my saved config" — the ai-section calls
// /api/ai/test with an empty body): resolve via the SAME path generation
// uses — the first enabled+credentialed entry of `resolveProviderChain`.
// The legacy codex→admin fallback used to probe a different provider than
// generation ran (observed: test→admin-key while generation→codex), so a
// green test gave no signal about whether overnight generation would
// work. Falling through to the chain keeps the two paths in lock-step.
// uses — the first entry of `resolveProviderChain`. The legacy codex→admin
// fallback used to probe a different provider than generation ran
// (observed: test→admin-key while generation→codex), so a green test gave
// no signal about whether overnight generation would work.
if (!provider) {
const chain = await resolveProviderChain(userId);
if (chain.length > 0) return chain[0].instance;
// Chain empty (no enabled+credentialed entry) — mirror the regular
// single-provider resolution one more time before giving up.
const legacy = await resolveProvider(userId);
if (legacy.type !== "none") return legacy;
throw new AITestConfigError(422, "No AI provider configured");
}
@@ -167,6 +167,22 @@ describe("runStatusCompletion — capability at the wire", () => {
expect(ledgerTotal).toBe(0);
});
it("sends nothing on an empty chain, whatever the single-provider resolver holds", async () => {
// The chain carries the person's provider switches; an empty one is no
// provider, never a quiet hop to one they switched off.
resolveProviderChain.mockResolvedValue([]);
resolveProvider.mockResolvedValue({ type: "admin-key" });
const outcome = await runStatusCompletion(
completionArgs({ capability: "statusText" }) as never,
);
expect(outcome).toEqual({ kind: "none", reason: "no_provider" });
expect(resolveProvider).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
expect(ledgerTotal).toBe(0);
});
it("re-checks exactly the resolved chain and refuses before any budget or provider work", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
+3 -10
View File
@@ -5,7 +5,7 @@
*/
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker";
import { annotate } from "@/lib/logging/context";
@@ -19,10 +19,7 @@ import { buildBriefingPrompt } from "./prompt";
/**
* The user's full provider chain, so a hard failure on the primary (401,
* 5xx, network) cascades to the next configured fallback rather than
* surfacing to the dashboard. An empty chain falls back to the
* single-provider resolution when the legacy `aiProvider` field still points
* at something usable, so accounts that never customised the chain keep
* working. Null when there is no provider at all.
* surfacing to the dashboard. Null when there is no provider at all.
*
* The consent gate runs on the resolved chain before anything leaves for a
* model. Required for the operator's global key (`admin-openai`); BYOK /
@@ -33,11 +30,7 @@ export async function resolveConsentedBriefingChain(
userId: string,
): Promise<ProviderChainResolved[] | null> {
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") return null;
chain.push({ providerType: "admin-openai", instance: legacy });
}
if (chain.length === 0) return null;
await assertConsentForChain({ userId, chain, surface: "insights" });
return chain;
}
+2 -6
View File
@@ -91,7 +91,7 @@ import { Prisma as PrismaSql } from "@/generated/prisma/client";
import { dayWeightedRowsSql, windowMeanSql } from "@/lib/measurements/day-mean";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import { AllProvidersFailedError } from "@/lib/ai/provider-runner";
import {
BriefingBudgetExceededError,
@@ -864,11 +864,7 @@ export async function generateComprehensiveInsight(
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
return { status: "skipped", reason: "no-provider" };
}
chain.push({ providerType: "admin-openai", instance: legacy });
return { status: "skipped", reason: "no-provider" };
}
// The wire re-check for exactly this chain (`aiEgressRefusal`): the
+6 -31
View File
@@ -1,17 +1,10 @@
import {
probeProviderPresence,
resolveProvider,
resolveProviderChain,
} from "@/lib/ai/provider";
import { probeProviderPresence, resolveProviderChain } from "@/lib/ai/provider";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
import type {
AiCapabilityKey,
AiUnavailableReason,
} from "@/lib/ai/capabilities/types";
import {
runRawCompletionWithFallback,
type ProviderChainResolved,
} from "@/lib/ai/provider-runner";
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import { spentOnFailure } from "@/lib/ai/reasoning/answerless";
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
import { annotate } from "@/lib/logging/context";
@@ -73,9 +66,8 @@ export function statusRunBoundMs(effectiveTimeoutMs: number): number {
* but-slow generations into the generic fallback.
*
* This helper mirrors the Coach (`chat/route.ts`): resolve the user's
* provider chain, fall back to the legacy single provider when the chain
* is empty, and run `runRawCompletionWithFallback` so a degraded provider
* cascades to the next. The whole thing is still wrapped in
* provider chain and run `runRawCompletionWithFallback` so a degraded
* provider cascades to the next. The whole thing is still wrapped in
* `withTimeout` (`statusRunBoundMs`) so a total stall cannot hold the job —
* but a timeout / error is reported as a transient miss (the caller serves
* the fallback without persisting it), never as the day's cached assessment.
@@ -138,23 +130,6 @@ interface RunStatusCompletionArgs {
reasoningJob?: BackgroundReasoningJob;
}
/**
* Resolve the provider chain for `userId`, falling back to the legacy
* single provider. Returns `null` when the user has no usable provider
* anywhere (the caller surfaces the no-key fallback with
* `hasProvider:false`).
*/
async function resolveStatusChain(
userId: string,
): Promise<ProviderChainResolved[] | null> {
const chain = await resolveProviderChain(userId);
if (chain.length > 0) return chain;
const legacy = await resolveProvider(userId);
if (legacy.type === "none") return null;
return [{ providerType: "admin-openai", instance: legacy }];
}
/**
* Whether any configured provider could serve text for `userId`: the one
* presence probe (`probeProviderPresence`). No client construction, no token
@@ -192,8 +167,8 @@ export async function runStatusCompletion(
return { kind: "none", reason };
}
const chain = await resolveStatusChain(userId);
if (chain === null) {
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
return { kind: "none", reason: "no_provider" };
}
+4 -17
View File
@@ -17,7 +17,7 @@ import {
} from "@/lib/ai/capabilities/refusal";
import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import { chainEntryReadsImages } from "@/lib/ai/codex-slug-chain";
import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf";
import { prisma } from "@/lib/db";
@@ -82,8 +82,9 @@ export interface VisionProviderPick {
/**
* Resolve the user's provider chain and find the first entry that can read
* images. Returns the whole chain (the consent gate needs it) plus the chosen
* vision entry (or null). Mirrors the Coach's chain-then-legacy fallback so a
* user served only by the operator's admin key is still covered.
* vision entry (or null). The chain is the one every model call resolves, so a
* user served only by the operator's admin key is covered through its
* `admin-openai` entry.
*/
export async function resolveVisionProvider(
userId: string,
@@ -108,14 +109,6 @@ export async function resolveVisionProvider(
};
const resolvedChain = await resolveProviderChain(userId);
if (resolvedChain.length === 0) {
// Mirror the Coach: fall back to the legacy single-provider resolution and
// tag it as the admin-managed entry the consent gate recognises.
const legacy = await resolveProvider(userId);
if (legacy.type !== "none") {
resolvedChain.push({ providerType: "admin-openai", instance: legacy });
}
}
// Apply the caller's reorder (document class demotes external providers below
// local) before picking; labs passes no reorder → identity.
@@ -157,12 +150,6 @@ export async function resolveTextProvider(
// The text-mode structuring pass needs no vision — any configured provider in
// the main chain can structure the OCR'd text.
const resolvedChain = await resolveProviderChain(userId);
if (resolvedChain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type !== "none") {
resolvedChain.push({ providerType: "admin-openai", instance: legacy });
}
}
const chain = options?.reorder
? options.reorder(resolvedChain)
: resolvedChain;