mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
a2260d5bb8
commit
05fe2a00bf
4 files changed
+330
-1
No files matched your search
@@ -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: <T extends (...args: unknown[]) => 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<Response>)();
|
||||
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<Response>)();
|
||||
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<Response>)();
|
||||
const body = (await res.json()) as AiProviderResponse;
|
||||
|
||||
expect(body.data.aiAvailable).toBe(false);
|
||||
expect(body.data.managedBy).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user