From ed8a93afc23fd1c8f8a4b1776f4c36c42edb28a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 15 Sep 2026 23:01:10 +0200 Subject: [PATCH] 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 --- .env.production.example | 7 ++ docker-compose.yml | 6 ++ docs/api/openapi.yaml | 6 +- docs/integrations/ai-providers.md | 10 +- scripts/env-manifest.json | 4 + .../labs/ocr/extract/__tests__/route.test.ts | 97 ++++++++++++++++++- src/app/api/labs/ocr/extract/route.ts | 59 ++++++----- src/lib/labs/__tests__/ocr-rate-limit.test.ts | 75 ++++++++++++++ src/lib/labs/ocr-rate-limit.ts | 75 ++++++++++++++ src/lib/openapi/routes/ocr.ts | 2 +- 10 files changed, 304 insertions(+), 37 deletions(-) create mode 100644 src/lib/labs/__tests__/ocr-rate-limit.test.ts create mode 100644 src/lib/labs/ocr-rate-limit.ts diff --git a/.env.production.example b/.env.production.example index c87444ee7..fe3144723 100644 --- a/.env.production.example +++ b/.env.production.example @@ -290,6 +290,13 @@ API_TOKEN_HMAC_KEY="" # docs/integrations/ai-providers.md. # DOCUMENT_AI_LIMIT_PER_HOUR="" +# Per-user hourly ceiling for lab-report scans (optional). The photo/PDF scan +# and the browser-OCR text scan share one bucket per user. Default 6, clamped +# to 1-1000. Raise it when users scan a stack of reports at once against a +# generous provider budget; cost stays bounded by the daily AI budget. See +# docs/integrations/ai-providers.md. +# LABS_OCR_LIMIT_PER_HOUR="" + # Extra hosts the ADMIN-configured AI base URL may point at, on top of the # built-in public providers (comma-separated bare hostnames). Set it when you # run a gateway this instance should trust. It widens the admin setting only; diff --git a/docker-compose.yml b/docker-compose.yml index 2ad99006b..fd8815206 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -290,6 +290,12 @@ x-healthlog-env: &healthlog_env # (the `environment:` block is a whitelist). See # docs/integrations/ai-providers.md. DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_LIMIT_PER_HOUR:-}" + # Per-user hourly ceiling for lab-report scans (photo/PDF and browser-OCR + # text share one bucket; optional; default 6, clamped 1-1000). A scan that + # fails before the provider is called hands its slot back. Listed here so an + # operator value in .env reaches the container. See + # docs/integrations/ai-providers.md. + LABS_OCR_LIMIT_PER_HOUR: "${LABS_OCR_LIMIT_PER_HOUR:-}" # v1.17.1 — SMTP transport for the email notification channel (optional, # all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE # toggles implicit TLS). Listed here so operator values in .env reach the diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 97743e581..2a78a0b1f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13489,8 +13489,10 @@ paths: provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only - provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are gated by AI consent, a 6/hour - rate bucket, and the per-day token budget, and return proposed rows for the mandatory human review screen — + provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are gated by AI consent, a + per-user hourly rate bucket (default 6, operator-tunable via `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed + when the scan reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-*` triple and + `meta.retryAt`), and the per-day token budget, and return proposed rows for the mandatory human review screen — nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary." requestBody: diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md index 35d53b2c8..e4574e949 100644 --- a/docs/integrations/ai-providers.md +++ b/docs/integrations/ai-providers.md @@ -412,7 +412,15 @@ many documents against a generous provider budget; lower it on a tight one. The 429 names the actual wait (the window is an hour) and carries the reset instant in `X-RateLimit-Reset`. -All three variables must also be listed in the compose +`LABS_OCR_LIMIT_PER_HOUR` (default `6`, clamped to 1–1000) does the +same for lab-report scans. The photo or PDF scan and the browser-OCR +text scan share one bucket per user, and a scan that fails before the +provider is called (malformed body, file too large or not an image or +PDF, a PDF that cannot be rendered, exhausted daily budget) hands its +slot back. Raise it when users scan a stack of reports at once; the +daily AI budget still bounds the cost. + +All four variables must also be listed in the compose `environment:` whitelist to reach the container; the shipped `docker-compose.yml` lists them. diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 5c251bfe1..83b7df229 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -137,6 +137,10 @@ { "name": "DOCUMENT_AI_LIMIT_PER_HOUR", "purpose": "Per-user hourly ceiling for the document-AI actions (read/index, extract, suggest, summarise share one bucket). Default 6, clamped to 1-1000." + }, + { + "name": "LABS_OCR_LIMIT_PER_HOUR", + "purpose": "Per-user hourly ceiling for lab-report scans (photo/PDF and browser-OCR text share one bucket). Default 6, clamped to 1-1000." } ] }, diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index 97b655c31..b5f9f03bc 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 901b5b0f5..36a7f9139 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -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; } } diff --git a/src/lib/labs/__tests__/ocr-rate-limit.test.ts b/src/lib/labs/__tests__/ocr-rate-limit.test.ts new file mode 100644 index 000000000..15705a21b --- /dev/null +++ b/src/lib/labs/__tests__/ocr-rate-limit.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + refundRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({ "Retry-After": "1800" })), +})); + +import { + checkLabsOcrRateLimit, + labsOcrRateLimited, + refundLabsOcrSlot, + resolveLabsOcrLimitPerHour, +} from "../ocr-rate-limit"; +import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe("LABS_OCR_LIMIT_PER_HOUR", () => { + it("defaults to 6 when unset or not a number", () => { + vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", ""); + expect(resolveLabsOcrLimitPerHour()).toBe(6); + vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "lots"); + expect(resolveLabsOcrLimitPerHour()).toBe(6); + }); + + it("uses an operator value inside the range", () => { + vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "40"); + expect(resolveLabsOcrLimitPerHour()).toBe(40); + }); + + it("clamps to 1-1000 so a typo can neither switch scans off nor remove the cap", () => { + vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "0"); + expect(resolveLabsOcrLimitPerHour()).toBe(1); + vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "-5"); + expect(resolveLabsOcrLimitPerHour()).toBe(1); + vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "50000"); + expect(resolveLabsOcrLimitPerHour()).toBe(1000); + }); + + it("charges the existing bucket key with the resolved ceiling and an hour window", async () => { + vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "25"); + await checkLabsOcrRateLimit("user-1"); + expect(checkRateLimit).toHaveBeenCalledWith( + "labs-ocr:user-1", + 25, + 60 * 60 * 1000, + ); + }); + + it("refunds the same bucket and never throws when the refund fails", async () => { + vi.mocked(refundRateLimit).mockRejectedValueOnce(new Error("db down")); + await expect(refundLabsOcrSlot("user-1")).resolves.toBeUndefined(); + expect(refundRateLimit).toHaveBeenCalledWith("labs-ocr:user-1"); + }); + + it("names the reset instant on the 429", async () => { + const resetAt = Date.parse("2026-09-15T15:00:00Z"); + const res = labsOcrRateLimited({ + allowed: false, + remaining: 0, + resetAt, + } as never); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("1800"); + 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"); + }); +}); diff --git a/src/lib/labs/ocr-rate-limit.ts b/src/lib/labs/ocr-rate-limit.ts new file mode 100644 index 000000000..945724214 --- /dev/null +++ b/src/lib/labs/ocr-rate-limit.ts @@ -0,0 +1,75 @@ +import { apiError } from "@/lib/api-response"; +import { + checkRateLimit, + rateLimitHeaders, + refundRateLimit, + type RateLimitResult, +} from "@/lib/rate-limit"; + +/** + * The lab-report scan (`POST /api/labs/ocr/extract`, both the vision and the + * browser-OCR text mode) charges one per-user hourly bucket. The ceiling is + * operator-tunable via `LABS_OCR_LIMIT_PER_HOUR` (default 6, clamped to + * 1-1000 so a typo can neither switch the scan off nor remove the cap), the + * same posture as `DOCUMENT_AI_LIMIT_PER_HOUR` for the document vault. Unset or + * non-numeric falls back to the default. Cost is bounded separately by the + * daily AI budget; this bucket only bounds the request rate. + */ +const LABS_OCR_LIMIT_DEFAULT = 6; +const LABS_OCR_LIMIT_MIN = 1; +const LABS_OCR_LIMIT_MAX = 1000; + +export function resolveLabsOcrLimitPerHour(): number { + const raw = process.env.LABS_OCR_LIMIT_PER_HOUR; + if (!raw) return LABS_OCR_LIMIT_DEFAULT; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return LABS_OCR_LIMIT_DEFAULT; + return Math.min(LABS_OCR_LIMIT_MAX, Math.max(LABS_OCR_LIMIT_MIN, parsed)); +} + +export const LABS_OCR_WINDOW_MS = 60 * 60 * 1000; +/** Bucket key prefix. Unchanged, so a running window survives the upgrade. */ +export const LABS_OCR_BUCKET = "labs-ocr"; + +/** Charge one slot of the per-user lab-scan bucket. */ +export async function checkLabsOcrRateLimit( + userId: string, +): Promise { + return checkRateLimit( + `${LABS_OCR_BUCKET}:${userId}`, + resolveLabsOcrLimitPerHour(), + LABS_OCR_WINDOW_MS, + ); +} + +/** + * Give a charged slot back when the scan failed BEFORE the provider was + * called: a malformed body, a file that is too large or not an image or PDF, + * a PDF that could not be rendered, or a daily budget that turned it away. + * The bucket is charged early so a 429 stays cheap, but a slot must stand for + * a scan that was actually sent. Best-effort: a failed refund never masks the + * response the caller is about to return. + */ +export async function refundLabsOcrSlot(userId: string): Promise { + try { + await refundRateLimit(`${LABS_OCR_BUCKET}:${userId}`); + } catch { + // The original response matters more than the bookkeeping. + } +} + +/** + * The 429 for the lab-scan bucket. Carries the standard `X-RateLimit-*` + * headers and mirrors the reset instant into `meta.retryAt` (ISO 8601) so the + * client can name the actual wait. + */ +export function labsOcrRateLimited(rl: RateLimitResult): Response { + const response = apiError("Too many scans. Try again later.", 429, { + errorCode: "labs.ocr.rateLimited", + retryAt: new Date(rl.resetAt).toISOString(), + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; +} diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 3be1612d6..d4c9a280a 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -165,7 +165,7 @@ export const ocrPaths: NonNullable = { tags: ["Labs"], summary: "Extract lab readings from a photo, PDF, or OCR'd text", description: - "Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are gated by AI consent, a 6/hour rate bucket, and the per-day token budget, and return proposed rows for the mandatory human review screen — nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary.", + "Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are gated by AI consent, a per-user hourly rate bucket (default 6, operator-tunable via `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed when the scan reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-*` triple and `meta.retryAt`), and the per-day token budget, and return proposed rows for the mandatory human review screen — nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary.", requestBody: { required: true, content: {