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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: {} },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user