mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user