diff --git a/messages/de.json b/messages/de.json
index 334ca82d0..e129a0e77 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index 6287e5518..936b669a6 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index d276a21c1..79a461607 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index bdc8fe468..cbccefe22 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index f58556352..6e50a69ab 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/ko.json b/messages/ko.json
index 9fb976c03..4ecc63940 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -3137,7 +3137,8 @@
"noProvider": "사용할 수 있는 제공자가 없어요",
"saving": "저장 중…",
"saveFailed": "변경 사항을 저장하지 못했어요.",
- "allSettings": "모든 AI 설정"
+ "allSettings": "모든 AI 설정",
+ "providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요."
},
"answer": {
"scrollToBottom": "최신 메시지로 이동",
diff --git a/messages/pl.json b/messages/pl.json
index 98c255698..b324f39b2 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
diff --git a/src/app/api/insights/provider-chain/__tests__/route.test.ts b/src/app/api/insights/provider-chain/__tests__/route.test.ts
index efb66afab..e66ca14e8 100644
--- a/src/app/api/insights/provider-chain/__tests__/route.test.ts
+++ b/src/app/api/insights/provider-chain/__tests__/route.test.ts
@@ -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
+ {notice} +
+ ) : null} {error ? ({error} diff --git a/src/lib/ai/__tests__/provider-presence.test.ts b/src/lib/ai/__tests__/provider-presence.test.ts index 502b4f1e6..5662de426 100644 --- a/src/lib/ai/__tests__/provider-presence.test.ts +++ b/src/lib/ai/__tests__/provider-presence.test.ts @@ -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"], }); }); diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index 5b97aee38..0152a7ac7 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -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 diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index a88e6e5ad..4f28958f1 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -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, }; },