mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(documents): make the document-AI rate limit an operator setting with honest accounting
The document-AI actions (read/index, extract, suggest, summarise) shared a hard-coded 6/hour bucket that lied twice: the 429 said "try again in a few minutes" while the window is an hour, and several failure paths burned a slot before any provider was dispatched. - DOCUMENT_AI_LIMIT_PER_HOUR configures the shared per-user ceiling (default 6, clamped 1-1000), wired end to end: compose environment whitelist, .env.production.example, env manifest, operator docs. - INSIGHTS_RATE_LIMIT_PER_HOUR joins the compose whitelist and manifest; it was documented but never listed, so an operator value could not reach the container. - The 429 mirrors the bucket's reset instant into meta.retryAt (plus the existing X-RateLimit-Reset header); the client derives "try again in about N minutes" from it in all six locales, with a plural-aware key. - A slot is consumed only when a request actually reaches the provider (or the local index/echo work): malformed bodies, unreadable originals, refused consent, and exhausted daily budgets refund the charged slot via a window-scoped refundRateLimit primitive. Consistent across all four routes sharing the bucket.
This commit is contained in:
1 parent
592d1e9aa8
commit
8e16df6894
26 files changed
+560
-160
No files matched your search
@@ -230,6 +230,18 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# ("AI response was cut off"). See docs/integrations/ai-providers.md.
|
||||
# INSIGHTS_MAX_TOKENS=""
|
||||
|
||||
# Per-user hourly ceiling for POST /api/insights/generate (optional).
|
||||
# Default 10. Lower it on a tight LLM budget; the 24h cache already
|
||||
# short-circuits read traffic. See docs/integrations/ai-providers.md.
|
||||
# INSIGHTS_RATE_LIMIT_PER_HOUR=""
|
||||
|
||||
# Per-user hourly ceiling for the document-AI actions (optional). Read/index,
|
||||
# extract, suggest and summarise share one bucket per user. Default 6,
|
||||
# clamped to 1-1000. Raise it when users batch-read many documents against a
|
||||
# generous provider budget; lower it on a tight one. See
|
||||
# docs/integrations/ai-providers.md.
|
||||
# DOCUMENT_AI_LIMIT_PER_HOUR=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Off-host backups -- all-or-none
|
||||
|
||||
@@ -199,6 +199,18 @@ x-healthlog-env: &healthlog_env
|
||||
# Listed here so an operator value in .env reaches the container (the
|
||||
# `environment:` block is a whitelist). See docs/integrations/ai-providers.md.
|
||||
INSIGHTS_MAX_TOKENS: "${INSIGHTS_MAX_TOKENS:-}"
|
||||
# Per-user hourly ceiling for POST /api/insights/generate (optional;
|
||||
# default 10). Was documented but missing from this whitelist, so an
|
||||
# operator value never reached the container. See
|
||||
# docs/integrations/ai-providers.md.
|
||||
INSIGHTS_RATE_LIMIT_PER_HOUR: "${INSIGHTS_RATE_LIMIT_PER_HOUR:-}"
|
||||
# Per-user hourly ceiling for the document-AI actions (read/index, extract,
|
||||
# suggest, summarise — one shared bucket; optional; default 6, clamped
|
||||
# 1-1000). Raise on a self-host with a generous provider budget, lower on a
|
||||
# tight one. Listed here so an operator value in .env reaches the container
|
||||
# (the `environment:` block is a whitelist). See
|
||||
# docs/integrations/ai-providers.md.
|
||||
DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_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
|
||||
|
||||
@@ -12495,9 +12495,11 @@ paths:
|
||||
description: "Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION,
|
||||
empty body) or browser-OCR'd text (TEXT, `application/json` `{ mode: \"text\", text }`, opt-in local OCR) and
|
||||
returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the
|
||||
6/hour extract bucket). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422
|
||||
(`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title
|
||||
is a neutral filing label."
|
||||
per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via
|
||||
`DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries
|
||||
the reset instant in `X-RateLimit-Reset` and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips
|
||||
status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured.
|
||||
Never interprets or diagnoses; the title is a neutral filing label."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
|
||||
@@ -393,6 +393,20 @@ verbose model gets its briefing cut off mid-JSON — the API reports
|
||||
that case as "AI response was cut off" rather than the generic
|
||||
invalid-JSON error.
|
||||
|
||||
`DOCUMENT_AI_LIMIT_PER_HOUR` (default `6`, clamped to 1–1000) bounds
|
||||
the document-AI actions per user per hour. Read/index, extract,
|
||||
suggest and summarise share one bucket, and a slot is only consumed
|
||||
when a request actually reaches the provider — a request that fails
|
||||
during input preparation (unreadable file, exhausted daily budget,
|
||||
malformed body) hands its slot back. Raise it when users batch-read
|
||||
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
|
||||
`environment:` whitelist to reach the container; the shipped
|
||||
`docker-compose.yml` lists them.
|
||||
|
||||
## Connection test
|
||||
|
||||
`POST /api/ai/test` runs a one-shot probe against the resolved
|
||||
|
||||
+4
-1
@@ -9560,7 +9560,10 @@
|
||||
"unavailableAction": "KI-Einstellungen öffnen",
|
||||
"errorGeneric": "Dokument konnte nicht gelesen werden. Bitte erneut versuchen.",
|
||||
"errorOcr": "Aus diesem Bild konnte kein Text gelesen werden.",
|
||||
"errorRateLimited": "Zu viele KI-Anfragen — bitte in ein paar Minuten erneut versuchen.",
|
||||
"errorRateLimited": "Das stündliche KI-Leselimit ist erreicht. Bitte später erneut versuchen.",
|
||||
"errorRateLimitedWaitOne": "Das stündliche KI-Leselimit ist erreicht. Bitte in etwa einer Minute erneut versuchen.",
|
||||
"errorRateLimitedWaitFew": "Das stündliche KI-Leselimit ist erreicht. Bitte in etwa {count} Minuten erneut versuchen.",
|
||||
"errorRateLimitedWaitOther": "Das stündliche KI-Leselimit ist erreicht. Bitte in etwa {count} Minuten erneut versuchen.",
|
||||
"errorBudget": "Dein KI-Budget für heute ist aufgebraucht.",
|
||||
"errorPdfNeedsVision": "Zum Scannen von PDFs wird ein Claude-Vision-Anbieter benötigt.",
|
||||
"errorFileType": "Dieser Dateityp kann nicht gelesen werden.",
|
||||
|
||||
+4
-1
@@ -9560,7 +9560,10 @@
|
||||
"unavailableAction": "Open AI settings",
|
||||
"errorGeneric": "Couldn't read the document. Try again.",
|
||||
"errorOcr": "Couldn't read text from this image.",
|
||||
"errorRateLimited": "Too many AI requests — try again in a few minutes.",
|
||||
"errorRateLimited": "The hourly AI reading limit is reached. Try again later.",
|
||||
"errorRateLimitedWaitOne": "The hourly AI reading limit is reached. Try again in about a minute.",
|
||||
"errorRateLimitedWaitFew": "The hourly AI reading limit is reached. Try again in about {count} minutes.",
|
||||
"errorRateLimitedWaitOther": "The hourly AI reading limit is reached. Try again in about {count} minutes.",
|
||||
"errorBudget": "Your AI budget for today is used up.",
|
||||
"errorPdfNeedsVision": "Scanning PDFs needs a Claude vision provider.",
|
||||
"errorFileType": "This file type can't be read.",
|
||||
|
||||
+4
-1
@@ -9560,7 +9560,10 @@
|
||||
"unavailableAction": "Abrir ajustes de IA",
|
||||
"errorGeneric": "No se pudo leer el documento. Inténtalo de nuevo.",
|
||||
"errorOcr": "No se pudo leer texto de esta imagen.",
|
||||
"errorRateLimited": "Demasiadas solicitudes de IA: inténtalo en unos minutos.",
|
||||
"errorRateLimited": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo más tarde.",
|
||||
"errorRateLimitedWaitOne": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo en aproximadamente un minuto.",
|
||||
"errorRateLimitedWaitFew": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo en aproximadamente {count} minutos.",
|
||||
"errorRateLimitedWaitOther": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo en aproximadamente {count} minutos.",
|
||||
"errorBudget": "Tu presupuesto de IA de hoy se ha agotado.",
|
||||
"errorPdfNeedsVision": "Escanear PDF requiere un proveedor de visión Claude.",
|
||||
"errorFileType": "Este tipo de archivo no se puede leer.",
|
||||
|
||||
+4
-1
@@ -9560,7 +9560,10 @@
|
||||
"unavailableAction": "Ouvrir les paramètres d'IA",
|
||||
"errorGeneric": "Impossible de lire le document. Réessayez.",
|
||||
"errorOcr": "Impossible de lire le texte de cette image.",
|
||||
"errorRateLimited": "Trop de requêtes IA — réessayez dans quelques minutes.",
|
||||
"errorRateLimited": "La limite horaire de lectures IA est atteinte. Réessayez plus tard.",
|
||||
"errorRateLimitedWaitOne": "La limite horaire de lectures IA est atteinte. Réessayez dans environ une minute.",
|
||||
"errorRateLimitedWaitFew": "La limite horaire de lectures IA est atteinte. Réessayez dans environ {count} minutes.",
|
||||
"errorRateLimitedWaitOther": "La limite horaire de lectures IA est atteinte. Réessayez dans environ {count} minutes.",
|
||||
"errorBudget": "Votre budget IA du jour est épuisé.",
|
||||
"errorPdfNeedsVision": "La lecture des PDF nécessite un fournisseur de vision Claude.",
|
||||
"errorFileType": "Ce type de fichier ne peut pas être lu.",
|
||||
|
||||
+4
-1
@@ -9560,7 +9560,10 @@
|
||||
"unavailableAction": "Apri impostazioni IA",
|
||||
"errorGeneric": "Impossibile leggere il documento. Riprova.",
|
||||
"errorOcr": "Impossibile leggere il testo da questa immagine.",
|
||||
"errorRateLimited": "Troppe richieste IA — riprova tra qualche minuto.",
|
||||
"errorRateLimited": "Il limite orario di letture IA è stato raggiunto. Riprova più tardi.",
|
||||
"errorRateLimitedWaitOne": "Il limite orario di letture IA è stato raggiunto. Riprova tra circa un minuto.",
|
||||
"errorRateLimitedWaitFew": "Il limite orario di letture IA è stato raggiunto. Riprova tra circa {count} minuti.",
|
||||
"errorRateLimitedWaitOther": "Il limite orario di letture IA è stato raggiunto. Riprova tra circa {count} minuti.",
|
||||
"errorBudget": "Il tuo budget IA di oggi è esaurito.",
|
||||
"errorPdfNeedsVision": "La scansione dei PDF richiede un provider di visione Claude.",
|
||||
"errorFileType": "Questo tipo di file non può essere letto.",
|
||||
|
||||
+4
-1
@@ -9560,7 +9560,10 @@
|
||||
"unavailableAction": "Otwórz ustawienia AI",
|
||||
"errorGeneric": "Nie udało się odczytać dokumentu. Spróbuj ponownie.",
|
||||
"errorOcr": "Nie udało się odczytać tekstu z tego obrazu.",
|
||||
"errorRateLimited": "Zbyt wiele żądań AI — spróbuj za kilka minut.",
|
||||
"errorRateLimited": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie później.",
|
||||
"errorRateLimitedWaitOne": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie za około minutę.",
|
||||
"errorRateLimitedWaitFew": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie za około {count} minuty.",
|
||||
"errorRateLimitedWaitOther": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie za około {count} minut.",
|
||||
"errorBudget": "Twój dzisiejszy budżet AI został wyczerpany.",
|
||||
"errorPdfNeedsVision": "Skanowanie plików PDF wymaga dostawcy wizji Claude.",
|
||||
"errorFileType": "Tego typu pliku nie można odczytać.",
|
||||
|
||||
@@ -129,6 +129,14 @@
|
||||
{
|
||||
"name": "INSIGHTS_MAX_TOKENS",
|
||||
"purpose": "Daily-briefing output-token ceiling. Default 2500, clamped to 500-8000."
|
||||
},
|
||||
{
|
||||
"name": "INSIGHTS_RATE_LIMIT_PER_HOUR",
|
||||
"purpose": "Per-user hourly ceiling for POST /api/insights/generate. Default 10; values below 1 fall back to the default."
|
||||
},
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -55,21 +55,21 @@ import {
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
import {
|
||||
checkDocumentAiRateLimit,
|
||||
documentAiRateLimited,
|
||||
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
refundDocumentAiSlot,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */
|
||||
const EXTRACT_LIMIT_PER_HOUR = 6;
|
||||
const EXTRACT_WINDOW_MS = 60 * 60 * 1000;
|
||||
const TEXT_BODY_MAX_BYTES = 512 * 1024;
|
||||
|
||||
/** A loaded, owner-scoped, still-extractable document row. */
|
||||
type LoadedDocument = {
|
||||
id: string;
|
||||
@@ -213,28 +213,20 @@ async function handleTextExtract(
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
EXTRACT_LIMIT_PER_HOUR,
|
||||
EXTRACT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many extractions. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: TEXT_BODY_MAX_BYTES,
|
||||
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
if (jsonError) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return jsonError;
|
||||
}
|
||||
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
@@ -248,6 +240,7 @@ async function handleTextExtract(
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
@@ -315,23 +308,12 @@ async function handleVisionExtract(
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
EXTRACT_LIMIT_PER_HOUR,
|
||||
EXTRACT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many extractions. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
// Decrypt the stored original and re-derive its MIME from the bytes (never
|
||||
// trust a stored label for the provider call).
|
||||
// trust a stored label for the provider call). Every miss below happens
|
||||
// before the provider dispatch, so the charged slot goes back.
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = decryptDocumentContent(
|
||||
@@ -339,6 +321,7 @@ async function handleVisionExtract(
|
||||
document.contentCodec,
|
||||
);
|
||||
} catch {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Couldn't read the stored document.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
@@ -346,6 +329,7 @@ async function handleVisionExtract(
|
||||
|
||||
const mime = detectOcrMimeType(buffer);
|
||||
if (!mime) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError(
|
||||
"This document can't be scanned. Use local OCR (text mode).",
|
||||
422,
|
||||
@@ -354,6 +338,7 @@ async function handleVisionExtract(
|
||||
}
|
||||
|
||||
if (mime === "application/pdf" && !pick.pdfSupported) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError(
|
||||
"PDF scanning needs a Claude vision provider; use local OCR instead.",
|
||||
422,
|
||||
@@ -369,6 +354,7 @@ async function handleVisionExtract(
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
|
||||
@@ -5,6 +5,12 @@ import { NextRequest } from "next/server";
|
||||
* Content-index route (Document vault P2). Pins: vision path transcribes then
|
||||
* upserts the index; text path indexes posted OCR text with no provider;
|
||||
* provider-gated on vision; only the index sibling is written (never facts).
|
||||
*
|
||||
* Bucket honesty: the route charges the shared document-AI bucket up front
|
||||
* (so a 429 stays cheap) but a slot must reflect work actually performed —
|
||||
* a 429 names the reset instant in `meta.retryAt`, a preparation failure
|
||||
* before any provider dispatch refunds the charged slot, and a successful
|
||||
* dispatch keeps it.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -47,7 +53,11 @@ vi.mock("@/lib/modules/gate", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
refundRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
rateLimitHeaders: (result: { remaining?: number; resetAt?: number }) => ({
|
||||
"X-RateLimit-Remaining": String(result.remaining ?? 0),
|
||||
"X-RateLimit-Reset": new Date(result.resetAt ?? 0).toISOString(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -69,7 +79,10 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { reserveBudget } from "@/lib/ai/coach/budget";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { decryptDocumentContent } from "@/lib/documents/store";
|
||||
import { transcribeDocument } from "@/lib/documents/describe";
|
||||
import { upsertContentIndex } from "@/lib/documents/content-index";
|
||||
|
||||
@@ -95,9 +108,30 @@ const textReq = (id: string, text: string) =>
|
||||
},
|
||||
);
|
||||
|
||||
const PICK = {
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
providerType: "anthropic",
|
||||
pdfSupported: true,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60 * 60 * 1000,
|
||||
});
|
||||
vi.mocked(reserveBudget).mockResolvedValue({
|
||||
allowed: true,
|
||||
reserved: 1,
|
||||
} as never);
|
||||
vi.mocked(decryptDocumentContent).mockReturnValue(
|
||||
Buffer.from([1, 2, 3]) as never,
|
||||
);
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
kind: "OTHER",
|
||||
@@ -110,14 +144,7 @@ beforeEach(() => {
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/index", () => {
|
||||
it("vision: transcribes then upserts the index, never facts", async () => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
providerType: "anthropic",
|
||||
pdfSupported: true,
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(transcribeDocument).mockResolvedValue({
|
||||
text: "hemoglobin 14.2 leukocytes normal",
|
||||
} as never);
|
||||
@@ -176,3 +203,60 @@ describe("POST /api/documents/inbound/[id]/index", () => {
|
||||
expect(upsertContentIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/index — bucket honesty", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(transcribeDocument).mockResolvedValue({
|
||||
text: "hemoglobin 14.2 leukocytes normal",
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("returns 429 with the reset instant in meta.retryAt when the bucket is exhausted", async () => {
|
||||
const resetAt = Date.now() + 47 * 60_000;
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt,
|
||||
});
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(429);
|
||||
const body = (await res.json()) as {
|
||||
meta?: { errorCode?: string; retryAt?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("documents.inbound.rateLimited");
|
||||
expect(body.meta?.retryAt).toBe(new Date(resetAt).toISOString());
|
||||
expect(refundRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refunds the charged slot when input preparation fails before dispatch", async () => {
|
||||
vi.mocked(decryptDocumentContent).mockImplementation(() => {
|
||||
throw new Error("bad codec");
|
||||
});
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(422);
|
||||
expect(refundRateLimit).toHaveBeenCalledTimes(1);
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("documents-inbound:user-1");
|
||||
expect(transcribeDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refunds the charged slot when the daily budget refuses before dispatch", async () => {
|
||||
vi.mocked(reserveBudget).mockResolvedValue({
|
||||
allowed: false,
|
||||
reserved: 0,
|
||||
} as never);
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(429);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("documents.inbound.budgetExceeded");
|
||||
expect(refundRateLimit).toHaveBeenCalledTimes(1);
|
||||
expect(transcribeDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the slot once the provider was actually dispatched", async () => {
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
expect(transcribeDocument).toHaveBeenCalledTimes(1);
|
||||
expect(refundRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -39,12 +39,12 @@ import {
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
DOCUMENT_AI_BUCKET,
|
||||
DOCUMENT_AI_LIMIT_PER_HOUR,
|
||||
checkDocumentAiRateLimit,
|
||||
documentAiRateLimited,
|
||||
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
DOCUMENT_AI_WINDOW_MS,
|
||||
loadOwnedDocument,
|
||||
prepareVisionInput,
|
||||
refundDocumentAiSlot,
|
||||
type LoadedDocument,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import { upsertContentIndex } from "@/lib/documents/content-index";
|
||||
@@ -56,23 +56,12 @@ import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
function rateLimited(rl: Awaited<ReturnType<typeof checkRateLimit>>): Response {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -131,19 +120,19 @@ async function handleTextIndex(
|
||||
});
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
DOCUMENT_AI_LIMIT_PER_HOUR,
|
||||
DOCUMENT_AI_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) return rateLimited(rl);
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
if (jsonError) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return jsonError;
|
||||
}
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
@@ -177,15 +166,13 @@ async function handleVisionIndex(
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
DOCUMENT_AI_LIMIT_PER_HOUR,
|
||||
DOCUMENT_AI_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) return rateLimited(rl);
|
||||
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.
|
||||
await refundDocumentAiSlot(userId);
|
||||
if (vision.reason === "pdfNeedsAnthropic") {
|
||||
return apiError(
|
||||
"PDF scanning needs a Claude vision provider; use local OCR instead.",
|
||||
@@ -213,6 +200,7 @@ async function handleVisionIndex(
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
|
||||
@@ -36,12 +36,12 @@ import {
|
||||
type DocumentSuggestion,
|
||||
} from "@/lib/documents/assist";
|
||||
import {
|
||||
DOCUMENT_AI_BUCKET,
|
||||
DOCUMENT_AI_LIMIT_PER_HOUR,
|
||||
checkDocumentAiRateLimit,
|
||||
documentAiRateLimited,
|
||||
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
DOCUMENT_AI_WINDOW_MS,
|
||||
loadOwnedDocument,
|
||||
prepareVisionInput,
|
||||
refundDocumentAiSlot,
|
||||
type LoadedDocument,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import {
|
||||
@@ -51,23 +51,12 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
function rateLimited(rl: Awaited<ReturnType<typeof checkRateLimit>>): Response {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -146,19 +135,19 @@ async function handleTextSuggest(
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
DOCUMENT_AI_LIMIT_PER_HOUR,
|
||||
DOCUMENT_AI_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) return rateLimited(rl);
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
if (jsonError) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return jsonError;
|
||||
}
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
@@ -172,6 +161,7 @@ async function handleTextSuggest(
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
@@ -226,15 +216,13 @@ async function handleVisionSuggest(
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
DOCUMENT_AI_LIMIT_PER_HOUR,
|
||||
DOCUMENT_AI_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) return rateLimited(rl);
|
||||
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.
|
||||
await refundDocumentAiSlot(userId);
|
||||
if (vision.reason === "pdfNeedsAnthropic") {
|
||||
return apiError(
|
||||
"PDF scanning needs a Claude vision provider; use local OCR instead.",
|
||||
@@ -262,6 +250,7 @@ async function handleVisionSuggest(
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
|
||||
@@ -38,12 +38,12 @@ import {
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
DOCUMENT_AI_BUCKET,
|
||||
DOCUMENT_AI_LIMIT_PER_HOUR,
|
||||
checkDocumentAiRateLimit,
|
||||
documentAiRateLimited,
|
||||
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
DOCUMENT_AI_WINDOW_MS,
|
||||
loadOwnedDocument,
|
||||
prepareVisionInput,
|
||||
refundDocumentAiSlot,
|
||||
type LoadedDocument,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import {
|
||||
@@ -62,7 +62,6 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
DOCUMENT_SUMMARY_MODES,
|
||||
inboundTextExtractSchema,
|
||||
@@ -76,16 +75,6 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
function rateLimited(rl: Awaited<ReturnType<typeof checkRateLimit>>): Response {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
type SummaryPersistenceTarget = {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
@@ -295,19 +284,19 @@ async function handleTextSummary(
|
||||
});
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
DOCUMENT_AI_LIMIT_PER_HOUR,
|
||||
DOCUMENT_AI_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) return rateLimited(rl);
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
if (jsonError) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return jsonError;
|
||||
}
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
@@ -326,15 +315,22 @@ async function handleTextSummary(
|
||||
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
try {
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
} catch (err) {
|
||||
// Refused before any dispatch — the slot goes back with the refusal.
|
||||
await refundDocumentAiSlot(userId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
@@ -344,6 +340,7 @@ async function handleTextSummary(
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
@@ -394,15 +391,13 @@ async function handleVisionSummary(
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
DOCUMENT_AI_LIMIT_PER_HOUR,
|
||||
DOCUMENT_AI_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) return rateLimited(rl);
|
||||
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.
|
||||
await refundDocumentAiSlot(userId);
|
||||
if (vision.reason === "pdfNeedsAnthropic") {
|
||||
return apiError(
|
||||
"PDF scanning needs a Claude vision provider; use local OCR instead.",
|
||||
@@ -430,6 +425,7 @@ async function handleVisionSummary(
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("<DocumentSummaryPanel>", () => {
|
||||
output="summary"
|
||||
result={null}
|
||||
isPending
|
||||
errorKey={null}
|
||||
errorText={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -167,7 +167,7 @@ describe("<DocumentSummaryPanel>", () => {
|
||||
output="summary"
|
||||
result={{ summary: "A lipid panel dated 1 March 2026." }}
|
||||
isPending={false}
|
||||
errorKey={null}
|
||||
errorText={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -180,7 +180,7 @@ describe("<DocumentSummaryPanel>", () => {
|
||||
output="text"
|
||||
result={{ text: "LDL 3.1 mmol/L" }}
|
||||
isPending={false}
|
||||
errorKey={null}
|
||||
errorText={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -195,7 +195,7 @@ describe("<DocumentSummaryPanel>", () => {
|
||||
output="summary"
|
||||
result={null}
|
||||
isPending={false}
|
||||
errorKey="documents.assist.errorGeneric"
|
||||
errorText="Couldn't read the document. Try again."
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -209,7 +209,7 @@ describe("<DocumentSummaryPanel>", () => {
|
||||
output="summary"
|
||||
result={{ summary: "A lipid panel dated 1 March 2026." }}
|
||||
isPending={false}
|
||||
errorKey={null}
|
||||
errorText={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The honest-wait derivation for a document-AI 429. The routes mirror the
|
||||
* bucket's reset instant into `meta.retryAt`; the client turns it into "try
|
||||
* again in about N minutes". This pins the mapping: only a document-AI
|
||||
* rate-limit error yields minutes, the floor is one minute, and anything
|
||||
* malformed falls back to null (the generic copy).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
|
||||
import { documentAiRetryMinutes } from "../use-document-assist";
|
||||
|
||||
const NOW = new Date("2026-08-12T12:00:00.000Z").getTime();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function rateLimited(retryAt?: unknown): ApiError {
|
||||
return new ApiError("Too many document AI requests this hour.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
...(retryAt !== undefined ? { retryAt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("documentAiRetryMinutes", () => {
|
||||
it("derives whole minutes from the reset instant, rounding up", () => {
|
||||
const err = rateLimited(new Date(NOW + 42.4 * 60_000).toISOString());
|
||||
expect(documentAiRetryMinutes(err)).toBe(43);
|
||||
});
|
||||
|
||||
it("floors at one minute for a reset only seconds away", () => {
|
||||
const err = rateLimited(new Date(NOW + 20_000).toISOString());
|
||||
expect(documentAiRetryMinutes(err)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null when the error carries no usable instant", () => {
|
||||
expect(documentAiRetryMinutes(rateLimited())).toBeNull();
|
||||
expect(documentAiRetryMinutes(rateLimited("soon"))).toBeNull();
|
||||
expect(documentAiRetryMinutes(rateLimited(42))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for any other error class", () => {
|
||||
expect(
|
||||
documentAiRetryMinutes(
|
||||
new ApiError("Budget", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(documentAiRetryMinutes(new Error("network"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -202,13 +202,14 @@ export function DocumentSummaryPanel({
|
||||
output,
|
||||
result,
|
||||
isPending,
|
||||
errorKey,
|
||||
errorText,
|
||||
onClose,
|
||||
}: {
|
||||
output: DocumentSummaryMode;
|
||||
result: DocumentDescribeResult | null;
|
||||
isPending: boolean;
|
||||
errorKey: string | null;
|
||||
/** Resolved user-facing error sentence (already translated), or null. */
|
||||
errorText: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
@@ -247,9 +248,9 @@ export function DocumentSummaryPanel({
|
||||
<Skeleton className="h-3.5 w-11/12" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
) : errorKey ? (
|
||||
) : errorText ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(errorKey)}
|
||||
{errorText}
|
||||
</p>
|
||||
) : body !== null ? (
|
||||
<p
|
||||
|
||||
@@ -92,6 +92,7 @@ export function DocumentAiSection({
|
||||
actionsDisabled: boolean;
|
||||
onSuggest: () => void;
|
||||
suggestPending: boolean;
|
||||
/** Resolved user-facing error sentence (already translated), or null. */
|
||||
suggestErrorKey: string | null;
|
||||
onSummarise: (output: DocumentSummaryMode) => void;
|
||||
summaryPending: boolean;
|
||||
@@ -105,6 +106,7 @@ export function DocumentAiSection({
|
||||
onDismissSuggestion: () => void;
|
||||
summaryOutput: DocumentSummaryMode | null;
|
||||
summaryResult: DocumentDescribeResult | null;
|
||||
/** Resolved user-facing error sentence (already translated), or null. */
|
||||
summaryErrorKey: string | null;
|
||||
onCloseSummary: () => void;
|
||||
hasContentIndex: boolean;
|
||||
@@ -233,7 +235,7 @@ export function DocumentAiSection({
|
||||
|
||||
{suggestErrorKey ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(suggestErrorKey)}
|
||||
{suggestErrorKey}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -255,7 +257,7 @@ export function DocumentAiSection({
|
||||
output={summaryOutput}
|
||||
result={summaryResult}
|
||||
isPending={summaryPending}
|
||||
errorKey={summaryErrorKey}
|
||||
errorText={summaryErrorKey}
|
||||
onClose={onCloseSummary}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -87,8 +87,8 @@ import { DocumentShareSheet } from "./document-share-sheet";
|
||||
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
|
||||
import { useIndexDocument } from "./use-content-index";
|
||||
import {
|
||||
documentAiErrorKey,
|
||||
useDocumentAiCapability,
|
||||
useDocumentAiErrorText,
|
||||
useDocumentsAutoAiRead,
|
||||
useDocumentSummary,
|
||||
useSuggestDetails,
|
||||
@@ -393,6 +393,9 @@ export function DocumentDetailSheet({
|
||||
);
|
||||
const suggest = useSuggestDetails();
|
||||
const summary = useDocumentSummary();
|
||||
// Resolves an AI error to final copy — a 429 names the actual wait, derived
|
||||
// from the reset instant the route mirrors into the error meta.
|
||||
const aiErrorText = useDocumentAiErrorText();
|
||||
// Stored generation has an independent mutation state so transient
|
||||
// Summarise/Show text requests cannot change this block's spinner or result.
|
||||
const storedSummary = useDocumentSummary();
|
||||
@@ -593,7 +596,7 @@ export function DocumentDetailSheet({
|
||||
queryKey: queryKeys.inboundDocument(documentId),
|
||||
});
|
||||
},
|
||||
onError: (error) => toast.error(t(documentAiErrorKey(error))),
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -604,7 +607,7 @@ export function DocumentDetailSheet({
|
||||
{ mode: aiMode, target: aiTarget },
|
||||
{
|
||||
onSuccess: () => toast.success(t("documents.ai.readDone")),
|
||||
onError: (error) => toast.error(t(documentAiErrorKey(error))),
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -836,7 +839,7 @@ export function DocumentDetailSheet({
|
||||
onSuggest={runSuggest}
|
||||
suggestPending={suggest.isPending}
|
||||
suggestErrorKey={
|
||||
suggest.isError ? documentAiErrorKey(suggest.error) : null
|
||||
suggest.isError ? aiErrorText(suggest.error) : null
|
||||
}
|
||||
onSummarise={runSummary}
|
||||
summaryPending={summary.isPending}
|
||||
@@ -851,7 +854,7 @@ export function DocumentDetailSheet({
|
||||
summaryOutput={summaryOutput}
|
||||
summaryResult={summary.data ?? null}
|
||||
summaryErrorKey={
|
||||
summary.isError ? documentAiErrorKey(summary.error) : null
|
||||
summary.isError ? aiErrorText(summary.error) : null
|
||||
}
|
||||
onCloseSummary={() => {
|
||||
setSummaryOutput(null);
|
||||
|
||||
@@ -13,9 +13,12 @@
|
||||
* prefills; the summary is transient. The only mutation is the caller applying
|
||||
* a reviewed draft through the existing edit-on-commit machinery.
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet, ApiError } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
DocumentAiCapabilityDto,
|
||||
@@ -129,6 +132,45 @@ export function useDocumentSummary() {
|
||||
* `meta.errorCode`; client-side precondition failures carry a `reason`. The
|
||||
* default is the calm "try again" message — never a raw provider string.
|
||||
*/
|
||||
/**
|
||||
* The actual wait, in whole minutes, behind a document-AI 429. The routes
|
||||
* mirror the bucket's reset instant into the error meta (`retryAt`, ISO 8601);
|
||||
* the window is an hour, so a fixed "try again in a few minutes" understated
|
||||
* the wait for most of it. Null when the error is not a document-AI rate-limit
|
||||
* response or carries no usable instant — the caller falls back to the generic
|
||||
* copy. Floored at one minute: a reset seconds away still reads as "about
|
||||
* 1 minute", never "0 minutes".
|
||||
*/
|
||||
export function documentAiRetryMinutes(err: unknown): number | null {
|
||||
if (!(err instanceof ApiError)) return null;
|
||||
if (err.meta?.errorCode !== "documents.inbound.rateLimited") return null;
|
||||
const raw = err.meta?.retryAt;
|
||||
if (typeof raw !== "string") return null;
|
||||
const at = Date.parse(raw);
|
||||
if (!Number.isFinite(at)) return null;
|
||||
return Math.max(1, Math.ceil((at - Date.now()) / 60_000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a document-AI error to the user-facing sentence. Rate-limited
|
||||
* responses derive their copy from the ACTUAL reset instant ("try again in
|
||||
* about N minutes"); everything else maps through `documentAiErrorKey`. The
|
||||
* result is final text — callers render or toast it as-is.
|
||||
*/
|
||||
export function useDocumentAiErrorText(): (err: unknown) => string {
|
||||
const { t, tCount } = useTranslations();
|
||||
return useCallback(
|
||||
(err: unknown) => {
|
||||
const minutes = documentAiRetryMinutes(err);
|
||||
if (minutes !== null) {
|
||||
return tCount("documents.assist.errorRateLimitedWait", minutes);
|
||||
}
|
||||
return t(documentAiErrorKey(err));
|
||||
},
|
||||
[t, tCount],
|
||||
);
|
||||
}
|
||||
|
||||
export function documentAiErrorKey(err: unknown): string {
|
||||
if (err instanceof DocumentAssistClientError) {
|
||||
switch (err.reason) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* The document-AI hourly ceiling is an operator setting
|
||||
* (`DOCUMENT_AI_LIMIT_PER_HOUR`), not a constant. This pins the resolver's
|
||||
* contract: unset / non-numeric falls back to the default 6, a numeric value
|
||||
* takes effect, and the clamp keeps a typo from zeroing the surface out
|
||||
* (floor 1) or removing the cap entirely (ceiling 1000).
|
||||
*
|
||||
* Also pins the shared 429 builder: the response mirrors the bucket's reset
|
||||
* instant into `meta.retryAt` so the client can name the ACTUAL wait instead
|
||||
* of a fixed "a few minutes".
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
$executeRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptDocumentContent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/documents/rasterize-pdf", () => ({
|
||||
rasterizePdf: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
detectOcrMimeType: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
documentAiRateLimited,
|
||||
resolveDocumentAiLimitPerHour,
|
||||
} from "../ai-route-support";
|
||||
|
||||
const ORIGINAL = process.env.DOCUMENT_AI_LIMIT_PER_HOUR;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.DOCUMENT_AI_LIMIT_PER_HOUR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL === undefined) delete process.env.DOCUMENT_AI_LIMIT_PER_HOUR;
|
||||
else process.env.DOCUMENT_AI_LIMIT_PER_HOUR = ORIGINAL;
|
||||
});
|
||||
|
||||
describe("resolveDocumentAiLimitPerHour", () => {
|
||||
it("defaults to 6 when the variable is unset", () => {
|
||||
expect(resolveDocumentAiLimitPerHour()).toBe(6);
|
||||
});
|
||||
|
||||
it("takes an operator value from the environment", () => {
|
||||
process.env.DOCUMENT_AI_LIMIT_PER_HOUR = "12";
|
||||
expect(resolveDocumentAiLimitPerHour()).toBe(12);
|
||||
});
|
||||
|
||||
it("falls back to the default on a non-numeric value", () => {
|
||||
process.env.DOCUMENT_AI_LIMIT_PER_HOUR = "plenty";
|
||||
expect(resolveDocumentAiLimitPerHour()).toBe(6);
|
||||
});
|
||||
|
||||
it("floors at 1 so a zero/negative typo cannot zero the surface out", () => {
|
||||
process.env.DOCUMENT_AI_LIMIT_PER_HOUR = "0";
|
||||
expect(resolveDocumentAiLimitPerHour()).toBe(1);
|
||||
process.env.DOCUMENT_AI_LIMIT_PER_HOUR = "-4";
|
||||
expect(resolveDocumentAiLimitPerHour()).toBe(1);
|
||||
});
|
||||
|
||||
it("caps at 1000 so a typo cannot remove the ceiling entirely", () => {
|
||||
process.env.DOCUMENT_AI_LIMIT_PER_HOUR = "500000";
|
||||
expect(resolveDocumentAiLimitPerHour()).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentAiRateLimited", () => {
|
||||
it("mirrors the reset instant into meta.retryAt and the headers", async () => {
|
||||
const resetAt = Date.now() + 42 * 60_000;
|
||||
const response = documentAiRateLimited({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt,
|
||||
});
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get("X-RateLimit-Reset")).toBe(
|
||||
new Date(resetAt).toISOString(),
|
||||
);
|
||||
const body = (await response.json()) as {
|
||||
error: string | null;
|
||||
meta?: { errorCode?: string; retryAt?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("documents.inbound.rateLimited");
|
||||
expect(body.meta?.retryAt).toBe(new Date(resetAt).toISOString());
|
||||
});
|
||||
});
|
||||
@@ -9,16 +9,88 @@
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
|
||||
import { decryptDocumentContent } from "@/lib/documents/store";
|
||||
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
|
||||
import {
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
refundRateLimit,
|
||||
type RateLimitResult,
|
||||
} from "@/lib/rate-limit";
|
||||
|
||||
/**
|
||||
* The document-AI actions (extract / suggest / summary / index) share one
|
||||
* per-user hourly egress bucket. The ceiling is operator-tunable via
|
||||
* `DOCUMENT_AI_LIMIT_PER_HOUR` (default 6, clamped to 1–1000 so a typo can
|
||||
* neither zero the surface out nor remove the cap entirely) — same posture as
|
||||
* `INSIGHTS_RATE_LIMIT_PER_HOUR` on the briefing route. Unset / non-numeric
|
||||
* falls back to the default.
|
||||
*/
|
||||
const DOCUMENT_AI_LIMIT_DEFAULT = 6;
|
||||
const DOCUMENT_AI_LIMIT_MIN = 1;
|
||||
const DOCUMENT_AI_LIMIT_MAX = 1000;
|
||||
|
||||
export function resolveDocumentAiLimitPerHour(): number {
|
||||
const raw = process.env.DOCUMENT_AI_LIMIT_PER_HOUR;
|
||||
if (!raw) return DOCUMENT_AI_LIMIT_DEFAULT;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed)) return DOCUMENT_AI_LIMIT_DEFAULT;
|
||||
return Math.min(
|
||||
DOCUMENT_AI_LIMIT_MAX,
|
||||
Math.max(DOCUMENT_AI_LIMIT_MIN, parsed),
|
||||
);
|
||||
}
|
||||
|
||||
/** The three AI actions share the extract route's 6/hour egress bucket. */
|
||||
export const DOCUMENT_AI_LIMIT_PER_HOUR = 6;
|
||||
export const DOCUMENT_AI_WINDOW_MS = 60 * 60 * 1000;
|
||||
/** The shared rate-limit bucket key prefix (identical to the extract route). */
|
||||
export const DOCUMENT_AI_BUCKET = "documents-inbound";
|
||||
|
||||
/** Charge one slot of the shared per-user document-AI bucket. */
|
||||
export async function checkDocumentAiRateLimit(
|
||||
userId: string,
|
||||
): Promise<RateLimitResult> {
|
||||
return checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
resolveDocumentAiLimitPerHour(),
|
||||
DOCUMENT_AI_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a charged slot back when the request failed BEFORE the metered work
|
||||
* (provider dispatch, or the local index/echo work on the no-egress paths).
|
||||
* The bucket is charged early so a 429 stays cheap, but a slot must reflect
|
||||
* work actually performed — a malformed body, an unreadable original, or an
|
||||
* exhausted budget dispatched nothing and burns no allowance. Best-effort:
|
||||
* a failed refund never masks the response the caller is about to return.
|
||||
*/
|
||||
export async function refundDocumentAiSlot(userId: string): Promise<void> {
|
||||
try {
|
||||
await refundRateLimit(`${DOCUMENT_AI_BUCKET}:${userId}`);
|
||||
} catch {
|
||||
// The original (error) response matters more than the bookkeeping.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared 429 for the document-AI bucket. Carries the standard
|
||||
* `X-RateLimit-*` headers AND mirrors the reset instant into the error meta
|
||||
* (`retryAt`, ISO 8601) so the client can tell the user the ACTUAL wait —
|
||||
* the window is an hour, not "a few minutes".
|
||||
*/
|
||||
export function documentAiRateLimited(rl: RateLimitResult): Response {
|
||||
const response = apiError("Too many document AI requests this hour.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
retryAt: new Date(rl.resetAt).toISOString(),
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
/** Cap on a browser-OCR text body (mirrors the extract text mode). */
|
||||
export const DOCUMENT_AI_TEXT_BODY_MAX_BYTES = 512 * 1024;
|
||||
|
||||
|
||||
@@ -769,7 +769,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Suggest filing metadata (drafts only)",
|
||||
description:
|
||||
'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the 6/hour extract bucket). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.',
|
||||
'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries the reset instant in `X-RateLimit-Reset` and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.',
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
|
||||
@@ -52,6 +52,24 @@ export async function checkRateLimit(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Give one slot back to a bucket whose charge turned out to be premature.
|
||||
*
|
||||
* A caller that charges the bucket up front (to keep the 429 cheap) but then
|
||||
* fails BEFORE doing the metered work refunds the slot so the user's allowance
|
||||
* reflects work actually performed, not attempts. Scoped to the live window:
|
||||
* once `reset_at` has passed the counter is stale and a decrement would bleed
|
||||
* into the NEXT window's budget, so an expired row is left untouched. Never
|
||||
* drops below zero (concurrent refunds after a window flip are a no-op).
|
||||
*/
|
||||
export async function refundRateLimit(key: string): Promise<void> {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE rate_limits
|
||||
SET count = GREATEST(count - 1, 0)
|
||||
WHERE key = ${key} AND reset_at >= NOW()
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit response headers.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user