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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:11:32 +02:00
1 parent a2260d5bb8
commit 05fe2a00bf
4 files changed
+330 -1

No files matched your search

+96 -1
View File
@@ -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();
});
});
+95
View File
@@ -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.