mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
+2
-1
@@ -3137,7 +3137,8 @@
|
||||
"noProvider": "Kein Anbieter verfügbar",
|
||||
"saving": "Wird gespeichert…",
|
||||
"saveFailed": "Die Änderung ließ sich nicht speichern.",
|
||||
"allSettings": "Alle KI-Einstellungen"
|
||||
"allSettings": "Alle KI-Einstellungen",
|
||||
"providerNotActive": "{provider} konnte nicht übernehmen: Es fehlt eine funktionierende Anmeldung oder ein Schlüssel. Richte das in den KI-Einstellungen ein."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Zur neuesten Nachricht",
|
||||
|
||||
+2
-1
@@ -3137,7 +3137,8 @@
|
||||
"noProvider": "No provider available",
|
||||
"saving": "Saving…",
|
||||
"saveFailed": "That change could not be saved.",
|
||||
"allSettings": "All AI settings"
|
||||
"allSettings": "All AI settings",
|
||||
"providerNotActive": "{provider} could not take over: it has no working sign-in or key. Set it up in AI settings."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Jump to the latest message",
|
||||
|
||||
+2
-1
@@ -3137,7 +3137,8 @@
|
||||
"noProvider": "Ningún proveedor disponible",
|
||||
"saving": "Guardando…",
|
||||
"saveFailed": "No se pudo guardar el cambio.",
|
||||
"allSettings": "Todos los ajustes de IA"
|
||||
"allSettings": "Todos los ajustes de IA",
|
||||
"providerNotActive": "{provider} no pudo encargarse: no tiene un inicio de sesión ni una clave que funcionen. Configúralo en los ajustes de IA."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Ir al último mensaje",
|
||||
|
||||
+2
-1
@@ -3137,7 +3137,8 @@
|
||||
"noProvider": "Aucun fournisseur disponible",
|
||||
"saving": "Enregistrement…",
|
||||
"saveFailed": "Impossible d’enregistrer cette modification.",
|
||||
"allSettings": "Tous les paramètres IA"
|
||||
"allSettings": "Tous les paramètres IA",
|
||||
"providerNotActive": "{provider} n'a pas pu prendre le relais : aucune connexion ni clé valide. Configurez-le dans les réglages IA."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Aller au dernier message",
|
||||
|
||||
+2
-1
@@ -3137,7 +3137,8 @@
|
||||
"noProvider": "Nessun fornitore disponibile",
|
||||
"saving": "Salvataggio…",
|
||||
"saveFailed": "Impossibile salvare la modifica.",
|
||||
"allSettings": "Tutte le impostazioni IA"
|
||||
"allSettings": "Tutte le impostazioni IA",
|
||||
"providerNotActive": "{provider} non è subentrato: manca un accesso o una chiave funzionante. Configuralo nelle impostazioni IA."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Vai all'ultimo messaggio",
|
||||
|
||||
+2
-1
@@ -3137,7 +3137,8 @@
|
||||
"noProvider": "사용할 수 있는 제공자가 없어요",
|
||||
"saving": "저장 중…",
|
||||
"saveFailed": "변경 사항을 저장하지 못했어요.",
|
||||
"allSettings": "모든 AI 설정"
|
||||
"allSettings": "모든 AI 설정",
|
||||
"providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "최신 메시지로 이동",
|
||||
|
||||
+2
-1
@@ -3137,7 +3137,8 @@
|
||||
"noProvider": "Brak dostępnego dostawcy",
|
||||
"saving": "Zapisywanie…",
|
||||
"saveFailed": "Nie udało się zapisać zmiany.",
|
||||
"allSettings": "Wszystkie ustawienia AI"
|
||||
"allSettings": "Wszystkie ustawienia AI",
|
||||
"providerNotActive": "{provider} nie mógł przejąć odpowiedzi: brak działającego logowania lub klucza. Skonfiguruj go w ustawieniach AI."
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "Przejdź do najnowszej wiadomości",
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
modelControlFor,
|
||||
reasoningFieldFor,
|
||||
selectableProviders,
|
||||
switchDidNotTake,
|
||||
} from "../coach-model-picker";
|
||||
|
||||
const CHAIN: ProviderChainData["configuredChain"] = [
|
||||
@@ -72,6 +73,23 @@ describe("selectableProviders", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("switchDidNotTake", () => {
|
||||
it("is quiet when the chosen provider leads the chain the server resolves", () => {
|
||||
expect(switchDidNotTake("openai", { activeProvider: "openai" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("speaks up when the server still answers with another provider", () => {
|
||||
// A provider without a working key was saved to the front of the chain;
|
||||
// the resolver skipped it and the select would jump back silently.
|
||||
expect(switchDidNotTake("anthropic", { activeProvider: "openai" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(switchDidNotTake("anthropic", { activeProvider: null })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelControlFor", () => {
|
||||
it("offers the presets for the provider whose key the person saved", () => {
|
||||
const control = modelControlFor("openai", userProvider());
|
||||
|
||||
@@ -70,6 +70,18 @@ export function selectableProviders(
|
||||
.map((entry) => entry.providerType);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a saved switch did not take: the chain the server resolves after
|
||||
* the save is not led by the provider the person chose. A provider whose key
|
||||
* stops working between the read and the save lands here.
|
||||
*/
|
||||
export function switchDidNotTake(
|
||||
chosen: ProviderType,
|
||||
fresh: Pick<ProviderChainData, "activeProvider">,
|
||||
): boolean {
|
||||
return fresh.activeProvider !== chosen;
|
||||
}
|
||||
|
||||
export type ModelControl =
|
||||
| { kind: "preset"; presets: readonly string[]; value: string | null }
|
||||
| { kind: "gateway"; value: string | null }
|
||||
@@ -139,6 +151,7 @@ export function CoachModelPicker() {
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const onSaved = () => {
|
||||
setError(null);
|
||||
@@ -152,11 +165,26 @@ export function CoachModelPicker() {
|
||||
);
|
||||
|
||||
const switchProvider = useMutation({
|
||||
mutationFn: (provider: ProviderType) =>
|
||||
apiPut("/api/insights/provider-chain", {
|
||||
mutationFn: async (provider: ProviderType) => {
|
||||
setNotice(null);
|
||||
await apiPut("/api/insights/provider-chain", {
|
||||
chain: chainWithFirst(chainQuery.data?.configuredChain ?? [], provider),
|
||||
}),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
// Read back what the server now resolves, so a switch that did not
|
||||
// take is said rather than shown as the select jumping back.
|
||||
return apiGet<ProviderChainData>("/api/insights/provider-chain");
|
||||
},
|
||||
onSuccess: (fresh, provider) => {
|
||||
queryClient.setQueryData(queryKeys.insightsProviderChain(), fresh);
|
||||
onSaved();
|
||||
if (switchDidNotTake(provider, fresh)) {
|
||||
setNotice(
|
||||
t("insights.coach.frame.providerNotActive", {
|
||||
provider: t(`settings.ai.providerChain.types.${provider}`),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: onFailed,
|
||||
});
|
||||
const patchProvider = useMutation({
|
||||
@@ -294,6 +322,15 @@ export function CoachModelPicker() {
|
||||
{t("insights.coach.frame.saving")}
|
||||
</p>
|
||||
) : null}
|
||||
{notice ? (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="coach-quick-provider-notice"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{error}
|
||||
|
||||
@@ -90,6 +90,7 @@ describe("probeProviderChain", () => {
|
||||
entries: [],
|
||||
localOcrEnabled: false,
|
||||
managedBy: null,
|
||||
availableTypes: [],
|
||||
});
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -123,6 +124,22 @@ describe("probeProviderChain", () => {
|
||||
expect(presence.managedBy).toBe("user");
|
||||
});
|
||||
|
||||
it("names every type whose credential is present, enabled in the chain or not", async () => {
|
||||
load(
|
||||
row({
|
||||
aiProviderChain: [
|
||||
{ providerType: "codex", enabled: true, priority: 0 },
|
||||
{ providerType: "openai", enabled: false, priority: 1 },
|
||||
{ providerType: "anthropic", enabled: true, priority: 2 },
|
||||
],
|
||||
aiOpenaiKeyEncrypted: "enc",
|
||||
}),
|
||||
);
|
||||
const presence = await probeProviderChain("u1", OWNER);
|
||||
// Codex is not signed in and no Anthropic key is saved: neither can answer.
|
||||
expect(presence.availableTypes).toEqual(["openai"]);
|
||||
});
|
||||
|
||||
it("marks which entries can read an image", async () => {
|
||||
load(
|
||||
row({
|
||||
@@ -162,6 +179,7 @@ describe("probeProviderChain", () => {
|
||||
entries: [{ providerType: "admin-openai", vision: true }],
|
||||
localOcrEnabled: false,
|
||||
managedBy: "server",
|
||||
availableTypes: ["admin-openai"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,6 +60,12 @@ export interface ProviderPresence {
|
||||
/** The person's in-browser OCR opt-in, which lets a text-only provider read a document. */
|
||||
localOcrEnabled: boolean;
|
||||
managedBy: AiProviderManagedBy | null;
|
||||
/**
|
||||
* Every chain tag whose credential is present, whether or not the person
|
||||
* enabled it in their chain: what a chain editor may offer as able to
|
||||
* answer. Absent reads as none, so a hand-built presence offers nothing.
|
||||
*/
|
||||
availableTypes?: readonly string[];
|
||||
/**
|
||||
* The record's `aiResponseTimeoutSeconds`, read off the same row. Not a
|
||||
* presence fact, but the account payload publishes the effective timeout
|
||||
|
||||
+39
-25
@@ -16,7 +16,11 @@ import {
|
||||
} from "./codex-oauth";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { isLocalAiHostAllowed } from "./local-host-allowlist";
|
||||
import { parseProviderChain, type ProviderChainType } from "./provider-chain";
|
||||
import {
|
||||
parseProviderChain,
|
||||
PROVIDER_CHAIN_TYPES,
|
||||
type ProviderChainType,
|
||||
} from "./provider-chain";
|
||||
import type { ProviderChainResolved } from "./provider-runner";
|
||||
import {
|
||||
providerCredentialPolicy,
|
||||
@@ -917,6 +921,7 @@ const NO_PRESENCE: ProviderPresence = Object.freeze({
|
||||
entries: [],
|
||||
localOcrEnabled: false,
|
||||
managedBy: null,
|
||||
availableTypes: [],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -995,6 +1000,7 @@ export function probeProviderChain(
|
||||
// A guardian's in-browser OCR is not the record's opt-in to use.
|
||||
localOcrEnabled: false,
|
||||
managedBy: adminKey ? "server" : null,
|
||||
availableTypes: adminKey ? ["admin-openai"] : [],
|
||||
responseTimeoutSeconds: row.aiResponseTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
@@ -1003,36 +1009,43 @@ export function probeProviderChain(
|
||||
row.codexConnectionStatus === "connected" &&
|
||||
!!row.codexAccessTokenEncrypted &&
|
||||
!!row.codexRefreshTokenEncrypted;
|
||||
const credentialPresent = (providerType: ProviderChainType): boolean => {
|
||||
switch (providerType) {
|
||||
case "codex":
|
||||
return codexConnected;
|
||||
case "openai":
|
||||
return !!row.aiOpenaiKeyEncrypted;
|
||||
case "anthropic":
|
||||
return !!row.aiAnthropicKeyEncrypted;
|
||||
case "local":
|
||||
return !!row.aiBaseUrl;
|
||||
case "openai-compatible":
|
||||
return !!(
|
||||
row.aiCompatBaseUrl &&
|
||||
(row.aiCompatModel || row.aiModel)
|
||||
);
|
||||
case "admin-openai":
|
||||
return adminKey;
|
||||
default:
|
||||
// `admin-codex` is never resolved from a stored chain entry.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const centralCodex =
|
||||
row.useCentralCodex && appSettingsCentralCodexConnected(settings);
|
||||
const entries: ProviderEntryPresence[] = [];
|
||||
for (const entry of parseProviderChain(row.aiProviderChain ?? null)) {
|
||||
if (!entry.enabled) continue;
|
||||
const present = (() => {
|
||||
switch (entry.providerType) {
|
||||
case "codex":
|
||||
return codexConnected;
|
||||
case "openai":
|
||||
return !!row.aiOpenaiKeyEncrypted;
|
||||
case "anthropic":
|
||||
return !!row.aiAnthropicKeyEncrypted;
|
||||
case "local":
|
||||
return !!row.aiBaseUrl;
|
||||
case "openai-compatible":
|
||||
return !!(
|
||||
row.aiCompatBaseUrl &&
|
||||
(row.aiCompatModel || row.aiModel)
|
||||
);
|
||||
case "admin-openai":
|
||||
return adminKey;
|
||||
default:
|
||||
// `admin-codex` is never resolved from a stored chain entry.
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
if (present) entries.push(vision(entry.providerType));
|
||||
if (credentialPresent(entry.providerType)) {
|
||||
entries.push(vision(entry.providerType));
|
||||
}
|
||||
}
|
||||
if (row.useCentralCodex && appSettingsCentralCodexConnected(settings)) {
|
||||
if (centralCodex) {
|
||||
entries.push(vision("admin-codex"));
|
||||
}
|
||||
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).
|
||||
@@ -1051,6 +1064,7 @@ export function probeProviderChain(
|
||||
entries,
|
||||
localOcrEnabled: row.labsLocalOcrEnabled,
|
||||
managedBy: entries.length > 0 ? managedBy : null,
|
||||
availableTypes,
|
||||
responseTimeoutSeconds: row.aiResponseTimeoutSeconds,
|
||||
};
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user