diff --git a/CLAUDE.md b/CLAUDE.md index 0a0a51a6..40bc92f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is Paperless-AI **next** — a community fork of clusterzx/paperless-ai. A Node.js/Express app that uses -LLMs (OpenAI, Ollama, Azure, or any OpenAI-compatible "custom" endpoint) plus optional OCR to auto-tag, +LLMs (OpenAI, Ollama, Azure, Anthropic, or any OpenAI-compatible "custom" endpoint) plus optional OCR to auto-tag, title, classify, and extract metadata for documents in a Paperless-ngx instance. Server-rendered EJS UI, no frontend framework. Single SQLite database (better-sqlite3, WAL mode). @@ -54,12 +54,18 @@ the `services/` singletons. `class Foo { ... }; module.exports = new Foo();`. Import and call directly; do not `new` them. **AI provider abstraction:** `services/aiServiceFactory.js` returns the right service based on -`config.aiProvider` (`openai` | `ollama` | `custom` | `azure`). Every provider service implements +`config.aiProvider` (`openai` | `ollama` | `custom` | `azure` | `anthropic`). Every provider service implements `analyzeDocument(content, doc, existingTags, correspondents)`. To add a provider: add a service singleton, wire it into the factory switch, and add its config block in `config/config.js`. -**Token handling:** `services/serviceUtils.js` — `calculateTokens()` (tiktoken for OpenAI, ÷4 char -estimate otherwise) and `truncateToTokenLimit()`. Also holds AI-error → OCR-fallback classification +**Anthropic** is not OpenAI-compatible and has its own service (`services/anthropicService.js`). Per-model +request rules (thinking, effort, structured output, cache minimums), the client factory, usage mapping and +the truncation/refusal check live in `services/anthropicSupport.js` and are shared with Claude OCR; never +send `temperature` to it or read `content[0]`. Its analysis prompt and JSON schema come from +`services/analysisPrompt.js`, which the other providers do not use yet. + +**Token handling:** `services/serviceUtils.js` — `calculateTokens()` (tiktoken for OpenAI, ÷3 char +estimate for `claude-*`, ÷4 otherwise) and `truncateToTokenLimit()`. Also holds AI-error → OCR-fallback classification helpers used by the scan loop. **Prompt restrictions:** `services/restrictionPromptService.js` replaces placeholders like @@ -69,7 +75,7 @@ in prompts. The base prompts live in `config/config.js` (`specialPromptPreDefine normalize them with `toNameList()` from `services/serviceUtils.js` instead of joining them directly. **OCR:** `services/mistralOcrService.js` — provider can be `mistral`, `ollama` (native `/api/chat` -vision), or OpenAI-compatible `/v1`. OCR is a fallback path when AI analysis fails on low-text docs. +vision), OpenAI-compatible `/v1`, or `anthropic` (Claude reads PDFs and images natively). OCR is a fallback path when AI analysis fails on low-text docs. **Database:** `models/document.js` — better-sqlite3 at `data/documents.db`, WAL mode, prepared statements throughout. Key tables: `processed_documents`, `history_documents` (server-side paginated diff --git a/OPENAPI/openapi.json b/OPENAPI/openapi.json index ec2c115c..7c75fea0 100644 --- a/OPENAPI/openapi.json +++ b/OPENAPI/openapi.json @@ -4609,6 +4609,10 @@ "nullable": true, "description": "Recommended default; absent for providers without model classification" }, + "live": { + "type": "boolean", + "description": "anthropic only — false when the live list failed and the suggested Claude models are returned instead" + }, "resolvedApiUrl": { "type": "string" }, @@ -4697,17 +4701,21 @@ "openai", "ollama", "custom", - "azure" + "azure", + "anthropic" ] }, "apiUrl": { - "type": "string" + "type": "string", + "description": "Not needed for anthropic unless a proxy sits in front of api.anthropic.com" }, "token": { - "type": "string" + "type": "string", + "description": "Omit or leave empty to use the stored key of the selected provider" }, "model": { - "type": "string" + "type": "string", + "description": "For anthropic, checked with a models lookup that spends no tokens" }, "setupValidationTimeoutMs": { "type": "integer" @@ -4727,6 +4735,86 @@ } } }, + "/api/settings/ocr/test": { + "post": { + "summary": "Test OCR provider connectivity from the settings page", + "description": "Runs one OCR request on a generated test image. For `anthropic` the\nkey is the OCR key when one is given or stored for Claude OCR, and\notherwise the Anthropic AI provider's key (`anthropicApiKey`, then\nthe stored ANTHROPIC_API_KEY).\n", + "tags": [ + "Settings" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "string", + "enum": [ + "yes", + "no" + ] + }, + "provider": { + "type": "string", + "enum": [ + "mistral", + "custom", + "ollama", + "anthropic" + ] + }, + "apiUrl": { + "type": "string", + "description": "Ignored for mistral. For anthropic an optional proxy URL; empty uses the Anthropic AI provider's URL, then api.anthropic.com" + }, + "apiKey": { + "type": "string", + "description": "Omit or leave empty to use the stored key" + }, + "model": { + "type": "string", + "description": "For anthropic, a Claude model; anything else falls back to the AI model" + }, + "setupOcrValidationTimeoutMs": { + "type": "integer" + }, + "anthropicApiKey": { + "type": "string", + "description": "Anthropic AI key typed but not saved yet; Claude OCR falls back to it" + }, + "anthropicApiUrl": { + "type": "string", + "description": "Anthropic AI proxy URL typed but not saved yet" + }, + "anthropicModel": { + "type": "string", + "description": "Anthropic AI model typed but not saved yet" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OCR connectivity result returned" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, "/api/settings/ocr/models": { "post": { "summary": "Discover available OCR models from the settings page", @@ -4754,7 +4842,8 @@ "enum": [ "mistral", "custom", - "ollama" + "ollama", + "anthropic" ] }, "apiUrl": { @@ -4762,10 +4851,22 @@ }, "apiKey": { "type": "string", - "description": "Omit or leave empty to use the stored OCR_API_KEY / MISTRAL_API_KEY" + "description": "Omit or leave empty to use the stored OCR_API_KEY / MISTRAL_API_KEY (for anthropic, the stored Claude OCR key, then the Anthropic AI key)" }, "setupOcrValidationTimeoutMs": { "type": "integer" + }, + "anthropicApiKey": { + "type": "string", + "description": "Anthropic AI key typed but not saved yet; used by anthropic when no OCR key is set" + }, + "anthropicApiUrl": { + "type": "string", + "description": "Anthropic AI proxy URL typed but not saved yet" + }, + "anthropicModel": { + "type": "string", + "description": "Anthropic AI model; anthropic returns it as suggestedModel" } } } @@ -4802,6 +4903,10 @@ "nullable": true, "description": "Recommended default; absent for providers without model classification" }, + "live": { + "type": "boolean", + "description": "anthropic only — false when the live list failed and the suggested Claude models are returned instead" + }, "resolvedApiUrl": { "type": "string" }, @@ -5642,7 +5747,8 @@ "openai", "ollama", "custom", - "azure" + "azure", + "anthropic" ], "example": "openai" }, @@ -5686,6 +5792,58 @@ "description": "Model name for custom LLM provider", "example": "custom-model" }, + "anthropicApiKey": { + "type": "string", + "description": "API key for Anthropic (required when aiProvider is 'anthropic' and none is stored; leave empty to keep the configured value). Saved only with aiProvider 'anthropic'; the anthropic* fields below likewise.", + "example": "sk-ant-api03-abc123" + }, + "anthropicModel": { + "type": "string", + "description": "Claude model for analysis; empty keeps the configured one", + "example": "claude-sonnet-5-5" + }, + "anthropicEffort": { + "type": "string", + "description": "Effort level; empty leaves it to the model. Rejected with 400 when it is not one of the listed levels.", + "enum": [ + "", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "example": "" + }, + "anthropicMaxTokens": { + "type": "integer", + "minimum": 1, + "description": "Completion cap per request, thinking included. Rejected with 400 when it is not a positive whole number; empty keeps the configured value.", + "example": 16000 + }, + "anthropicStructuredOutput": { + "type": "string", + "description": "Constrain the analysis answer to a JSON schema (yes/no)", + "enum": [ + "yes", + "no" + ], + "example": "yes" + }, + "anthropicPromptCaching": { + "type": "string", + "description": "Mark the system prompt cacheable (yes/no)", + "enum": [ + "yes", + "no" + ], + "example": "yes" + }, + "anthropicApiUrl": { + "type": "string", + "description": "Only for a proxy; empty uses api.anthropic.com", + "example": "" + }, "scanInterval": { "type": "number", "description": "Interval in minutes for scanning new documents", @@ -5922,6 +6080,17 @@ "description": "How many entries the vocabulary proposed from the tag names should aim at, types and topics together (5-100, default 25). Out-of-range values are clamped, a non-numeric value keeps the current setting.", "example": 25 }, + "ocrProvider": { + "type": "string", + "description": "OCR fallback provider. For anthropic (Claude OCR) an empty ocrApiKey uses the Anthropic AI key, and ocrApiUrl is an optional proxy URL; a key or URL stored for another provider is cleared when switching to it.", + "enum": [ + "mistral", + "custom", + "ollama", + "anthropic" + ], + "example": "mistral" + }, "ocrAutoProcessEnabled": { "type": "string", "description": "Process queued OCR documents automatically (yes/no)", diff --git a/config/ai-provider-presets.json b/config/ai-provider-presets.json index cd427dc0..3dae96b6 100644 --- a/config/ai-provider-presets.json +++ b/config/ai-provider-presets.json @@ -8,6 +8,14 @@ "model": "gpt-4o-mini", "tokenPlaceholder": "sk-..." }, + { + "id": "anthropic-default", + "label": "Anthropic (Claude)", + "provider": "anthropic", + "apiUrl": "", + "model": "claude-haiku-5-5", + "tokenPlaceholder": "sk-ant-..." + }, { "id": "lmstudio-local", "label": "LM Studio (OpenAI compatible)", diff --git a/config/changelog.js b/config/changelog.js index 3f6fe20c..707e605d 100644 --- a/config/changelog.js +++ b/config/changelog.js @@ -143,6 +143,15 @@ const RELEASES = [ 'New: Settings sections for Duplicates and Simplify tags', ], }, + { + version: 'v2026.10.01', + entries: [ + 'New: Anthropic (Claude) as AI provider. Settings and the setup wizard take an API key and list the Claude models it can use; Claude Haiku 5.5 is the default. The analysis comes back as structured output, and prompt caching keeps the repeated instructions cheap', + 'New: Claude as OCR provider. It reads PDFs and images directly and uses the API key and model of the AI provider unless OCR has its own', + 'Fix: Saving the settings no longer signs everybody out. The restart after a save created a new sign-in key, so every open session ended, and a page left open only showed vague errors such as "Model discovery failed". A session that ends now sends the page to the login screen', + 'Fix: The manual tagging page uses the current design again. Its buttons, cards, document preview and tag chips were still built on styles that no longer exist', + ], + }, ]; const latestRelease = RELEASES[RELEASES.length - 1]; diff --git a/config/config.js b/config/config.js index 61eeb338..d1d9ce9e 100644 --- a/config/config.js +++ b/config/config.js @@ -433,7 +433,7 @@ startupLog(logLevel, 'info', 'Configuration loaded:', { }); module.exports = { - PAPERLESS_AI_VERSION: 'v2026.09.03', + PAPERLESS_AI_VERSION: 'v2026.10.01', CONFIGURED: false, configSourceMode: CONFIG_SOURCE_MODE, getApiKey, @@ -648,6 +648,33 @@ module.exports = { deploymentName: process.env.AZURE_DEPLOYMENT_NAME || '', apiVersion: process.env.AZURE_API_VERSION || '2023-05-15', }, + // The Claude API speaks its own Messages dialect, so it is a provider of + // its own rather than a preset of `custom`. Request rules per model live in + // services/anthropicSupport.js. + anthropic: { + apiKey: process.env.ANTHROPIC_API_KEY || '', + // Empty means api.anthropic.com; set only for a proxy in front of it. + apiUrl: process.env.ANTHROPIC_API_URL || '', + model: process.env.ANTHROPIC_MODEL || 'claude-haiku-5-5', + // low | medium | high | xhigh | max; empty leaves the model's default. + effort: String(process.env.ANTHROPIC_EFFORT || '') + .trim() + .toLowerCase(), + // Completion cap per request. Thinking counts toward it, which is why it + // is separate from RESPONSE_TOKENS. + maxTokens: parseTokenCount( + process.env.ANTHROPIC_MAX_TOKENS, + 16000, + 'ANTHROPIC_MAX_TOKENS' + ), + // Constrain the analysis answer to a JSON schema (output_config.format). + structuredOutput: parseEnvBoolean( + process.env.ANTHROPIC_STRUCTURED_OUTPUT, + 'yes' + ), + // Mark the system prompt cacheable; it repeats for every document of a scan. + promptCaching: parseEnvBoolean(process.env.ANTHROPIC_PROMPT_CACHING, 'yes'), + }, mistralOcr: { enabled: parseEnvBoolean(process.env.MISTRAL_OCR_ENABLED, 'no'), provider: String( diff --git a/package-lock.json b/package-lock.json index 5d854c6d..21d78df8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.133.0", "axios": "^1.20.0", "bcryptjs": "^3.0.2", "better-sqlite3": "^12.11.1", @@ -40,6 +41,27 @@ "stylelint-config-standard": "^40.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.133.0.tgz", + "integrity": "sha512-jlqUN8p8ESTJj+GJnA4KjzbEfF1waEoGzRT8jwytlYGFFgv+/DUQOxlYGvYw10UzhD1kwLqYzxvA3FrTB2YWuQ==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", @@ -149,6 +171,15 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -660,6 +691,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1130,9 +1167,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2165,6 +2202,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -2830,9 +2873,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "license": "MIT", "engines": { "node": ">= 12" @@ -2999,6 +3042,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3800,9 +3856,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", - "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3874,9 +3930,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -3884,6 +3940,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -4646,9 +4706,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4680,6 +4740,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4732,9 +4802,9 @@ } }, "node_modules/stylelint": { - "version": "17.15.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.15.0.tgz", - "integrity": "sha512-mWIkesYQvQjf4Kvdeu9ns0IL8/K/wtjaGxPqsPd6DlLZvaQxGysJsocxUDrBcyHQ5VleAk4uSMat4yMrVED7PA==", + "version": "17.16.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.16.0.tgz", + "integrity": "sha512-ejlWnbJ13x1GU32+zZXa6sYQJsKKkwSdv2B4rAdO3OWdEmvhSJKcXi5pOphQYSzyaj1kXpuGJr7pcMBNfDIuPw==", "dev": true, "funding": [ { @@ -5184,6 +5254,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", diff --git a/package.json b/package.json index e92ab614..7e3b3caa 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:setup-remote-guard": "node scripts/run-tests.js --test setup-remote-guard", "test:area:security": "node scripts/run-tests.js --area security", "test:area:duplicates": "node scripts/run-tests.js --area duplicates", + "test:area:anthropic": "node scripts/run-tests.js --area anthropic", "test:restriction-service": "node scripts/run-tests.js --test restriction-service", "test:updated-service": "node scripts/run-tests.js --test updated-service", "test:list": "node scripts/run-tests.js --list", @@ -57,6 +58,7 @@ "author": "admonstrator", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.133.0", "axios": "^1.20.0", "bcryptjs": "^3.0.2", "better-sqlite3": "^12.11.1", diff --git a/public/css/badges.css b/public/css/badges.css index b6925366..bc875958 100644 --- a/public/css/badges.css +++ b/public/css/badges.css @@ -159,4 +159,10 @@ opacity: 1; background: color-mix(in srgb, var(--zr-brand) 18%, transparent); } + /* The default 16px icon is wider than its 15px button and stuck out past + the chip's rounded end. */ + .zr-chip button .zr-icon { + width: 11px; + height: 11px; + } } diff --git a/public/css/pages/shared.css b/public/css/pages/shared.css index 94fa05a4..0aea7d3a 100644 --- a/public/css/pages/shared.css +++ b/public/css/pages/shared.css @@ -1,5 +1,5 @@ /* ========================================================================== - Cross-page legacy pieces: the pre-framework modal system, .stat-box, + Cross-page legacy pieces: the pre-framework modal system, the manual document search, the queue toolbar and the odds and ends the page scripts of history, settings and manual generate at runtime. Not one page — several, which is why they are not in any @@ -163,23 +163,37 @@ transition: filter 0.3s ease; } - /* Stats Box */ - .stat-box { - background: var(--zr-surface-2); - padding: 1rem; - border-radius: 8px; - border: 1px solid var(--zr-line); - transition: all 0.2s ease; - color: var(--zr-text); - } - /* The manual preview claims its height up front, so picking a document does - not make the rest of the page jump. Frame and padding come from .stat-box. */ - /* Tall enough to balance the analysis column once content loads; while empty + not make the rest of the page jump. Frame and padding come from .zr-panel. + Tall enough to balance the analysis column once content loads; while empty the #previewEmpty hint fills it instead of a bare grey area. */ .manual-preview { min-height: 360px; } + /* Document text keeps its own line breaks and scrolls inside the panel: a + long document used to run together into one paragraph and stretch the + page to its full length. */ + .manual-preview #contentPreview { + max-height: 600px; + overflow-y: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--zr-fs-sm); + } + + /* An empty tag list says so instead of leaving its label over nothing. */ + .manual-tags:empty::before { + content: attr(data-empty); + font-size: var(--zr-fs-sm); + color: var(--zr-text-faint); + } + /* A suggestion is not on the document yet: outlined rather than filled. The + padding gives back the border's pixel so both chips stand equally tall. */ + .manual-chip--suggested { + padding: 1px 3px 1px 7px; + background: transparent; + border: 1px dashed color-mix(in srgb, var(--zr-brand) 55%, transparent); + } /* The empty hint shows only while the preview has no content. */ .manual-preview #contentPreview:not(:empty) + #previewEmpty { display: none; @@ -190,12 +204,9 @@ .manual-preview { min-height: 160px; } - } - - .stat-box:hover { - transform: translateY(-2px); - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - background: var(--zr-surface-2); + .manual-preview #contentPreview { + max-height: 320px; + } } /* Modal Styles */ diff --git a/public/js/bug-report-link.js b/public/js/bug-report-link.js index d1d905ac..476d7890 100644 --- a/public/js/bug-report-link.js +++ b/public/js/bug-report-link.js @@ -78,6 +78,8 @@ `AZURE_ENDPOINT=${supportInfo.azureEndpoint || 'unknown'}`, `AZURE_DEPLOYMENT_NAME=${supportInfo.azureDeploymentName || 'unknown'}`, `AZURE_API_VERSION=${supportInfo.azureApiVersion || 'unknown'}`, + `ANTHROPIC_MODEL=${supportInfo.anthropicModel || 'unknown'}`, + `ANTHROPIC_API_URL=${supportInfo.anthropicApiUrl || 'unknown'}`, `MISTRAL_OCR_MODEL=${supportInfo.mistralOcrModel || 'unknown'}`, `TRUST_PROXY=${supportInfo.trustProxy || 'unknown'}`, `USE_EXISTING_DATA=${supportInfo.useExistingData || 'no'}`, @@ -88,6 +90,7 @@ `OPENAI_API_KEY=${keyState(supportInfo.openAiKeySet)}`, `CUSTOM_API_KEY=${keyState(supportInfo.customKeySet)}`, `AZURE_API_KEY=${keyState(supportInfo.azureKeySet)}`, + `ANTHROPIC_API_KEY=${keyState(supportInfo.anthropicKeySet)}`, `MISTRAL_API_KEY=${keyState(supportInfo.mistralKeySet)}`, `API_KEY=${keyState(supportInfo.apiKeySet)}`, ].join('\n') diff --git a/public/js/failed.js b/public/js/failed.js index ea797579..150dda26 100644 --- a/public/js/failed.js +++ b/public/js/failed.js @@ -147,6 +147,9 @@ 'Insufficient content (< 10 chars)', ai_response_truncated: 'AI answer cut off (token limit)', + // A Claude model declined the request (stop_reason "refusal"). + ai_refused: + 'Declined by the AI model', }; if (map[reason]) return map[reason]; diff --git a/public/js/session-guard.js b/public/js/session-guard.js new file mode 100644 index 00000000..cd0a0f3d --- /dev/null +++ b/public/js/session-guard.js @@ -0,0 +1,48 @@ +/** + * Sends the browser to the login page once a request finds the session gone. + * + * Every signed-in route answers a missing or expired session with a redirect + * to /login, and fetch follows redirects silently: the caller receives the + * login page with status 200, fails to read it as JSON and reports something + * generic such as "Model discovery failed". The page itself kept looking + * signed in, so every further action failed the same way and nothing pointed + * at the session. + * + * Loaded in
right after csrf.js, so it wraps that patch and sees a + * request's final answer, after any CSRF token retry. + */ +(function () { + 'use strict'; + + const LOGIN_PATH = '/login'; + const innerFetch = window.fetch; + let leaving = false; + + function endedAtLogin(response) { + if (!response?.redirected || !response.url) return false; + try { + const target = new URL(response.url, window.location.href); + return ( + target.origin === window.location.origin && + target.pathname === LOGIN_PATH + ); + } catch { + return false; + } + } + + window.fetch = async (...args) => { + const response = await innerFetch(...args); + + if ( + !leaving && + window.location.pathname !== LOGIN_PATH && + endedAtLogin(response) + ) { + leaving = true; + window.location.assign(LOGIN_PATH); + } + + return response; + }; +})(); diff --git a/public/js/settings.js b/public/js/settings.js index 3a728d66..c4d5b038 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -114,6 +114,7 @@ class FormManager { const ollamaSettings = document.getElementById('ollamaSettings'); const customSettings = document.getElementById('customSettings'); const azureSettings = document.getElementById('azureSettings'); + const anthropicSettings = document.getElementById('anthropicSettings'); // Get all provider-specific fields const openaiKey = document.getElementById('openaiKey'); @@ -141,6 +142,13 @@ class FormManager { ollamaSettings.classList.add('hidden'); customSettings.classList.add('hidden'); azureSettings.classList.add('hidden'); + anthropicSettings?.classList.add('hidden'); + + // Claude models reject sampling parameters, so the temperature fields + // stay (they apply again after switching back) but say they are ignored. + document.querySelectorAll('[data-anthropic-hint]').forEach((hint) => { + hint.classList.toggle('hidden', provider !== 'anthropic'); + }); // Reset all required fields openaiKey.required = false; @@ -175,6 +183,11 @@ class FormManager { azureDeploymentName.required = true; azureApiVersion.required = true; break; + case 'anthropic': + // Nothing is required here: an empty key keeps the stored one (the + // save route refuses when there is none), and the model has a default. + anthropicSettings?.classList.remove('hidden'); + break; } } @@ -501,6 +514,15 @@ function initializeFormHandlers() { const aiProviderPresets = Array.isArray(settingsBootstrap.aiProviderPresets) ? settingsBootstrap.aiProviderPresets : []; + // Claude models and defaults as services/anthropicSupport.js defines them. + const anthropicOptions = settingsBootstrap.anthropic || {}; + const anthropicSuggestedModelIds = ( + Array.isArray(anthropicOptions.suggestedModels) + ? anthropicOptions.suggestedModels + : [] + ) + .map((entry) => String(entry?.id || '').trim()) + .filter(Boolean); const aiPresetSelect = document.getElementById('aiPreset'); const aiPresetHint = document.getElementById('aiPresetHint'); @@ -515,6 +537,12 @@ function initializeFormHandlers() { const fetchCustomAiModelsBtn = document.getElementById( 'fetchCustomAiModelsBtn' ); + const anthropicApiKeyInput = document.getElementById('anthropicApiKey'); + const anthropicModelInput = document.getElementById('anthropicModel'); + const anthropicApiUrlInput = document.getElementById('anthropicApiUrl'); + const fetchAnthropicModelsBtn = document.getElementById( + 'fetchAnthropicModelsBtn' + ); const ocrEnabledSelect = document.getElementById('mistralOcrEnabled'); const ocrFieldsContainer = document.getElementById('ocrFieldsContainer'); @@ -672,6 +700,16 @@ function initializeFormHandlers() { ).trim(), }; } + if (provider === 'anthropic') { + // An empty URL is api.anthropic.com, which is what the preset says. + return { + provider, + apiUrl: String(anthropicApiUrlInput?.value || '') + .trim() + .replace(/\/+$/, ''), + model: String(anthropicModelInput?.value || '').trim(), + }; + } return { provider: 'custom', @@ -757,6 +795,27 @@ function initializeFormHandlers() { azureEndpoint.value = String(preset.apiUrl || '').trim(); if (azureDeploymentName) azureDeploymentName.value = String(preset.model || '').trim(); + } else if (provider === 'anthropic') { + if (anthropicApiUrlInput) + anthropicApiUrlInput.value = String(preset.apiUrl || '').trim(); + if (anthropicModelInput) { + const presetModel = + String(preset.model || '').trim() || + String(anthropicOptions.defaultModel || ''); + populateModelSelect( + anthropicModelInput, + [...anthropicSuggestedModelIds, presetModel], + 'Select Claude model', + { preferred: presetModel } + ); + } + if ( + anthropicApiKeyInput && + preset.tokenPlaceholder && + anthropicApiKeyInput.placeholder !== 'Configured' + ) { + anthropicApiKeyInput.placeholder = preset.tokenPlaceholder; + } } if (aiPresetHint) { @@ -824,6 +883,8 @@ function initializeFormHandlers() { document.getElementById('openaiModel'), document.getElementById('azureEndpoint'), document.getElementById('azureDeploymentName'), + anthropicModelInput, + anthropicApiUrlInput, ].forEach((element) => { if (element) { element.addEventListener('change', () => refreshAiPresetSelection()); @@ -1002,6 +1063,83 @@ function initializeFormHandlers() { }); } + /* The live list needs a key; without one (or when the call fails) the route + answers with the suggested models and says so in its message. The list is + laid out like the OCR model select, and the model chosen before stays + selected and listed even when the answer does not name it. */ + const fillAnthropicModelSelect = (models) => { + const current = String(anthropicModelInput?.value || '').trim(); + const preferred = current || String(anthropicOptions.defaultModel || ''); + populateModelSelect( + anthropicModelInput, + [...models, preferred], + 'Select Claude model', + { preferred } + ); + }; + + if (fetchAnthropicModelsBtn) { + fetchAnthropicModelsBtn.addEventListener('click', async () => { + setButtonLoading(fetchAnthropicModelsBtn, true); + try { + const response = await fetch('/api/settings/ai/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + aiProvider: 'anthropic', + apiUrl: String(anthropicApiUrlInput?.value || '').trim(), + // Empty means the stored key; the route resolves it. + token: String(anthropicApiKeyInput?.value || '').trim(), + }), + }); + const result = await response.json().catch(() => ({})); + if (!response.ok || !result.success) { + throw new Error( + result.error || result.message || 'Model discovery failed' + ); + } + + const models = Array.isArray(result.models) ? result.models : []; + fillAnthropicModelSelect( + models.length > 0 ? models : anthropicSuggestedModelIds + ); + await zrDialog({ + icon: result.live === false ? 'warning' : 'success', + title: + result.live === false ? 'Suggested models shown' : 'Models loaded', + text: result.message || `Found ${models.length} model(s).`, + }); + } catch (error) { + fillAnthropicModelSelect(anthropicSuggestedModelIds); + await zrDialog({ + icon: 'error', + title: 'Loading failed', + text: String(error?.message || error), + }); + } finally { + setButtonLoading(fetchAnthropicModelsBtn, false); + } + }); + } + + /* What Claude OCR falls back to when its own fields are empty: the + Anthropic AI provider's key, URL and model — but only while that provider + is selected, because the save route stores them only then. */ + const getAnthropicFallbackForOcr = () => { + if ( + String(aiProviderSelect?.value || '') + .trim() + .toLowerCase() !== 'anthropic' + ) { + return {}; + } + return { + anthropicApiKey: String(anthropicApiKeyInput?.value || '').trim(), + anthropicApiUrl: String(anthropicApiUrlInput?.value || '').trim(), + anthropicModel: String(anthropicModelInput?.value || '').trim(), + }; + }; + const setOcrTestPill = (state, text) => { if (!ocrTestState) return; ocrTestState.textContent = text; @@ -1017,9 +1155,13 @@ function initializeFormHandlers() { const normalizeOcrProviderForApi = (provider) => { const normalized = String(provider || 'mistral').toLowerCase(); - return normalized === 'custom' ? 'custom' : 'mistral'; + return ['custom', 'anthropic'].includes(normalized) + ? normalized + : 'mistral'; }; + // Mistral has no URL field. For Claude the field is an optional proxy URL, + // so whatever is in it was typed for Claude (switching to Claude empties it). const normalizeOcrApiUrlForProvider = (provider, rawUrl) => { const normalizedProvider = normalizeOcrProviderForApi(provider); if (normalizedProvider === 'mistral') { @@ -1029,6 +1171,27 @@ function initializeFormHandlers() { return String(rawUrl || '').trim(); }; + const isClaudeModelName = (model) => /^claude-/i.test(String(model || '')); + + /* The OCR model select for Claude: the suggested models, with the current + choice kept when it is a Claude model and the AI provider's model (or the + default) preselected otherwise. */ + const offerAnthropicOcrModels = () => { + if (!ocrModelInput) return; + const current = String(ocrModelInput.value || '').trim(); + const aiModel = getAnthropicFallbackForOcr().anthropicModel || ''; + const preferred = isClaudeModelName(current) + ? current + : (isClaudeModelName(aiModel) && aiModel) || + String(anthropicOptions.defaultModel || ''); + populateModelSelect( + ocrModelInput, + [...anthropicSuggestedModelIds, preferred], + 'Select Claude model', + { preferred } + ); + }; + const getOcrValidationTimeoutMs = () => { const rawSeconds = Number.parseInt( String(ocrValidationTimeoutInput?.value || '30').trim(), @@ -1040,6 +1203,13 @@ function initializeFormHandlers() { return normalizedSeconds * 1000; }; + // The provider the OCR fields were last laid out for, so a switch to + // Claude can be told apart from a re-render (the quickstart sets the select + // without a change event and then calls toggleOcrFields()). + let previousOcrProvider = String( + ocrProviderSelect?.value || 'mistral' + ).toLowerCase(); + const toggleOcrFields = () => { if (!ocrEnabledSelect || !ocrProviderSelect) return; @@ -1057,10 +1227,31 @@ function initializeFormHandlers() { if (ocrApiUrlContainer) { ocrApiUrlContainer.classList.toggle( 'hidden', - !enabled || provider !== 'custom' + !enabled || !['custom', 'anthropic'].includes(provider) ); } + // Claude OCR: the key is optional (the AI provider's key is the + // fallback) and the URL is only for a proxy. Placeholders and hints for + // both cases are rendered by the server, which knows what is stored. + const isAnthropicOcr = provider === 'anthropic'; + [ocrApiKeyInput, ocrApiUrlInput].forEach((input) => { + if (!input) return; + const placeholder = isAnthropicOcr + ? input.dataset.placeholderAnthropic + : input.dataset.placeholderDefault; + if (placeholder) input.placeholder = placeholder; + }); + document + .getElementById('ocrApiKeyHint') + ?.classList.toggle('hidden', isAnthropicOcr); + document + .getElementById('ocrApiKeyAnthropicHint') + ?.classList.toggle('hidden', !isAnthropicOcr); + document + .getElementById('ocrApiUrlAnthropicHint') + ?.classList.toggle('hidden', !isAnthropicOcr); + // The recommended/other grouping in the model dropdown only exists on the // classification path, which the Mistral provider does not take. const ocrModelVisionHint = document.getElementById('ocrModelVisionHint'); @@ -1071,8 +1262,8 @@ function initializeFormHandlers() { ); } - // PDF page rendering only applies to local vision models; the Mistral - // provider handles PDFs natively. + // PDF page rendering only applies to local vision models; Mistral and + // Claude read PDFs natively. const ocrPdfRenderContainer = document.getElementById( 'ocrPdfRenderContainer' ); @@ -1086,6 +1277,8 @@ function initializeFormHandlers() { if (testOcrBtn) { testOcrBtn.disabled = !enabled; } + + previousOcrProvider = provider; }; if (ocrProviderSelect) { @@ -1095,8 +1288,29 @@ function initializeFormHandlers() { if (initialProvider === 'ollama') { ocrProviderSelect.value = 'custom'; } + if (initialProvider === 'anthropic') { + offerAnthropicOcrModels(); + } ocrProviderSelect.addEventListener('change', () => { + const provider = String( + ocrProviderSelect.value || 'mistral' + ).toLowerCase(); + if (provider === 'anthropic' && previousOcrProvider !== 'anthropic') { + // A URL left from another provider (an auto-detected Ollama URL, + // say) would be used as a Claude proxy. + if (ocrApiUrlInput) ocrApiUrlInput.value = ''; + offerAnthropicOcrModels(); + } else if ( + provider === 'mistral' && + isClaudeModelName(ocrModelInput?.value) + ) { + populateModelSelect( + ocrModelInput, + ['mistral-ocr-latest'], + 'Select OCR model' + ); + } toggleOcrFields(); setOcrTestPill('default', 'Not tested'); }); @@ -1625,9 +1839,15 @@ function initializeFormHandlers() { ocrApiUrlInput?.value ), apiKey: String(ocrApiKeyInput?.value || '').trim(), + // Claude OCR picks the AI model when this is empty; the Mistral + // default would only be replaced again. model: - String(ocrModelInput?.value || '').trim() || 'mistral-ocr-latest', + String(ocrModelInput?.value || '').trim() || + (ocrProviderSelect?.value === 'anthropic' + ? '' + : 'mistral-ocr-latest'), setupOcrValidationTimeoutMs: getOcrValidationTimeoutMs(), + ...getAnthropicFallbackForOcr(), }; const originalHtml = testOcrBtn.innerHTML; @@ -1713,6 +1933,7 @@ function initializeFormHandlers() { apiUrl, apiKey, setupOcrValidationTimeoutMs: getOcrValidationTimeoutMs(), + ...getAnthropicFallbackForOcr(), }), }); @@ -1727,6 +1948,14 @@ function initializeFormHandlers() { ocrApiUrlInput.value = String(result.resolvedApiUrl).trim(); } + // A Claude model already chosen stays chosen; the route suggests the + // AI provider's model otherwise. + const currentOcrModel = String(ocrModelInput?.value || '').trim(); + const preferredOcrModel = + provider === 'anthropic' && isClaudeModelName(currentOcrModel) + ? currentOcrModel + : result.suggestedModel; + const models = Array.isArray(result.models) ? result.models : []; const visionModels = Array.isArray(result.visionModels) ? result.visionModels @@ -1738,11 +1967,14 @@ function initializeFormHandlers() { recommended: visionModels, recommendedLabel: 'Recommended (vision detected)', otherLabel: 'Other models', - preferred: result.suggestedModel, + preferred: preferredOcrModel, }); await zrDialog({ - icon: 'success', - title: 'OCR models loaded', + icon: result.live === false ? 'warning' : 'success', + title: + result.live === false + ? 'Suggested models shown' + : 'OCR models loaded', text: models.length > 0 ? result.message : 'No models found.', }); } catch (error) { @@ -2382,6 +2614,7 @@ function initializeFormHandlers() { ), apiKey: String(formData.get('ocrApiKey') || '').trim(), setupOcrValidationTimeoutMs: getOcrValidationTimeoutMs(), + ...getAnthropicFallbackForOcr(), }; const response = await fetch('/api/settings/ocr/models', { @@ -2786,6 +3019,16 @@ function initializeRuntimeOverridePills() { { selector: '#azureApiKey', envKey: 'AZURE_API_KEY' }, { selector: '#azureDeploymentName', envKey: 'AZURE_DEPLOYMENT_NAME' }, { selector: '#azureApiVersion', envKey: 'AZURE_API_VERSION' }, + { selector: '#anthropicApiKey', envKey: 'ANTHROPIC_API_KEY' }, + { selector: '#anthropicModel', envKey: 'ANTHROPIC_MODEL' }, + { selector: '#anthropicEffort', envKey: 'ANTHROPIC_EFFORT' }, + { selector: '#anthropicMaxTokens', envKey: 'ANTHROPIC_MAX_TOKENS' }, + { + selector: '#anthropicStructuredOutput', + envKey: 'ANTHROPIC_STRUCTURED_OUTPUT', + }, + { selector: '#anthropicPromptCaching', envKey: 'ANTHROPIC_PROMPT_CACHING' }, + { selector: '#anthropicApiUrl', envKey: 'ANTHROPIC_API_URL' }, { selector: '#tokenLimit', envKey: 'TOKEN_LIMIT' }, { selector: '#responseTokens', envKey: 'RESPONSE_TOKENS' }, { selector: '#aiTemperatureAnalysis', envKey: 'AI_TEMPERATURE_ANALYSIS' }, diff --git a/public/js/setup.js b/public/js/setup.js index 40c88ce2..6711a53e 100644 --- a/public/js/setup.js +++ b/public/js/setup.js @@ -7,6 +7,15 @@ class SetupWizard { this.presets = Array.isArray(this.bootstrap.aiProviderPresets) ? this.bootstrap.aiProviderPresets : []; + // Claude models and defaults as services/anthropicSupport.js defines them. + this.anthropic = this.bootstrap.anthropic || {}; + this.anthropicSuggestedModelIds = ( + Array.isArray(this.anthropic.suggestedModels) + ? this.anthropic.suggestedModels + : [] + ) + .map((entry) => String(entry?.id || '').trim()) + .filter(Boolean); this.steps = Array.from(document.querySelectorAll('.zr-steppane')); this.stepLabel = document.getElementById('setupStepLabel'); @@ -115,6 +124,7 @@ class SetupWizard { this.aiPresetHint = document.getElementById('aiPresetHint'); this.aiProvider = document.getElementById('aiProvider'); this.aiApiUrl = document.getElementById('aiApiUrl'); + this.aiApiUrlHint = document.getElementById('aiApiUrlHint'); this.aiToken = document.getElementById('aiToken'); this.aiModel = document.getElementById('aiModel'); this.fetchAiModelsBtn = document.getElementById('fetchAiModelsBtn'); @@ -277,6 +287,11 @@ class SetupWizard { ); this.aiModel.value = this.config.AZURE_DEPLOYMENT_NAME || ''; this.aiToken.value = this.config.AZURE_API_KEY || ''; + } else if (provider === 'anthropic') { + // The key never reaches the page (sanitizeConfigForBootstrap drops it). + this.aiApiUrl.value = this.config.ANTHROPIC_API_URL || ''; + this.offerAnthropicAiModels(this.config.ANTHROPIC_MODEL); + this.aiToken.value = ''; } else { this.aiProvider.value = 'custom'; this.aiApiUrl.value = this.config.CUSTOM_BASE_URL || ''; @@ -288,6 +303,88 @@ class SetupWizard { this.aiModel.value = this.config.CUSTOM_MODEL || ''; this.aiToken.value = this.config.CUSTOM_API_KEY || ''; } + this.updateAiProviderFields(); + // After the AI values, whose model Claude OCR prefers. + if (this.ocrProvider.value === 'anthropic') { + this.offerAnthropicOcrModels(); + } + } + + isClaudeModelName(model) { + return /^claude-/i.test(String(model || '')); + } + + /* The model select for Anthropic: the suggested models, preselecting the + given one (or the default). A live list replaces it on "Fetch models". */ + offerAnthropicAiModels(model) { + const preferred = + String(model || '').trim() || String(this.anthropic.defaultModel || ''); + this.setModelSelectOptions( + this.aiModel, + [...this.anthropicSuggestedModelIds, preferred], + 'Select model', + { preferred } + ); + } + + /* Anthropic is the one provider whose API URL is optional: the SDK knows + api.anthropic.com and the field only serves a proxy. */ + updateAiProviderFields() { + const isAnthropic = + String(this.aiProvider.value || '') + .trim() + .toLowerCase() === 'anthropic'; + this.aiApiUrl.required = !isAnthropic; + this.aiApiUrl.placeholder = isAnthropic ? 'https://api.anthropic.com' : ''; + this.aiApiUrlHint?.classList.toggle('hidden', !isAnthropic); + } + + aiProviderNeedsApiUrl(provider) { + return String(provider || '').toLowerCase() !== 'anthropic'; + } + + /* What Claude OCR falls back to when its own key is empty: the AI step's + key, URL and model, while Anthropic is the AI provider. */ + getAnthropicFallbackForOcr() { + if ( + String(this.aiProvider.value || '') + .trim() + .toLowerCase() !== 'anthropic' + ) { + return {}; + } + return { + anthropicApiKey: this.aiToken.value.trim(), + anthropicApiUrl: this.aiApiUrl.value.trim(), + anthropicModel: this.aiModel.value.trim(), + }; + } + + /* The OCR model sent to the server. Claude OCR picks the AI model itself + when this is empty, so the Mistral default is only filled in for others. */ + getOcrModelForRequest() { + const model = this.mistralOcrModel.value.trim(); + if (model) return model; + return (this.ocrProvider.value || 'mistral').toLowerCase() === 'anthropic' + ? '' + : 'mistral-ocr-latest'; + } + + /* The OCR model select for Claude: the suggested models, keeping a Claude + model already chosen and preferring the AI step's model otherwise. */ + offerAnthropicOcrModels() { + const current = this.mistralOcrModel.value.trim(); + const aiModel = this.getAnthropicFallbackForOcr().anthropicModel || ''; + const preferred = this.isClaudeModelName(current) + ? current + : (this.isClaudeModelName(aiModel) && aiModel) || + String(this.anthropic.defaultModel || ''); + this.setModelSelectOptions( + this.mistralOcrModel, + [...this.anthropicSuggestedModelIds, preferred], + 'Select Claude model', + { preferred } + ); } findMatchingPreset() { @@ -893,12 +990,52 @@ class SetupWizard { this.ocrApiKeyContainer.classList.toggle('hidden', !enabled); } + // Switching to Claude: a URL left from another provider (an + // auto-detected Ollama URL, say) would be used as a Claude proxy, and the + // model select needs the Claude models. Back to Mistral, a Claude model + // would be sent to Mistral. + const previousProvider = this.previousOcrProvider || provider; + if (provider === 'anthropic' && previousProvider !== 'anthropic') { + this.ocrApiUrl.value = ''; + this.offerAnthropicOcrModels(); + } else if ( + provider === 'mistral' && + previousProvider !== 'mistral' && + this.isClaudeModelName(this.mistralOcrModel.value) + ) { + this.setModelSelectOptions( + this.mistralOcrModel, + ['mistral-ocr-latest'], + 'Select OCR model' + ); + } + this.previousOcrProvider = provider; + + const isAnthropic = provider === 'anthropic'; if (this.ocrApiUrlContainer) { this.ocrApiUrlContainer.classList.toggle( 'hidden', - provider !== 'custom' || !enabled + !['custom', 'anthropic'].includes(provider) || !enabled ); } + document + .getElementById('ocrApiUrlHint') + ?.classList.toggle('hidden', isAnthropic); + document + .getElementById('ocrApiUrlAnthropicHint') + ?.classList.toggle('hidden', !isAnthropic); + document + .getElementById('ocrApiKeyHint') + ?.classList.toggle('hidden', isAnthropic); + document + .getElementById('ocrApiKeyAnthropicHint') + ?.classList.toggle('hidden', !isAnthropic); + [this.ocrApiKey, this.ocrApiUrl].forEach((input) => { + const placeholder = isAnthropic + ? input?.dataset.placeholderAnthropic + : input?.dataset.placeholderDefault; + if (placeholder) input.placeholder = placeholder; + }); // The recommended/other grouping in the model dropdown only exists on the // classification path, which the Mistral provider does not take. @@ -929,8 +1066,9 @@ class SetupWizard { this.ocrApiUrl.value ), apiKey: this.ocrApiKey.value.trim(), - model: this.mistralOcrModel.value.trim() || 'mistral-ocr-latest', + model: this.getOcrModelForRequest(), setupOcrValidationTimeoutMs: this.getOcrValidationTimeoutMs(), + ...this.getAnthropicFallbackForOcr(), }; if (!payload.enabled) { @@ -1316,6 +1454,7 @@ class SetupWizard { applyPreset(preset) { if (!preset) { this.aiProvider.value = 'custom'; + this.updateAiProviderFields(); this.aiPresetHint.textContent = 'Manual mode: choose provider and enter values yourself. Token is optional for custom endpoints.'; return; @@ -1323,7 +1462,13 @@ class SetupWizard { this.aiProvider.value = preset.provider || 'custom'; this.aiApiUrl.value = preset.apiUrl || ''; - this.aiModel.value = preset.model || ''; + if (this.aiProvider.value === 'anthropic') { + // The select has to offer the model before it can show it. + this.offerAnthropicAiModels(preset.model); + } else { + this.aiModel.value = preset.model || ''; + } + this.updateAiProviderFields(); this.aiToken.placeholder = preset.tokenPlaceholder || 'Enter API token if required'; this.aiPresetHint.textContent = `Preset "${preset.label}" selected.${this.aiProvider.value === 'custom' ? ' Token can stay empty if your endpoint allows anonymous access.' : ''}`; @@ -1501,6 +1646,7 @@ class SetupWizard { (selectedAiModel && this.aiModel.value !== selectedAiModel); this.aiProvider.value = detection.aiProvider || 'custom'; + this.updateAiProviderFields(); this.aiApiUrl.value = detection.resolvedAiApiUrl || ''; this.aiToken.value = quickstartKey; if (selectedAiModel) { @@ -1833,7 +1979,10 @@ class SetupWizard { return []; } - if (!['openai', 'azure'].includes(payload.aiProvider) && !payload.apiUrl) { + if ( + !['openai', 'azure', 'anthropic'].includes(payload.aiProvider) && + !payload.apiUrl + ) { if (!silent) { await this.showPopup({ icon: 'warning', @@ -1862,8 +2011,15 @@ class SetupWizard { const resolvedInfo = result.resolvedApiUrl ? `\nResolved API URL: ${String(result.resolvedApiUrl).trim()}` : ''; + // Anthropic answers with the suggested models when the live list + // fails (live: false); the message says so. await this.showPopup({ - icon: models.length > 0 ? 'success' : 'info', + icon: + result.live === false + ? 'warning' + : models.length > 0 + ? 'success' + : 'info', title: 'AI models loaded', text: `${result.message || (models.length > 0 ? 'Models discovered successfully.' : 'No models found.')}${resolvedInfo}`, }); @@ -1900,6 +2056,7 @@ class SetupWizard { ), apiKey: this.ocrApiKey.value.trim(), setupOcrValidationTimeoutMs: this.getOcrValidationTimeoutMs(), + ...this.getAnthropicFallbackForOcr(), }; // No local refusal on an empty field: an operator who already has @@ -1907,6 +2064,7 @@ class SetupWizard { // resolves it. Whether a key exists is the server's question to answer. this.setButtonLoading(this.fetchOcrModelsBtn, true, 'Loading...'); try { + const previousOcrModel = this.mistralOcrModel.value.trim(); const result = await this.request('/api/setup/ocr/models', payload); // The result dialog is awaited below; reset the button first so it // does not keep claiming the test is still running. @@ -1930,13 +2088,23 @@ class SetupWizard { : [], recommendedLabel: 'Recommended (vision detected)', otherLabel: 'Other models', - preferred: result.suggestedModel, + // A Claude model already chosen stays chosen. + preferred: + payload.provider === 'anthropic' && + this.isClaudeModelName(previousOcrModel) + ? previousOcrModel + : result.suggestedModel, } ); if (!silent) { await this.showPopup({ - icon: models.length > 0 ? 'success' : 'info', + icon: + result.live === false + ? 'warning' + : models.length > 0 + ? 'success' + : 'info', title: 'OCR models loaded', text: result.message || @@ -1981,11 +2149,17 @@ class SetupWizard { setupValidationTimeoutMs: this.getAiValidationTimeoutMs(), }; - if (!payload.aiProvider || !payload.model || !payload.apiUrl) { + if ( + !payload.aiProvider || + !payload.model || + (!payload.apiUrl && this.aiProviderNeedsApiUrl(payload.aiProvider)) + ) { await this.showPopup({ icon: 'warning', title: 'Missing values', - text: 'Provider, API URL, and model are required.', + text: this.aiProviderNeedsApiUrl(payload.aiProvider) + ? 'Provider, API URL, and model are required.' + : 'Provider and model are required.', }); return; } @@ -2201,21 +2375,26 @@ class SetupWizard { await this.fetchAiModels(true); } + const providerNeedsUrl = this.aiProviderNeedsApiUrl( + this.aiProvider.value.trim() + ); if ( !this.aiProvider.value.trim() || - !this.aiApiUrl.value.trim() || + (providerNeedsUrl && !this.aiApiUrl.value.trim()) || !this.aiModel.value.trim() ) { await this.showPopup({ icon: 'warning', title: 'Missing values', - text: 'Provider, API URL, and model are required.', + text: providerNeedsUrl + ? 'Provider, API URL, and model are required.' + : 'Provider and model are required.', }); return false; } const provider = this.aiProvider.value.trim().toLowerCase(); - const tokenRequired = provider === 'openai' || provider === 'azure'; + const tokenRequired = ['openai', 'azure', 'anthropic'].includes(provider); if (tokenRequired && !this.aiToken.value.trim()) { await this.showPopup({ icon: 'warning', @@ -2283,11 +2462,11 @@ class SetupWizard { await this.fetchOcrModels(true); } - if (!['mistral', 'custom'].includes(provider)) { + if (!['mistral', 'custom', 'anthropic'].includes(provider)) { await this.showPopup({ icon: 'warning', title: 'Invalid OCR provider', - text: 'Choose either mistral or custom for OCR fallback.', + text: 'Choose Mistral, custom or Claude for OCR fallback.', }); return false; } @@ -2342,6 +2521,15 @@ class SetupWizard { preview.push(`AZURE_API_KEY=${this.aiToken.value.trim()}`); preview.push(`AZURE_DEPLOYMENT_NAME=${this.aiModel.value.trim()}`); preview.push('AZURE_API_VERSION=2023-05-15'); + } else if (provider === 'anthropic') { + preview.push(`ANTHROPIC_API_KEY=${this.aiToken.value.trim()}`); + preview.push( + `ANTHROPIC_MODEL=${this.aiModel.value.trim() || this.anthropic.defaultModel || ''}` + ); + // Written only when set, like the server does: empty is api.anthropic.com. + if (this.aiApiUrl.value.trim()) { + preview.push(`ANTHROPIC_API_URL=${this.aiApiUrl.value.trim()}`); + } } else { preview.push(`CUSTOM_BASE_URL=${this.aiApiUrl.value.trim()}`); preview.push(`CUSTOM_API_KEY=${this.aiToken.value.trim()}`); @@ -2362,14 +2550,14 @@ class SetupWizard { this.ocrProvider.value || 'mistral' ).toLowerCase(); preview.push( - `OCR_PROVIDER=${normalizedOcrProvider === 'custom' ? 'custom' : 'mistral'}` + `OCR_PROVIDER=${['custom', 'anthropic'].includes(normalizedOcrProvider) ? normalizedOcrProvider : 'mistral'}` ); preview.push( `OCR_API_URL=${this.normalizeOcrApiUrlForProvider(normalizedOcrProvider, this.ocrApiUrl.value)}` ); preview.push(`OCR_API_KEY=${this.ocrApiKey.value.trim()}`); preview.push( - `MISTRAL_OCR_MODEL=${this.mistralOcrModel.value.trim() || 'mistral-ocr-latest'}` + `MISTRAL_OCR_MODEL=${this.getOcrModelForRequest() || this.getAnthropicFallbackForOcr().anthropicModel || this.anthropic.defaultModel || ''}` ); return preview.join('\n'); @@ -2425,7 +2613,7 @@ class SetupWizard { this.ocrApiUrl.value ), this.ocrApiKey.value.trim(), - this.mistralOcrModel.value.trim() || 'mistral-ocr-latest', + this.getOcrModelForRequest(), ]), }; } @@ -2478,8 +2666,7 @@ class SetupWizard { this.ocrApiUrl.value ), ocrApiKey: this.ocrApiKey.value.trim(), - mistralOcrModel: - this.mistralOcrModel.value.trim() || 'mistral-ocr-latest', + mistralOcrModel: this.getOcrModelForRequest(), }; } diff --git a/routes/setup.js b/routes/setup.js index d41598cb..02d2fe0b 100644 --- a/routes/setup.js +++ b/routes/setup.js @@ -51,6 +51,13 @@ const { const { sanitizeConfigForBootstrap, } = require('../services/bootstrapConfigSanitizer'); +const { + ANTHROPIC_DEFAULT_MODEL, + ANTHROPIC_SUGGESTED_MODELS, + ANTHROPIC_EFFORT_LEVELS, + ANTHROPIC_DEFAULT_MAX_TOKENS, + isConfiguredAnthropicHost, +} = require('../services/anthropicSupport'); const config = require('../config/config.js'); require('dotenv').config({ path: '../data/.env' }); @@ -86,6 +93,7 @@ const SETTINGS_SECRET_FIELDS = [ 'OLLAMA_API_KEY', 'CUSTOM_API_KEY', 'AZURE_API_KEY', + 'ANTHROPIC_API_KEY', 'OCR_API_KEY', 'MISTRAL_API_KEY', 'API_KEY', @@ -3343,8 +3351,33 @@ const DEFAULT_AI_PROVIDER_PRESETS = [ model: 'meta-llama/llama-3.3-70b-instruct', tokenPlaceholder: 'ionos-api-key', }, + { + // No URL: the SDK knows api.anthropic.com, and ANTHROPIC_API_URL is only + // for a proxy in front of it. + id: 'anthropic-default', + label: 'Anthropic (Claude)', + provider: 'anthropic', + apiUrl: '', + model: ANTHROPIC_DEFAULT_MODEL, + tokenPlaceholder: 'sk-ant-...', + }, ]; +/* What the setup wizard and the settings page need to offer the Claude + models and effort levels without a request: the same constants the + provider itself runs with. */ +function getAnthropicFormOptions() { + return { + defaultModel: ANTHROPIC_DEFAULT_MODEL, + suggestedModels: ANTHROPIC_SUGGESTED_MODELS.map((entry) => ({ + id: entry.id, + label: entry.label, + })), + effortLevels: ANTHROPIC_EFFORT_LEVELS.slice(), + defaultMaxTokens: ANTHROPIC_DEFAULT_MAX_TOKENS, + }; +} + function cleanupExpiredSetupMfaChallenges() { const now = Date.now(); for (const [challengeId, challenge] of setupMfaChallenges.entries()) { @@ -3505,6 +3538,13 @@ const ENV_EXPORT_GROUPS = [ 'AZURE_API_KEY', 'AZURE_DEPLOYMENT_NAME', 'AZURE_API_VERSION', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_API_URL', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_EFFORT', + 'ANTHROPIC_MAX_TOKENS', + 'ANTHROPIC_STRUCTURED_OUTPUT', + 'ANTHROPIC_PROMPT_CACHING', ], }, { @@ -3668,6 +3708,9 @@ function toEnvPreviewLines(config) { 'AZURE_API_KEY', 'AZURE_DEPLOYMENT_NAME', 'AZURE_API_VERSION', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_API_URL', + 'ANTHROPIC_MODEL', 'MISTRAL_OCR_ENABLED', 'OCR_PROVIDER', 'OCR_API_URL', @@ -3717,7 +3760,9 @@ async function loadAiProviderPresets() { ), })) .filter((item) => - ['openai', 'ollama', 'custom', 'azure'].includes(item.provider) + ['openai', 'ollama', 'custom', 'azure', 'anthropic'].includes( + item.provider + ) ); } catch (error) { console.warn( @@ -3739,12 +3784,21 @@ function resolveStoredAiToken(aiProvider) { if (provider === 'custom') return process.env.CUSTOM_API_KEY || ''; if (provider === 'openai') return process.env.OPENAI_API_KEY || ''; if (provider === 'azure') return process.env.AZURE_API_KEY || ''; + if (provider === 'anthropic') return process.env.ANTHROPIC_API_KEY || ''; return ''; } -function resolveSettingsAiToken(aiProvider, token) { +function resolveSettingsAiToken(aiProvider, token, apiUrl) { const normalizedToken = String(token || '').trim(); - return normalizedToken || resolveStoredAiToken(aiProvider); + if (normalizedToken) return normalizedToken; + // The saved Claude key only goes to a Claude endpoint already configured. + const provider = String(aiProvider || '') + .trim() + .toLowerCase(); + if (provider === 'anthropic' && !isConfiguredAnthropicHost(apiUrl)) { + return ''; + } + return resolveStoredAiToken(aiProvider); } /* Accepts what the row menu sends (one id) and what the bulk menu sends (a @@ -3830,6 +3884,33 @@ function resolveOcrApiKey(apiKey) { ); } +/* resolveOcrApiKey() for a request that names its OCR provider. Claude OCR + must never receive a stored Mistral key: MISTRAL_API_KEY never belongs to + it, and OCR_API_KEY only when Claude is the OCR provider it was saved for. + An empty answer is fine — setupService falls back to the Anthropic AI key. + `apiUrl` is where the request goes; the stored key is withheld from any + host that is not already configured for Claude. */ +function resolveOcrApiKeyForProvider(provider, apiKey, apiUrl = '') { + const normalizedProvider = String(provider || '') + .trim() + .toLowerCase(); + if (normalizedProvider !== 'anthropic') { + return resolveOcrApiKey(apiKey); + } + + const normalizedApiKey = String(apiKey || '').trim(); + if (normalizedApiKey) { + return normalizedApiKey; + } + + const storedProvider = String(process.env.OCR_PROVIDER || '') + .trim() + .toLowerCase(); + return storedProvider === 'anthropic' && isConfiguredAnthropicHost(apiUrl) + ? process.env.OCR_API_KEY || '' + : ''; +} + async function validatePaperlessConnectionForSetup( paperlessUrl, paperlessToken @@ -3897,7 +3978,7 @@ async function validateAiConnectionForSetup({ if ( !provider || - !['openai', 'ollama', 'custom', 'azure'].includes(provider) + !['openai', 'ollama', 'custom', 'azure', 'anthropic'].includes(provider) ) { return { success: false, @@ -3985,6 +4066,29 @@ async function validateAiConnectionForSetup({ }; } + if (provider === 'anthropic') { + if (!normalizedToken) { + return { + success: false, + message: 'An API key is required for Anthropic.', + }; + } + + // The URL stays optional: empty means api.anthropic.com. + const valid = await setupService.validateAnthropicConfig( + normalizedToken, + normalizedModel || ANTHROPIC_DEFAULT_MODEL, + normalizedApiUrl + ); + + return { + success: valid, + message: valid + ? 'Anthropic API key and model are valid.' + : 'Anthropic test failed. Check the API key, the model and, if set, the proxy URL.', + }; + } + if (!normalizedApiUrl || !normalizedModel) { return { success: false, @@ -4031,6 +4135,9 @@ async function validateOcrConnectionForSetup({ apiKey, model, setupOcrValidationTimeoutMs, + anthropicApiKey, + anthropicApiUrl, + anthropicModel, }) { return setupService.withTemporaryValidationTimeout( setupOcrValidationTimeoutMs, @@ -4072,7 +4179,17 @@ async function validateOcrConnectionForSetup({ provider: normalizedProvider, apiUrl: resolvedApiUrl, apiKey: String(apiKey || '').trim(), - model: String(model || '').trim() || 'mistral-ocr-latest', + // Claude OCR picks its own model when this one is not a Claude model + // (see setupService.resolveAnthropicOcrModel), so the Mistral default + // must not be filled in for it. + model: + String(model || '').trim() || + (normalizedProvider === 'anthropic' ? '' : 'mistral-ocr-latest'), + // The Anthropic AI provider's key, URL and model, which Claude OCR + // falls back to; the environment covers them when they are saved. + anthropicApiKey, + anthropicApiUrl, + anthropicModel, }); return { @@ -4086,6 +4203,21 @@ async function validateOcrConnectionForSetup({ ); } +/* The model-discovery answer for Anthropic. The list is never empty — it + falls back to the suggested models — so the message has to say which list + the picker now shows, or a refused key would look like a working one. */ +function describeAnthropicModelDiscovery(discovery, apiUrl) { + return { + success: true, + models: discovery.models, + live: discovery.live, + resolvedApiUrl: apiUrl, + message: discovery.live + ? `Discovered ${discovery.models.length} model(s).` + : `Could not load the live model list (${discovery.error}). Showing the suggested Claude models instead.`, + }; +} + async function discoverAiModelsForSetup({ aiProvider, apiUrl, @@ -4101,6 +4233,16 @@ async function discoverAiModelsForSetup({ const normalizedApiUrl = String(apiUrl || '').trim(); const normalizedToken = String(token || '').trim(); + if (provider === 'anthropic') { + return describeAnthropicModelDiscovery( + await setupService.discoverAnthropicModels({ + apiKey: normalizedToken, + apiUrl: normalizedApiUrl, + }), + normalizedApiUrl + ); + } + // For local providers, classify models via quickstart detection so // embedding-only models are excluded from the AI model dropdown. if (['custom', 'ollama'].includes(provider) && normalizedApiUrl) { @@ -4162,6 +4304,9 @@ async function discoverOcrModelsForSetup({ apiUrl, apiKey, setupOcrValidationTimeoutMs, + anthropicApiKey, + anthropicApiUrl, + anthropicModel, }) { return setupService.withTemporaryValidationTimeout( setupOcrValidationTimeoutMs, @@ -4172,6 +4317,27 @@ async function discoverOcrModelsForSetup({ const normalizedApiUrl = String(apiUrl || '').trim(); const normalizedApiKey = String(apiKey || '').trim(); + // Claude OCR lists the same models as the AI provider, reached with the + // OCR key or the AI key, and preselects the model OCR would run with. + if (normalizedProvider === 'anthropic') { + const connection = setupService.resolveAnthropicOcrConnection({ + apiKey: normalizedApiKey, + apiUrl: normalizedApiUrl, + anthropicApiKey, + anthropicApiUrl, + }); + return { + ...describeAnthropicModelDiscovery( + await setupService.discoverAnthropicModels(connection), + normalizedApiUrl + ), + suggestedModel: setupService.resolveAnthropicOcrModel( + '', + anthropicModel + ), + }; + } + // For local providers, classify models via quickstart detection so // embedding-only models are excluded from the OCR dropdown // (metadata-first via LM Studio /api/v0/models or Ollama /api/show, @@ -4391,6 +4557,10 @@ router.get('/setup', async (req, res) => { AZURE_API_KEY: process.env.AZURE_API_KEY || '', AZURE_DEPLOYMENT_NAME: process.env.AZURE_DEPLOYMENT_NAME || '', AZURE_API_VERSION: process.env.AZURE_API_VERSION || '', + // The key is dropped by sanitizeConfigForBootstrap() before rendering. + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || '', + ANTHROPIC_API_URL: process.env.ANTHROPIC_API_URL || '', + ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL || ANTHROPIC_DEFAULT_MODEL, MISTRAL_OCR_ENABLED: process.env.MISTRAL_OCR_ENABLED || 'no', OCR_PROVIDER: process.env.OCR_PROVIDER || 'mistral', OCR_API_URL: process.env.OCR_API_URL || '', @@ -4464,6 +4634,7 @@ router.get('/setup', async (req, res) => { config: sanitizedConfig, success: successMessage, aiProviderPresets, + anthropicOptions: getAnthropicFormOptions(), defaults: { scanInterval: getDefaultScanInterval(), }, @@ -4475,6 +4646,7 @@ router.get('/setup', async (req, res) => { config: {}, error: 'An error occurred while loading the setup page.', aiProviderPresets, + anthropicOptions: getAnthropicFormOptions(), defaults: { scanInterval: getDefaultScanInterval(), }, @@ -4855,6 +5027,9 @@ router.post('/api/setup/ocr/test', express.json(), async (req, res) => { setupOcrValidationTimeoutMs: req.body?.setupOcrValidationTimeoutMs ?? req.body?.setupValidationTimeoutMs, + anthropicApiKey: req.body?.anthropicApiKey, + anthropicApiUrl: req.body?.anthropicApiUrl, + anthropicModel: req.body?.anthropicModel, }); return res.json(validation); @@ -4905,6 +5080,9 @@ router.post('/api/setup/ocr/test', express.json(), async (req, res) => { * type: string * nullable: true * description: Recommended default; absent for providers without model classification + * live: + * type: boolean + * description: anthropic only — false when the live list failed and the suggested Claude models are returned instead * resolvedApiUrl: * type: string * message: @@ -4923,7 +5101,14 @@ router.post('/api/setup/ocr/models', express.json(), async (req, res) => { apiUrl: req.body?.apiUrl, // The wizard used to take the field at face value, so an operator whose // key already sat in the environment was told to supply one. - apiKey: resolveOcrApiKey(req.body?.apiKey), + apiKey: resolveOcrApiKeyForProvider( + req.body?.provider, + req.body?.apiKey, + req.body?.apiUrl || req.body?.anthropicApiUrl + ), + anthropicApiKey: req.body?.anthropicApiKey, + anthropicApiUrl: req.body?.anthropicApiUrl, + anthropicModel: req.body?.anthropicModel, // Same key mismatch as in /api/setup/ocr/test above: the discovery // helper takes setupOcrValidationTimeoutMs. It matters here because // classifying an Ollama catalogue costs one /api/show per model and @@ -5019,13 +5204,16 @@ router.post( * properties: * aiProvider: * type: string - * enum: [openai, ollama, custom, azure] + * enum: [openai, ollama, custom, azure, anthropic] * apiUrl: * type: string + * description: Not needed for anthropic unless a proxy sits in front of api.anthropic.com * token: * type: string + * description: Omit or leave empty to use the stored key of the selected provider * model: * type: string + * description: For anthropic, checked with a models lookup that spends no tokens * setupValidationTimeoutMs: * type: integer * responses: @@ -5043,7 +5231,11 @@ router.post( const validation = await validateAiConnectionForSetup({ aiProvider: req.body?.aiProvider, apiUrl: req.body?.apiUrl, - token: resolveSettingsAiToken(req.body?.aiProvider, req.body?.token), + token: resolveSettingsAiToken( + req.body?.aiProvider, + req.body?.token, + req.body?.apiUrl + ), model: req.body?.model, azureApiVersion: req.body?.azureApiVersion, setupValidationTimeoutMs: req.body?.setupValidationTimeoutMs, @@ -5060,6 +5252,60 @@ router.post( } ); +/** + * @swagger + * /api/settings/ocr/test: + * post: + * summary: Test OCR provider connectivity from the settings page + * description: | + * Runs one OCR request on a generated test image. For `anthropic` the + * key is the OCR key when one is given or stored for Claude OCR, and + * otherwise the Anthropic AI provider's key (`anthropicApiKey`, then + * the stored ANTHROPIC_API_KEY). + * tags: + * - Settings + * security: + * - BearerAuth: [] + * - ApiKeyAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: string + * enum: ["yes", "no"] + * provider: + * type: string + * enum: [mistral, custom, ollama, anthropic] + * apiUrl: + * type: string + * description: Ignored for mistral. For anthropic an optional proxy URL; empty uses the Anthropic AI provider's URL, then api.anthropic.com + * apiKey: + * type: string + * description: Omit or leave empty to use the stored key + * model: + * type: string + * description: For anthropic, a Claude model; anything else falls back to the AI model + * setupOcrValidationTimeoutMs: + * type: integer + * anthropicApiKey: + * type: string + * description: Anthropic AI key typed but not saved yet; Claude OCR falls back to it + * anthropicApiUrl: + * type: string + * description: Anthropic AI proxy URL typed but not saved yet + * anthropicModel: + * type: string + * description: Anthropic AI model typed but not saved yet + * responses: + * 200: + * description: OCR connectivity result returned + * 401: + * description: Unauthorized + */ router.post( '/api/settings/ocr/test', isAuthenticated, @@ -5070,11 +5316,20 @@ router.post( enabled: req.body?.enabled, provider: req.body?.provider, apiUrl: req.body?.apiUrl, - apiKey: resolveOcrApiKey(req.body?.apiKey), + apiKey: resolveOcrApiKeyForProvider( + req.body?.provider, + req.body?.apiKey, + req.body?.apiUrl || req.body?.anthropicApiUrl + ), model: req.body?.model, setupOcrValidationTimeoutMs: req.body?.setupOcrValidationTimeoutMs ?? req.body?.setupValidationTimeoutMs, + // A key typed into the Anthropic AI fields but not saved yet; the + // stored one comes from the environment. + anthropicApiKey: req.body?.anthropicApiKey, + anthropicApiUrl: req.body?.anthropicApiUrl, + anthropicModel: req.body?.anthropicModel, }); return res.json(validation); @@ -5097,7 +5352,11 @@ router.post( const result = await discoverAiModelsForSetup({ aiProvider: req.body?.aiProvider, apiUrl: req.body?.apiUrl, - token: resolveSettingsAiToken(req.body?.aiProvider, req.body?.token), + token: resolveSettingsAiToken( + req.body?.aiProvider, + req.body?.token, + req.body?.apiUrl + ), setupValidationTimeoutMs: req.body?.setupValidationTimeoutMs, }); @@ -5154,14 +5413,23 @@ router.get('/api/settings/ai/presets', isAuthenticated, async (_req, res) => { * properties: * provider: * type: string - * enum: [mistral, custom, ollama] + * enum: [mistral, custom, ollama, anthropic] * apiUrl: * type: string * apiKey: * type: string - * description: Omit or leave empty to use the stored OCR_API_KEY / MISTRAL_API_KEY + * description: Omit or leave empty to use the stored OCR_API_KEY / MISTRAL_API_KEY (for anthropic, the stored Claude OCR key, then the Anthropic AI key) * setupOcrValidationTimeoutMs: * type: integer + * anthropicApiKey: + * type: string + * description: Anthropic AI key typed but not saved yet; used by anthropic when no OCR key is set + * anthropicApiUrl: + * type: string + * description: Anthropic AI proxy URL typed but not saved yet + * anthropicModel: + * type: string + * description: Anthropic AI model; anthropic returns it as suggestedModel * responses: * 200: * description: OCR model list returned @@ -5186,6 +5454,9 @@ router.get('/api/settings/ai/presets', isAuthenticated, async (_req, res) => { * type: string * nullable: true * description: Recommended default; absent for providers without model classification + * live: + * type: boolean + * description: anthropic only — false when the live list failed and the suggested Claude models are returned instead * resolvedApiUrl: * type: string * message: @@ -5204,7 +5475,14 @@ router.post( const result = await discoverOcrModelsForSetup({ provider: req.body?.provider, apiUrl: req.body?.apiUrl, - apiKey: resolveOcrApiKey(req.body?.apiKey), + apiKey: resolveOcrApiKeyForProvider( + req.body?.provider, + req.body?.apiKey, + req.body?.apiUrl || req.body?.anthropicApiUrl + ), + anthropicApiKey: req.body?.anthropicApiKey, + anthropicApiUrl: req.body?.anthropicApiUrl, + anthropicModel: req.body?.anthropicModel, setupOcrValidationTimeoutMs: req.body?.setupOcrValidationTimeoutMs ?? req.body?.setupValidationTimeoutMs, @@ -5408,15 +5686,25 @@ router.post('/api/setup/complete', express.json(), async (req, res) => { .trim() .toLowerCase(); const ocrApiUrlRaw = String(req.body?.ocrApiUrl || '').trim(); + // Mistral OCR has no URL field. For Claude OCR the field is an optional + // proxy URL: empty falls back to the Anthropic AI provider's URL, and + // that to api.anthropic.com. The wizard empties the field when Claude is + // picked, so a value here was typed for it. const ocrApiUrl = ocrProvider === 'mistral' ? '' : ocrApiUrlRaw; const ocrApiKey = String( req.body?.ocrApiKey || req.body?.mistralApiKey || '' ).trim(); + const anthropicAiSelected = aiProvider === 'anthropic'; const mistralOcrModel = - String(req.body?.mistralOcrModel || 'mistral-ocr-latest').trim() || - 'mistral-ocr-latest'; + ocrProvider === 'anthropic' + ? setupService.resolveAnthropicOcrModel( + req.body?.mistralOcrModel, + anthropicAiSelected ? aiModel : '' + ) + : String(req.body?.mistralOcrModel || 'mistral-ocr-latest').trim() || + 'mistral-ocr-latest'; - if (!['mistral', 'custom', 'ollama'].includes(ocrProvider)) { + if (!['mistral', 'custom', 'ollama', 'anthropic'].includes(ocrProvider)) { return res.status(400).json({ success: false, error: 'A valid OCR provider is required.', @@ -5431,6 +5719,20 @@ router.post('/api/setup/complete', express.json(), async (req, res) => { }); } + if ( + mistralOcrEnabled && + ocrProvider === 'anthropic' && + !ocrApiKey && + !(anthropicAiSelected && aiToken) && + !process.env.ANTHROPIC_API_KEY + ) { + return res.status(400).json({ + success: false, + error: + 'Claude OCR needs an Anthropic API key: enter one for OCR, or choose Anthropic as the AI provider.', + }); + } + if (!adminUsername || !adminPassword) { return res.status(400).json({ success: false, @@ -5462,7 +5764,7 @@ router.post('/api/setup/complete', express.json(), async (req, res) => { if ( !aiProvider || - !['openai', 'ollama', 'custom', 'azure'].includes(aiProvider) + !['openai', 'ollama', 'custom', 'azure', 'anthropic'].includes(aiProvider) ) { return res.status(400).json({ success: false, @@ -5509,6 +5811,9 @@ router.post('/api/setup/complete', express.json(), async (req, res) => { apiKey: ocrApiKey, model: mistralOcrModel, setupOcrValidationTimeoutMs, + anthropicApiKey: anthropicAiSelected ? aiToken : '', + anthropicApiUrl: anthropicAiSelected ? aiApiUrl : '', + anthropicModel: anthropicAiSelected ? aiModel : '', }); if (!ocrValidation.success) { @@ -5554,7 +5859,8 @@ router.post('/api/setup/complete', express.json(), async (req, res) => { OCR_PROVIDER: ocrProvider, OCR_API_URL: ocrApiUrl, OCR_API_KEY: ocrApiKey, - MISTRAL_API_KEY: ocrApiKey, + // An Anthropic key must not land where Mistral OCR would read it. + MISTRAL_API_KEY: ocrProvider === 'anthropic' ? '' : ocrApiKey, MISTRAL_OCR_MODEL: mistralOcrModel, SETUP_VALIDATION_TIMEOUT_MS: String(setupValidationTimeoutMs), SETUP_OCR_VALIDATION_TIMEOUT_MS: String(setupOcrValidationTimeoutMs), @@ -5572,6 +5878,11 @@ router.post('/api/setup/complete', express.json(), async (req, res) => { finalConfig.AZURE_API_KEY = aiToken; finalConfig.AZURE_DEPLOYMENT_NAME = aiModel; finalConfig.AZURE_API_VERSION = aiAzureApiVersion; + } else if (aiProvider === 'anthropic') { + finalConfig.ANTHROPIC_API_KEY = aiToken; + finalConfig.ANTHROPIC_MODEL = aiModel || ANTHROPIC_DEFAULT_MODEL; + // Only a proxy needs one; leaving it out keeps api.anthropic.com. + if (aiApiUrl) finalConfig.ANTHROPIC_API_URL = aiApiUrl; } else { finalConfig.CUSTOM_BASE_URL = aiApiUrl; finalConfig.CUSTOM_API_KEY = aiToken; @@ -6617,6 +6928,18 @@ router.get('/settings', async (req, res) => { AZURE_API_KEY: process.env.AZURE_API_KEY || '', AZURE_DEPLOYMENT_NAME: process.env.AZURE_DEPLOYMENT_NAME || '', AZURE_API_VERSION: process.env.AZURE_API_VERSION || '', + // Same defaults as config.anthropic, so a fresh instance shows the values + // the provider would actually run with. The key is blanked below and only + // reported through configuredSecrets. + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || '', + ANTHROPIC_API_URL: process.env.ANTHROPIC_API_URL || '', + ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL || ANTHROPIC_DEFAULT_MODEL, + ANTHROPIC_EFFORT: process.env.ANTHROPIC_EFFORT || '', + ANTHROPIC_MAX_TOKENS: + process.env.ANTHROPIC_MAX_TOKENS || String(ANTHROPIC_DEFAULT_MAX_TOKENS), + ANTHROPIC_STRUCTURED_OUTPUT: + process.env.ANTHROPIC_STRUCTURED_OUTPUT || 'yes', + ANTHROPIC_PROMPT_CACHING: process.env.ANTHROPIC_PROMPT_CACHING || 'yes', RESTRICT_TO_EXISTING_TAGS: process.env.RESTRICT_TO_EXISTING_TAGS || 'no', RESTRICT_TO_EXISTING_CORRESPONDENTS: process.env.RESTRICT_TO_EXISTING_CORRESPONDENTS || 'no', @@ -6778,6 +7101,7 @@ router.get('/settings', async (req, res) => { lockedEnvKeys, lockedEnvDetails, aiProviderPresets, + anthropicOptions: getAnthropicFormOptions(), mfaSettings, changelogReleases: changelog.releases, // No banner for the normal case: "already configured" was shown on every @@ -7536,6 +7860,26 @@ router.post('/manual/analyze', express.json(), async (req, res) => { id || [] ); return res.json(analyzeDocument); + } else if (process.env.AI_PROVIDER === 'anthropic') { + // Through the factory, which owns the Claude service like every scan + // does. Its tokens are billed like OpenAI's, so they are recorded too. + const analyzeDocument = + await AIServiceFactory.getService().analyzeDocument( + content, + existingTagsList, + existingCorrespondentList, + existingDocumentTypesList, + id || [] + ); + if (analyzeDocument.metrics && id) { + await documentModel.addOpenAIMetrics( + id, + analyzeDocument.metrics.promptTokens, + analyzeDocument.metrics.completionTokens, + analyzeDocument.metrics.totalTokens + ); + } + return res.json(analyzeDocument); } else { return res.status(500).json({ error: 'AI provider not configured' }); } @@ -7863,7 +8207,7 @@ router.get('/health', async (req, res) => { * aiProvider: * type: string * description: Selected AI provider for document analysis - * enum: ["openai", "ollama", "custom", "azure"] + * enum: ["openai", "ollama", "custom", "azure", "anthropic"] * example: "openai" * openaiKey: * type: string @@ -7897,6 +8241,38 @@ router.get('/health', async (req, res) => { * type: string * description: Model name for custom LLM provider * example: "custom-model" + * anthropicApiKey: + * type: string + * description: API key for Anthropic (required when aiProvider is 'anthropic' and none is stored; leave empty to keep the configured value). Saved only with aiProvider 'anthropic'; the anthropic* fields below likewise. + * example: "sk-ant-api03-abc123" + * anthropicModel: + * type: string + * description: Claude model for analysis; empty keeps the configured one + * example: "claude-sonnet-5-5" + * anthropicEffort: + * type: string + * description: Effort level; empty leaves it to the model. Rejected with 400 when it is not one of the listed levels. + * enum: ["", "low", "medium", "high", "xhigh", "max"] + * example: "" + * anthropicMaxTokens: + * type: integer + * minimum: 1 + * description: Completion cap per request, thinking included. Rejected with 400 when it is not a positive whole number; empty keeps the configured value. + * example: 16000 + * anthropicStructuredOutput: + * type: string + * description: Constrain the analysis answer to a JSON schema (yes/no) + * enum: ["yes", "no"] + * example: "yes" + * anthropicPromptCaching: + * type: string + * description: Mark the system prompt cacheable (yes/no) + * enum: ["yes", "no"] + * example: "yes" + * anthropicApiUrl: + * type: string + * description: Only for a proxy; empty uses api.anthropic.com + * example: "" * scanInterval: * type: number * description: Interval in minutes for scanning new documents @@ -8086,6 +8462,11 @@ router.get('/health', async (req, res) => { * maximum: 100 * description: How many entries the vocabulary proposed from the tag names should aim at, types and topics together (5-100, default 25). Out-of-range values are clamped, a non-numeric value keeps the current setting. * example: 25 + * ocrProvider: + * type: string + * description: OCR fallback provider. For anthropic (Claude OCR) an empty ocrApiKey uses the Anthropic AI key, and ocrApiUrl is an optional proxy URL; a key or URL stored for another provider is cleared when switching to it. + * enum: ["mistral", "custom", "ollama", "anthropic"] + * example: "mistral" * ocrAutoProcessEnabled: * type: string * description: Process queued OCR documents automatically (yes/no) @@ -8212,6 +8593,13 @@ router.post('/settings', express.json(), async (req, res) => { azureApiKey, azureDeploymentName, azureApiVersion, + anthropicApiKey, + anthropicModel, + anthropicEffort, + anthropicMaxTokens, + anthropicStructuredOutput, + anthropicPromptCaching, + anthropicApiUrl, tagCacheTTL, mistralOcrEnabled, ocrProvider, @@ -8318,6 +8706,16 @@ router.post('/settings', express.json(), async (req, res) => { AZURE_API_KEY: process.env.AZURE_API_KEY || '', AZURE_DEPLOYMENT_NAME: process.env.AZURE_DEPLOYMENT_NAME || '', AZURE_API_VERSION: process.env.AZURE_API_VERSION || '', + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || '', + ANTHROPIC_API_URL: process.env.ANTHROPIC_API_URL || '', + ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL || ANTHROPIC_DEFAULT_MODEL, + ANTHROPIC_EFFORT: process.env.ANTHROPIC_EFFORT || '', + ANTHROPIC_MAX_TOKENS: + process.env.ANTHROPIC_MAX_TOKENS || + String(ANTHROPIC_DEFAULT_MAX_TOKENS), + ANTHROPIC_STRUCTURED_OUTPUT: + process.env.ANTHROPIC_STRUCTURED_OUTPUT || 'yes', + ANTHROPIC_PROMPT_CACHING: process.env.ANTHROPIC_PROMPT_CACHING || 'yes', RESTRICT_TO_EXISTING_TAGS: process.env.RESTRICT_TO_EXISTING_TAGS || 'no', RESTRICT_TO_EXISTING_CORRESPONDENTS: process.env.RESTRICT_TO_EXISTING_CORRESPONDENTS || 'no', @@ -8396,18 +8794,42 @@ router.post('/settings', express.json(), async (req, res) => { const effectiveAzureApiKey = hasAzureApiKeyInput ? azureApiKey.trim() : currentConfig.AZURE_API_KEY; + const hasAnthropicApiKeyInput = hasValue(anthropicApiKey); + const effectiveAnthropicApiKey = hasAnthropicApiKeyInput + ? anthropicApiKey.trim() + : currentConfig.ANTHROPIC_API_KEY; const normalizedOcrApiKeyInput = hasValue(ocrApiKey) ? String(ocrApiKey).trim() : String(mistralApiKey || '').trim(); const hasOcrApiKeyInput = hasValue(normalizedOcrApiKeyInput); - const effectiveOcrApiKey = hasOcrApiKeyInput - ? normalizedOcrApiKeyInput - : currentConfig.OCR_API_KEY || currentConfig.MISTRAL_API_KEY || ''; const normalizedOcrProvider = String( ocrProvider || currentConfig.OCR_PROVIDER || 'mistral' ) .trim() .toLowerCase(); + // A stored OCR key belongs to the provider it was saved for. Claude OCR + // reads OCR_API_KEY before the Anthropic AI key, and Mistral OCR reads it + // before MISTRAL_API_KEY, so a key left behind when the provider crosses + // to or from Claude would be sent to the wrong vendor (a 401 at best). + // Such a key is cleared below unless a new one is typed; the same goes + // for a URL that would turn into a Claude proxy. + const ocrCrossesClaude = + (normalizedOcrProvider === 'anthropic') !== + (String(currentConfig.OCR_PROVIDER || 'mistral') + .trim() + .toLowerCase() === + 'anthropic'); + let storedOcrApiKey; + if (normalizedOcrProvider === 'anthropic') { + storedOcrApiKey = ocrCrossesClaude ? '' : currentConfig.OCR_API_KEY; + } else { + storedOcrApiKey = ocrCrossesClaude + ? currentConfig.MISTRAL_API_KEY + : currentConfig.OCR_API_KEY || currentConfig.MISTRAL_API_KEY; + } + const effectiveOcrApiKey = hasOcrApiKeyInput + ? normalizedOcrApiKeyInput + : storedOcrApiKey || ''; const effectiveOcrEnabled = hasValue(mistralOcrEnabled) ? String(mistralOcrEnabled).trim().toLowerCase() : String(currentConfig.MISTRAL_OCR_ENABLED || 'no') @@ -8421,9 +8843,14 @@ router.post('/settings', express.json(), async (req, res) => { 30000 ); - if (!['mistral', 'custom', 'ollama'].includes(normalizedOcrProvider)) { + if ( + !['mistral', 'custom', 'ollama', 'anthropic'].includes( + normalizedOcrProvider + ) + ) { return res.status(400).json({ - error: 'Invalid OCR provider. Allowed values are mistral and custom.', + error: + 'Invalid OCR provider. Allowed values are mistral, custom and anthropic.', }); } @@ -8438,6 +8865,27 @@ router.post('/settings', express.json(), async (req, res) => { }); } + // Claude OCR may borrow the Anthropic AI key, but only one that is saved: + // a key typed into the hidden Anthropic block while another AI provider is + // selected is not persisted (see the provider branch below). + const anthropicAiKeyForOcr = + String(aiProvider || currentConfig.AI_PROVIDER) + .trim() + .toLowerCase() === 'anthropic' + ? effectiveAnthropicApiKey + : currentConfig.ANTHROPIC_API_KEY; + if ( + effectiveOcrEnabled === 'yes' && + normalizedOcrProvider === 'anthropic' && + !effectiveOcrApiKey && + !anthropicAiKeyForOcr + ) { + return res.status(400).json({ + error: + 'Claude OCR needs an Anthropic API key: enter one for OCR, or configure the Anthropic AI provider.', + }); + } + // Saving deliberately runs no live OCR connection test — only the value // checks above. Use the "Test OCR Connection" button (/api/settings/ocr/test) // to verify connectivity on demand. @@ -8661,6 +9109,99 @@ router.post('/settings', express.json(), async (req, res) => { if (azureDeploymentName) updatedConfig.AZURE_DEPLOYMENT_NAME = azureDeploymentName; if (azureApiVersion) updatedConfig.AZURE_API_VERSION = azureApiVersion; + } else if (selectedAiProvider === 'anthropic') { + if (!effectiveAnthropicApiKey) { + return res.status(400).json({ + error: + 'Anthropic API key is required when the Anthropic provider is selected.', + }); + } + + // Empty is a value of its own here: it leaves the effort to the + // model. Anything else must be a level the provider knows, because + // the API rejects unknown ones on every request of a scan. + const normalizedAnthropicEffort = + anthropicEffort === undefined + ? currentConfig.ANTHROPIC_EFFORT + : String(anthropicEffort ?? '') + .trim() + .toLowerCase(); + if ( + normalizedAnthropicEffort && + !ANTHROPIC_EFFORT_LEVELS.includes(normalizedAnthropicEffort) + ) { + return res.status(400).json({ + error: `Invalid Anthropic effort. Allowed values are ${ANTHROPIC_EFFORT_LEVELS.join(', ')}, or empty for the model default.`, + }); + } + + // Same strictness as Token Limit: "16k" must not be read as 16. + const normalizedAnthropicMaxTokens = String( + anthropicMaxTokens ?? '' + ).trim(); + if ( + normalizedAnthropicMaxTokens && + (!/^\d+$/.test(normalizedAnthropicMaxTokens) || + Number(normalizedAnthropicMaxTokens) < 1) + ) { + return res.status(400).json({ + error: + 'Invalid Anthropic max tokens. Expected a positive whole number.', + }); + } + + const normalizedAnthropicApiUrl = + typeof anthropicApiUrl === 'string' ? anthropicApiUrl.trim() : null; + if (normalizedAnthropicApiUrl) { + let parsedAnthropicApiUrl = null; + try { + parsedAnthropicApiUrl = new URL(normalizedAnthropicApiUrl); + } catch { + parsedAnthropicApiUrl = null; + } + if ( + !parsedAnthropicApiUrl || + !['http:', 'https:'].includes(parsedAnthropicApiUrl.protocol) + ) { + return res.status(400).json({ + error: + 'Invalid Anthropic API URL. Expected an http(s) URL, or leave it empty for api.anthropic.com.', + }); + } + } + + // The switches arrive as the hidden yes/no inputs of the + // settings-switch partial; anything else keeps the stored value. + const anthropicSwitch = (rawValue, fallbackValue) => { + const normalizedValue = String(rawValue ?? '') + .trim() + .toLowerCase(); + return ['yes', 'no'].includes(normalizedValue) + ? normalizedValue + : String(fallbackValue); + }; + + if (hasAnthropicApiKeyInput) + updatedConfig.ANTHROPIC_API_KEY = effectiveAnthropicApiKey; + updatedConfig.ANTHROPIC_MODEL = + String(anthropicModel || '').trim() || + currentConfig.ANTHROPIC_MODEL || + ANTHROPIC_DEFAULT_MODEL; + updatedConfig.ANTHROPIC_EFFORT = normalizedAnthropicEffort; + if (normalizedAnthropicMaxTokens) + updatedConfig.ANTHROPIC_MAX_TOKENS = String( + Number(normalizedAnthropicMaxTokens) + ); + updatedConfig.ANTHROPIC_STRUCTURED_OUTPUT = anthropicSwitch( + anthropicStructuredOutput, + currentConfig.ANTHROPIC_STRUCTURED_OUTPUT + ); + updatedConfig.ANTHROPIC_PROMPT_CACHING = anthropicSwitch( + anthropicPromptCaching, + currentConfig.ANTHROPIC_PROMPT_CACHING + ); + if (normalizedAnthropicApiUrl !== null) + updatedConfig.ANTHROPIC_API_URL = normalizedAnthropicApiUrl; } } @@ -8940,16 +9481,43 @@ router.post('/settings', express.json(), async (req, res) => { updatedConfig.MISTRAL_OCR_ENABLED = mistralOcrEnabled; if (ocrProvider) updatedConfig.OCR_PROVIDER = String(ocrProvider).trim().toLowerCase(); + const submittedOcrApiUrl = + typeof ocrApiUrl === 'string' ? ocrApiUrl.trim() : null; if (normalizedOcrProvider === 'mistral') { updatedConfig.OCR_API_URL = ''; - } else if (typeof ocrApiUrl === 'string') { - updatedConfig.OCR_API_URL = ocrApiUrl.trim(); + } else if (normalizedOcrProvider === 'anthropic' && ocrCrossesClaude) { + // For Claude OCR the URL is an optional proxy. The one stored for the + // previous provider (an auto-detected Ollama URL, say) would be used as + // one, so only a URL that differs from it counts as typed for Claude; + // empty falls back to ANTHROPIC_API_URL. + updatedConfig.OCR_API_URL = + submittedOcrApiUrl && submittedOcrApiUrl !== currentConfig.OCR_API_URL + ? submittedOcrApiUrl + : ''; + } else if (submittedOcrApiUrl !== null) { + updatedConfig.OCR_API_URL = submittedOcrApiUrl; } if (hasOcrApiKeyInput) { updatedConfig.OCR_API_KEY = effectiveOcrApiKey; - updatedConfig.MISTRAL_API_KEY = effectiveOcrApiKey; + // An Anthropic key must not land where Mistral OCR would read it. + if (normalizedOcrProvider !== 'anthropic') { + updatedConfig.MISTRAL_API_KEY = effectiveOcrApiKey; + } + } else if (ocrCrossesClaude) { + updatedConfig.OCR_API_KEY = ''; + } + if (normalizedOcrProvider === 'anthropic') { + // Stored as the model Claude OCR will run with, so the page shows it + // instead of a leftover Mistral model name. + if (mistralOcrModel || ocrCrossesClaude) { + updatedConfig.MISTRAL_OCR_MODEL = setupService.resolveAnthropicOcrModel( + mistralOcrModel, + updatedConfig.ANTHROPIC_MODEL || currentConfig.ANTHROPIC_MODEL + ); + } + } else if (mistralOcrModel) { + updatedConfig.MISTRAL_OCR_MODEL = mistralOcrModel; } - if (mistralOcrModel) updatedConfig.MISTRAL_OCR_MODEL = mistralOcrModel; if (typeof ocrPdfRenderEnabled === 'string') { const normalizedPdfRenderEnabled = ocrPdfRenderEnabled .trim() @@ -9430,6 +9998,9 @@ router.get('/about', protectApiRoute, async (req, res) => { azureEndpoint: configFile.azure?.endpoint || 'unknown', azureDeploymentName: configFile.azure?.deploymentName || 'unknown', azureApiVersion: configFile.azure?.apiVersion || 'unknown', + anthropicModel: configFile.anthropic?.model || 'unknown', + // Empty means api.anthropic.com, which is worth saying in a bug report. + anthropicApiUrl: configFile.anthropic?.apiUrl || 'default', ocrProvider: configFile.mistralOcr?.provider || 'mistral', mistralOcrModel: configFile.mistralOcr?.model || 'unknown', scanInterval: configFile.scanInterval || 'unknown', @@ -9446,6 +10017,7 @@ router.get('/about', protectApiRoute, async (req, res) => { openAiKeySet: Boolean(configFile.openai?.apiKey), customKeySet: Boolean(configFile.custom?.apiKey), azureKeySet: Boolean(configFile.azure?.apiKey), + anthropicKeySet: Boolean(configFile.anthropic?.apiKey), mistralKeySet: Boolean(configFile.mistralOcr?.apiKey), apiKeySet: Boolean(configFile.getApiKey && configFile.getApiKey()), }; @@ -9479,6 +10051,8 @@ router.get('/about', protectApiRoute, async (req, res) => { azureEndpoint: configFile.azure?.endpoint || 'unknown', azureDeploymentName: configFile.azure?.deploymentName || 'unknown', azureApiVersion: configFile.azure?.apiVersion || 'unknown', + anthropicModel: configFile.anthropic?.model || 'unknown', + anthropicApiUrl: configFile.anthropic?.apiUrl || 'default', ocrProvider: configFile.mistralOcr?.provider || 'mistral', mistralOcrModel: configFile.mistralOcr?.model || 'unknown', scanInterval: configFile.scanInterval || 'unknown', @@ -9495,6 +10069,7 @@ router.get('/about', protectApiRoute, async (req, res) => { openAiKeySet: Boolean(configFile.openai?.apiKey), customKeySet: Boolean(configFile.custom?.apiKey), azureKeySet: Boolean(configFile.azure?.apiKey), + anthropicKeySet: Boolean(configFile.anthropic?.apiKey), mistralKeySet: Boolean(configFile.mistralOcr?.apiKey), apiKeySet: Boolean(configFile.getApiKey && configFile.getApiKey()), }, diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 65a4db53..00238656 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -157,10 +157,22 @@ const TESTS = { 'review-sheet': 'test-review-sheet.js', 'review-assist': 'test-review-assist.js', 'review-voice': 'test-review-voice.js', + 'anthropic-support': 'test-anthropic-support.js', + 'anthropic-provider': 'test-anthropic-provider.js', + 'anthropic-settings': 'test-anthropic-settings.js', + 'ocr-provider-anthropic': 'test-ocr-provider-anthropic.js', + 'settings-save-session-secret': 'test-settings-save-session-secret.js', + 'session-guard': 'test-session-guard.js', }; const AREAS = { - auth: ['login-mfa-flow', 'rate-limiting', 'thumbnail-auth-guard'], + auth: [ + 'login-mfa-flow', + 'rate-limiting', + 'thumbnail-auth-guard', + 'settings-save-session-secret', + 'session-guard', + ], ocr: [ 'ocr-fallback-ai-errors', 'ocr-startup-recovery', @@ -290,6 +302,12 @@ const AREAS = { 'simplify-assistant-ui', ], review: ['review-sheet', 'review-assist', 'review-voice'], + anthropic: [ + 'anthropic-support', + 'anthropic-provider', + 'anthropic-settings', + 'ocr-provider-anthropic', + ], }; // Tests intentionally excluded from the auto-discovery drift check below. diff --git a/server.js b/server.js index a7e1bc22..57ac7807 100644 --- a/server.js +++ b/server.js @@ -362,6 +362,9 @@ app.use((req, res, next) => { res.locals.appAzureEndpoint = config.azure?.endpoint || 'unknown'; res.locals.appAzureDeploymentName = config.azure?.deploymentName || 'unknown'; res.locals.appAzureApiVersion = config.azure?.apiVersion || 'unknown'; + res.locals.appAnthropicModel = config.anthropic?.model || 'unknown'; + // Empty means api.anthropic.com, which is worth saying in a bug report. + res.locals.appAnthropicApiUrl = config.anthropic?.apiUrl || 'default'; res.locals.appMistralOcrModel = config.mistralOcr?.model || 'unknown'; res.locals.appScanInterval = config.scanInterval || 'unknown'; res.locals.appTokenLimit = String(config.tokenLimit || 'unknown'); @@ -377,6 +380,7 @@ app.use((req, res, next) => { res.locals.appOpenAiKeySet = Boolean(config.openai?.apiKey); res.locals.appCustomKeySet = Boolean(config.custom?.apiKey); res.locals.appAzureKeySet = Boolean(config.azure?.apiKey); + res.locals.appAnthropicKeySet = Boolean(config.anthropic?.apiKey); res.locals.appMistralKeySet = Boolean(config.mistralOcr?.apiKey); res.locals.appApiKeySet = Boolean(config.getApiKey && config.getApiKey()); res.locals.loginCookieSecurityWarning = null; diff --git a/services/aiGenerateOptions.js b/services/aiGenerateOptions.js index b8f05a78..40f1cce7 100644 --- a/services/aiGenerateOptions.js +++ b/services/aiGenerateOptions.js @@ -5,6 +5,10 @@ const { estimateTokenCount, jsonFromText, } = require('./serviceUtils'); +const { + assertAnthropicCompleted, + mapAnthropicUsage, +} = require('./anthropicSupport'); /** * The optional second argument of `generateText(prompt, options)`. @@ -673,6 +677,134 @@ async function runChatCompletionStream({ return text; } +/** + * Reads a Claude Messages stream into a collector. + * + * The Messages API streams typed events instead of chat chunks: a text block's + * deltas are the answer, a thinking block's deltas are reasoning (empty under + * the default `display: "omitted"`, so a thinking model is often only heard + * once it answers), and the stop reason and usage arrive with the final + * message, which the caller reads off the stream afterwards. + * + * Returns as soon as the signal is aborted; leaving the loop early makes the + * SDK abort the request, so the server sees the connection go. + * + * @param {AsyncIterable