diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1217fd14..40d8c00c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
## [Unreleased]
+## [1.28.12] — 2026-07-09 — Lab scans use your AI provider; tidier dashboard
+
+Scanning a lab report now uses the AI provider you already configured — including
+a scanned PDF, which is rendered to images the same way documents are, so a
+subscription or local vision provider reads it instead of only a Claude one. The
+separate lab-scan provider setting is gone; there is one place to configure AI.
+On the dashboard, the "log dose" action for an overdue medication now sits
+directly under its line instead of far to the right.
+
## [1.28.11] — 2026-07-09 — Strava workouts + Garmin guidance
Connect Strava to bring your activities in as workouts. Like the other wearable
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 9f24c2809..9fcb1df37 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.28.11
+ version: 1.28.12
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -7612,13 +7612,14 @@ paths:
- Labs
summary: Extract lab readings from a photo, PDF, or OCR'd text
description: "Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file`
- (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) is run through the user's vision-capable
- provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in
- local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted
- text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both
- modes are gated by AI consent, a 6/hour rate bucket, and the per-day token budget, and return proposed rows for
- the mandatory human review screen — nothing is written. Extracted content is treated as untrusted
- (prompt-injection); the review step is the safety boundary."
+ (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision
+ provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is
+ never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image
+ (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only
+ provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are gated by AI consent, a 6/hour
+ rate bucket, and the per-day token budget, and return proposed rows for the mandatory human review screen —
+ nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety
+ boundary."
requestBody:
required: true
content:
@@ -18492,35 +18493,6 @@ components:
- type: "null"
description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in
comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.
- ocrEnabled:
- type: boolean
- description: v1.22 (#90) — whether a dedicated document-scan (Lab-OCR) provider is active. When false the OCR path uses
- the main provider chain.
- ocrProvider:
- anyOf:
- - type: string
- enum:
- - OPENAI
- - ANTHROPIC
- - LOCAL
- - type: "null"
- description: The dedicated document-scan provider, or null when unset.
- ocrModel:
- anyOf:
- - type: string
- - type: "null"
- ocrBaseUrl:
- anyOf:
- - type: string
- - type: "null"
- description: Custom base URL (LOCAL OCR provider only); null otherwise.
- hasOcrKey:
- type: boolean
- ocrKeyPreview:
- anyOf:
- - type: string
- - type: "null"
- description: "`...` + last 4 chars of the stored document-scan provider key, or null."
required:
- provider
- model
@@ -18533,12 +18505,6 @@ components:
- hasOpenaiKey
- openaiKeyPreview
- responseTimeoutSeconds
- - ocrEnabled
- - ocrProvider
- - ocrModel
- - ocrBaseUrl
- - hasOcrKey
- - ocrKeyPreview
additionalProperties: false
description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`).
Reports key presence + a masked preview only — never a plaintext key.
@@ -25372,8 +25338,8 @@ components:
description: Whether the caller's configured AI provider can ingest a lab report (drives the UI's scan affordance).
`mode` is `vision` when the provider reads the image directly, `text` when the image is OCR'd in-browser and
only the extracted text is sent (opt-in local OCR for text-only providers), or null when unavailable. `reason`
- explains an unavailable state; `pdfSupported` is true only for an Anthropic vision provider (native PDF). No
- provider call is made.
+ explains an unavailable state; `pdfSupported` is true when the provider reads PDFs natively (Anthropic) or the
+ server-side rasterizer can render the pages for any other vision provider. No provider call is made.
OcrExtractEnvelope:
type: object
properties:
diff --git a/messages/de.json b/messages/de.json
index b0a1ecdad..32b001003 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4775,18 +4775,6 @@
"responseTimeoutLabel": "Antwort-Timeout (Sekunden)",
"responseTimeoutPlaceholder": "Standard (~120)",
"responseTimeoutInvalid": "Gib eine Zahl zwischen 10 und 600 Sekunden ein.",
- "ocrHeading": "Dokumentenscan (Laborberichte)",
- "ocrBody": "Verwende einen eigenen Anbieter zum Auslesen hochgeladener Laborberichte. Ist die Option aus, nutzt der Scan deinen Hauptanbieter.",
- "ocrEnableLabel": "Eigenen Anbieter für den Dokumentenscan aktivieren",
- "ocrProviderLabel": "Scan-Anbieter",
- "ocrProviderOption": {
- "OPENAI": "OpenAI (dein API-Key)",
- "ANTHROPIC": "Anthropic (Claude)",
- "LOCAL": "Lokales Modell (OpenAI-kompatibel)"
- },
- "ocrModelPlaceholder": "z. B. gpt-4o",
- "ocrVisionHint": "Dieses Modell kann möglicherweise keine Bilder lesen. Wähle für den Dokumentenscan ein Modell mit Bildverständnis.",
- "ocrKeyLabel": "API-Key",
"autoRead": {
"title": "Dokumente automatisch mit KI lesen",
"subLabel": "Neu hochgeladene Dokumente werden von deinem konfigurierten KI-Anbieter gelesen und indiziert, ohne dass du jedes Dokument einzeln bestätigen musst.",
diff --git a/messages/en.json b/messages/en.json
index 95d906c39..04dea0d72 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4775,18 +4775,6 @@
"responseTimeoutLabel": "Response timeout (seconds)",
"responseTimeoutPlaceholder": "Default (~120)",
"responseTimeoutInvalid": "Enter a number between 10 and 600 seconds.",
- "ocrHeading": "Document scanning (lab reports)",
- "ocrBody": "Use a dedicated provider for reading uploaded lab reports. When off, scanning uses your main provider.",
- "ocrEnableLabel": "Enable a dedicated document-scanning provider",
- "ocrProviderLabel": "Scanning provider",
- "ocrProviderOption": {
- "OPENAI": "OpenAI (your API key)",
- "ANTHROPIC": "Anthropic (Claude)",
- "LOCAL": "Local model (OpenAI-compatible)"
- },
- "ocrModelPlaceholder": "e.g. gpt-4o",
- "ocrVisionHint": "This model may not be able to read images. Choose a vision-capable model for document scanning.",
- "ocrKeyLabel": "API key",
"autoRead": {
"title": "Read documents automatically with AI",
"subLabel": "Newly uploaded documents are read and indexed by your configured AI provider without a per-document tap.",
diff --git a/messages/es.json b/messages/es.json
index 24b832f90..da4b1fd5a 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4775,18 +4775,6 @@
"responseTimeoutLabel": "Tiempo de espera de la respuesta (segundos)",
"responseTimeoutPlaceholder": "Predeterminado (~120)",
"responseTimeoutInvalid": "Introduce un número entre 10 y 600 segundos.",
- "ocrHeading": "Escaneo de documentos (informes de laboratorio)",
- "ocrBody": "Usa un proveedor dedicado para leer los informes de laboratorio subidos. Si está desactivado, el escaneo usa tu proveedor principal.",
- "ocrEnableLabel": "Activar un proveedor dedicado para el escaneo de documentos",
- "ocrProviderLabel": "Proveedor de escaneo",
- "ocrProviderOption": {
- "OPENAI": "OpenAI (tu clave de API)",
- "ANTHROPIC": "Anthropic (Claude)",
- "LOCAL": "Modelo local (compatible con OpenAI)"
- },
- "ocrModelPlaceholder": "p. ej. gpt-4o",
- "ocrVisionHint": "Puede que este modelo no pueda leer imágenes. Elige un modelo con capacidad de visión para el escaneo de documentos.",
- "ocrKeyLabel": "Clave de API",
"autoRead": {
"title": "Leer documentos automáticamente con IA",
"subLabel": "Los documentos recién subidos son leídos e indexados por tu proveedor de IA configurado, sin confirmar cada documento.",
diff --git a/messages/fr.json b/messages/fr.json
index 8013abee6..545782c31 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4775,18 +4775,6 @@
"responseTimeoutLabel": "Délai de réponse (secondes)",
"responseTimeoutPlaceholder": "Par défaut (~120)",
"responseTimeoutInvalid": "Saisissez un nombre entre 10 et 600 secondes.",
- "ocrHeading": "Numérisation de documents (comptes rendus de laboratoire)",
- "ocrBody": "Utilisez un fournisseur dédié pour lire les comptes rendus de laboratoire importés. Désactivé, la numérisation utilise votre fournisseur principal.",
- "ocrEnableLabel": "Activer un fournisseur dédié à la numérisation de documents",
- "ocrProviderLabel": "Fournisseur de numérisation",
- "ocrProviderOption": {
- "OPENAI": "OpenAI (votre clé API)",
- "ANTHROPIC": "Anthropic (Claude)",
- "LOCAL": "Modèle local (compatible OpenAI)"
- },
- "ocrModelPlaceholder": "p. ex. gpt-4o",
- "ocrVisionHint": "Ce modèle ne peut peut-être pas lire les images. Choisissez un modèle capable de vision pour la numérisation de documents.",
- "ocrKeyLabel": "Clé API",
"autoRead": {
"title": "Lire les documents automatiquement avec l'IA",
"subLabel": "Les documents nouvellement téléversés sont lus et indexés par votre fournisseur d'IA configuré, sans validation document par document.",
diff --git a/messages/it.json b/messages/it.json
index 43fea9fed..8e5194643 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4775,18 +4775,6 @@
"responseTimeoutLabel": "Timeout della risposta (secondi)",
"responseTimeoutPlaceholder": "Predefinito (~120)",
"responseTimeoutInvalid": "Inserisci un numero tra 10 e 600 secondi.",
- "ocrHeading": "Scansione documenti (referti di laboratorio)",
- "ocrBody": "Usa un provider dedicato per leggere i referti di laboratorio caricati. Se disattivato, la scansione usa il tuo provider principale.",
- "ocrEnableLabel": "Attiva un provider dedicato per la scansione dei documenti",
- "ocrProviderLabel": "Provider di scansione",
- "ocrProviderOption": {
- "OPENAI": "OpenAI (la tua chiave API)",
- "ANTHROPIC": "Anthropic (Claude)",
- "LOCAL": "Modello locale (compatibile con OpenAI)"
- },
- "ocrModelPlaceholder": "es. gpt-4o",
- "ocrVisionHint": "Questo modello potrebbe non essere in grado di leggere le immagini. Scegli un modello con capacità di visione per la scansione dei documenti.",
- "ocrKeyLabel": "Chiave API",
"autoRead": {
"title": "Leggi i documenti automaticamente con l'IA",
"subLabel": "I documenti appena caricati vengono letti e indicizzati dal provider di IA configurato, senza confermare ogni documento.",
diff --git a/messages/pl.json b/messages/pl.json
index 400253bd8..26917e36e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4775,18 +4775,6 @@
"responseTimeoutLabel": "Limit czasu odpowiedzi (sekundy)",
"responseTimeoutPlaceholder": "Domyślnie (~120)",
"responseTimeoutInvalid": "Podaj liczbę od 10 do 600 sekund.",
- "ocrHeading": "Skanowanie dokumentów (wyniki badań)",
- "ocrBody": "Użyj dedykowanego dostawcy do odczytu przesłanych wyników badań. Gdy wyłączone, skanowanie używa Twojego głównego dostawcy.",
- "ocrEnableLabel": "Włącz dedykowanego dostawcę do skanowania dokumentów",
- "ocrProviderLabel": "Dostawca skanowania",
- "ocrProviderOption": {
- "OPENAI": "OpenAI (Twój klucz API)",
- "ANTHROPIC": "Anthropic (Claude)",
- "LOCAL": "Model lokalny (zgodny z OpenAI)"
- },
- "ocrModelPlaceholder": "np. gpt-4o",
- "ocrVisionHint": "Ten model może nie odczytywać obrazów. Wybierz model obsługujący obraz do skanowania dokumentów.",
- "ocrKeyLabel": "Klucz API",
"autoRead": {
"title": "Automatyczne czytanie dokumentów przez AI",
"subLabel": "Nowo przesłane dokumenty są czytane i indeksowane przez skonfigurowanego dostawcę AI bez potwierdzania każdego dokumentu.",
diff --git a/package.json b/package.json
index 5bf310940..f943bfeef 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.28.11",
+ "version": "1.28.12",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/prisma/migrations/0234_drop_lab_ocr_provider/migration.sql b/prisma/migrations/0234_drop_lab_ocr_provider/migration.sql
new file mode 100644
index 000000000..cd7dda0b6
--- /dev/null
+++ b/prisma/migrations/0234_drop_lab_ocr_provider/migration.sql
@@ -0,0 +1,21 @@
+-- Retire the dedicated document-scan (Lab-OCR) provider.
+--
+-- The separate `ai_ocr_*` provider entry is gone: lab-report OCR now always
+-- uses the user's main configured provider chain (the same providers, incl.
+-- codex / OAuth, that the Coach and Insights already use), and a PDF is read
+-- natively on Anthropic or rasterized to page images for any other vision
+-- provider. The five per-user columns that backed the standalone provider are
+-- no longer read or written anywhere, so drop them.
+--
+-- `ai_ocr_key_encrypted` was an AES-256-GCM column; it is removed from the
+-- encrypted-column registry + the rotation script in the same change. Dropping
+-- it here means those rows are gone before any future key rotation runs.
+--
+-- Idempotent (IF EXISTS); applies clean on a 0233 database and on a fresh
+-- database as part of the ordered replay.
+ALTER TABLE "users"
+ DROP COLUMN IF EXISTS "ai_ocr_enabled",
+ DROP COLUMN IF EXISTS "ai_ocr_provider",
+ DROP COLUMN IF EXISTS "ai_ocr_model",
+ DROP COLUMN IF EXISTS "ai_ocr_base_url",
+ DROP COLUMN IF EXISTS "ai_ocr_key_encrypted";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 119ebc129..d3a51cf52 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -312,19 +312,6 @@ model User {
// default timed the Coach out before the first token landed.
aiResponseTimeoutSeconds Int? @map("ai_response_timeout_seconds")
- // v1.22 (#90) — dedicated document-scan (Lab-OCR) provider. OFF by default:
- // the lab-report OCR path uses the main provider chain unchanged. When
- // `aiOcrEnabled` is true the OCR ingestion resolves THIS provider/model/key
- // instead, so a user can keep a cheap text Coach on one provider and route
- // expensive vision OCR to a vision-capable one. The key is AES-256-GCM at
- // rest like every other `*Encrypted` column (registered in
- // `src/lib/crypto/encrypted-columns.ts` + the rotation script).
- aiOcrEnabled Boolean @default(false) @map("ai_ocr_enabled")
- aiOcrProvider String? @map("ai_ocr_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL"
- aiOcrModel String? @map("ai_ocr_model")
- aiOcrBaseUrl String? @map("ai_ocr_base_url") // LOCAL OCR provider only
- aiOcrKeyEncrypted String? @map("ai_ocr_key_encrypted")
-
// Automatic AI reading of uploaded vault documents. OFF by default: the vault
// stays local-first — a newly stored document is only read externally when the
// user explicitly taps a per-document AI action (which still needs an active
diff --git a/public/sw.js b/public/sw.js
index d22d403c2..37dda533d 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.28.11";
+ /* @sw-version-fallback */ "v1.28.12";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts
index cf2dfc72f..8d190ad96 100644
--- a/scripts/rotate-encryption-key.ts
+++ b/scripts/rotate-encryption-key.ts
@@ -204,7 +204,6 @@ async function main() {
"aiAnthropicKeyEncrypted",
"aiLocalKeyEncrypted",
"aiOpenaiKeyEncrypted",
- "aiOcrKeyEncrypted",
"insuranceNumberEncrypted",
// v1.23 — TOTP shared secret (second factor).
"totpSecretEncrypted",
diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts
index fadc16f8f..0dd958db5 100644
--- a/src/__tests__/i18n-english-leak-guard.test.ts
+++ b/src/__tests__/i18n-english-leak-guard.test.ts
@@ -104,7 +104,6 @@ const LEGIT_IDENTICAL = new Set([
"insights.healthScore.provenance.sources.appleHealth",
"measurements.sourceAppleHealth",
"onboarding.source.more.appleHealth",
- "settings.ai.ocrProviderOption.ANTHROPIC",
"settings.ai.providerChain.types.anthropic",
"settings.ai.providerChain.types.codex",
"settings.ai.providerSelect.anthropic",
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 f4617a05c..5f1f4583d 100644
--- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts
+++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts
@@ -30,6 +30,9 @@ vi.mock("@/lib/labs/ocr-capability", () => ({
resolveTextProvider: vi.fn(),
resolveVisionProvider: vi.fn(),
}));
+vi.mock("@/lib/documents/rasterize-pdf", () => ({
+ rasterizePdf: vi.fn(),
+}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
}));
@@ -54,7 +57,11 @@ import { POST } from "../route";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
-import { resolveTextProvider } from "@/lib/labs/ocr-capability";
+import {
+ resolveTextProvider,
+ resolveVisionProvider,
+} from "@/lib/labs/ocr-capability";
+import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
@@ -130,3 +137,78 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => {
);
});
});
+
+describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
+ function pdfReq(): Request {
+ // A minimal `%PDF-` header is all `detectOcrMimeType` needs to sniff a PDF.
+ const bytes = new Uint8Array([
+ 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, 0x0a, 0x25, 0xe2, 0xe3,
+ 0xcf, 0xd3, 0x0a,
+ ]);
+ const file = new File([bytes], "report.pdf", {
+ type: "application/pdf",
+ });
+ const form = new FormData();
+ form.append("file", file);
+ return new Request("http://localhost/api/labs/ocr/extract", {
+ method: "POST",
+ body: form,
+ });
+ }
+
+ beforeEach(() => {
+ // A non-Anthropic vision provider (codex): no native PDF block, so the
+ // route must rasterize.
+ vi.mocked(resolveVisionProvider).mockResolvedValue({
+ chain: [{ providerType: "codex", instance: {} as never }],
+ localOcrEnabled: false,
+ pick: {
+ entry: { providerType: "codex", instance: {} as never },
+ providerType: "codex",
+ pdfSupported: false,
+ },
+ } as never);
+ vi.mocked(reserveBudget).mockResolvedValue({
+ allowed: true,
+ reserved: AI_BUDGETS.ocrExtract.maxTokens ?? 0,
+ totalAfter: AI_BUDGETS.ocrExtract.maxTokens ?? 0,
+ } as never);
+ });
+
+ it("rasterizes a PDF for a non-Anthropic vision provider and sends the page images", async () => {
+ vi.mocked(rasterizePdf).mockResolvedValue({
+ ok: true,
+ images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
+ });
+ vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
+
+ const res = await POST(pdfReq());
+ expect(res.status).toBe(200);
+ expect(rasterizePdf).toHaveBeenCalledOnce();
+ // The rendered page images flow through as `input_image`s; no native PDF
+ // document block is sent for a non-Anthropic provider.
+ expect(runOcrExtraction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
+ documents: [],
+ }),
+ );
+ });
+
+ it("falls back to pdfNeedsAnthropic when rasterization fails", async () => {
+ vi.mocked(rasterizePdf).mockResolvedValue({ ok: false });
+
+ const res = await POST(pdfReq());
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { error: string | null };
+ expect(body.error).toBeTruthy();
+ // A failed render never reaches the provider — and refunds the reservation.
+ expect(runOcrExtraction).not.toHaveBeenCalled();
+ expect(reconcileSpend).toHaveBeenCalledWith(
+ "user-1",
+ AI_BUDGETS.ocrExtract.maxTokens,
+ 0,
+ "2026-06-26",
+ );
+ });
+});
diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts
index ebddbf99c..bb6824d9d 100644
--- a/src/app/api/labs/ocr/extract/route.ts
+++ b/src/app/api/labs/ocr/extract/route.ts
@@ -38,6 +38,7 @@ import {
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
+import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import {
resolveTextProvider,
resolveVisionProvider,
@@ -323,27 +324,52 @@ async function handleVisionExtract(
});
}
- // 7. PDF gate — only Anthropic reads a native document block in v1.
- if (mime === "application/pdf" && !pick.pdfSupported) {
- annotate({
- action: { name: "labs.ocr.fileRejected" },
- meta: { reason: "pdf_provider_unsupported" },
- });
- await reconcileSpend(userId, reservation.reserved, 0, dateKey);
- return apiError(
- "PDF scanning needs a Claude vision provider; upload a photo instead.",
- 422,
- { errorCode: "labs.ocr.pdfNeedsAnthropic" },
- );
- }
+ // 7. PDF handling — mirror the document vault's `prepareVisionInput`
+ // (`src/lib/documents/ai-route-support.ts`) exactly:
+ // - Anthropic (`pick.pdfSupported`) reads the native `document` block at
+ // highest fidelity;
+ // - every other vision provider (codex / any image-only wire) reads the
+ // PDF via `rasterizePdf`, which renders the pages to JPEG `input_image`
+ // parts the provider already handles. Rendering is pure local compute
+ // (no egress). `rasterizePdf` never throws: a malformed / encrypted /
+ // unrenderable PDF returns `{ ok: false }`, and we keep the shipped
+ // `pdfNeedsAnthropic` reject as the graceful fallback.
+ let images: {
+ mediaType: "image/jpeg" | "image/png" | "image/webp";
+ dataBase64: string;
+ }[];
+ let documents: { mediaType: "application/pdf"; dataBase64: string }[];
- const dataBase64 = buffer.toString("base64");
- const images =
- mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }];
- const documents =
- mime === "application/pdf"
- ? [{ mediaType: "application/pdf" as const, dataBase64 }]
- : [];
+ if (mime === "application/pdf") {
+ if (pick.pdfSupported) {
+ images = [];
+ documents = [
+ {
+ mediaType: "application/pdf",
+ dataBase64: buffer.toString("base64"),
+ },
+ ];
+ } else {
+ const raster = await rasterizePdf(buffer);
+ if (!raster.ok) {
+ annotate({
+ action: { name: "labs.ocr.fileRejected" },
+ meta: { reason: "pdf_rasterize_failed" },
+ });
+ await reconcileSpend(userId, reservation.reserved, 0, dateKey);
+ return apiError(
+ "Couldn't read this PDF; upload a photo instead.",
+ 422,
+ { errorCode: "labs.ocr.pdfNeedsAnthropic" },
+ );
+ }
+ images = raster.images;
+ documents = [];
+ }
+ } else {
+ images = [{ mediaType: mime, dataBase64: buffer.toString("base64") }];
+ documents = [];
+ }
// 8. Run the extraction. The actual token spend reconciles the reservation.
let actualTokens = 0;
diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts
index 6f2250e96..29d875e1b 100644
--- a/src/app/api/user/ai-provider/__tests__/route.test.ts
+++ b/src/app/api/user/ai-provider/__tests__/route.test.ts
@@ -139,7 +139,7 @@ describe("GET /api/user/ai-provider availability", () => {
expect(body.data.managedBy).toBeNull();
});
- it("returns the v1.22 timeout + OCR fields", async () => {
+ it("returns the v1.22 response timeout field", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProvider: "OPENAI",
aiModel: "gpt-4o",
@@ -148,11 +148,6 @@ describe("GET /api/user/ai-provider availability", () => {
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiResponseTimeoutSeconds: 240,
- aiOcrEnabled: true,
- aiOcrProvider: "ANTHROPIC",
- aiOcrModel: "claude-sonnet-4-6",
- aiOcrBaseUrl: null,
- aiOcrKeyEncrypted: "enc-ocr-1234",
} as never);
vi.mocked(resolveProviderAvailability).mockResolvedValue({
aiAvailable: true,
@@ -161,21 +156,10 @@ describe("GET /api/user/ai-provider availability", () => {
const res = await (GET as () => Promise)();
const body = (await res.json()) as {
- data: {
- responseTimeoutSeconds: number | null;
- ocrEnabled: boolean;
- ocrProvider: string | null;
- hasOcrKey: boolean;
- ocrKeyPreview: string | null;
- };
+ data: { responseTimeoutSeconds: number | null };
};
expect(body.data.responseTimeoutSeconds).toBe(240);
- expect(body.data.ocrEnabled).toBe(true);
- expect(body.data.ocrProvider).toBe("ANTHROPIC");
- expect(body.data.hasOcrKey).toBe(true);
- // Preview is `...` + last 4 of the decrypted value (decrypt → `dec:...`).
- expect(body.data.ocrKeyPreview).toMatch(/^\.\.\./);
});
});
@@ -207,32 +191,4 @@ describe("PATCH /api/user/ai-provider — v1.22 fields", () => {
}),
);
});
-
- it("persists OCR provider config and encrypts the key", async () => {
- const res = await patch(
- patchRequest({
- ocrEnabled: true,
- ocrProvider: "OPENAI",
- ocrModel: "gpt-4o",
- ocrKey: "sk-ocr-secret",
- }),
- );
- expect(res.status).toBe(200);
- expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith(
- expect.objectContaining({
- data: expect.objectContaining({
- aiOcrEnabled: true,
- aiOcrProvider: "OPENAI",
- aiOcrModel: "gpt-4o",
- aiOcrKeyEncrypted: "enc:sk-ocr-secret",
- }),
- }),
- );
- });
-
- it("rejects an invalid OCR provider", async () => {
- await expect(
- patch(patchRequest({ ocrProvider: "CHATGPT_OAUTH" })),
- ).rejects.toThrow(/Invalid OCR provider/);
- });
});
diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts
index 2fa312548..6f654ae14 100644
--- a/src/app/api/user/ai-provider/route.ts
+++ b/src/app/api/user/ai-provider/route.ts
@@ -11,9 +11,6 @@ import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
const ALLOWED = new Set(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]);
-// v1.22 (#90) — the dedicated document-scan provider has no OAuth path, so the
-// allowlist is the three keyed/self-hosted providers only (no CHATGPT_OAUTH).
-const OCR_PROVIDERS = new Set(["OPENAI", "ANTHROPIC", "LOCAL"]);
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -28,13 +25,8 @@ export const GET = apiHandler(async () => {
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
- // v1.22 (#89 + #90)
+ // v1.22 (#89)
aiResponseTimeoutSeconds: true,
- aiOcrEnabled: true,
- aiOcrProvider: true,
- aiOcrModel: true,
- aiOcrBaseUrl: true,
- aiOcrKeyEncrypted: true,
},
});
@@ -62,16 +54,6 @@ export const GET = apiHandler(async () => {
: null,
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null,
- // v1.22 (#90) — dedicated document-scan (Lab-OCR) provider config. Key
- // material is presence + 4-char preview only, like every other provider.
- ocrEnabled: Boolean(u?.aiOcrEnabled),
- ocrProvider: u?.aiOcrProvider ?? null,
- ocrModel: u?.aiOcrModel ?? null,
- ocrBaseUrl: u?.aiOcrBaseUrl ?? null,
- hasOcrKey: Boolean(u?.aiOcrKeyEncrypted),
- ocrKeyPreview: u?.aiOcrKeyEncrypted
- ? `...${decrypt(u.aiOcrKeyEncrypted).slice(-4)}`
- : null,
});
});
@@ -179,62 +161,6 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
}
}
- // ── v1.22 (#90) — dedicated document-scan (Lab-OCR) provider ──
- if (body.ocrEnabled !== undefined) {
- if (typeof body.ocrEnabled === "boolean") {
- updates.aiOcrEnabled = body.ocrEnabled;
- } else {
- throw new HttpError(422, "Invalid ocrEnabled");
- }
- }
-
- if (body.ocrProvider !== undefined) {
- if (body.ocrProvider === null || body.ocrProvider === "") {
- updates.aiOcrProvider = null;
- } else if (
- typeof body.ocrProvider === "string" &&
- OCR_PROVIDERS.has(body.ocrProvider)
- ) {
- updates.aiOcrProvider = body.ocrProvider;
- } else {
- throw new HttpError(422, "Invalid OCR provider");
- }
- }
-
- if (body.ocrModel !== undefined) {
- if (body.ocrModel === null || body.ocrModel === "") {
- updates.aiOcrModel = null;
- } else if (typeof body.ocrModel === "string") {
- updates.aiOcrModel = body.ocrModel.trim() || null;
- }
- }
-
- if (body.ocrBaseUrl !== undefined) {
- if (body.ocrBaseUrl === null || body.ocrBaseUrl === "") {
- updates.aiOcrBaseUrl = null;
- } else if (typeof body.ocrBaseUrl === "string") {
- const trimmed = body.ocrBaseUrl.trim();
- // Same SSRF guard as the main LOCAL base URL: reject private/internal
- // hosts unless the operator opted in via ALLOW_LOCAL_AI_PRIVATE_HOSTS.
- const allowPrivate = isLocalAiHostAllowed(trimmed);
- if (!allowPrivate && !isPublicUrl(trimmed)) {
- return apiError(
- "OCR base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
- 422,
- );
- }
- updates.aiOcrBaseUrl = trimmed;
- }
- }
-
- if (body.ocrKey !== undefined) {
- if (body.ocrKey === null || body.ocrKey === "") {
- updates.aiOcrKeyEncrypted = null;
- } else if (typeof body.ocrKey === "string") {
- updates.aiOcrKeyEncrypted = encrypt(body.ocrKey.trim());
- }
- }
-
if (Object.keys(updates).length === 0) {
throw new HttpError(422, "No valid fields");
}
diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx
index b8be17dec..3d1e5fac5 100644
--- a/src/components/dashboard/hero/dashboard-hero.tsx
+++ b/src/components/dashboard/hero/dashboard-hero.tsx
@@ -399,17 +399,18 @@ export function DashboardHero({
>
{welcomeText}
- {/* Text left, action right, one row — the PageHeader/TileHeader
- inline-action pattern. The sentence takes the available width and
- wraps internally (`min-w-0 flex-1`); the button never wraps to its
- own line (`shrink-0`), so the action stays beside the text. */}
-
+ {/* Sentence then action, stacked — the overdue-dose button sits
+ DIRECTLY beneath the verdict text (tight `gap-1.5`), left-aligned
+ and intrinsic width (`self-start w-auto`), on every viewport. An
+ earlier `justify-between` shoved the button to the far container
+ edge on desktop, stranding it from the sentence it acts on. */}
+