Files
HealthLog/src/lib/labs/ocr-capability.ts
T
Marc-André Bombeck d7e616eaa8 feat(ai): add operator-shared central Codex connection with per-user opt-in
Introduce an operator-level ChatGPT-subscription (Codex) credential that the
operator connects once and shares server-wide, plus a per-user switch to route
AI egress through it.

- Schema (migration 0235): three encrypted columns on AppSettings for the
  operator's central Codex access/refresh tokens and the ChatGPT-Account-ID
  claim, plus expiry/connected-at/status; a per-user useCentralCodex flag
  (default false). Register the new encrypted columns in the rotation registry
  and script.
- Resolution: add the admin-codex chain type; append it to a user's resolved
  chain only when they opted in AND the operator connected it. Tokens refresh
  server-side against the singleton row (CAS-style), same primitives as the
  per-user Codex path.
- Budget: bill an admin-codex primary against the operator cost cap, not the
  user plan — it drains the operator's account.
- Consent: admin-codex is server-managed external egress, gated by the existing
  consent machinery (self-snapshot and document surfaces alike). No existing
  consent path is weakened.
- Document order: rank admin-codex with the subscription tier (last), so the
  document class stays local-first.
- UI: admin device-code connect/disconnect card (cookie-only admin routes) with
  honest copy on account pooling, shared rate limits, and training-by-default;
  a per-user opt-in switch shown only once the operator has connected it, off by
  default, behind a one-time honesty confirm.
- i18n across all six locales; unit tests for the chain type, budget cap,
  consent gate, vision capability, and document order; a mocked e2e for both
  the admin connect surface and the per-user switch.

The central credential's tokens are encrypted at rest and never returned to a
non-admin API; the opt-in only takes effect once the operator has connected the
shared account.
2026-07-10 00:10:56 +02:00

234 lines
8.3 KiB
TypeScript

/**
* v1.18.9 — resolve whether a user's configured AI provider can read images,
* and (for the extract route) pick the first vision-capable provider instance.
*
* Capability is a function of (providerType, model). The user's `aiModel`
* applies to a per-user provider entry; the operator's `adminAiModel` applies
* to the `admin-openai` fallback entry. We resolve the chain instances via
* `resolveProviderChain` (so credentials + Codex-token refresh are handled in
* one place) and pair each surviving entry with the model that drives its
* vision allowlist.
*/
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { resolveCodexVisionSlug } from "@/lib/ai/codex-client";
import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf";
import { prisma } from "@/lib/db";
import {
supportsPdfForProvider,
supportsVisionForConfig,
type VisionProviderType,
} from "@/lib/ai/vision-capability";
import type { OcrCapabilityDto } from "@/lib/validations/labs-ocr";
interface ModelContext {
/** The user's selected model (per-user provider entries). */
userModel: string | null;
/** The operator's admin-key model (the `admin-openai` chain entry). */
adminModel: string | null;
}
/** The model that drives a chain entry's vision allowlist. */
function modelForEntry(
providerType: ProviderChainResolved["providerType"],
ctx: ModelContext,
): string | null {
// Codex resolves its working slug at request time from the OAuth slug
// fallback chain — NOT from the user's `aiModel`. Use the cached/chain-head
// slug so the codex vision gate tests the model that actually runs. The
// operator-shared central Codex (`admin-codex`) rides the same slug chain.
if (providerType === "codex" || providerType === "admin-codex") {
return resolveCodexVisionSlug();
}
return providerType === "admin-openai" ? ctx.adminModel : ctx.userModel;
}
/**
* A transform applied to the resolved provider chain before the vision/text
* pick is made. Identity by default (labs behaviour unchanged); the document
* class passes a reorder that demotes external/train-by-default providers below
* local (see `@/lib/documents/provider-order`).
*/
export type ChainReorder = (
chain: ProviderChainResolved[],
) => ProviderChainResolved[];
export interface ResolveProviderOptions {
/** Reorder the resolved chain before picking. Defaults to identity. */
reorder?: ChainReorder;
}
/** A vision-capable provider pick: its instance, logical tag, and model. */
export interface VisionProviderPick {
/** The full resolved chain (for `assertConsentForChain`). */
chain: ProviderChainResolved[];
/** The first vision-capable entry, or null when none can read images. */
pick: {
entry: ProviderChainResolved;
providerType: VisionProviderType;
pdfSupported: boolean;
} | null;
/** Whether the user opted into local (in-browser) OCR for text-only providers. */
localOcrEnabled: boolean;
}
/**
* Resolve the user's provider chain and find the first entry that can read
* images. Returns the whole chain (the consent gate needs it) plus the chosen
* vision entry (or null). Mirrors the Coach's chain-then-legacy fallback so a
* user served only by the operator's admin key is still covered.
*/
export async function resolveVisionProvider(
userId: string,
options?: ResolveProviderOptions,
): Promise<VisionProviderPick> {
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: { aiModel: true, labsLocalOcrEnabled: true },
});
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { adminAiModel: true },
});
const localOcrEnabled = userRow?.labsLocalOcrEnabled ?? false;
// The OCR path always uses the main configured provider chain — the user's
// model drives the vision allowlist.
const ctx: ModelContext = {
userModel: userRow?.aiModel ?? null,
adminModel: settings?.adminAiModel ?? null,
};
const resolvedChain = await resolveProviderChain(userId);
if (resolvedChain.length === 0) {
// Mirror the Coach: fall back to the legacy single-provider resolution and
// tag it as the admin-managed entry the consent gate recognises.
const legacy = await resolveProvider(userId);
if (legacy.type !== "none") {
resolvedChain.push({ providerType: "admin-openai", instance: legacy });
}
}
// Apply the caller's reorder (document class demotes external providers below
// local) before picking; labs passes no reorder → identity.
const chain = options?.reorder
? options.reorder(resolvedChain)
: resolvedChain;
for (const entry of chain) {
const providerType = entry.providerType as VisionProviderType;
const model = modelForEntry(entry.providerType, ctx);
if (supportsVisionForConfig(providerType, model)) {
return {
chain,
localOcrEnabled,
pick: {
entry,
providerType,
pdfSupported: supportsPdfForProvider(providerType),
},
};
}
}
return { chain, localOcrEnabled, pick: null };
}
/**
* Resolve the provider chain for a TEXT-mode (local-OCR) extract. There is no
* vision requirement here — ANY configured provider can structure OCR'd text —
* so this returns the whole chain plus the first entry to drive the structuring
* call. Returns `null` when nothing is configured.
*/
export async function resolveTextProvider(
userId: string,
options?: ResolveProviderOptions,
): Promise<{
chain: ProviderChainResolved[];
pick: { entry: ProviderChainResolved; providerType: string } | null;
}> {
// The text-mode structuring pass needs no vision — any configured provider in
// the main chain can structure the OCR'd text.
const resolvedChain = await resolveProviderChain(userId);
if (resolvedChain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type !== "none") {
resolvedChain.push({ providerType: "admin-openai", instance: legacy });
}
}
const chain = options?.reorder
? options.reorder(resolvedChain)
: resolvedChain;
const entry = chain[0];
return {
chain,
pick: entry ? { entry, providerType: entry.providerType } : null,
};
}
/**
* The cheap capability DTO for the probe endpoint: whether scanning is
* available + why not + whether PDFs are accepted. Built from the same
* resolution the extract route uses, so the UI never shows an entry the
* extract route would 422.
*/
export async function resolveOcrCapability(
userId: string,
options?: ResolveProviderOptions,
): Promise<OcrCapabilityDto> {
const { chain, pick, localOcrEnabled } = await resolveVisionProvider(
userId,
options,
);
// Prefer the native vision path whenever it is available — it is more
// accurate and the client does not download the OCR WASM. PDFs are readable
// whenever the picked provider natively supports them (Anthropic) OR the
// server-side rasterizer is available (every other vision provider reads a
// PDF via rasterized page images), so the scan-a-PDF affordance shows for a
// codex / OAuth provider too. The extract route keeps the native-block vs
// raster decision on `pick.pdfSupported` itself.
if (pick) {
return {
available: true,
mode: "vision",
reason: null,
pdfSupported: pick.pdfSupported || RASTERIZATION_AVAILABLE,
};
}
// No vision pick. Nothing configured at all → no-provider, regardless of the
// toggle (there is no provider to structure the OCR text).
if (chain.length === 0) {
return {
available: false,
mode: null,
reason: "no-provider",
pdfSupported: false,
};
}
// A provider IS configured but it can't read images. Local OCR is the
// text-only fallback: in-browser OCR → text → the configured provider. It is
// available only when the user opted in.
if (localOcrEnabled) {
// Text mode is image-only (tesseract.js can't read PDFs).
return {
available: true,
mode: "text",
reason: null,
pdfSupported: false,
};
}
// A text-only provider is configured but the user has not enabled local OCR —
// surface the actionable reason so the UI can point at the toggle.
return {
available: false,
mode: null,
reason: "enable-local-ocr",
pdfSupported: false,
};
}