mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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.
This commit is contained in:
1 parent
a7888a9011
commit
1be2392c81
4 files changed
+480
-103
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+211
-99
@@ -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<boolean> {
|
||||
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<ProviderPresence> {
|
||||
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<boolean> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user