docs(self-hosting): AI runs on AWS Bedrock, not ANTHROPIC/OPENAI keys (#1407)

* docs(self-hosting): AI runs on AWS Bedrock, not ANTHROPIC/OPENAI keys

A self-hoster followed SELF-HOSTING.md, set ANTHROPIC_API_KEY and
OPENAI_API_KEY, and found document interpretation dead (with a 30s
extraction-poll hang per upload). Neither key has been read since the
ai-chat / receipt-ocr / ai-categorization extensions were removed in
PR #157: all AI (document extraction and the assistant) goes through
Claude on AWS Bedrock via AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.

- SELF-HOSTING.md, DOCKER.md, .env.example: document the Bedrock
  credentials and model overrides; point plain-key support at #1406
- SELF-HOSTING.md: drop the receipts-bucket setup for the removed
  receipt-ocr extension
- EXTENSIONS.md: replace removed extensions with live ones in trees
  and examples; drop the AI-consent system claims (lib/extensions/
  ai-consent.ts no longer exists); services-pattern example now uses
  the real stripe/skatteverket services
- lib/init.ts: startup env warning now checks the AWS keys instead of
  the two dead vars, so a misconfigured self-host logs the truth

Direct Anthropic API key support and pluggable providers: #1406.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: address review, dedupe tree entry and match provider-chain claim to code

extractInvoiceFields bails without both static AWS keys, so only the
assistant client actually falls back to the credential provider chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-04 20:17:25 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 86c6af6976
commit af9db54405
5 changed files with 67 additions and 68 deletions
+7 -3
View File
@@ -38,9 +38,13 @@ CRON_SECRET=generate-a-random-secret
# AUTH_SIGNUPS_DISABLED=false
# ── Optional: extension features (core runs without these) ─
# AI features
# ANTHROPIC_API_KEY=
# OPENAI_API_KEY=
# AI features: Claude via AWS Bedrock (document extraction + AI assistant).
# Needs an AWS account with Bedrock model access to Claude. Plain
# ANTHROPIC_API_KEY is NOT supported yet, see issue #1406.
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# AWS_REGION=eu-north-1
# BEDROCK_MODEL_ID=
# Bank connections (Enable Banking)
# ENABLE_BANKING_APP_ID=
# ENABLE_BANKING_PRIVATE_KEY=
+8 -3
View File
@@ -168,13 +168,18 @@ If you already have nginx / a managed load balancer / Cloudflare in front, skip
The self-hosted image ships with all extensions enabled (except Enable Banking, which requires private PSD2 credentials). Each extension activates when you provide its env vars: without them, the app works normally and the feature is simply unavailable.
### AI Features (ai-categorization, ai-chat, receipt-ocr, invoice-inbox)
### AI Features (document-extraction, invoice-inbox, AI assistant)
All AI runs Claude via AWS Bedrock; provide AWS credentials with Bedrock model access to Claude:
```env
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=eu-north-1
```
`ANTHROPIC_API_KEY` and `OPENAI_API_KEY` from earlier versions are no longer used (plain-key support is tracked in [#1406](https://github.com/erp-mafia/accounted/issues/1406)). See [SELF-HOSTING.md](./SELF-HOSTING.md#ai-features) for optional model overrides.
### Email (invoice sending, reminders)
```env
+33 -37
View File
@@ -56,12 +56,12 @@ Both general and sector extensions live in the same system:
```
extensions/
general/ ← General extensions (any business)
receipt-ocr/
ai-categorization/
ai-chat/
document-extraction/
invoice-inbox/
mcp-server/
skatteverket/
push-notifications/
enable-banking/
invoice-inbox/
calendar/
email/
restaurant/ ← Restaurant sector extensions
@@ -298,16 +298,15 @@ type ExtensionCategory = 'accounting' | 'reports' | 'import' | 'operations'
```
extensions/ ← Extension source code (opt-in via config)
general/ ← General extensions
receipt-ocr/
invoice-inbox/
manifest.json ← Metadata, entry point, env vars, workspace path
index.ts ← Extension definition + logic (exports Extension)
lib/
__tests__/
ai-categorization/
document-extraction/
manifest.json
index.ts
lib/
ai-chat/
skatteverket/
manifest.json
index.ts
lib/
@@ -323,9 +322,6 @@ extensions/ ← Extension source code (opt-in via conf
manifest.json
index.ts
lib/
invoice-inbox/
manifest.json
index.ts
calendar/
manifest.json
index.ts
@@ -465,13 +461,11 @@ To check if an extension is compiled in at runtime (e.g. for conditional UI), us
```typescript
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
if (ENABLED_EXTENSION_IDS.has('receipt-ocr')) {
// Show OCR UI
if (ENABLED_EXTENSION_IDS.has('document-extraction')) {
// Show AI extraction status UI
}
```
AI extensions (`receipt-ocr`, `ai-categorization`, `ai-chat`) additionally require per-user AI consent before making API calls. This is a separate system using the `extension_data` table, managed by `lib/extensions/ai-consent.ts`.
> **Note:** The `extension_toggles` database table still exists but is no longer queried by any code. It can be dropped in a future migration.
### API Routes for Extensions
@@ -536,8 +530,7 @@ The sidebar (`DashboardNav.tsx`) has a section for extensions. It shows all comp
```
── Your Extensions ──────────
📷 Receipt OCR → /e/general/receipt-ocr
🤖 AI Categorization → /e/general/ai-categorization
📥 Dokumentinkorg → /e/general/invoice-inbox
📊 Food Cost % → /e/restaurant/food-cost
🍷 Earnings Per Liter → /e/restaurant/earnings-per-liter
```
@@ -594,9 +587,9 @@ The previous architecture had extensions "always loaded" via hardcoded static im
1. **Extension opt-in system** -- Extensions are configured via `extensions.config.json`. A generator script (`npm run setup:extensions`) reads manifest files and produces `lib/extensions/_generated/` files. Core compiles and runs with an empty config (zero extensions).
2. **Services pattern** -- Extensions can expose named services via `services?: Record<string, (...args: any[]) => Promise<any>>` on the Extension interface. Core code uses `extensionRegistry.get('ext-id')?.services?.methodName` for runtime lookup instead of direct imports. This is how `ai-categorization` provides template embedding functions to core booking logic.
2. **Services pattern** -- Extensions can expose named services via `services?: Record<string, (...args: any[]) => Promise<any>>` on the Extension interface. Core code uses `extensionRegistry.get('ext-id')?.services?.methodName` for runtime lookup instead of direct imports. This is how `skatteverket` exposes declaration submission to core pending-operation commits, and how `stripe` provides invoice payment links.
3. **Catch-all API dispatcher** -- Extension API routes are registered via `apiRoutes: ApiRouteDefinition[]` on the Extension object. The catch-all at `/api/extensions/ext/[...path]/route.ts` handles auth, AI consent checks, path param extraction, and dispatches to the handler. URL pattern: `/api/extensions/ext/{extensionId}/{path}`.
3. **Catch-all API dispatcher** -- Extension API routes are registered via `apiRoutes: ApiRouteDefinition[]` on the Extension object. The catch-all at `/api/extensions/ext/[...path]/route.ts` handles auth, path param extraction, and dispatches to the handler. URL pattern: `/api/extensions/ext/{extensionId}/{path}`.
4. **SRU/NE-bilaga are core** -- These tax compliance features were moved from `extensions/` into `lib/reports/sru-export/` and `lib/reports/ne-bilaga/`. They are always available regardless of extension configuration.
@@ -630,7 +623,7 @@ The previous architecture had extensions "always loaded" via hardcoded static im
| Shared UI components | Done | KPICard, DataEntryForm, DateRangeFilter, etc. |
| Extension API routes (generic CRUD) | Done | `app/api/extensions/[sector]/[slug]/` |
| Catch-all API dispatcher | Done | `app/api/extensions/ext/[...path]/route.ts` |
| Services pattern | Done | Used by ai-categorization for template embeddings |
| Services pattern | Done | Used by skatteverket (declaration submission) and stripe (payment links) |
| Email service interface | Done | `lib/email/service.ts` + email extension |
| SRU/NE-bilaga moved to core | Done | `lib/reports/sru-export/` + `lib/reports/ne-bilaga/` |
| Manifest files for all extensions | Done | 25 manifest.json files |
@@ -679,15 +672,16 @@ npx tsx scripts/generate-extension-registry.ts --list
# 2. Edit extensions.config.json: add extension IDs
{
"extensions": ["receipt-ocr", "ai-categorization", "email"]
"extensions": ["invoice-inbox", "document-extraction", "email"]
}
# 3. Regenerate (also runs automatically on build/dev)
npm run setup:extensions
# 4. Set extension-specific env vars (check each manifest.json for requiredEnvVars)
export ANTHROPIC_API_KEY=...
export OPENAI_API_KEY=...
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_REGION=eu-north-1
export RESEND_API_KEY=...
export RESEND_FROM_EMAIL=...
@@ -769,15 +763,15 @@ Extensions with `exportName: null` and `entryPoint: null` are metadata-only -- t
Extensions can expose named services for cross-boundary calls. This avoids direct imports from extension code into core:
```typescript
// Extension: ai-categorization/index.ts
export const aiCategorizationExtension: Extension = {
id: 'ai-categorization',
name: 'AI Categorization',
// Extension: stripe/index.ts
export const stripeExtension: Extension = {
id: 'stripe',
name: 'Stripe',
version: '1.0.0',
services: {
findSimilarTemplates: async (description: string, limit?: number) => {
// ... embedding search logic
return matches
createInvoicePaymentLink: async (companyId: string, invoiceId: string) => {
// ... eligibility checks + Stripe API call
return { url, externalId }
},
},
}
@@ -787,16 +781,18 @@ Core code calls the service via registry lookup:
```typescript
// Core code: no direct import from extensions/
const ext = extensionRegistry.get('ai-categorization')
const results = await ext?.services?.findSimilarTemplates(description, 5)
if (results) {
// Use results
// (real example: lib/extensions/payment-links.ts)
const ext = extensionRegistry.get('stripe')
const link = await ext?.services?.createInvoicePaymentLink(companyId, invoiceId)
if (link) {
// Use link
}
// Gracefully degrades if extension is not loaded
```
This pattern is used for:
- `ai-categorization` providing template embedding search to core booking suggestions
- `stripe` providing invoice payment links to the core invoice send routes (`lib/extensions/payment-links.ts`)
- `skatteverket` exposing declaration submission to core pending-operation commits
- Any extension that needs to provide functionality callable by core without a direct dependency
### Event Bus Usage
@@ -861,10 +857,10 @@ This pattern can be reused for any capability that should degrade gracefully whe
**Core functionality** is the standard accounting system: bookkeeping, invoicing, reports, tax (SRU, NE-bilaga), bank reconciliation. Every user gets this. Core compiles and runs with zero extensions.
**Extensions** are everything beyond core accounting. They come in three kinds:
- **General extensions** (receipt-ocr, ai-categorization, email, etc.) -- useful for any business, not sector-specific
- **General extensions** (invoice-inbox, document-extraction, email, etc.) -- useful for any business, not sector-specific
- **Sector extensions** (food cost %, earnings per liter, etc.) -- tied to a specific market sector
- **Export extensions** (EU Sales List, Intrastat, etc.) -- for businesses with international trade
All extensions live in the same system, appear in the same marketplace, and show up in the sidebar when they have a workspace + quickAction. All compiled extensions are active for all users -- the operator chooses which to include in `extensions.config.json` at build time. AI extensions additionally require per-user consent before making API calls.
All extensions live in the same system, appear in the same marketplace, and show up in the sidebar when they have a workspace + quickAction. All compiled extensions are active for all users -- the operator chooses which to include in `extensions.config.json` at build time.
Extensions are read-only with respect to the core accounting system. They can be fed accounting data, they can accept manual user input, but they never write back to the bookkeeping. They can expose services to core via the registry lookup pattern, and they can register API routes that are dispatched by the catch-all handler.
+11 -23
View File
@@ -188,27 +188,22 @@ Additionally, migration 048 schedules a `pg_cron` job inside the database that m
### AI Features
The self-hosted Docker image includes these AI-powered extensions: receipt OCR, AI categorization, AI chat, and invoice inbox. To enable them, add API keys to your `.env`:
All AI features (automatic interpretation of uploaded receipts and invoices via the `document-extraction` and `invoice-inbox` extensions, and the in-app AI assistant) run Claude via AWS Bedrock. To enable them, add AWS credentials for an account with Bedrock model access to Claude:
```bash
ANTHROPIC_API_KEY=sk-ant-... # Required for all AI features
OPENAI_API_KEY=sk-... # Required for embedding-based features (categorization, chat)
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=eu-north-1 # default; keeps inference in the EU
BEDROCK_MODEL_ID=eu.anthropic.claude-sonnet-5 # optional: document extraction model
BEDROCK_OPUS_MODEL_ID=... # optional: assistant model, heavy intents
BEDROCK_SONNET_MODEL_ID=... # optional: assistant model, standard intents
```
Each user must individually grant AI consent in the UI before AI features activate (per GDPR requirements).
Set the two static keys explicitly. The AI assistant's client can fall back to the standard AWS credential provider chain (instance profile, IRSA) when they are absent, but document extraction requires `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` and silently returns empty results without them.
**AI chat knowledge base** (optional): The AI chat can answer Swedish tax and accounting questions using a RAG knowledge base. To populate it, create `dev_docs/ai_knowledge_base/` with markdown files and run:
Without working credentials the rest of the app runs normally: uploads are stored but not auto-interpreted, and the AI assistant cannot answer.
```bash
npx tsx extensions/general/ai-chat/ingestion/ingest.ts
```
**Booking template embeddings** (optional): For AI-powered transaction categorization suggestions, seed the template embeddings by calling:
```bash
curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
https://your-domain.com/api/admin/seed-template-embeddings
```
> **Note:** `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` from earlier versions are no longer read by any code path. Support for a plain Anthropic API key (and pluggable providers) is tracked in [#1406](https://github.com/erp-mafia/accounted/issues/1406).
### Email (Invoice Sending and Reminders)
@@ -240,14 +235,7 @@ Sentry is disabled if these are not set. No errors are thrown.
## Storage Buckets
Migration 024 automatically creates the `documents` storage bucket (private, 50 MB limit, WORM, no update/delete).
If you enable the **receipt-ocr** extension, you must manually create a `receipts` storage bucket in the Supabase dashboard:
1. Go to **Storage** in the Supabase dashboard.
2. Create a new bucket named `receipts`.
3. Set it as **public** (receipt images are referenced by public URL).
4. Set an appropriate file size limit (e.g., 10 MB).
Migration 024 automatically creates the `documents` storage bucket (private, 50 MB limit, WORM, no update/delete). No other buckets need to be created manually.
## Updating
+8 -2
View File
@@ -26,11 +26,17 @@ const REQUIRED_CORE_VARS = [
// fallback in extensions/general/enable-banking/lib/jwt.ts (_PRODUCTION ||
// base) so Vercel prod (which only sets the _PRODUCTION variants) doesn't
// warn on every cold start.
// AI features run Claude via AWS Bedrock (see lib/agent/composer/client.ts and
// extensions/general/invoice-inbox/lib/extract-invoice-fields.ts), so the
// static AWS keys are what actually gates them. The assistant's client can
// fall back to the AWS credential provider chain (instance profile, IRSA),
// but document extraction requires both static keys, so this log-only warning
// stays useful even on AWS infrastructure.
const REQUIRED_EXTENSION_VARS: ReadonlyArray<readonly string[]> = [
['ENABLE_BANKING_APP_ID_PRODUCTION', 'ENABLE_BANKING_APP_ID'],
['ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_PRIVATE_KEY'],
['ANTHROPIC_API_KEY'],
['OPENAI_API_KEY'],
['AWS_ACCESS_KEY_ID'],
['AWS_SECRET_ACCESS_KEY'],
] as const
function validateEnvironment(): void {