mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
fix(coach): offer only providers that can answer and say when a switch does not take
The provider-chain summary marked every entry available, so the Coach's provider select offered entries without a key or sign-in. Choosing one saved it to the front of the chain, the resolver skipped it, and the select jumped back without a word. Availability now comes from the presence probe, which names every chain type whose credential is present. After a switch the picker reads the chain back and says so when the server still answers with another provider.
This commit is contained in:
1 parent
a2ef2eec24
commit
2a556b47c3
14 files changed
+188
-45
No files matched your search
@@ -26,6 +26,12 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
probeProviderChain: vi.fn(async () => ({
|
||||
entries: [],
|
||||
localOcrEnabled: false,
|
||||
managedBy: null,
|
||||
availableTypes: ["codex", "openai", "admin-openai"],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
@@ -46,7 +52,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { probeProviderChain, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { getLastWorkingProvider } from "@/lib/ai/provider-runner";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
@@ -176,4 +182,37 @@ describe("GET /api/insights/provider-chain", () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("marks an entry without a credential as unavailable, enabled or not", async () => {
|
||||
// The Coach's provider select offers only available entries; an entry
|
||||
// marked available without a key was saved to the front of the chain
|
||||
// and never answered.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProviderChain: [
|
||||
{ providerType: "codex", priority: 1, enabled: true },
|
||||
{ providerType: "anthropic", priority: 2, enabled: true },
|
||||
{ providerType: "openai", priority: 3, enabled: false },
|
||||
{ providerType: "local", priority: 4, enabled: true },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(probeProviderChain).mockResolvedValueOnce({
|
||||
entries: [{ providerType: "codex", vision: false }],
|
||||
localOcrEnabled: false,
|
||||
managedBy: "user",
|
||||
availableTypes: ["codex", "openai"],
|
||||
});
|
||||
vi.mocked(resolveProviderChain).mockResolvedValue([
|
||||
{ providerType: "codex", instance: { type: "codex" } as never },
|
||||
]);
|
||||
vi.mocked(getLastWorkingProvider).mockReturnValue(null);
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const body = (await res.json()) as Envelope;
|
||||
expect(body.data?.configuredChain).toEqual([
|
||||
{ providerType: "codex", enabled: true, available: true },
|
||||
{ providerType: "anthropic", enabled: true, available: false },
|
||||
{ providerType: "openai", enabled: false, available: true },
|
||||
{ providerType: "local", enabled: true, available: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { probeProviderChain, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import {
|
||||
forgetLastWorkingProvider,
|
||||
getLastWorkingProvider,
|
||||
@@ -26,11 +26,11 @@ import { providerChainPutSchema } from "@/lib/validations/ai-provider";
|
||||
* in-process cache, when one is set. Surfaces "Active: codex
|
||||
* (cached: openai)" so a user can see the runner has rerouted to
|
||||
* a fallback without digging through logs.
|
||||
* - `configuredChain`: ordered list of `{providerType, available}`
|
||||
* entries. `available` is always true here since the chain
|
||||
* resolver only returns instantiable entries; the field is wired
|
||||
* for the v1.4.17 UX where unconfigured slots should still render
|
||||
* in the list with a "needs setup" pill.
|
||||
* - `configuredChain`: ordered list of `{providerType, enabled,
|
||||
* available}` entries. `available` is the presence probe's own
|
||||
* answer for that entry's credential (`probeProviderChain`), enabled
|
||||
* or not: an entry without a key, address or sign-in is listed but
|
||||
* cannot answer, so a picker must not offer it.
|
||||
*
|
||||
* v1.4.16 phase B2 added the matching `PUT` for the new dropdown-driven
|
||||
* settings UX — the user reorders/toggles entries in the section and
|
||||
@@ -58,7 +58,11 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
const persisted = parseProviderChain(userRow?.aiProviderChain ?? null);
|
||||
|
||||
const resolved = await resolveProviderChain(user.id);
|
||||
const [resolved, presence] = await Promise.all([
|
||||
resolveProviderChain(user.id),
|
||||
probeProviderChain(user.id),
|
||||
]);
|
||||
const availableTypes = new Set(presence.availableTypes ?? []);
|
||||
const cached = getLastWorkingProvider(user.id);
|
||||
|
||||
annotate({
|
||||
@@ -77,7 +81,7 @@ export const GET = apiHandler(async () => {
|
||||
configuredChain: persisted.map((entry) => ({
|
||||
providerType: entry.providerType,
|
||||
enabled: entry.enabled,
|
||||
available: true,
|
||||
available: availableTypes.has(entry.providerType),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user