diff --git a/src/pages/api/grade.ts b/src/pages/api/grade.ts index f469c28..fa18896 100644 --- a/src/pages/api/grade.ts +++ b/src/pages/api/grade.ts @@ -1,10 +1,17 @@ -import { GRADING_TIMEOUT_MS } from "@/resume-checker/constants"; +import { + GRADING_MAX_OUTPUT_TOKENS, + GRADING_THINKING_BUDGET, + GRADING_TIMEOUT_MS, +} from "@/resume-checker/constants"; import { fetchRemoteResume, MAX_RESUME_BYTES, ResumeFetchError, } from "@/resume-checker/fetch-resume"; -import { parseResume } from "@/resume-checker/parse-resume"; +import { + InvalidResumePdfError, + parseResume, +} from "@/resume-checker/parse-resume"; import { exampleResponses, getSysPrompt, @@ -14,7 +21,7 @@ import { sanitizeCompletion, } from "@/resume-checker/prompts/grade"; import { GatewayError } from "@ai-sdk/gateway"; -import { generateObject } from "ai"; +import { generateObject, NoObjectGeneratedError } from "ai"; import type { NextApiRequest, NextApiResponse } from "next"; function isMultipartFormData(req: NextApiRequest) { @@ -24,6 +31,10 @@ function isMultipartFormData(req: NextApiRequest) { ); } +function isGatewayUnavailable(statusCode: number) { + return statusCode === 402 || statusCode === 429 || statusCode >= 500; +} + export default async function handler( req: NextApiRequest, res: NextApiResponse, @@ -85,12 +96,27 @@ export default async function handler( messages: messages(pdfBuffer), schema: ResponseSchema, abortSignal: gradingSignal, + // A degenerate generation used to run until the 60s deadline; capping + // tokens makes it fail fast instead of holding the request. + maxOutputTokens: GRADING_MAX_OUTPUT_TOKENS, + providerOptions: { + google: { thinkingConfig: { thinkingBudget: GRADING_THINKING_BUDGET } }, + }, }); if (!completion) { throw new Error("GradingError"); } + console.info( + JSON.stringify({ + "http.route": "/api/grade", + outcome: "graded", + finishReason: completion.finishReason, + usage: completion.usage, + }), + ); + const sanitized = sanitizeCompletion(completion); res.status(200).json(sanitized); @@ -111,11 +137,8 @@ export default async function handler( return; } - if ( - e.message.includes("InvalidPDFException") || - e.message.includes("Invalid PDF structure") - ) { - console.warn(e); + if (e instanceof InvalidResumePdfError) { + console.warn(e.cause); res.status(400).json({ error: "InvalidPDFException", }); @@ -129,13 +152,27 @@ export default async function handler( } // The SDK only retries APICallError, so a gateway 5xx surfaces on the - // first attempt. It is an upstream outage, not a bug in this route. - if (GatewayError.isInstance(e) && e.statusCode >= 500) { + // first attempt. It is an upstream outage, not a bug in this route. A 402 + // (team budget exceeded) or 429 (rate limited) clears up on its own too. + if (GatewayError.isInstance(e) && isGatewayUnavailable(e.statusCode)) { console.error(e); res.status(503).json({ error: "GradingUnavailable" }); return; } + if (NoObjectGeneratedError.isInstance(e)) { + console.error( + JSON.stringify({ + "http.route": "/api/grade", + outcome: "no_object_generated", + finishReason: e.finishReason, + usage: e.usage, + }), + ); + res.status(500).json({ error: "GradingError" }); + return; + } + // The client renders this string straight into a badge, so it must stay a // stable code rather than whatever the PDF parser or the model threw. console.error(e); diff --git a/src/resume-checker/constants.ts b/src/resume-checker/constants.ts index 64f6a7d..7bd29c2 100644 --- a/src/resume-checker/constants.ts +++ b/src/resume-checker/constants.ts @@ -1,3 +1,5 @@ export const GRADING_TIMEOUT_MS = 60_000; +export const GRADING_MAX_OUTPUT_TOKENS = 8_192; +export const GRADING_THINKING_BUDGET = 4_096; export const PDFJS_WARNING_PREFIX = "Warning: "; diff --git a/src/resume-checker/pages/index.tsx b/src/resume-checker/pages/index.tsx index 5789ab4..5d7bb95 100644 --- a/src/resume-checker/pages/index.tsx +++ b/src/resume-checker/pages/index.tsx @@ -132,7 +132,7 @@ export function Home() { return ( <> - {error && } + Resume Checker diff --git a/src/resume-checker/parse-resume.ts b/src/resume-checker/parse-resume.ts index 424030a..7f1e30e 100644 --- a/src/resume-checker/parse-resume.ts +++ b/src/resume-checker/parse-resume.ts @@ -1,6 +1,13 @@ import pdf from "pdf-parse"; import { PDFJS_WARNING_PREFIX } from "./constants"; +export class InvalidResumePdfError extends Error { + constructor(readonly cause: unknown) { + super("InvalidPDFException"); + this.name = "InvalidResumePdfError"; + } +} + let activeParses = 0; let restoreConsoleLog = () => {}; @@ -28,13 +35,24 @@ function forwardPdfjsWarnings() { }; } +/* + * Grading only reads the PDF metadata. Rendering page text makes pdf.js load + * and sanitize every embedded font, which is where "TT: undefined function" + * warnings come from. + */ +function skipPageText() { + return ""; +} + export async function parseResume(buffer: Buffer) { if (activeParses++ === 0) { forwardPdfjsWarnings(); } try { - return await pdf(buffer); + return await pdf(buffer, { pagerender: skipPageText }); + } catch (e) { + throw new InvalidResumePdfError(e); } finally { if (--activeParses === 0) { restoreConsoleLog(); diff --git a/src/resume-checker/utils.ts b/src/resume-checker/utils.ts index c6ca6d9..fb99bad 100644 --- a/src/resume-checker/utils.ts +++ b/src/resume-checker/utils.ts @@ -12,7 +12,7 @@ const ERROR_MESSAGES: Record = { BlockedResumeURL: "Ese link no apunta a un CV que podamos descargar.", GradingTimeout: "El análisis tardó demasiado. Probá de nuevo en un rato.", GradingUnavailable: - "El servicio de análisis no está disponible ahora. Probá de nuevo en unos minutos.", + "Perdón, no podemos analizar tu CV en este momento. Probá de nuevo en unos minutos.", InvalidPDFException: "No pudimos leer el PDF. Probá con otro archivo.", InvalidResumeURL: "El link no es válido. Tiene que ser un PDF con https.", InvalidUploadRequest: "No pudimos leer el archivo que subiste.", diff --git a/tests/api/grade.test.ts b/tests/api/grade.test.ts index b406bc4..17e75fe 100644 --- a/tests/api/grade.test.ts +++ b/tests/api/grade.test.ts @@ -4,7 +4,7 @@ import { GatewayRateLimitError, } from "@ai-sdk/gateway"; import { exampleResponses } from "@/resume-checker/prompts/grade"; -import { generateObject } from "ai"; +import { generateObject, NoObjectGeneratedError } from "ai"; import type { NextApiRequest, NextApiResponse } from "next"; import type { IncomingHttpHeaders } from "node:http"; import { Readable } from "node:stream"; @@ -12,7 +12,10 @@ import pdf from "pdf-parse"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("pdf-parse", () => ({ default: vi.fn() })); -vi.mock("ai", () => ({ generateObject: vi.fn() })); +vi.mock("ai", async (importOriginal) => ({ + ...(await importOriginal()), + generateObject: vi.fn(), +})); const MULTIPART = { "content-type": "multipart/form-data; boundary=abc" }; @@ -63,6 +66,7 @@ describe("/api/grade", () => { vi.resetAllMocks(); vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "info").mockImplementation(() => {}); }); afterEach(() => { @@ -97,6 +101,45 @@ describe("/api/grade", () => { expect(vi.mocked(pdf).mock.calls[0][0]).toEqual(Buffer.from("pdf-bytes")); }); + /* An unbounded generation used to hold the request until the 60s deadline. */ + it("bounds the model's output and thinking tokens", async () => { + vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never); + vi.mocked(generateObject).mockResolvedValueOnce({ + object: { grade: "B", red_flags: [], yellow_flags: [] }, + } as never); + + await call( + request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }), + ); + + const [options] = vi.mocked(generateObject).mock.calls[0]; + expect(options.maxOutputTokens).toBe(8_192); + expect(options.providerOptions).toEqual({ + google: { thinkingConfig: { thinkingBudget: 4_096 } }, + }); + }); + + it("answers 500 and logs usage when the model returns no object", async () => { + vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never); + vi.mocked(generateObject).mockRejectedValueOnce( + new NoObjectGeneratedError({ + response: {} as never, + usage: { inputTokens: 1, outputTokens: 8_192, totalTokens: 8_193 }, + finishReason: "length", + }), + ); + + const res = await call( + request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }), + ); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: "GradingError" }); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('"finishReason":"length"'), + ); + }); + /* Crawlers submit the resume-checker form as a GET, so these must not be 500s. */ it.each([ ["no url", {}], @@ -179,6 +222,24 @@ describe("/api/grade", () => { expect(generateObject).not.toHaveBeenCalled(); }); + /* pdf.js wraps lexer failures such as a malformed command token this way. */ + it("answers 400 to any PDF the parser rejects", async () => { + vi.mocked(pdf).mockRejectedValueOnce( + Object.assign(new Error("Command token too long: 128"), { + name: "UnknownErrorException", + details: "FormatError: Command token too long: 128", + }), + ); + + const res = await call( + request({ method: "POST", headers: MULTIPART, body: "garbled-pdf" }), + ); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "InvalidPDFException" }); + expect(generateObject).not.toHaveBeenCalled(); + }); + /* The client renders this straight into a badge, so it must stay a code. */ it("hides the underlying message when grading fails", async () => { vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never); @@ -209,7 +270,25 @@ describe("/api/grade", () => { expect(res.body).toEqual({ error: "GradingUnavailable" }); }); - it("keeps a gateway 4xx as a grading error", async () => { + /* Vercel answers 402 once the team's AI Gateway budget is spent. */ + it("answers 503 when the AI gateway budget is exceeded", async () => { + vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never); + vi.mocked(generateObject).mockRejectedValueOnce( + new GatewayInternalServerError({ + message: "Team budget exceeded. Current spend: $100.62, limit: $100.00.", + statusCode: 402, + }), + ); + + const res = await call( + request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }), + ); + + expect(res.status).toBe(503); + expect(res.body).toEqual({ error: "GradingUnavailable" }); + }); + + it("answers 503 when the AI gateway rate limits us", async () => { vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never); vi.mocked(generateObject).mockRejectedValueOnce( new GatewayRateLimitError(), @@ -219,6 +298,20 @@ describe("/api/grade", () => { request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }), ); + expect(res.status).toBe(503); + expect(res.body).toEqual({ error: "GradingUnavailable" }); + }); + + it("keeps any other gateway 4xx as a grading error", async () => { + vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never); + vi.mocked(generateObject).mockRejectedValueOnce( + new GatewayInternalServerError({ statusCode: 400 }), + ); + + const res = await call( + request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }), + ); + expect(res.status).toBe(500); expect(res.body).toEqual({ error: "GradingError" }); }); @@ -242,7 +335,8 @@ describe("/api/grade", () => { }); it("answers 500 to a thrown non-Error", async () => { - vi.mocked(pdf).mockRejectedValueOnce("boom"); + vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never); + vi.mocked(generateObject).mockRejectedValueOnce("boom"); const res = await call( request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }), diff --git a/tests/resume-checker/parse-resume.test.ts b/tests/resume-checker/parse-resume.test.ts index 23ae9aa..af2f19c 100644 --- a/tests/resume-checker/parse-resume.test.ts +++ b/tests/resume-checker/parse-resume.test.ts @@ -36,6 +36,16 @@ describe("parseResume", () => { expect(log).not.toHaveBeenCalledWith("Warning: TT: undefined function: 21"); }); + /* Rendering page text loads every font, which is what emits "TT:" warnings. */ + it("reads the metadata without rendering page text", async () => { + vi.mocked(pdf).mockResolvedValueOnce({ text: "" } as never); + + await parseResume(Buffer.from("pdf")); + + const [, options] = vi.mocked(pdf).mock.calls[0]; + expect(await options?.pagerender?.({})).toBe(""); + }); + it("restores console.log once the last concurrent parse settles", async () => { let finishFirst = () => {}; vi.mocked(pdf) diff --git a/tests/resume-checker/utils.test.ts b/tests/resume-checker/utils.test.ts index 0a1a48d..b20b090 100644 --- a/tests/resume-checker/utils.test.ts +++ b/tests/resume-checker/utils.test.ts @@ -50,7 +50,7 @@ describe("getErrorMessage", () => { await expect( getErrorMessage(json(503, { error: "GradingUnavailable" })), ).resolves.toBe( - "El servicio de análisis no está disponible ahora. Probá de nuevo en unos minutos.", + "Perdón, no podemos analizar tu CV en este momento. Probá de nuevo en unos minutos.", ); });