feat(labs): make the lab-scan limit operator-tunable

A self-hoster scanning a stack of lab reports ran into a fixed six scans
an hour with no way to raise it. LABS_OCR_LIMIT_PER_HOUR now sets the
per-user ceiling (default 6, clamped to 1-1000, falling back to the
default when unset or not a number), the same shape as
DOCUMENT_AI_LIMIT_PER_HOUR. It is on the compose whitelist, in the
production example, the env manifest and the AI provider guide.

The route also charged the slot before it had looked at the request, so
a malformed body, an unsupported or oversized file, a PDF that could not
be rendered or a scan the daily budget refused still used up the hour.
Those failures hand the slot back now; a scan that reached the provider
keeps it. The 429 carries meta.retryAt. Refs #985
This commit is contained in:
Marc-André Bombeck committed 2026-09-15 23:01:10 +02:00
1 parent 8457e4ffcb
commit ed8a93afc2
10 files changed
+304 -37

No files matched your search

@@ -37,7 +37,8 @@ vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
checkRateLimit: vi.fn(),
refundRateLimit: vi.fn().mockResolvedValue(undefined),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
@@ -65,6 +66,7 @@ import {
} from "@/lib/labs/ocr-capability";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
const SESSION_OK = {
@@ -82,6 +84,12 @@ function textReq(text = "Glucose 95 mg/dL"): Request {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 3_600_000,
} as never);
vi.mocked(requireAuth).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
labsLocalOcrEnabled: true,
@@ -217,6 +225,25 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
);
});
it("hands the slot back when the upload is not an image or PDF", async () => {
const form = new FormData();
form.append(
"file",
new File([new TextEncoder().encode("plain text")], "note.txt", {
type: "text/plain",
}),
);
const res = await POST(
new Request("http://localhost/api/labs/ocr/extract", {
method: "POST",
body: form,
}) as never,
);
expect(res.status).toBe(415);
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,
@@ -239,3 +266,71 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
);
});
});
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(checkRateLimit).toHaveBeenCalledWith(
"labs-ocr:user-1",
40,
60 * 60 * 1000,
);
});
it("answers 429 with the reset instant when the bucket is spent", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.parse("2026-09-15T15:00:00Z"),
} as never);
const res = await POST(textReq() as never);
expect(res.status).toBe(429);
const body = (await res.json()) as {
meta: { errorCode: string; retryAt: string };
};
expect(body.meta.errorCode).toBe("labs.ocr.rateLimited");
expect(body.meta.retryAt).toBe("2026-09-15T15:00:00.000Z");
expect(runOcrExtraction).not.toHaveBeenCalled();
});
it("hands the slot back for a malformed text body", async () => {
const res = await POST(
new Request("http://localhost/api/labs/ocr/extract", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mode: "text" }),
}) as never,
);
expect(res.status).toBe(422);
expect(refundRateLimit).toHaveBeenCalledWith("labs-ocr:user-1");
});
it("hands the slot back when the daily budget turns the scan away", async () => {
vi.mocked(reserveBudget).mockResolvedValue({
allowed: false,
reserved: 0,
totalAfter: 999_999,
owner: "operator",
operatorAfter: 999_999,
} as never);
const res = await POST(textReq() as never);
expect(res.status).toBe(429);
expect(refundRateLimit).toHaveBeenCalledWith("labs-ocr:user-1");
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);
expect(refundRateLimit).not.toHaveBeenCalled();
});
});
+27 -32
View File
@@ -18,8 +18,10 @@
* `labsLocalOcrEnabled` preference.
*
* Guards mirror the Coach's discipline in both modes:
* requireAuth → resolve provider → assertConsentForChain → rate-limit (6/h)
* → reserveBudget → run extraction → reconcile budget.
* requireAuth → resolve provider → assertConsentForChain → 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.
*
* Extracted text is UNTRUSTED (prompt-injection): the server never acts on an
* instruction inside the document — the human review step is the safety
@@ -58,15 +60,15 @@ import {
readBoundedBody,
} from "@/lib/labs/ocr-upload";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
checkLabsOcrRateLimit,
labsOcrRateLimited,
refundLabsOcrSlot,
} from "@/lib/labs/ocr-rate-limit";
import { ocrTextExtractSchema } from "@/lib/validations/labs-ocr";
export const dynamic = "force-dynamic";
/** 6 extraction calls per hour — they are expensive (vision) / metered (text). */
const EXTRACT_LIMIT_PER_HOUR = 6;
const EXTRACT_WINDOW_MS = 60 * 60 * 1000;
/** OCR'd text is bounded in the schema; cap the JSON body proportionally. */
const TEXT_BODY_MAX_BYTES = 512 * 1024;
@@ -138,29 +140,23 @@ async function handleTextExtract(
// egress is the user's own act (the toggle is the consent for local OCR).
await assertConsentForChain({ userId, chain, surface: "insights" });
const rl = await checkRateLimit(
`labs-ocr:${userId}`,
EXTRACT_LIMIT_PER_HOUR,
EXTRACT_WINDOW_MS,
);
const rl = await checkLabsOcrRateLimit(userId);
if (!rl.allowed) {
annotate({ action: { name: "labs.ocr.rateLimited" } });
const response = apiError("Too many scans. Try again later.", 429, {
errorCode: "labs.ocr.rateLimited",
});
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
return labsOcrRateLimited(rl);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: TEXT_BODY_MAX_BYTES,
});
if (jsonError) return jsonError;
if (jsonError) {
await refundLabsOcrSlot(userId);
return jsonError;
}
const parsed = ocrTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundLabsOcrSlot(userId);
return apiValidationError(
"Invalid OCR text payload",
sanitiseZodIssues(parsed.error.issues),
@@ -192,6 +188,7 @@ async function handleTextExtract(
action: { name: "labs.ocr.budget.exceeded" },
meta: { totalAfter: reservation.totalAfter, mode: "text" },
});
await refundLabsOcrSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "labs.ocr.budgetExceeded",
});
@@ -263,20 +260,10 @@ async function handleVisionExtract(
await assertConsentForChain({ userId, chain, surface: "insights" });
// 3. Rate-limit (vision calls are costly).
const rl = await checkRateLimit(
`labs-ocr:${userId}`,
EXTRACT_LIMIT_PER_HOUR,
EXTRACT_WINDOW_MS,
);
const rl = await checkLabsOcrRateLimit(userId);
if (!rl.allowed) {
annotate({ action: { name: "labs.ocr.rateLimited" } });
const response = apiError("Too many scans. Try again later.", 429, {
errorCode: "labs.ocr.rateLimited",
});
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
return labsOcrRateLimited(rl);
}
// 4. Reserve the day's budget BEFORE the provider call (atomic, TOCTOU-safe).
@@ -297,6 +284,7 @@ async function handleVisionExtract(
action: { name: "labs.ocr.budget.exceeded" },
meta: { totalAfter: reservation.totalAfter },
});
await refundLabsOcrSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "labs.ocr.budgetExceeded",
});
@@ -317,6 +305,7 @@ async function handleVisionExtract(
servedBy: null,
reservedOwner: reservation.owner,
});
await refundLabsOcrSlot(userId);
return apiError("File is too large (max 12 MB).", 413, {
errorCode: "labs.ocr.fileTooLarge",
});
@@ -333,6 +322,7 @@ async function handleVisionExtract(
servedBy: null,
reservedOwner: reservation.owner,
});
await refundLabsOcrSlot(userId);
if (err instanceof BodyTooLargeError) {
annotate({
action: { name: "labs.ocr.fileRejected" },
@@ -351,6 +341,7 @@ async function handleVisionExtract(
servedBy: null,
reservedOwner: reservation.owner,
});
await refundLabsOcrSlot(userId);
return apiError("Field 'file' must be a file", 422);
}
@@ -362,6 +353,7 @@ async function handleVisionExtract(
servedBy: null,
reservedOwner: reservation.owner,
});
await refundLabsOcrSlot(userId);
return apiError("Failed to read uploaded file", 400);
}
@@ -376,6 +368,7 @@ async function handleVisionExtract(
servedBy: null,
reservedOwner: reservation.owner,
});
await refundLabsOcrSlot(userId);
return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, {
errorCode: "labs.ocr.fileType",
});
@@ -417,6 +410,7 @@ async function handleVisionExtract(
servedBy: null,
reservedOwner: reservation.owner,
});
await refundLabsOcrSlot(userId);
return apiError(
"Couldn't read this PDF; upload a photo instead.",
422,
@@ -485,6 +479,7 @@ async function handleVisionExtract(
servedBy: null,
reservedOwner: reservation.owner,
}).catch(() => {});
await refundLabsOcrSlot(userId);
throw err;
}
}