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:
Marc-André Bombeck committed 2026-10-05 07:45:05 +02:00
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 },
]);
});
});
+12 -8
View File
@@ -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),
})),
});
});