mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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
@@ -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",
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user