diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index f5358fa51..936f1901f 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -136,6 +136,16 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => { "2026-06-26", ); }); + + it("identifies provider failures instead of blaming image quality", async () => { + vi.mocked(runOcrExtraction).mockRejectedValue(new Error("provider down")); + + const res = await POST(textReq()); + const body = (await res.json()) as { error: string }; + + expect(res.status).toBe(502); + expect(body.error).toContain("configured AI provider"); + }); }); describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => { diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index bb6824d9d..d6219657e 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -63,6 +63,25 @@ const EXTRACT_WINDOW_MS = 60 * 60 * 1000; /** OCR'd text is bounded in the schema; cap the JSON body proportionally. */ const TEXT_BODY_MAX_BYTES = 512 * 1024; +function providerFailureMeta(error: unknown, mode?: "text") { + const err = error as { + httpStatus?: unknown; + model?: unknown; + bodyExcerpt?: unknown; + }; + return { + reason: "provider_error", + ...(mode ? { mode } : {}), + ...(typeof err.httpStatus === "number" + ? { upstreamStatus: err.httpStatus } + : {}), + ...(typeof err.model === "string" ? { model: err.model } : {}), + ...(typeof err.bodyExcerpt === "string" + ? { upstreamError: err.bodyExcerpt } + : {}), + }; +} + export const POST = apiHandler(async (request: Request) => { const { user } = await requireAuth(); @@ -191,11 +210,13 @@ async function handleTextExtract( } annotate({ action: { name: "labs.ocr.extractFailed" }, - meta: { reason: "provider_error", mode: "text" }, - }); - return apiError("Couldn't read the report. Try a clearer photo.", 502, { - errorCode: "labs.ocr.extractFailed", + meta: providerFailureMeta(err, "text"), }); + return apiError( + "The configured AI provider could not process this report. Check the provider configuration and retry.", + 502, + { errorCode: "labs.ocr.extractFailed" }, + ); } } @@ -397,11 +418,13 @@ async function handleVisionExtract( } annotate({ action: { name: "labs.ocr.extractFailed" }, - meta: { reason: "provider_error" }, - }); - return apiError("Couldn't read the report. Try a clearer photo.", 502, { - errorCode: "labs.ocr.extractFailed", + meta: providerFailureMeta(err), }); + return apiError( + "The configured AI provider could not process this report. Check the provider configuration and retry.", + 502, + { errorCode: "labs.ocr.extractFailed" }, + ); } } catch (err) { // A guard threw after the reservation (e.g. consent races) — refund fully. diff --git a/src/lib/ai/__tests__/vision-capability.test.ts b/src/lib/ai/__tests__/vision-capability.test.ts index c540eabf4..775d8c385 100644 --- a/src/lib/ai/__tests__/vision-capability.test.ts +++ b/src/lib/ai/__tests__/vision-capability.test.ts @@ -37,6 +37,7 @@ describe("supportsVisionForConfig", () => { ] as const) { expect(supportsVisionForConfig(providerType, "gpt-4o")).toBe(true); expect(supportsVisionForConfig(providerType, "gpt-4o-mini")).toBe(true); + expect(supportsVisionForConfig(providerType, "gpt-5.6-terra")).toBe(true); expect(supportsVisionForConfig(providerType, "gpt-4.1")).toBe(true); expect(supportsVisionForConfig(providerType, "gpt-4-turbo")).toBe(true); // Full o-series reasoning models read images. diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts index 56e5af9cb..c1f36260e 100644 --- a/src/lib/ai/ai-budgets.ts +++ b/src/lib/ai/ai-budgets.ts @@ -189,13 +189,14 @@ export const AI_BUDGETS = { * v1.18.9 — Lab-OCR structured extraction. ONE vision call transcribes a * photo / PDF of a paper lab report into a JSON array of analyte rows * (analyte + value/valueText + unit + reference range + date + per-field - * confidence). A full panel is a dozen-plus rows of compact JSON; 4000 - * tokens covers a dense report plus the envelope. Temperature 0 — the task + * confidence). A full panel is a dozen-plus rows of compact JSON; 8000 + * tokens leaves room for GPT-5 reasoning plus the dense report envelope. + * Temperature 0 — the task * is faithful transcription, not generation, so the lowest setting minimises * invented values. Vision input tokens are expensive, so the route also * gates with a tight 6/hour rate bucket + reserveBudget + upload-size cap. */ - ocrExtract: { temperature: 0, maxTokens: 4000 }, + ocrExtract: { temperature: 0, maxTokens: 8000 }, /** * v1.20.1 — Lab-OCR text-mode structuring. The browser OCR's the image diff --git a/src/lib/ai/vision-capability.ts b/src/lib/ai/vision-capability.ts index 515e99184..ce7c55468 100644 --- a/src/lib/ai/vision-capability.ts +++ b/src/lib/ai/vision-capability.ts @@ -63,6 +63,7 @@ function isVisionReasoningSlug(m: string): boolean { function openaiSupportsVision(model: string): boolean { const m = model.toLowerCase(); return ( + m.startsWith("gpt-5") || m.startsWith("gpt-4o") || m.startsWith("gpt-4.1") || m.startsWith("gpt-4-turbo") ||