Merge remote-tracking branch 'origin/main' into feat/key-backup-and-transport

# Conflicts:
#	docs/api/openapi.yaml
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 19:22:01 +02:00
commit 37793298a8
59 files changed
+4736 -905

No files matched your search

@@ -194,6 +194,14 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
* claim the guard checks against the file.
*/
export const DATA_ROUTES: Readonly<Record<string, string>> = {
// v1.40 — the poll for a background document AI run. A plain read of a row
// the caller created: every capability question was asked when the run was
// queued and again in the worker before anything left, and serving the
// answer to a read the person already asked for calls no model. Not
// `aiCapabilityToServe` either: a run result is the caller's own request
// result, not stored model text shown later.
"src/app/api/ai-runs/[id]/route.ts":
"The caller's own background run: status, result, failure.",
// The design's list of formerly AI-gated data reads and writes.
"src/app/api/insights/derived/batch/route.ts":
"Deterministic scores and assessments for the whole grid.",
+45
View File
@@ -0,0 +1,45 @@
/**
* v1.40 — GET /api/ai-runs/{id}: where one background document AI run stands.
*
* A plain read of a row the caller created: the run id came back in the 202
* of the route that queued it, and the row is scoped to the caller here, so
* another account's run (or one that never existed, or one an hour past its
* end) is the same 404. No AI gate: serving the answer to a read the person
* already asked for calls no model, and every capability question was asked
* when the run was queued and again in the worker before anything left.
*
* `result` is the body the synchronous route answers with (by `kind`), and
* `error` carries the code and status it would have answered with, so a
* client handles both paths through one table.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { readAiRunForUser } from "@/lib/documents/ai-runs/store";
import { AI_RUN_ERROR_CODES } from "@/lib/documents/ai-runs/types";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const run = await readAiRunForUser(user.id, id);
if (!run) {
return apiError("Run not found", 404, {
errorCode: AI_RUN_ERROR_CODES.notFound,
});
}
const response = apiSuccess(run);
// A poll answer is for one person at one instant.
response.headers.set("Cache-Control", "no-store");
if (run.retryAfterMs !== null) {
response.headers.set(
"Retry-After",
String(Math.max(1, Math.ceil(run.retryAfterMs / 1000))),
);
}
return response;
},
);
+85 -167
View File
@@ -19,46 +19,52 @@
* the lab / extract text mode already uses — no provider egress at all.
*
* Persists ONLY AES-256-GCM ciphertext text + opaque HMAC token hashes (A4).
*
* v1.40 — `Prefer: respond-async` (RFC 7240) runs the read in the background
* worker: every refusal that can be answered quickly is still answered here,
* then the route answers 202 with a run id and `GET /api/ai-runs/{id}` serves
* the same body this route answers with synchronously. Without the header the
* route behaves exactly as before; the iPhone app relies on that. Both paths
* run one body (`executeDocumentIndex`).
*/
import { Buffer } from "node:buffer";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
import {
checkDocumentAiRateLimit,
documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
loadOwnedDocument,
prepareVisionInput,
refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
import { upsertContentIndex } from "@/lib/documents/content-index";
import { recordIndexAttempt } from "@/lib/documents/index-document";
import {
DocumentDescribeError,
transcribeDocument,
} from "@/lib/documents/describe";
acceptedRunResponse,
outcomeResponse,
prefersRespondAsync,
} from "@/lib/documents/ai-runs/http";
import {
DOCUMENT_INDEX_RESERVE_TOKENS,
executeDocumentIndex,
} from "@/lib/documents/ai-runs/index-run";
import { findLiveDocumentRun, startAiRun } from "@/lib/documents/ai-runs/start";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
@@ -82,57 +88,26 @@ export const POST = apiHandler(
});
}
const background = prefersRespondAsync(request);
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return handleTextIndex(request, user.id, document);
return handleTextIndex(request, user.id, document, background);
}
return handleVisionIndex(request, user.id, document);
return handleVisionIndex(request, user.id, document, background);
},
);
async function finishIndex(
request: NextRequest,
/**
* A read of this document already queued or running: attach to it rather than
* charge a second one. Background path only; the synchronous path keeps its
* behaviour of one request, one read.
*/
async function liveRunResponse(
userId: string,
documentId: string,
source: "vision" | "text-ocr",
tokenCount: number,
): Promise<Response> {
// Refs #776 — the manual route is the second writer of the attempt record
// (the auto job + backfill share `indexLoadedDocument`): a success clears
// any stored failure reason so the detail view stops explaining a problem
// that no longer exists.
await recordIndexAttempt(userId, documentId, {
indexed: true,
source,
tokenCount,
});
// Read with AI is the person reading the document on purpose, so an
// import's hold on automatic AI reading (`aiRead=defer`) ends here, before
// the lab staging below that would otherwise refuse it.
await prisma.inboundDocument.updateMany({
where: { id: documentId, userId, aiReadDeferred: true },
data: { aiReadDeferred: false },
});
await auditLog("documents.inbound.index", {
userId,
ipAddress: getClientIp(request),
details: { documentId, source, tokens: tokenCount },
});
annotate({
action: { name: "documents.contentIndex.upsert" },
meta: { documentId, source, tokens: tokenCount },
});
// A manual read continues into the SAME lab staging the automatic index
// worker performs, so a skipped or failed auto run is recoverable per
// document without a re-upload. Every guard lives inside the helper (both
// modules on, still STORED with no facts, provider + consent, looks like a
// lab report) — a non-lab document is a tagged no-op, and a staging failure
// never fails the index that just succeeded.
const staging = await maybeAutoStageLabFacts(userId, documentId).catch(
() => null,
);
const labFactsStaged = staging?.staged === true ? staging.facts : 0;
return apiSuccess({ documentId, indexed: true, tokenCount, labFactsStaged });
): Promise<Response | null> {
const live = await findLiveDocumentRun(userId, documentId, "DOCUMENT_INDEX");
return live ? acceptedRunResponse(live) : null;
}
/** TEXT mode — index browser-OCR'd text (no provider egress). */
@@ -140,6 +115,7 @@ async function handleTextIndex(
request: NextRequest,
userId: string,
document: LoadedDocument,
background: boolean,
): Promise<Response> {
const row = await prisma.user.findUnique({
where: { id: userId },
@@ -151,6 +127,11 @@ async function handleTextIndex(
});
}
if (background) {
const live = await liveRunResponse(userId, document.id);
if (live) return live;
}
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -174,14 +155,27 @@ async function handleTextIndex(
);
}
const { tokenCount } = await upsertContentIndex({
userId,
documentId: document.id,
text: parsed.data.text,
source: "text-ocr",
providerType: null,
});
return finishIndex(request, userId, document.id, "text-ocr", tokenCount);
if (background) {
// The text is indexed in the worker, which then continues into the same
// lab staging (model calls) the synchronous path makes.
return startAiRun({
userId,
kind: "DOCUMENT_INDEX",
documentId: document.id,
params: { mode: "text" },
input: Buffer.from(parsed.data.text, "utf8"),
refundSlot: () => refundDocumentAiSlot(userId),
});
}
return outcomeResponse(
await executeDocumentIndex({
userId,
document,
input: { mode: "text", text: parsed.data.text },
origin: { ipAddress: getClientIp(request), worker: false },
}),
);
}
/** VISION mode — transcribe the stored original, then index the text. */
@@ -189,6 +183,7 @@ async function handleVisionIndex(
request: NextRequest,
userId: string,
document: LoadedDocument,
background: boolean,
): Promise<Response> {
// Transcribing the stored original is model work; indexing text the browser
// already read (the text mode above) is not, and stays open with AI off so
@@ -197,59 +192,18 @@ async function handleVisionIndex(
await requireAiCapability("documentAi", { pickDecides: true });
const pick = await requireDocumentVisionProvider(userId);
if (background) {
const live = await liveRunResponse(userId, document.id);
if (live) return live;
}
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
const vision = await prepareVisionInput(document, pick.pdfSupported);
if (!vision.ok) {
// Preparation failed before any provider dispatch — the slot goes back.
// Refs #776 — each failure is also recorded on the row so the detail view
// can explain the missing index after the toast is gone.
await refundDocumentAiSlot(userId);
if (vision.reason === "pdfNeedsAnthropic") {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "pdf-needs-anthropic",
});
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
422,
{ errorCode: "documents.inbound.pdfNeedsAnthropic" },
);
}
if (vision.reason === "rasterFailed") {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "raster-failed",
});
return apiError("The PDF pages couldn't be rendered for scanning.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
if (vision.reason === "fileType") {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "local-unsupported",
});
return apiError(
"This document can't be scanned. Use local OCR (text mode).",
422,
{ errorCode: "documents.inbound.fileType" },
);
}
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "decrypt-error",
});
return apiError("Couldn't read the stored document.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.documentTranscribe.maxTokens,
DOCUMENT_INDEX_RESERVE_TOKENS,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
@@ -261,66 +215,30 @@ async function handleVisionIndex(
errorCode: "documents.inbound.budgetExceeded",
});
}
const budget = {
reserved: reservation.reserved,
owner: reservation.owner,
dateKey,
};
try {
const { text } = await transcribeDocument({
provider: pick.entry.instance,
providerType: pick.providerType,
images: vision.images,
documents: vision.documents,
});
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
// Refs #776 — the empty-transcription guard, same contract as the auto
// path (`tryProviderIndex`): a provider answer with no text must never
// become a "successful" empty index. The spend stays charged (the
// provider was called); the honest answer is an error plus the recorded
// reason.
if (text.trim().length === 0) {
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "empty-transcription",
});
return apiError(
"The provider returned no text for this document. Try a clearer copy.",
422,
{ errorCode: "documents.inbound.extractFailed" },
);
}
const { tokenCount } = await upsertContentIndex({
if (background) {
// The worker picks the provider again and re-checks the wire for it right
// before the stored original leaves.
return startAiRun({
userId,
kind: "DOCUMENT_INDEX",
documentId: document.id,
text,
source: "vision",
providerType: pick.providerType,
});
return finishIndex(request, userId, document.id, "vision", tokenCount);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "provider-error",
});
if (err instanceof DocumentDescribeError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
});
}
annotate({
action: { name: "documents.contentIndex.failed" },
meta: { reason: "provider_error", mode: "vision" },
});
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
errorCode: "documents.inbound.extractFailed",
params: { mode: "vision", budget },
refundSlot: () => refundDocumentAiSlot(userId),
});
}
return outcomeResponse(
await executeDocumentIndex({
userId,
document,
input: { mode: "vision", pick, budget },
origin: { ipAddress: getClientIp(request), worker: false },
}),
);
}
@@ -1,10 +1,13 @@
/**
* v1.20.1 — POST /api/labs/ocr/extract, TEXT mode.
* v1.20.1 — POST /api/labs/ocr/extract.
*
* Focus: the text-mode structuring pass reserves the proportionate text budget
* ceiling (`AI_BUDGETS.ocrExtractText`), not the far larger vision ceiling, and
* on a clean extraction failure it refunds the reservation in full rather than
* charging it.
* ceiling (`AI_BUDGETS.ocrExtractText`), not the far larger vision ceiling.
*
* v1.40 — the route queues the read and answers 202; the read itself, its
* budget settlement and its failures are pinned in
* `src/lib/labs/__tests__/ocr-run.test.ts`. What stays here is everything the
* route still refuses before anything is queued.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -48,6 +51,18 @@ vi.mock("@/lib/ai/coach/budget", () => ({
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/documents/ai-runs/start", () => ({
startAiRun: vi.fn(
async () =>
new Response(
JSON.stringify({
data: { runId: "run-1", status: "QUEUED", pollAfterMs: 1500 },
error: null,
}),
{ status: 202 },
),
),
}));
vi.mock("@/lib/labs/ocr-extract", async () => {
const actual = await vi.importActual<typeof import("@/lib/labs/ocr-extract")>(
"@/lib/labs/ocr-extract",
@@ -56,6 +71,7 @@ vi.mock("@/lib/labs/ocr-extract", async () => {
});
import { POST } from "../route";
import { startAiRun } from "@/lib/documents/ai-runs/start";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
@@ -63,10 +79,9 @@ import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
import { reserveBudget } from "@/lib/ai/coach/budget";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
import { annotate } from "@/lib/logging/context";
import { runOcrExtraction } from "@/lib/labs/ocr-extract";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -113,10 +128,8 @@ beforeEach(() => {
describe("POST /api/labs/ocr/extract — text mode budget", () => {
it("reserves the cheaper text ceiling, not the vision ceiling", async () => {
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(textReq());
expect(res.status).toBe(200);
expect(res.status).toBe(202);
// The reservation must use the text ceiling — proven distinct from the
// vision ceiling so a future merge can't silently re-point it.
@@ -136,61 +149,29 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => {
);
});
it("refunds the reservation in full on a clean extract failure", async () => {
vi.mocked(runOcrExtraction).mockRejectedValue(
new OcrExtractError("unreadable"),
);
const res = await POST(textReq());
expect(res.status).toBe(422);
// actual spend reconciles to 0 — the failed structuring pass is refunded,
// not charged at the reserved estimate.
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
AI_BUDGETS.ocrExtractText.maxTokens,
0,
"2026-06-26",
0,
{ servedBy: null, reservedOwner: "operator" },
);
});
it("identifies provider failures instead of blaming image quality", async () => {
vi.mocked(runOcrExtraction).mockRejectedValue(new Error("provider down"));
const res = await POST(textReq());
const body = (await res.json()) as { error: string };
expect(res.status).toBe(502);
expect(body.error).toContain("configured AI provider");
});
it("never passes the upstream body on, to the caller or to the event", async () => {
const upstreamBody = "ami-id instance-id iam/security-credentials/admin";
vi.mocked(runOcrExtraction).mockRejectedValue(
Object.assign(new Error("Local AI request failed (403)"), {
httpStatus: 403,
model: "llama3",
bodyExcerpt: upstreamBody,
}),
);
const res = await POST(textReq());
expect(res.status).toBe(502);
expect(await res.text()).not.toContain("iam/security-credentials");
const events = JSON.stringify(vi.mocked(annotate).mock.calls);
expect(events).not.toContain("iam/security-credentials");
// The status and the model stay, so an operator can still tell why.
expect(annotate).toHaveBeenCalledWith(
it("queues the text, sealed in the run, with the reservation it took", async () => {
const res = await POST(textReq("Glucose 95 mg/dL"));
expect(res.status).toBe(202);
expect(runOcrExtraction).not.toHaveBeenCalled();
expect(startAiRun).toHaveBeenCalledWith(
expect.objectContaining({
meta: expect.objectContaining({ upstreamStatus: 403, model: "llama3" }),
userId: "user-1",
kind: "LABS_OCR_EXTRACT",
params: {
mode: "text",
budget: {
reserved: AI_BUDGETS.ocrExtractText.maxTokens,
owner: "operator",
dateKey: "2026-06-26",
},
},
input: Buffer.from("Glucose 95 mg/dL", "utf8"),
}),
);
});
});
describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
describe("POST /api/labs/ocr/extract — vision uploads", () => {
function pdfReq(): Request {
// A minimal `%PDF-` header is all `detectOcrMimeType` needs to sniff a PDF.
const bytes = new Uint8Array([
@@ -225,50 +206,23 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
} as never);
});
it("rasterizes a PDF for a non-Anthropic vision provider and sends the page images", async () => {
vi.mocked(rasterizePdf).mockResolvedValue({
ok: true,
images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
pageCount: 1,
});
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
it("queues a PDF with its sniffed type; the worker renders it", async () => {
const res = await POST(pdfReq());
expect(res.status).toBe(200);
// The whole PDF was read: no coverage note on the response.
const whole = (await res.json()) as { data: Record<string, unknown> };
expect(whole.data).not.toHaveProperty("pageCoverage");
expect(rasterizePdf).toHaveBeenCalledOnce();
// The rendered page images flow through as `input_image`s; no native PDF
// document block is sent for a non-Anthropic provider.
expect(runOcrExtraction).toHaveBeenCalledWith(
expect(res.status).toBe(202);
// Rendering is the worker's job now; nothing is read in the request.
expect(rasterizePdf).not.toHaveBeenCalled();
expect(runOcrExtraction).not.toHaveBeenCalled();
expect(startAiRun).toHaveBeenCalledWith(
expect.objectContaining({
images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
documents: [],
kind: "LABS_OCR_EXTRACT",
params: expect.objectContaining({
mode: "vision",
mime: "application/pdf",
}),
}),
);
});
it("says on the response when only the first pages of a long PDF were read", async () => {
const tenPages = Array.from({ length: 10 }, () => ({
mediaType: "image/jpeg" as const,
dataBase64: "cGFnZQ==",
}));
vi.mocked(rasterizePdf).mockResolvedValue({
ok: true,
images: tenPages,
pageCount: 23,
});
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(pdfReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { pageCoverage?: { read: number; total: number } };
};
expect(body.data.pageCoverage).toEqual({ read: 10, total: 23 });
});
it("hands the slot back when the upload is not an image or PDF", async () => {
const form = new FormData();
form.append(
@@ -287,36 +241,13 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
expect(refundRateLimit).toHaveBeenCalledWith("labs-ocr:user-1");
expect(runOcrExtraction).not.toHaveBeenCalled();
});
it("falls back to pdfNeedsAnthropic when rasterization fails", async () => {
vi.mocked(rasterizePdf).mockResolvedValue({
ok: false,
reason: "render-failed",
});
const res = await POST(pdfReq());
expect(res.status).toBe(422);
const body = (await res.json()) as { error: string | null };
expect(body.error).toBeTruthy();
// A failed render never reaches the provider — and refunds the reservation.
expect(runOcrExtraction).not.toHaveBeenCalled();
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
AI_BUDGETS.ocrExtract.maxTokens,
0,
"2026-06-26",
0,
{ servedBy: null, reservedOwner: "operator" },
);
});
});
describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => {
it("charges the ceiling the operator set", async () => {
vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "40");
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(textReq() as never);
expect(res.status).toBe(200);
expect(res.status).toBe(202);
expect(checkRateLimit).toHaveBeenCalledWith(
"labs-ocr:user-1",
40,
@@ -366,15 +297,9 @@ describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => {
expect(runOcrExtraction).not.toHaveBeenCalled();
});
it("keeps the slot once the provider was actually called", async () => {
vi.mocked(runOcrExtraction).mockRejectedValue(new Error("provider down"));
const failed = await POST(textReq() as never);
expect(failed.status).toBe(502);
expect(refundRateLimit).not.toHaveBeenCalled();
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const ok = await POST(textReq() as never);
expect(ok.status).toBe(200);
it("keeps the slot once the scan is queued", async () => {
const queued = await POST(textReq() as never);
expect(queued.status).toBe(202);
expect(refundRateLimit).not.toHaveBeenCalled();
});
});
+54 -188
View File
@@ -3,7 +3,15 @@
*
* Read-only (NOT idempotent) extraction of a paper lab report into STRUCTURED
* proposed rows for the mandatory human-review screen. Nothing is written to
* the database here; the raw upload is never persisted or logged.
* the person's record here; the raw upload is never logged.
*
* v1.40 — the read runs in the background worker. This route answers every
* refusal it can answer quickly (capability, provider, rate bucket, budget, a
* malformed or oversized upload, an unknown file type), seals the upload or
* the text into a `DocumentAiRun` (AES-256-GCM, dropped the moment the run
* finishes, the row deleted an hour later) and answers 202 with the run id.
* `GET /api/ai-runs/{id}` serves the proposed rows. Only the web client calls
* this route, so there is no synchronous form to keep.
*
* Two modes, dispatched on the request content-type:
*
@@ -22,9 +30,11 @@
* re-check the capability for that pick (`requireLabsOcrProvider`: the
* operator's switch, the labs module, and a document consent receipt for a
* pick that leaves the machine) → rate-limit
* (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → run extraction
* → reconcile budget. A slot is charged early so a 429 stays cheap, and it is
* handed back when the scan fails before the provider is called.
* (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → queue the run.
* The worker resolves the provider again, re-checks the wire, runs the
* extraction and reconciles the budget (`src/lib/labs/ocr-run.ts`). A slot is
* charged early so a 429 stays cheap, and it is handed back when the scan
* fails before the provider is called.
*
* Extracted text is UNTRUSTED (prompt-injection): the server never acts on an
* instruction inside the document — the human review step is the safety
@@ -35,12 +45,10 @@ import { Buffer } from "node:buffer";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
@@ -49,10 +57,10 @@ import {
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { startAiRun } from "@/lib/documents/ai-runs/start";
import { prisma } from "@/lib/db";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
import { OCR_RESERVE_TOKENS } from "@/lib/labs/ocr-run";
import {
BodyTooLargeError,
detectOcrMimeType,
@@ -72,27 +80,6 @@ export const dynamic = "force-dynamic";
/** OCR'd text is bounded in the schema; cap the JSON body proportionally. */
const TEXT_BODY_MAX_BYTES = 512 * 1024;
/**
* The wide-event facts of a failed provider call: the status and the model,
* never the upstream body. The base URL can be one a user typed, and what an
* arbitrary endpoint answers has no business in the event stream (v1.39.3);
* the response to the caller carries neither.
*/
function providerFailureMeta(error: unknown, mode?: "text") {
const err = error as {
httpStatus?: unknown;
model?: unknown;
};
return {
reason: "provider_error",
...(mode ? { mode } : {}),
...(typeof err.httpStatus === "number"
? { upstreamStatus: err.httpStatus }
: {}),
...(typeof err.model === "string" ? { model: err.model } : {}),
};
}
export const POST = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
@@ -111,7 +98,7 @@ export const POST = apiHandler(async (request: Request) => {
});
/**
* TEXT mode (v1.18.10) — structure in-browser-OCR'd text via any configured
* TEXT mode (v1.18.10) — queue in-browser-OCR'd text for any configured
* provider. No image bytes reach the server; the raw image stayed on-device.
*/
async function handleTextExtract(
@@ -173,7 +160,7 @@ async function handleTextExtract(
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtractText.maxTokens,
OCR_RESERVE_TOKENS.text,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
@@ -190,51 +177,27 @@ async function handleTextExtract(
});
}
try {
const result = await runOcrExtraction({
userId,
provider: pick.entry.instance,
providerType: pick.providerType,
ocrText: parsed.data.text,
});
// A clean structuring pass spent (at most) the reservation; the provider
// already billed it, so reconcile against the reserved estimate.
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return apiSuccess(result);
} catch (err) {
// A failed structuring call produced no usable rows; mirror the vision
// path and refund the reservation in full rather than charging it.
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof OcrExtractError) {
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
errorCode: "labs.ocr.extractFailed",
});
}
annotate({
action: { name: "labs.ocr.extractFailed" },
meta: providerFailureMeta(err, "text"),
});
return apiError(
"The configured AI provider could not process this report. Check the provider configuration and retry.",
502,
{ errorCode: "labs.ocr.extractFailed" },
);
}
// The structuring pass runs in the worker, which picks the provider again
// and re-checks the wire for it before the text leaves.
return startAiRun({
userId,
kind: "LABS_OCR_EXTRACT",
params: {
mode: "text",
budget: {
reserved: reservation.reserved,
owner: reservation.owner,
dateKey,
},
},
input: Buffer.from(parsed.data.text, "utf8"),
refundSlot: () => refundLabsOcrSlot(userId),
});
}
/**
* VISION mode — run a multipart photo / PDF through the user's vision-capable
* provider. The image transits server memory ephemerally and is never stored.
* VISION mode — queue a multipart photo / PDF for the user's vision-capable
* provider. The upload is sealed into the run until the read finishes.
*/
async function handleVisionExtract(
request: Request,
@@ -258,7 +221,7 @@ async function handleVisionExtract(
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtract.maxTokens,
OCR_RESERVE_TOKENS.vision,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
@@ -359,120 +322,23 @@ async function handleVisionExtract(
});
}
// 7. PDF handling — mirror the document vault's `prepareVisionInput`
// (`src/lib/documents/ai-route-support.ts`) exactly:
// - Anthropic (`pick.pdfSupported`) reads the native `document` block at
// highest fidelity;
// - every other vision provider (codex / any image-only wire) reads the
// PDF via `rasterizePdf`, which renders the pages to JPEG `input_image`
// parts the provider already handles. Rendering is pure local compute
// (no egress). `rasterizePdf` never throws: a malformed / encrypted /
// unrenderable PDF returns `{ ok: false }`, and we keep the shipped
// `pdfNeedsAnthropic` reject as the graceful fallback.
let images: {
mediaType: "image/jpeg" | "image/png" | "image/webp";
dataBase64: string;
}[];
let documents: { mediaType: "application/pdf"; dataBase64: string }[];
let pageCoverage: { read: number; total: number } | undefined;
if (mime === "application/pdf") {
if (pick.pdfSupported) {
images = [];
documents = [
{
mediaType: "application/pdf",
dataBase64: buffer.toString("base64"),
},
];
} else {
const raster = await rasterizePdf(buffer);
if (!raster.ok) {
annotate({
action: { name: "labs.ocr.fileRejected" },
meta: { reason: "pdf_rasterize_failed" },
});
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
await refundLabsOcrSlot(userId);
return apiError(
"Couldn't read this PDF; upload a photo instead.",
422,
{ errorCode: "labs.ocr.pdfNeedsAnthropic" },
);
}
images = raster.images;
documents = [];
// A PDF longer than the page cap is read from its first pages only.
// That used to live in the wide event alone; the person reviewing the
// rows is the one who needs to know the rest of the report was not
// read.
if (raster.pageCount > raster.images.length) {
pageCoverage = {
read: raster.images.length,
total: raster.pageCount,
};
annotate({
action: { name: "labs.ocr.pagesCapped" },
meta: { read: raster.images.length, total: raster.pageCount },
});
}
}
} else {
images = [{ mediaType: mime, dataBase64: buffer.toString("base64") }];
documents = [];
}
// 8. Run the extraction. The actual token spend reconciles the reservation.
let actualTokens = 0;
try {
const result = await runOcrExtraction({
userId,
provider: pick.entry.instance,
providerType: pick.providerType,
images,
documents,
});
// The orchestration does not surface token counts; reconcile against the
// reserved estimate as the spend ceiling (the provider already billed it).
actualTokens = reservation.reserved;
await reconcileSpend(
userId,
reservation.reserved,
actualTokens,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return apiSuccess(pageCoverage ? { ...result, pageCoverage } : result);
} catch (err) {
// Provider/extraction failure — the call may still have burned tokens, so
// reconcile against the reserved estimate rather than refunding in full.
await reconcileSpend(
userId,
reservation.reserved,
actualTokens,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
if (err instanceof OcrExtractError) {
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
errorCode: "labs.ocr.extractFailed",
});
}
annotate({
action: { name: "labs.ocr.extractFailed" },
meta: providerFailureMeta(err),
});
return apiError(
"The configured AI provider could not process this report. Check the provider configuration and retry.",
502,
{ errorCode: "labs.ocr.extractFailed" },
);
}
// 7. Queue the read. The worker renders a PDF for a provider that cannot
// read one natively, runs the extraction, and settles the reservation.
return await startAiRun({
userId,
kind: "LABS_OCR_EXTRACT",
params: {
mode: "vision",
mime,
budget: {
reserved: reservation.reserved,
owner: reservation.owner,
dateKey,
},
},
input: buffer,
refundSlot: () => refundLabsOcrSlot(userId),
});
} catch (err) {
// A guard threw after the reservation (e.g. consent races) — refund fully.
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {