From 05fe2a00bfccfaf4169eec0482ce3384b54fbba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:11:32 +0200 Subject: [PATCH] Surface effective AI availability on the ai-provider endpoint Add aiAvailable + managedBy to GET /api/user/ai-provider so a server-managed or local provider is reported as usable even without a personal key. Resolution origin comes from a presence-only helper that mirrors provider resolution order; no admin keys or endpoints are exposed. --- .../user/ai-provider/__tests__/route.test.ts | 129 ++++++++++++++++++ src/app/api/user/ai-provider/route.ts | 10 ++ src/lib/ai/__tests__/provider.test.ts | 97 ++++++++++++- src/lib/ai/provider.ts | 95 +++++++++++++ 4 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 src/app/api/user/ai-provider/__tests__/route.test.ts diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts new file mode 100644 index 000000000..0c58d877b --- /dev/null +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mocks must be hoisted before importing the route. +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ + user: { id: "u-1" }, + session: { id: "s-1" }, + })), + HttpError: class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn((v: string) => `dec:${v}`), + encrypt: vi.fn((v: string) => `enc:${v}`), +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderAvailability: vi.fn(), +})); + +vi.mock("@/lib/validations/notifications", () => ({ + isPublicUrl: vi.fn(() => true), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { resolveProviderAvailability } from "@/lib/ai/provider"; + +interface AiProviderResponse { + data: { + provider: string | null; + aiAvailable: boolean; + managedBy: "user" | "local" | "server" | null; + hasAnthropicKey: boolean; + hasOpenaiKey: boolean; + }; + error: null; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/user/ai-provider availability", () => { + it("reports available + managedBy:user when the user holds a BYO key", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProvider: "OPENAI", + aiModel: "gpt-4o", + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: "enc-openai", + } as never); + vi.mocked(resolveProviderAvailability).mockResolvedValue({ + aiAvailable: true, + managedBy: "user", + }); + + const res = await (GET as () => Promise)(); + const body = (await res.json()) as AiProviderResponse; + + expect(vi.mocked(resolveProviderAvailability)).toHaveBeenCalledWith("u-1"); + expect(body.data.aiAvailable).toBe(true); + expect(body.data.managedBy).toBe("user"); + expect(body.data.hasOpenaiKey).toBe(true); + }); + + it("reports available + managedBy:server when only an admin-managed provider exists", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProvider: null, + aiModel: null, + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + } as never); + vi.mocked(resolveProviderAvailability).mockResolvedValue({ + aiAvailable: true, + managedBy: "server", + }); + + const res = await (GET as () => Promise)(); + const body = (await res.json()) as AiProviderResponse; + + // The user has no personal key, but the Coach must still surface. + expect(body.data.provider).toBeNull(); + expect(body.data.hasOpenaiKey).toBe(false); + expect(body.data.aiAvailable).toBe(true); + expect(body.data.managedBy).toBe("server"); + }); + + it("reports aiAvailable:false + managedBy:null when nothing is configured", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProvider: null, + aiModel: null, + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + } as never); + vi.mocked(resolveProviderAvailability).mockResolvedValue({ + aiAvailable: false, + managedBy: null, + }); + + const res = await (GET as () => Promise)(); + const body = (await res.json()) as AiProviderResponse; + + expect(body.data.aiAvailable).toBe(false); + expect(body.data.managedBy).toBeNull(); + }); +}); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 2134c6e84..5e3c0d197 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -4,6 +4,7 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { isPublicUrl } from "@/lib/validations/notifications"; import { encrypt, decrypt } from "@/lib/crypto"; +import { resolveProviderAvailability } from "@/lib/ai/provider"; import { annotate } from "@/lib/logging/context"; export const dynamic = "force-dynamic"; @@ -26,10 +27,19 @@ export const GET = apiHandler(async () => { }, }); + // Effective availability: surfaces whether ANY provider can serve this + // user — including the operator's admin-managed key when the user has set + // no personal provider. iOS keys its Coach visibility off `aiAvailable` so + // a server-managed provider is no longer invisible to the client. + // `managedBy` reports the origin only; no admin keys/endpoints are leaked. + const { aiAvailable, managedBy } = await resolveProviderAvailability(user.id); + return apiSuccess({ provider: u?.aiProvider ?? null, model: u?.aiModel ?? null, baseUrl: u?.aiBaseUrl ?? null, + aiAvailable, + managedBy, hasAnthropicKey: Boolean(u?.aiAnthropicKeyEncrypted), anthropicKeyPreview: u?.aiAnthropicKeyEncrypted ? `...${decrypt(u.aiAnthropicKeyEncrypted).slice(-4)}` diff --git a/src/lib/ai/__tests__/provider.test.ts b/src/lib/ai/__tests__/provider.test.ts index eecbadbf5..f4dced3b0 100644 --- a/src/lib/ai/__tests__/provider.test.ts +++ b/src/lib/ai/__tests__/provider.test.ts @@ -21,7 +21,11 @@ vi.mock("@/lib/ai/codex-oauth", () => ({ decryptCodexCreds: vi.fn(), })); -import { resolveProvider, resolveProviderForTest } from "../provider"; +import { + resolveProvider, + resolveProviderForTest, + resolveProviderAvailability, +} from "../provider"; import { OpenAIClient } from "../openai-client"; import { prisma } from "@/lib/db"; import { decrypt, encrypt } from "@/lib/crypto"; @@ -349,3 +353,94 @@ describe("resolveProviderForTest — empty override resolves via the chain", () expect(provider).toBeInstanceOf(OpenAIClient); }); }); + +describe("resolveProviderAvailability", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + const emptyUserRow = { + aiProvider: null, + aiProviderChain: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + aiBaseUrl: null, + codexConnectionStatus: null, + codexAccessTokenEncrypted: null, + codexRefreshTokenEncrypted: null, + }; + + it("returns managedBy:user when the user holds a BYO key", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...emptyUserRow, + aiOpenaiKeyEncrypted: "enc-user-openai", + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + adminAiKeyEncrypted: null, + } as never); + + const result = await resolveProviderAvailability("user-123"); + expect(result).toEqual({ aiAvailable: true, managedBy: "user" }); + }); + + it("returns managedBy:server when only an admin-managed key is present", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...emptyUserRow, + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + adminAiKeyEncrypted: "enc-admin-key", + } as never); + + const result = await resolveProviderAvailability("user-123"); + expect(result).toEqual({ aiAvailable: true, managedBy: "server" }); + }); + + it("returns managedBy:local when the user set a self-hosted base URL", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...emptyUserRow, + aiProvider: "LOCAL", + aiBaseUrl: "https://ollama.example/v1", + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + adminAiKeyEncrypted: null, + } as never); + + const result = await resolveProviderAvailability("user-123"); + expect(result).toEqual({ aiAvailable: true, managedBy: "local" }); + }); + + it("prefers a personal credential over the admin key", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...emptyUserRow, + aiAnthropicKeyEncrypted: "enc-anthropic", + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + adminAiKeyEncrypted: "enc-admin-key", + } as never); + + const result = await resolveProviderAvailability("user-123"); + expect(result).toEqual({ aiAvailable: true, managedBy: "user" }); + }); + + it("returns aiAvailable:false + managedBy:null when nothing is configured", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...emptyUserRow, + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + adminAiKeyEncrypted: null, + } as never); + + const result = await resolveProviderAvailability("user-123"); + expect(result).toEqual({ aiAvailable: false, managedBy: null }); + }); + + it("returns aiAvailable:false when the user row is missing", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + + 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 049da4605..93e5e2df2 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -423,6 +423,101 @@ export async function hasAnyConfiguredProvider( ); } +/** + * Origin of the provider that would serve a given user, surfaced to + * clients that need to show or hide an AI surface (the iOS Coach gate). + * + * - "user" — a personal cloud credential resolves (Codex OAuth, or a + * BYO OpenAI / Anthropic key). + * - "local" — a per-user self-hosted base URL (Ollama / LM Studio). + * - "server" — no personal config, but the operator's admin-managed + * key serves the user. This is the case iOS #24 missed: + * the Coach works server-side yet the client saw `null`. + * - null — nothing configured anywhere; no provider can serve. + * + * Presence-only, mirroring `userRowHasProviderCredential`: it never + * decrypts a key, builds a client, or probes liveness. + */ +export type ProviderManagedBy = "user" | "local" | "server"; + +function resolveManagedByFromRow( + row: ProviderCredentialRow, + adminKeyConfigured: boolean, +): ProviderManagedBy | null { + const codexConnected = + row.codexConnectionStatus === "connected" && + !!row.codexAccessTokenEncrypted && + !!row.codexRefreshTokenEncrypted; + + const chain = parseProviderChain(row.aiProviderChain ?? null).filter( + (e) => e.enabled, + ); + for (const entry of chain) { + switch (entry.providerType) { + case "codex": + if (codexConnected) return "user"; + break; + case "openai": + if (row.aiOpenaiKeyEncrypted) return "user"; + break; + case "anthropic": + if (row.aiAnthropicKeyEncrypted) return "user"; + break; + case "local": + if (row.aiBaseUrl) return "local"; + break; + case "admin-openai": + if (adminKeyConfigured) return "server"; + break; + } + } + + // Legacy `resolveProvider()` fallback — only reached when the chain + // resolves empty. Mirrors buildUserProvider → codex → admin in order. + const choice = row.aiProvider?.toUpperCase(); + if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return "user"; + if (choice === "LOCAL" && row.aiBaseUrl) return "local"; + if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return "user"; + if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return "user"; + return adminKeyConfigured ? "server" : null; +} + +/** + * 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. + */ +export async function resolveProviderAvailability( + userId: string, +): Promise<{ aiAvailable: boolean; managedBy: ProviderManagedBy | null }> { + const userRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { + aiProvider: true, + aiProviderChain: true, + aiAnthropicKeyEncrypted: true, + aiLocalKeyEncrypted: true, + aiOpenaiKeyEncrypted: true, + aiBaseUrl: true, + codexConnectionStatus: true, + codexAccessTokenEncrypted: true, + codexRefreshTokenEncrypted: true, + }, + }); + if (!userRow) return { aiAvailable: false, managedBy: null }; + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { adminAiKeyEncrypted: true }, + }); + const managedBy = resolveManagedByFromRow( + userRow, + !!settings?.adminAiKeyEncrypted, + ); + return { aiAvailable: managedBy !== null, managedBy }; +} + /** * Materialise a single chain entry. Returns null when the user lacks * the matching credential — the chain runner skips null entries.