From 1be2392c8170b21a95605515cfe945e7c062e10b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 07:39:56 +0200 Subject: [PATCH] feat(ai): one provider presence probe probeProviderChain describes the chain that would serve a record without decrypting, building a client or refreshing a token: which entries hold a credential, in chain order, whether each reads images, the in-browser OCR opt-in, and where the credential comes from. It applies the provider-work authority, appends the central Codex only behind the opt-in, and falls back to the legacy provider only for an empty chain. hasAnyConfiguredProvider and resolveProviderAvailability now answer through it. --- src/__tests__/sharing-surface-guard.test.ts | 20 +- .../ai/__tests__/provider-presence.test.ts | 251 ++++++++++++++ src/lib/ai/__tests__/provider.test.ts | 2 - src/lib/ai/provider.ts | 310 ++++++++++++------ 4 files changed, 480 insertions(+), 103 deletions(-) create mode 100644 src/lib/ai/__tests__/provider-presence.test.ts diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index fc7fc4016..4e87fbaae 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -2953,8 +2953,7 @@ describe("(g) the MANAGE route set is frozen", () => { for (const name of [ "resolveProvider", "resolveProviderChain", - "hasAnyConfiguredProvider", - "resolveProviderAvailability", + "probeProviderChain", ]) { const resolver = functionSource("lib/ai/provider.ts", name); expect(resolver.length, `${name} not found`).toBeGreaterThan(0); @@ -2962,6 +2961,23 @@ describe("(g) the MANAGE route set is frozen", () => { "providerCredentialPolicy", ); } + // The presence helpers answer through the one probe, which applies the + // policy above; a helper that read credentials itself would be a second + // definition of presence with its own chance to skip the policy. + for (const name of [ + "hasAnyConfiguredProvider", + "resolveProviderAvailability", + "probeProviderPresence", + ]) { + const helper = functionSource("lib/ai/provider.ts", name); + expect(helper.length, `${name} not found`).toBeGreaterThan(0); + expect(helper, `${name} bypasses the presence probe`).toMatch( + /probeProvider(?:Chain|Presence)\(/, + ); + expect(helper, `${name} reads credentials itself`).not.toContain( + "prisma.", + ); + } }); it("import completion retains bounded record and actor attribution", () => { diff --git a/src/lib/ai/__tests__/provider-presence.test.ts b/src/lib/ai/__tests__/provider-presence.test.ts new file mode 100644 index 000000000..502b4f1e6 --- /dev/null +++ b/src/lib/ai/__tests__/provider-presence.test.ts @@ -0,0 +1,251 @@ +/** + * The one provider-presence definition (`probeProviderChain` / + * `probeProviderPresence`): what it counts, in which order, and what it never + * does (decrypt, build a client, refresh a token). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn(() => { + throw new Error("the presence probe must never decrypt"); + }), + encrypt: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority"; + +import { + hasAnyConfiguredProvider, + probeProviderChain, + probeProviderPresence, + resolveProviderAvailability, +} from "../provider"; + +const OWNER: ProviderWorkAuthority = { + origin: "owner", + recordUserId: "u1", + actorUserId: "u1", + grantId: null, +}; + +function row(overrides: Record = {}) { + return { + aiProvider: null, + aiProviderChain: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + aiBaseUrl: null, + aiCompatBaseUrl: null, + aiCompatModel: null, + aiModel: null, + codexConnectionStatus: null, + codexAccessTokenEncrypted: null, + codexRefreshTokenEncrypted: null, + useCentralCodex: false, + managedProfileAt: null, + labsLocalOcrEnabled: false, + ...overrides, + }; +} + +function settings(overrides: Record = {}) { + return { + adminAiKeyEncrypted: null, + adminAiModel: null, + adminCodexConnectionStatus: null, + adminCodexAccessTokenEncrypted: null, + adminCodexRefreshTokenEncrypted: null, + adminCodexAccountIdEncrypted: null, + ...overrides, + }; +} + +function load(user: unknown, app: unknown = settings()) { + vi.mocked(prisma.user.findUnique).mockResolvedValue(user as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(app as never); +} + +beforeEach(() => { + vi.mocked(prisma.user.findUnique).mockReset(); + vi.mocked(prisma.appSettings.findUnique).mockReset(); +}); + +describe("probeProviderChain", () => { + it("reads nothing for a delegate, and finds nothing", async () => { + const presence = await probeProviderChain("u1", { + origin: "delegate", + recordUserId: "u1", + actorUserId: "u2", + grantId: "g1", + }); + expect(presence).toEqual({ + entries: [], + localOcrEnabled: false, + managedBy: null, + }); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("finds nothing on an account with nothing configured", async () => { + load(row()); + const presence = await probeProviderChain("u1", OWNER); + expect(presence.entries).toEqual([]); + expect(presence.managedBy).toBeNull(); + }); + + it("lists the person's own credentials in chain order, presence only", async () => { + load( + row({ + aiProviderChain: [ + { providerType: "anthropic", enabled: true, priority: 0 }, + { providerType: "local", enabled: true, priority: 1 }, + { providerType: "openai", enabled: false, priority: 2 }, + ], + aiAnthropicKeyEncrypted: "enc", + aiOpenaiKeyEncrypted: "enc", + aiBaseUrl: "http://10.0.0.5:11434/v1", + aiModel: "claude-sonnet-4-6", + }), + ); + const presence = await probeProviderChain("u1", OWNER); + expect(presence.entries.map((e) => e.providerType)).toEqual([ + "anthropic", + "local", + ]); + expect(presence.managedBy).toBe("user"); + }); + + it("marks which entries can read an image", async () => { + load( + row({ + aiProviderChain: [ + { providerType: "openai", enabled: true, priority: 0 }, + { providerType: "local", enabled: true, priority: 1 }, + ], + aiOpenaiKeyEncrypted: "enc", + aiBaseUrl: "http://10.0.0.5:11434/v1", + aiModel: "gpt-3.5-turbo", + }), + ); + const presence = await probeProviderChain("u1", OWNER); + expect(presence.entries).toEqual([ + { providerType: "openai", vision: false }, + // A self-hosted model is trusted to read images. + { providerType: "local", vision: true }, + ]); + }); + + it("gives a guardian the operator's key and nothing of the record's own", async () => { + load( + row({ + managedProfileAt: new Date(), + aiAnthropicKeyEncrypted: "enc", + labsLocalOcrEnabled: true, + }), + settings({ adminAiKeyEncrypted: "enc", adminAiModel: "gpt-4o" }), + ); + const presence = await probeProviderChain("u1", { + origin: "guardian", + recordUserId: "u1", + actorUserId: "u2", + grantId: "g1", + }); + expect(presence).toEqual({ + entries: [{ providerType: "admin-openai", vision: true }], + localOcrEnabled: false, + managedBy: "server", + }); + }); + + it("appends the operator's central Codex only behind the opt-in", async () => { + const connected = settings({ + adminCodexConnectionStatus: "connected", + adminCodexAccessTokenEncrypted: "a", + adminCodexRefreshTokenEncrypted: "r", + adminCodexAccountIdEncrypted: "i", + }); + load(row({ aiProviderChain: [] }), connected); + expect((await probeProviderChain("u1", OWNER)).entries).toEqual([]); + + load(row({ aiProviderChain: [], useCentralCodex: true }), connected); + const presence = await probeProviderChain("u1", OWNER); + expect(presence.entries.map((e) => e.providerType)).toEqual([ + "admin-codex", + ]); + 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. + load( + row({ + aiProviderChain: [ + { providerType: "anthropic", enabled: false, priority: 0 }, + ], + aiProvider: "ANTHROPIC", + aiAnthropicKeyEncrypted: "enc", + }), + ); + const presence = await probeProviderChain("u1", OWNER); + expect(presence.entries.map((e) => e.providerType)).toEqual([ + "admin-openai", + ]); + }); + + it("carries the person's in-browser OCR opt-in", async () => { + load(row({ labsLocalOcrEnabled: true })); + expect((await probeProviderChain("u1", OWNER)).localOcrEnabled).toBe(true); + }); +}); + +describe("probeProviderPresence", () => { + const textOnly = row({ + aiProviderChain: [{ providerType: "openai", enabled: true, priority: 0 }], + aiOpenaiKeyEncrypted: "enc", + aiModel: "gpt-3.5-turbo", + }); + + it("serves text from any entry", async () => { + load(textOnly); + expect(await probeProviderPresence("u1", "text")).toBe(true); + }); + + it("serves a document only from a vision entry, or text plus in-browser OCR", async () => { + load(textOnly); + expect(await probeProviderPresence("u1", "document")).toBe(false); + load({ ...textOnly, labsLocalOcrEnabled: true }); + expect(await probeProviderPresence("u1", "document")).toBe(true); + }); +}); + +describe("the helpers that answer through the probe", () => { + it("agree with it", async () => { + load( + row({ + aiProviderChain: [ + { providerType: "admin-openai", enabled: true, priority: 0 }, + ], + }), + settings({ adminAiKeyEncrypted: "enc" }), + ); + expect(await hasAnyConfiguredProvider("u1")).toBe(true); + expect(await resolveProviderAvailability("u1")).toEqual({ + aiAvailable: true, + managedBy: "server", + }); + load(row({ aiProviderChain: [] })); + expect(await hasAnyConfiguredProvider("u1")).toBe(false); + expect(await resolveProviderAvailability("u1")).toEqual({ + aiAvailable: false, + managedBy: null, + }); + }); +}); diff --git a/src/lib/ai/__tests__/provider.test.ts b/src/lib/ai/__tests__/provider.test.ts index f4dced3b0..a0490dd41 100644 --- a/src/lib/ai/__tests__/provider.test.ts +++ b/src/lib/ai/__tests__/provider.test.ts @@ -440,7 +440,5 @@ describe("resolveProviderAvailability", () => { const result = await resolveProviderAvailability("user-123"); expect(result).toEqual({ aiAvailable: false, managedBy: null }); - // No admin lookup needed once the user row is absent. - expect(prisma.appSettings.findUnique).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 017e4b4fe..6d788d6d2 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -1,7 +1,7 @@ import type { AIProvider, CompletionResult } from "./types"; import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; -import { CodexClient } from "./codex-client"; +import { CodexClient, resolveCodexVisionSlug } from "./codex-client"; import { OpenAIClient } from "./openai-client"; import { AnthropicClient } from "./anthropic-client"; import { LocalOpenAICompatibleClient } from "./local-client"; @@ -20,7 +20,18 @@ import { providerCredentialPolicy, providerWorkAuthorityForRecord, type ProviderCredentialPolicy, + type ProviderWorkAuthority, } from "@/lib/sharing/provider-work-authority"; +import { memoizePerRequest } from "@/lib/request-cache"; +import type { AiModality } from "@/lib/ai/capabilities/types"; +import type { + ProviderEntryPresence, + ProviderPresence, +} from "@/lib/ai/capabilities/resolve"; +import { + supportsVisionForConfig, + type VisionProviderType, +} from "./vision-capability"; const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes @@ -680,60 +691,15 @@ export function userRowHasProviderCredential( } /** - * Async single-user variant of `userRowHasProviderCredential`: two - * narrow presence reads (user credential columns + the admin key flag), - * no decrypt, no network. Used by read paths that only need to know - * whether a generation could ever produce provider-backed text (e.g. - * the dashboard snapshot's `briefingState: "no-provider"`). + * Whether any provider could serve text for this record: the presence probe, + * as a boolean. Used by read paths that only need to know whether a generation + * could ever produce provider-backed text (e.g. the dashboard snapshot's + * `briefingState: "no-provider"`). */ export async function hasAnyConfiguredProvider( userId: string, ): Promise { - const authority = providerWorkAuthorityForRecord(userId); - if (providerCredentialPolicy(authority) === "deny") return false; - const userRow = await prisma.user.findUnique({ - where: { id: userId }, - select: { - aiProvider: true, - aiProviderChain: true, - aiAnthropicKeyEncrypted: true, - aiLocalKeyEncrypted: true, - aiOpenaiKeyEncrypted: true, - aiBaseUrl: true, - aiCompatBaseUrl: true, - aiCompatModel: true, - aiModel: true, - codexConnectionStatus: true, - codexAccessTokenEncrypted: true, - codexRefreshTokenEncrypted: true, - useCentralCodex: true, - managedProfileAt: true, - }, - }); - if (!userRow) return false; - const policy = providerCredentialPolicy(authority, userRow.managedProfileAt); - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { - adminAiKeyEncrypted: true, - adminCodexConnectionStatus: true, - adminCodexAccessTokenEncrypted: true, - adminCodexRefreshTokenEncrypted: true, - adminCodexAccountIdEncrypted: true, - }, - }); - if ( - userRowHasProviderCredential( - userRow, - !!settings?.adminAiKeyEncrypted, - policy, - ) - ) { - return true; - } - // A user with no personal provider can still be served by the operator's - // shared central Codex when they opted in and the operator connected it. - return userRow.useCentralCodex && appSettingsCentralCodexConnected(settings); + return probeProviderPresence(userId, "text"); } /** @@ -837,60 +803,206 @@ function resolveManagedByFromRow( /** * Effective AI availability for a user: whether any provider can serve - * them, and which origin manages it. One narrow user read plus the - * shared admin-key flag — no decrypt, no network. Feeds the - * `GET /api/user/ai-provider` response so the iOS Coach surfaces even - * when the operator's admin-managed key is the only thing configured. + * them, and which origin manages it. The presence probe, projected; feeds the + * `GET /api/user/ai-provider` response so the iOS Coach surfaces even when the + * operator's admin-managed key is the only thing configured. */ export async function resolveProviderAvailability( userId: string, ): Promise<{ aiAvailable: boolean; managedBy: ProviderManagedBy | null }> { - const authority = providerWorkAuthorityForRecord(userId); - if (providerCredentialPolicy(authority) === "deny") { - return { aiAvailable: false, managedBy: null }; - } - const userRow = await prisma.user.findUnique({ - where: { id: userId }, - select: { - aiProvider: true, - aiProviderChain: true, - aiAnthropicKeyEncrypted: true, - aiLocalKeyEncrypted: true, - aiOpenaiKeyEncrypted: true, - aiBaseUrl: true, - aiCompatBaseUrl: true, - aiCompatModel: true, - aiModel: true, - codexConnectionStatus: true, - codexAccessTokenEncrypted: true, - codexRefreshTokenEncrypted: true, - useCentralCodex: true, - managedProfileAt: true, + const presence = await probeProviderChain(userId); + return presence.entries.length > 0 + ? { aiAvailable: true, managedBy: presence.managedBy } + : { aiAvailable: false, managedBy: null }; +} + +// ── The one presence definition ──────────────────────────────────────────── + +/** The credential-presence columns the probe reads off the record's row. */ +const PRESENCE_USER_SELECT = { + aiProvider: true, + aiProviderChain: true, + aiAnthropicKeyEncrypted: true, + aiLocalKeyEncrypted: true, + aiOpenaiKeyEncrypted: true, + aiBaseUrl: true, + aiCompatBaseUrl: true, + aiCompatModel: true, + aiModel: true, + codexConnectionStatus: true, + codexAccessTokenEncrypted: true, + codexRefreshTokenEncrypted: true, + useCentralCodex: true, + managedProfileAt: true, + labsLocalOcrEnabled: true, +} as const; + +const PRESENCE_SETTINGS_SELECT = { + adminAiKeyEncrypted: true, + adminAiModel: true, + adminCodexConnectionStatus: true, + adminCodexAccessTokenEncrypted: true, + adminCodexRefreshTokenEncrypted: true, + adminCodexAccountIdEncrypted: true, +} as const; + +const NO_PRESENCE: ProviderPresence = Object.freeze({ + entries: [], + localOcrEnabled: false, + managedBy: null, +}); + +/** + * Whether one chain entry's model can read an image. Mirrors the model each + * entry runs on (`src/lib/labs/ocr-capability.ts`): the Codex paths use the + * working Codex slug, the operator's slot uses the operator's model, every + * other entry the person's own. + */ +function entryVision( + providerType: string, + userModel: string | null, + adminModel: string | null, +): boolean { + const model = + providerType === "codex" || providerType === "admin-codex" + ? resolveCodexVisionSlug() + : providerType === "admin-openai" + ? adminModel + : userModel; + return supportsVisionForConfig(providerType as VisionProviderType, model); +} + +/** + * The provider chain that would serve a record, presence only: which entries + * hold a usable credential, in the order a chain run would try them, whether + * each can read an image, the person's in-browser OCR opt-in, and where the + * serving credential comes from. + * + * This is the ONE definition of "is a provider configured". It never decrypts + * a key, builds a client, refreshes a Codex token or touches the network, so a + * `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 + * policy of the provider-work authority: a delegate's chain is empty and a + * guardian's is the operator's key alone. + * + * Memoised per request and record, so the account payload, a capability gate + * and a presence read on one request share two point reads. + */ +export function probeProviderChain( + recordId: string, + authority: ProviderWorkAuthority | null = providerWorkAuthorityForRecord( + recordId, + ), +): Promise { + const basePolicy = providerCredentialPolicy(authority); + if (basePolicy === "deny") return Promise.resolve(NO_PRESENCE); + return memoizePerRequest( + `provider-presence:${recordId}:${authority?.origin ?? "none"}`, + async () => { + const [row, settings] = await Promise.all([ + prisma.user.findUnique({ + where: { id: recordId }, + select: PRESENCE_USER_SELECT, + }), + prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: PRESENCE_SETTINGS_SELECT, + }), + ]); + if (!row) return NO_PRESENCE; + const policy = providerCredentialPolicy(authority, row.managedProfileAt); + const adminKey = !!settings?.adminAiKeyEncrypted; + const adminModel = settings?.adminAiModel ?? null; + const vision = (providerType: string): ProviderEntryPresence => ({ + providerType, + vision: entryVision(providerType, row.aiModel, adminModel), + }); + + if (policy === "deny") return NO_PRESENCE; + if (policy === "operator-default") { + return { + entries: adminKey ? [vision("admin-openai")] : [], + // A guardian's in-browser OCR is not the record's opt-in to use. + localOcrEnabled: false, + managedBy: adminKey ? "server" : null, + }; + } + + const codexConnected = + row.codexConnectionStatus === "connected" && + !!row.codexAccessTokenEncrypted && + !!row.codexRefreshTokenEncrypted; + 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 (row.useCentralCodex && appSettingsCentralCodexConnected(settings)) { + entries.push(vision("admin-codex")); + } + 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")); + } + + 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, + }; }, - }); - if (!userRow) return { aiAvailable: false, managedBy: null }; - const policy = providerCredentialPolicy(authority, userRow.managedProfileAt); - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { - adminAiKeyEncrypted: true, - adminCodexConnectionStatus: true, - adminCodexAccessTokenEncrypted: true, - adminCodexRefreshTokenEncrypted: true, - adminCodexAccountIdEncrypted: true, - }, - }); - const managedBy = resolveManagedByFromRow( - userRow, - !!settings?.adminAiKeyEncrypted, - policy, ); - if (managedBy !== null) return { aiAvailable: true, managedBy }; - // The operator's shared central Codex is operator-managed egress ("server"). - if (userRow.useCentralCodex && appSettingsCentralCodexConnected(settings)) { - return { aiAvailable: true, managedBy: "server" }; - } - return { aiAvailable: false, managedBy: null }; +} + +/** + * Whether any configured provider could serve this modality for the record. + * `text` needs any entry; `document` needs one that reads images, or a text + * entry the person's in-browser OCR feeds. + */ +export async function probeProviderPresence( + recordId: string, + modality: AiModality = "text", +): Promise { + const presence = await probeProviderChain(recordId); + if (modality === "text") return presence.entries.length > 0; + return ( + presence.entries.some((entry) => entry.vision) || + (presence.localOcrEnabled && presence.entries.length > 0) + ); } /**