From e098fc882f8889d867ece37f801100b336b2a738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:29:11 +0100 Subject: [PATCH] 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. --- .../golden-transcripts.test.ts.snap | 338 +----------------- .../insights/chat/__tests__/golden-harness.ts | 2 - .../chat/__tests__/golden-transcripts.test.ts | 16 +- .../insights/generate/__tests__/route.test.ts | 35 +- .../extract/__tests__/route.test.ts | 6 +- src/app/api/medications/extract/route.ts | 20 +- .../ai/__tests__/provider-presence.test.ts | 35 +- src/lib/ai/__tests__/resolve-chain.test.ts | 62 ++++ src/lib/ai/capabilities/resolve.ts | 2 +- .../self-context-questions-consent.test.ts | 6 +- src/lib/ai/coach/self-context-questions.ts | 27 +- src/lib/ai/coach/turn/chain.ts | 26 +- src/lib/ai/provider.ts | 239 +++---------- .../__tests__/status-provider-budget.test.ts | 16 + src/lib/insights/briefing-request/generate.ts | 13 +- src/lib/insights/comprehensive-generate.ts | 8 +- src/lib/insights/status-provider.ts | 37 +- src/lib/labs/ocr-capability.ts | 21 +- 18 files changed, 227 insertions(+), 682 deletions(-) diff --git a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap index 0ba896537..67543a14b 100644 --- a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap +++ b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap @@ -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, diff --git a/src/app/api/insights/chat/__tests__/golden-harness.ts b/src/app/api/insights/chat/__tests__/golden-harness.ts index e08e7c9aa..d34fb3c1f 100644 --- a/src/app/api/insights/chat/__tests__/golden-harness.ts +++ b/src/app/api/insights/chat/__tests__/golden-harness.ts @@ -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", { diff --git a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts index 563defbfa..47b953db5 100644 --- a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts +++ b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts @@ -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?" }); }); diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 22eb6abba..cef1e81b7 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -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>; + +/** 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>); + }); } 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>); + }); } 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 () => { diff --git a/src/app/api/medications/extract/__tests__/route.test.ts b/src/app/api/medications/extract/__tests__/route.test.ts index 81d06c85b..0a9b558f1 100644 --- a/src/app/api/medications/extract/__tests__/route.test.ts +++ b/src/app/api/medications/extract/__tests__/route.test.ts @@ -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. diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index b0086519f..a4a190050 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -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 { // 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 diff --git a/src/lib/ai/__tests__/provider-presence.test.ts b/src/lib/ai/__tests__/provider-presence.test.ts index bbafe7d89..87b19c213 100644 --- a/src/lib/ai/__tests__/provider-presence.test.ts +++ b/src/lib/ai/__tests__/provider-presence.test.ts @@ -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 () => { diff --git a/src/lib/ai/__tests__/resolve-chain.test.ts b/src/lib/ai/__tests__/resolve-chain.test.ts index 1d2e51587..d25ac7afb 100644 --- a/src/lib/ai/__tests__/resolve-chain.test.ts +++ b/src/lib/ai/__tests__/resolve-chain.test.ts @@ -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([]); + }); }); diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index 945f56bdb..32b725147 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -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; diff --git a/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts index 8fdd10443..18dffda7f 100644 --- a/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts +++ b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts @@ -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); diff --git a/src/lib/ai/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index ac0959832..f0f579a5c 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -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 { - 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 diff --git a/src/lib/ai/coach/turn/chain.ts b/src/lib/ai/coach/turn/chain.ts index 87eba7ab3..45a1bb0a2 100644 --- a/src/lib/ai/coach/turn/chain.ts +++ b/src/lib/ai/coach/turn/chain.ts @@ -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 diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 0dc9da751..3e3d60cd8 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -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"); } diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index 046eb60bd..247c91459 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -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: {} }, diff --git a/src/lib/insights/briefing-request/generate.ts b/src/lib/insights/briefing-request/generate.ts index 4ab6029be..a74337880 100644 --- a/src/lib/insights/briefing-request/generate.ts +++ b/src/lib/insights/briefing-request/generate.ts @@ -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 { 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; } diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index a5fb5d5a9..5d1f6a0f1 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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 diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 37750e7c5..ca5e040a8 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -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 { - 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" }; } diff --git a/src/lib/labs/ocr-capability.ts b/src/lib/labs/ocr-capability.ts index e5787d2b5..12c2494ea 100644 --- a/src/lib/labs/ocr-capability.ts +++ b/src/lib/labs/ocr-capability.ts @@ -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;