Files
HealthLog/src/lib/labs/ocr-capability.ts
T
Marc-André Bombeck e098fc882f fix(ai): an empty provider chain means no provider
Eight call sites (Coach turn, status texts, briefing, comprehensive
insights, medication extraction, about-me questions, both lab OCR paths,
the saved-config connection test) answered an empty chain by calling the
single-provider resolver, which ignores the chain's switches. A person who
kept only the local model on and lost its address got the ChatGPT sign-in
or the operator key instead, and every copy tagged that provider as the
operator key, so consent and cost attribution saw the wrong owner.

The rule now lives once in `resolveProviderChain`, and the presence probe
reads the same rule: a saved chain is the person's word, so an empty one
stays empty. Only a chain never saved falls back, to the OpenAI-compatible
gateway picked under Settings, AI, because the default chain has no entry
for it; that entry carries its real type. The callers drop their copies,
and the presence helpers that restated the old fallback go with them.
2026-11-11 20:29:11 +01:00

302 lines
10 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 { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import {
AiUnavailableError,
type NoProviderRefusal,
} from "@/lib/ai/capabilities/refusal";
import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import { resolveProviderChain } from "@/lib/ai/provider";
import { chainEntryReadsImages } from "@/lib/ai/codex-slug-chain";
import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
supportsPdfForProvider,
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;
}
/** Whether a chain entry's model can read an image. */
function entryReadsImages(
providerType: ProviderChainResolved["providerType"],
userId: string,
ctx: ModelContext,
): boolean {
// Codex picks its model at request time from the sign-in's working slug
// and the models its account is offered, NOT from the user's `aiModel`.
// Each Codex path asks its own sign-in: the person's connection, or the
// operator-shared central one (`admin-codex`).
const model =
providerType === "admin-openai" ? ctx.adminModel : ctx.userModel;
return chainEntryReadsImages(providerType, userId, model);
}
/**
* 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). The chain is the one every model call resolves, so a
* user served only by the operator's admin key is covered through its
* `admin-openai` entry.
*/
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);
// 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;
if (entryReadsImages(entry.providerType, userId, ctx)) {
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);
const chain = options?.reorder
? options.reorder(resolvedChain)
: resolvedChain;
const entry = chain[0];
return {
chain,
pick: entry ? { entry, providerType: entry.providerType } : null,
};
}
/**
* How a lab scan refuses with no provider that can serve it. The code predates
* the capability envelope and a client already branches on it.
*/
export const LABS_OCR_NO_PROVIDER: NoProviderRefusal = {
errorCode: "labs.ocr.providerUnsupported",
status: 422,
};
/**
* The labs scan's wire. Resolve the provider for one scan mode and ask the
* `labsOcr` capability again about exactly that provider, immediately before
* the report is sent: the operator's switch, the labs module, the sharing
* grant, and a document-class consent receipt for any pick that leaves the
* machine (a lab report is a document). Throws the refusal for `apiHandler`
* to render; a missing provider keeps its `labs.ocr.providerUnsupported` code.
*
* Vision mode picks the first vision-capable entry in chain order; text mode
* (the person's in-browser OCR) takes the chain head, which is why the pick,
* not the published capability, answers the consent question.
*/
export async function requireLabsOcrProvider(
userId: string,
mode: "vision",
): Promise<NonNullable<VisionProviderPick["pick"]>>;
export async function requireLabsOcrProvider(
userId: string,
mode: "text",
): Promise<{ entry: ProviderChainResolved; providerType: string }>;
export async function requireLabsOcrProvider(
userId: string,
mode: "vision" | "text",
) {
const { chain, pick } =
mode === "vision"
? await resolveVisionProvider(userId)
: await resolveTextProvider(userId);
if (!pick) {
annotate({
action: { name: "labs.ocr.providerUnsupported" },
meta: {
chainLength: chain.length,
...(mode === "text" ? { mode } : {}),
},
});
throw new AiUnavailableError(
"labsOcr",
"no_provider",
null,
LABS_OCR_NO_PROVIDER,
);
}
const refusal = await aiEgressRefusal("labsOcr", userId, [pick.providerType]);
if (refusal) throw refusal;
return pick;
}
/**
* 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.
*
* `ai` is the `labsOcr` capability for the request's record. A scan the
* operator, the labs module or the sharing grant close is not offered
* (`available: false`, no mode). A missing consent receipt keeps the scan
* offered: the scan is where the person is asked for the receipt.
*/
export async function resolveOcrCapability(
userId: string,
): Promise<OcrCapabilityDto> {
const [{ chain, pick, localOcrEnabled }, ai] = await Promise.all([
resolveVisionProvider(userId),
getAiCapability("labsOcr"),
]);
if (ai.reason !== null && !PICK_DECIDED_REASONS.has(ai.reason)) {
return {
available: false,
mode: null,
reason: null,
pdfSupported: false,
ai,
};
}
// 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,
ai,
};
}
// 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,
ai,
};
}
// 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,
ai,
};
}
// 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,
ai,
};
}