mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
234 lines
8.3 KiB
TypeScript
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,
|
|
};
|
|
}
|