fix(ai): complete plain-key self-hosting path (#1584)
* feat(ai): resolve the Claude backend from the environment Tier 1 of #1406: a self-hosted deployment can now run every AI feature on a plain ANTHROPIC_API_KEY, with no AWS account. Hosted behaviour is unchanged. lib/ai/provider.ts resolves the backend once, from the environment: AI_PROVIDER explicit override, bedrock|anthropic AWS static key pair Bedrock ANTHROPIC_API_KEY the direct Anthropic API nothing set Bedrock, so the AWS credential provider chain (instance profile, IRSA) still resolves Bedrock deliberately wins when both credential sets are present. EU residency in eu-north-1 is a BFL/GDPR posture rather than a default, so adding an Anthropic key for an experiment must not silently move production inference out of the region. AI_PROVIDER is the way to say you meant it. Model ids are written bare in code and prefixed to eu.anthropic.* only for Bedrock, which needs the cross-region inference profile for on-demand throughput. An operator override that already carries a prefix passes through untouched, so BEDROCK_MODEL_ID and friends keep working as written. Converted call sites: the agent composer, invoice-inbox extraction, the document-extraction model label, and both receipt-hunt clients. The last two are not named in the issue, which predates receipt-hunt landing in main. @anthropic-ai/sdk is declared at 0.95.0, the version @anthropic-ai/bedrock-sdk 0.29.1 already pulled in transitively, so the lockfile dedupes to one copy with no new download. scripts/smoke-bedrock.ts becomes scripts/smoke-ai.ts and grows two steps. Unit tests can only prove which provider and model id get resolved; they cannot prove the resulting request is one the backend accepts. The script now sends real traffic over all three shapes the app uses: a plain create, a streamed turn carrying adaptive thinking, an effort level, an hour-long cache breakpoint and a tool, and document extraction end to end when given a file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * docs(self-hosting): document the AI smoke test The script added alongside the provider split is what closes the #1406 acceptance criterion ("document extraction and the assistant both work"), so a self-hoster needs to know it exists. Covers both invocations and states that it exits non-zero, which is what makes it usable as a post-deploy check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * test(ai): split the smoke test's thinking probe from its tool probe The combined probe could not falsify what it claimed to. It asked a question that needs a tool call, so the tool was used and adaptive thinking correctly declined to reason about it: the zero thinking-block count that came back was uninformative rather than a signal. 2a keeps the tool and drops thinking. 2b asks a question with several dependent steps (reverse charge, then a partial deduction, then the affected boxes) so that a model honouring the parameter must reason, and reports the thinking text length as well as the block count, since display:"summarized" can yield blocks with empty text. The cached system prompt is also padded past the 1024-token minimum cacheable prefix. Below that the API caches nothing and reports no error, so the old probe's cache counters read zero whether or not caching worked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * fix(document-extraction): stop requiring AWS_REGION in the manifest The extension now needs one of two credential sets, AWS static keys or ANTHROPIC_API_KEY, and the manifest schema cannot express "one of". Since requiredEnvVars only drives a build-time warning and never gates anything, listing AWS_REGION told every self-hoster running the direct API to set a variable that has no effect for them. The description was also still promising Sonnet 4.6 via Bedrock specifically, which is no longer what the extension does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * fix(ai): read documentKind defensively in the smoke test The field arrived with the receipt-aware extraction work, so referencing it directly stops the script compiling against any checkout from before that landed. tsconfig includes **/*.ts and next.config does not disable type checking, so on such a checkout this failed the production build rather than just the script: caught while preparing a test branch for a self-hosted instance that had not synced yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * fix(deps): restore the nested @swc/helpers entry in the lockfile Declaring @anthropic-ai/sdk with `npm install --package-lock-only` also pruned node_modules/next-intl/node_modules/@swc/helpers@0.5.23, an optional peer entry the local npm 11 considers redundant and the image's npm 10.9.8 does not. The result passed every local check and failed `npm ci` inside the Docker build, which is the only place the lockfile is actually enforced. The lockfile is now the previous one plus the single root dependency line, verified with `npm ci --dry-run`. @anthropic-ai/sdk needed nothing else: it was already in the tree as a transitive dependency of @anthropic-ai/bedrock-sdk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * Update DECISIONS.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update Docker documentation for AI provider credentials Clarify the role of credentials in AI provider selection and document extraction requirements. * Update SELF-HOSTING.md with smoke-ai script details Clarify usage of smoke-ai script for credential checks and document extraction. * Improve error handling and logging in smoke-ai script * fix(ai): complete plain-key self-hosting path Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5
coderabbitai[bot]
Bjorn Bergenheim
parent
0c3864cae5
commit
3829b6add3
+12
-3
@@ -61,13 +61,22 @@ RECEIPT_HUNT_COMPANY_IDS=
|
||||
# NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=true
|
||||
|
||||
# ── Optional: extension features (core runs without these) ─
|
||||
# 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.
|
||||
# AI features (document extraction + AI assistant). Two ways to provide a key;
|
||||
# set one of them. AI_PROVIDER (bedrock|anthropic) forces the choice if both
|
||||
# are present, which otherwise resolves to Bedrock.
|
||||
#
|
||||
# 1. Claude via AWS Bedrock. Needs an AWS account with Bedrock model access to
|
||||
# Claude. Keeps inference in eu-north-1, which is what hosted runs.
|
||||
# AWS_ACCESS_KEY_ID=
|
||||
# AWS_SECRET_ACCESS_KEY=
|
||||
# AWS_REGION=eu-north-1
|
||||
# BEDROCK_MODEL_ID=
|
||||
#
|
||||
# 2. Claude via the direct Anthropic API. No AWS account needed, so this is
|
||||
# usually the self-hosted option. Note that it has no EU-residency
|
||||
# guarantee: use Bedrock if you need one.
|
||||
# ANTHROPIC_API_KEY=
|
||||
# AI_PROVIDER=
|
||||
# Bank connections (Enable Banking)
|
||||
# ENABLE_BANKING_APP_ID=
|
||||
# ENABLE_BANKING_PRIVATE_KEY=
|
||||
|
||||
@@ -913,3 +913,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-13] Agent float panel: contain-on-open/resize via containFloatRect; clampFloatRect and the mid-session hang-off-the-edge allowance deliberately untouched.
|
||||
[2026-08-13] Issue #1408 audit aggregates 5820, 6072, and 2641 lines per journal entry and returns line-id arrays: individual line pairing creates false 25 percent matches on multi-line vouchers, while journal lines have no vat_rate field and tax_code is only non-operative metadata.
|
||||
[2026-08-13] Issue #1408 audit aggregates 5820, 6072, and 2641 lines per journal entry and returns line-id arrays: individual line pairing creates false 25 percent matches on multi-line vouchers, while dropping non-exact 6072 plus 2641 ratios misses defective representation hidden in mixed vouchers, so those remain as lower-priority manual review. Journal lines have no vat_rate field and tax_code is only non-operative metadata.
|
||||
[2026-08-11] AI credentials: added lib/ai/provider.ts, which resolves the backend from the environment (Tier 1 of issue #1406), instead of swapping Bedrock out for the direct Anthropic API. The swap was the smaller change but would have been impossible to contribute upstream: #1406 requires that hosted behaviour stay unchanged. Precedence when both credential sets are present: Bedrock wins, so that adding an ANTHROPIC_API_KEY cannot silently move production inference out of eu-north-1 (EU residency is a deliberate BFL/GDPR posture, not a default). AI_PROVIDER=bedrock|anthropic is the escape hatch. Model ids are written bare in code and prefixed to eu.anthropic.* only for Bedrock, because on-demand throughput requires the cross-region inference profile; an operator override that already carries a prefix passes through untouched. @anthropic-ai/sdk was declared explicitly at 0.95.0, the version bedrock-sdk 0.29.1 already pulled in transitively, so the lockfile dedupes to a single copy with no new download. Call sites converted: agent composer, invoice-inbox extraction, document-extraction model label, and both receipt-hunt clients (the last two are not named in the issue, which predates receipt-hunt landing in main).
|
||||
[2026-08-11] AI credentials: added lib/ai/provider.ts, which resolves the backend from the environment (Tier 1 of issue `#1406`), instead of swapping Bedrock out for the direct Anthropic API. The swap was the smaller change but would have been impossible to contribute upstream: `#1406` requires that hosted behaviour stay unchanged. Precedence when both credential sets are present: Bedrock wins, so that adding an ANTHROPIC_API_KEY cannot silently move production inference out of eu-north-1 (EU residency is a deliberate BFL/GDPR posture, not a default). AI_PROVIDER=bedrock|anthropic is the escape hatch. Model ids are written bare in code and prefixed to `eu.anthropic.*` only for Bedrock, because on-demand throughput requires the cross-region inference profile; an operator override that already carries a prefix passes through untouched. `@anthropic-ai/sdk` was declared explicitly at 0.95.0, the version bedrock-sdk 0.29.1 already pulled in transitively, so the lockfile dedupes to a single copy with no new download. Call sites converted: agent composer, invoice-inbox extraction, document-extraction model label, and both receipt-hunt clients (the last two are not named in the issue, which predates receipt-hunt landing in main).
|
||||
[2026-08-11] Self-hosted: the 30 s poll in lib/hooks/use-document-extraction.ts left alone even though #1406 names it. It is a symptom of missing credentials rather than a bug in itself: with AI configured, extraction finishes in 2-8 s and the wait disappears. The real fix is for the extraction-status endpoint to report "not configured" instead of leaving the column NULL forever, which is a separate change.
|
||||
[2026-08-13] Tier 1 self-hosting packages the `document-extraction` extension in the stock image: a plain `ANTHROPIC_API_KEY` must cover documents uploaded in the app as well as emailed invoices and the assistant to satisfy issue #1406; this does not add provider abstraction or other Tier 2 work.
|
||||
[2026-08-13] document-extraction's manifest no longer lists AWS_REGION under requiredEnvVars: the extension accepts either AWS static keys or ANTHROPIC_API_KEY, which the manifest schema cannot express as alternatives, and requiredEnvVars only drives a build-time warning rather than gating execution.
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"extensions": ["email", "invoice-inbox", "push-notifications", "calendar", "mcp-server"]}
|
||||
{"extensions": ["email", "invoice-inbox", "document-extraction", "push-notifications", "calendar", "mcp-server"]}
|
||||
|
||||
+13
-3
@@ -166,11 +166,17 @@ If you already have nginx / a managed load balancer / Cloudflare in front, skip
|
||||
|
||||
## Optional Extensions
|
||||
|
||||
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.
|
||||
The self-hosted image ships with a curated set of general extensions, including email, invoice inbox, document extraction, push notifications, calendar, and the MCP server. Enable Banking is excluded because it 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 (document-extraction, invoice-inbox, AI assistant)
|
||||
|
||||
All AI runs Claude via AWS Bedrock; provide AWS credentials with Bedrock model access to Claude:
|
||||
All AI runs Claude. Provide either a direct Anthropic API key:
|
||||
|
||||
```env
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
or AWS credentials with Bedrock model access to Claude, which keeps inference in eu-north-1:
|
||||
|
||||
```env
|
||||
AWS_ACCESS_KEY_ID=...
|
||||
@@ -178,7 +184,11 @@ 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.
|
||||
If both are set, Bedrock is used; `AI_PROVIDER=bedrock|anthropic` forces the choice. `OPENAI_API_KEY` from earlier versions is not read by any code path. See [SELF-HOSTING.md](./SELF-HOSTING.md#ai-features) for optional model overrides.
|
||||
|
||||
The stock self-hosted image includes both `invoice-inbox` and
|
||||
`document-extraction`, so the same provider credentials cover emailed invoices
|
||||
and documents uploaded in the app.
|
||||
|
||||
### Email (invoice sending, reminders)
|
||||
|
||||
|
||||
+41
-7
@@ -199,22 +199,56 @@ Additionally, migration 048 schedules a `pg_cron` job inside the database that m
|
||||
|
||||
### AI Features
|
||||
|
||||
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:
|
||||
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. There are two ways to provide credentials; pick one.
|
||||
|
||||
The stock self-hosted image includes both extraction extensions, so these credentials cover emailed invoices and documents uploaded in the app.
|
||||
|
||||
**Option 1: the direct Anthropic API.** The simplest option for self-hosting, since it needs nothing but a key from [console.anthropic.com](https://console.anthropic.com). Billing is your own, separate from any Claude subscription.
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
**Option 2: AWS Bedrock.** Requires an AWS account with Bedrock model access to Claude. This is what the hosted service runs, because it keeps inference inside eu-north-1: choose it if you need the AI calls to stay in the EU, which the direct API does not guarantee.
|
||||
|
||||
```bash
|
||||
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
|
||||
AWS_REGION=eu-north-1 # default
|
||||
```
|
||||
|
||||
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.
|
||||
Set both static AWS 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.
|
||||
|
||||
Optional model overrides, in either setup:
|
||||
|
||||
```bash
|
||||
BEDROCK_MODEL_ID=claude-sonnet-5 # document extraction model
|
||||
BEDROCK_OPUS_MODEL_ID=... # assistant model, heavy intents
|
||||
BEDROCK_SONNET_MODEL_ID=... # assistant model, standard intents
|
||||
AI_PROVIDER=bedrock|anthropic # force the backend (see below)
|
||||
```
|
||||
|
||||
When both credential sets are present, Bedrock wins, so that adding an Anthropic key for an experiment cannot silently move production inference out of eu-north-1. Set `AI_PROVIDER` to say which you mean. A model id written without a provider prefix is adapted to whichever backend is active; an id that already carries one (`eu.anthropic.…`) is used as-is.
|
||||
|
||||
Without working credentials the rest of the app runs normally: uploads are stored but not auto-interpreted, and the AI assistant cannot answer.
|
||||
|
||||
> **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).
|
||||
#### Verifying the setup
|
||||
|
||||
`scripts/smoke-ai.ts` sends real traffic to whichever backend your environment resolves to, so a wrong key, an unavailable model or a rejected parameter surfaces here rather than in front of a user:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/smoke-ai.ts # credentials, models, chat loop
|
||||
# Note: this check only detects static credentials (ANTHROPIC_API_KEY or
|
||||
# AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY). Bedrock deployments using an
|
||||
# instance profile or IRSA won't be picked up automatically: set
|
||||
# AI_PROVIDER=bedrock to run the probes against the AWS credential chain
|
||||
# anyway.
|
||||
npx tsx scripts/smoke-ai.ts ./receipt.pdf # also runs document extraction
|
||||
```
|
||||
|
||||
It prints the resolved provider and model ids first, then exercises a plain request, a streamed turn carrying the assistant's full parameter set (adaptive thinking, effort, prompt caching and a tool), and finally extraction of the file you pass. It exits non-zero if any step fails, so it works as a post-deploy check.
|
||||
|
||||
> **Note:** `OPENAI_API_KEY` from earlier versions is not read by any code path; there is no OpenAI route in the app. Pluggable providers beyond Claude are tracked in [#1406](https://github.com/erp-mafia/accounted/issues/1406).
|
||||
|
||||
### Email (Invoice Sending and Reminders)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
||||
import { toProviderModelId } from '@/lib/ai/provider'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
@@ -170,7 +171,7 @@ async function extractAndPersist(
|
||||
return
|
||||
}
|
||||
extractedData = data as unknown as Record<string, unknown>
|
||||
model = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-5'
|
||||
model = toProviderModelId(process.env.BEDROCK_MODEL_ID || 'claude-sonnet-5')
|
||||
} catch (err) {
|
||||
log.warn('extraction threw', {
|
||||
doc: document.id,
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
"sector": "general",
|
||||
"exportName": "documentExtractionExtension",
|
||||
"entryPoint": "@/extensions/general/document-extraction",
|
||||
"requiredEnvVars": [
|
||||
"AWS_REGION"
|
||||
],
|
||||
"requiredEnvVars": [],
|
||||
"definition": {
|
||||
"name": "AI-extrahering av underlag",
|
||||
"category": "accounting",
|
||||
@@ -14,6 +12,6 @@
|
||||
"hasOwnData": false,
|
||||
"readsCoreTables": ["document_attachments", "invoice_inbox_items"],
|
||||
"description": "Läser kvitton och fakturor med AI och fyller i leverantör, belopp, moms och datum automatiskt",
|
||||
"longDescription": "Lyssnar på document.uploaded-händelser och kör Sonnet 4.6 via AWS Bedrock på varje uppladdat kvitto eller faktura (PDF eller bild). De extraherade fälten skrivs till document_attachments.extracted_data så att den specialiserade bokföringsassistenten kan föreslå rätt BAS-konto utan att fråga användaren om sådant som redan står på underlaget. Hoppar över dokument som redan extraherats av andra extensions (t.ex. invoice-inbox) för att undvika dubbla AI-anrop."
|
||||
"longDescription": "Lyssnar på document.uploaded-händelser och kör Claude på varje uppladdat kvitto eller faktura (PDF eller bild), via AWS Bedrock eller Anthropics API beroende på vilka nycklar som är satta. De extraherade fälten skrivs till document_attachments.extracted_data så att den specialiserade bokföringsassistenten kan föreslå rätt BAS-konto utan att fråga användaren om sådant som redan står på underlaget. Hoppar över dokument som redan extraherats av andra extensions (t.ex. invoice-inbox) för att undvika dubbla AI-anrop."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,19 @@
|
||||
// the user can fill the fields in manually.
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import AnthropicBedrock from '@anthropic-ai/bedrock-sdk'
|
||||
import { z } from 'zod'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
import { createAiClient, hasAiCredentials, toProviderModelId } from '@/lib/ai/provider'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('invoice-inbox-extract')
|
||||
|
||||
// Both overridable via env vars so ops can swap models / raise token caps
|
||||
// without a code deploy. Defaults match what's expected to be set in
|
||||
// production (eu.anthropic.claude-sonnet-5 in eu-north-1, 8192 tokens:
|
||||
// enough headroom for invoices with 20+ line items).
|
||||
const MODEL = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-5'
|
||||
// without a code deploy. The model id is written bare and adapted to whichever
|
||||
// backend is configured (Bedrock in eu-north-1 on hosted, the direct Anthropic
|
||||
// API on self-hosted: see lib/ai/provider.ts). 8192 tokens is enough headroom
|
||||
// for invoices with 20+ line items.
|
||||
const MODEL = toProviderModelId(process.env.BEDROCK_MODEL_ID || 'claude-sonnet-5')
|
||||
const MAX_TOKENS = (() => {
|
||||
const parsed = Number(process.env.BEDROCK_MAX_TOKENS)
|
||||
// Use the env value only if it's a positive number: `||` would also
|
||||
@@ -476,18 +477,14 @@ export async function extractInvoiceFields(
|
||||
return { data: emptyResult(), rawText: null }
|
||||
}
|
||||
|
||||
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
|
||||
log.warn('AWS Bedrock credentials missing: returning empty extraction', {
|
||||
if (!hasAiCredentials()) {
|
||||
log.warn('AI credentials missing: returning empty extraction', {
|
||||
file_name_hash: createHash('sha256').update(input.fileName).digest('hex').slice(0, 12),
|
||||
})
|
||||
return { data: emptyResult(), rawText: null }
|
||||
}
|
||||
|
||||
const client = new AnthropicBedrock({
|
||||
awsRegion: process.env.AWS_REGION || 'eu-north-1',
|
||||
awsAccessKey: process.env.AWS_ACCESS_KEY_ID,
|
||||
awsSecretKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
})
|
||||
const client = createAiClient()
|
||||
|
||||
let rawText: string | null = null
|
||||
try {
|
||||
|
||||
@@ -1,75 +1,76 @@
|
||||
import AnthropicBedrock from '@anthropic-ai/bedrock-sdk'
|
||||
import {
|
||||
createAiClient,
|
||||
aiCredentialPrefix,
|
||||
hasAiCredentials,
|
||||
resolveAiProvider,
|
||||
toProviderModelId,
|
||||
type AiClient,
|
||||
} from '@/lib/ai/provider'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('agent-bedrock-client')
|
||||
const log = createLogger('agent-ai-client')
|
||||
|
||||
let cached: AnthropicBedrock | null = null
|
||||
let cached: AiClient | null = null
|
||||
|
||||
// Single AnthropicBedrock client for the agent composer + chat loop. Matches
|
||||
// the credential surface the rest of the codebase already uses (see
|
||||
// extensions/general/invoice-inbox/lib/extract-invoice-fields.ts) so:
|
||||
// Single Claude client for the agent composer + chat loop. Which backend it
|
||||
// talks to is resolved from the environment by lib/ai/provider.ts:
|
||||
//
|
||||
// 1. There's no separate ANTHROPIC_API_KEY to provision and rotate.
|
||||
// 2. All Claude traffic stays in eu-north-1: important for Swedish
|
||||
// accounting data under BFL retention.
|
||||
// 3. Failures and quotas show up in one AWS surface, not two.
|
||||
// - Hosted runs on AWS Bedrock. All Claude traffic stays in eu-north-1,
|
||||
// which matters for Swedish accounting data under BFL retention, and
|
||||
// failures and quotas show up in one AWS surface rather than two.
|
||||
// - Self-hosted deployments with no AWS account run against the direct
|
||||
// Anthropic API with a plain ANTHROPIC_API_KEY.
|
||||
//
|
||||
// Trade-off vs. the direct Anthropic API: Bedrock's prompt-cache TTL is
|
||||
// 5 minutes (default) rather than the 1h the plan §10 specifies. We still
|
||||
// pass `cache_control: { type: 'ephemeral', ttl: '1h' }` in the system
|
||||
// prompt assembly: Bedrock currently ignores the explicit TTL and uses 5m.
|
||||
// Cache effectiveness drops on multi-minute gaps but the loop still works.
|
||||
// Revisit if/when Bedrock exposes longer TTLs or if cost forces the direct
|
||||
// API.
|
||||
export function getAnthropic(): AnthropicBedrock {
|
||||
// The two SDKs expose the same `messages.create` / `messages.stream` surface,
|
||||
// which is all this loop uses, so the split is confined to the factory.
|
||||
//
|
||||
// Prompt-cache TTL differs between them. We pass
|
||||
// `cache_control: { type: 'ephemeral', ttl: '1h' }` in the system prompt
|
||||
// assembly (plan §10); Bedrock ignores the explicit TTL and uses its 5 minute
|
||||
// default, while the direct API honours the hour. Cache effectiveness on
|
||||
// multi-minute gaps is therefore better on the direct path; the loop works
|
||||
// either way.
|
||||
export function getAnthropic(): AiClient {
|
||||
if (cached) return cached
|
||||
const awsRegion = process.env.AWS_REGION || 'eu-north-1'
|
||||
const awsAccessKey = process.env.AWS_ACCESS_KEY_ID
|
||||
const awsSecretKey = process.env.AWS_SECRET_ACCESS_KEY
|
||||
|
||||
// Startup diagnostic: make a hosted misconfiguration visible in the logs
|
||||
// instead of it surfacing only as an opaque "request ended without sending
|
||||
// any chunks" at stream time. Runs once per cold start (the client is cached).
|
||||
// Never logs a secret: only the region, presence booleans, and the 4-char
|
||||
// access-key-id PREFIX (AKIA = long-term IAM user key; ASIA = STS/temporary
|
||||
// role credential, i.e. a platform-injected one rather than ours).
|
||||
if (!awsAccessKey || !awsSecretKey) {
|
||||
log.error('agent Bedrock credentials not loaded from env', undefined, {
|
||||
region: awsRegion,
|
||||
hasAccessKeyId: !!awsAccessKey,
|
||||
hasSecretAccessKey: !!awsSecretKey,
|
||||
// Startup diagnostic: make a misconfiguration visible in the logs instead of
|
||||
// it surfacing only as an opaque "request ended without sending any chunks"
|
||||
// at stream time. Runs once per cold start (the client is cached). Never
|
||||
// logs a secret: only the provider, a presence boolean, and the credential's
|
||||
// non-secret prefix (see aiCredentialPrefix).
|
||||
const provider = resolveAiProvider()
|
||||
if (!hasAiCredentials()) {
|
||||
log.error('agent AI credentials not loaded from env', undefined, {
|
||||
provider,
|
||||
hasCredentials: false,
|
||||
regionFromEnv: !!process.env.AWS_REGION,
|
||||
})
|
||||
} else {
|
||||
log.info('agent Bedrock client init', {
|
||||
region: awsRegion,
|
||||
keyPrefix: awsAccessKey.slice(0, 4),
|
||||
log.info('agent AI client init', {
|
||||
provider,
|
||||
keyPrefix: aiCredentialPrefix(),
|
||||
hasSessionToken: !!process.env.AWS_SESSION_TOKEN,
|
||||
regionFromEnv: !!process.env.AWS_REGION,
|
||||
})
|
||||
}
|
||||
|
||||
// When both static keys are present, pass them. Otherwise omit them so the
|
||||
// SDK falls back to the AWS credential provider chain (instance profile,
|
||||
// IRSA, EKS pod identity, ...). The two-overload SDK refuses a mix.
|
||||
cached =
|
||||
awsAccessKey && awsSecretKey
|
||||
? new AnthropicBedrock({ awsRegion, awsAccessKey, awsSecretKey })
|
||||
: new AnthropicBedrock({ awsRegion })
|
||||
cached = createAiClient()
|
||||
return cached
|
||||
}
|
||||
|
||||
// Bedrock model IDs. Region prefix `eu.` keeps inference inside eu-north-1
|
||||
// (a bare `anthropic.claude-sonnet-5` is rejected: on-demand throughput needs
|
||||
// the cross-region inference profile). Both are env-overridable so ops can
|
||||
// swap models without a code deploy.
|
||||
// Model ids, written bare and adapted to the resolved provider: Bedrock needs
|
||||
// the `eu.` inference-profile prefix, the direct API needs it absent. Both stay
|
||||
// env-overridable so ops can swap models without a code deploy; an override is
|
||||
// used verbatim, in whichever form the provider it was written for expects.
|
||||
//
|
||||
// Both point at Sonnet 5, verified enabled on this Bedrock account. The two
|
||||
// names are kept because the intents split on them: OPUS_MODEL marks the
|
||||
// Both point at Sonnet 5, verified enabled on the hosted Bedrock account. The
|
||||
// two names are kept because the intents split on them: OPUS_MODEL marks the
|
||||
// heavy-reasoning intents (supplier-invoice review, VAT review, bokslut) so
|
||||
// that split survives if a genuinely larger model is enabled here later.
|
||||
export const OPUS_MODEL = process.env.BEDROCK_OPUS_MODEL_ID || 'eu.anthropic.claude-sonnet-5'
|
||||
export const SONNET_MODEL = process.env.BEDROCK_SONNET_MODEL_ID || 'eu.anthropic.claude-sonnet-5'
|
||||
export const OPUS_MODEL =
|
||||
toProviderModelId(process.env.BEDROCK_OPUS_MODEL_ID || 'claude-sonnet-5')
|
||||
export const SONNET_MODEL =
|
||||
toProviderModelId(process.env.BEDROCK_SONNET_MODEL_ID || 'claude-sonnet-5')
|
||||
|
||||
// Reasoning depth for the chat intents.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import AnthropicBedrock from '@anthropic-ai/bedrock-sdk'
|
||||
import {
|
||||
aiCredentialPrefix,
|
||||
createAiClient,
|
||||
hasAiCredentials,
|
||||
resolveAiProvider,
|
||||
toProviderModelId,
|
||||
} from '../provider'
|
||||
|
||||
const AI_ENV_KEYS = [
|
||||
'AI_PROVIDER',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_REGION',
|
||||
] as const
|
||||
|
||||
let saved: Partial<Record<(typeof AI_ENV_KEYS)[number], string | undefined>> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
saved = {}
|
||||
for (const key of AI_ENV_KEYS) {
|
||||
saved[key] = process.env[key]
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of AI_ENV_KEYS) {
|
||||
if (saved[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = saved[key]
|
||||
}
|
||||
})
|
||||
|
||||
describe('resolveAiProvider', () => {
|
||||
it('uses Bedrock when static AWS keys are set (hosted stays unchanged)', () => {
|
||||
process.env.AWS_ACCESS_KEY_ID = 'AKIAEXAMPLE'
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'secret'
|
||||
expect(resolveAiProvider()).toBe('bedrock')
|
||||
})
|
||||
|
||||
it('uses the direct API when only an Anthropic key is set (self-hosted)', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-example'
|
||||
expect(resolveAiProvider()).toBe('anthropic')
|
||||
})
|
||||
|
||||
// The load-bearing precedence case: an operator who adds an Anthropic key
|
||||
// for a side experiment must not silently move production inference out of
|
||||
// eu-north-1, which is a deliberate BFL/GDPR posture rather than a default.
|
||||
it('prefers Bedrock when both credential sets are present', () => {
|
||||
process.env.AWS_ACCESS_KEY_ID = 'AKIAEXAMPLE'
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'secret'
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-example'
|
||||
expect(resolveAiProvider()).toBe('bedrock')
|
||||
})
|
||||
|
||||
it('lets AI_PROVIDER override the credential-based guess, both ways', () => {
|
||||
process.env.AWS_ACCESS_KEY_ID = 'AKIAEXAMPLE'
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'secret'
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-example'
|
||||
|
||||
process.env.AI_PROVIDER = 'anthropic'
|
||||
expect(resolveAiProvider()).toBe('anthropic')
|
||||
|
||||
process.env.AI_PROVIDER = 'bedrock'
|
||||
expect(resolveAiProvider()).toBe('bedrock')
|
||||
})
|
||||
|
||||
it('accepts AI_PROVIDER case-insensitively and trimmed', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-example'
|
||||
process.env.AI_PROVIDER = ' Bedrock '
|
||||
expect(resolveAiProvider()).toBe('bedrock')
|
||||
})
|
||||
|
||||
it('ignores an unrecognised AI_PROVIDER rather than failing closed', () => {
|
||||
process.env.AI_PROVIDER = 'openai'
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-example'
|
||||
expect(resolveAiProvider()).toBe('anthropic')
|
||||
})
|
||||
|
||||
// Hosted infrastructure that injects credentials via instance profile / IRSA
|
||||
// sets no env vars at all: that must still resolve to Bedrock so the AWS
|
||||
// credential provider chain gets its chance.
|
||||
it('falls back to Bedrock when nothing is configured', () => {
|
||||
expect(resolveAiProvider()).toBe('bedrock')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAiCredentials', () => {
|
||||
it('is true for a complete AWS static key pair', () => {
|
||||
process.env.AWS_ACCESS_KEY_ID = 'AKIAEXAMPLE'
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'secret'
|
||||
expect(hasAiCredentials()).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a half-configured AWS key pair', () => {
|
||||
process.env.AWS_ACCESS_KEY_ID = 'AKIAEXAMPLE'
|
||||
expect(hasAiCredentials()).toBe(false)
|
||||
})
|
||||
|
||||
it('is true for an Anthropic key', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-example'
|
||||
expect(hasAiCredentials()).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when nothing is configured', () => {
|
||||
expect(hasAiCredentials()).toBe(false)
|
||||
})
|
||||
|
||||
// AI_PROVIDER names the backend but does not conjure a credential for it.
|
||||
it('is false when AI_PROVIDER names a backend with no key', () => {
|
||||
process.env.AI_PROVIDER = 'anthropic'
|
||||
process.env.AWS_ACCESS_KEY_ID = 'AKIAEXAMPLE'
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'secret'
|
||||
expect(hasAiCredentials()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAiClient', () => {
|
||||
it('builds a Bedrock client when AWS keys are set', () => {
|
||||
process.env.AWS_ACCESS_KEY_ID = 'AKIAEXAMPLE'
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'secret'
|
||||
expect(createAiClient()).toBeInstanceOf(AnthropicBedrock)
|
||||
})
|
||||
|
||||
it('builds a direct Anthropic client when only an Anthropic key is set', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-example'
|
||||
expect(createAiClient()).toBeInstanceOf(Anthropic)
|
||||
})
|
||||
|
||||
it('builds a Bedrock client for the AWS provider chain when nothing is set', () => {
|
||||
expect(createAiClient()).toBeInstanceOf(AnthropicBedrock)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toProviderModelId', () => {
|
||||
it('passes the bare id through for the direct API', () => {
|
||||
expect(toProviderModelId('claude-sonnet-5', 'anthropic')).toBe('claude-sonnet-5')
|
||||
})
|
||||
|
||||
it('adds the eu inference-profile prefix for Bedrock', () => {
|
||||
expect(toProviderModelId('claude-sonnet-5', 'bedrock')).toBe('eu.anthropic.claude-sonnet-5')
|
||||
})
|
||||
|
||||
// An operator override may already be written in provider form; prefixing it
|
||||
// again would produce eu.anthropic.eu.anthropic.… and 404 at call time.
|
||||
it('leaves an already-prefixed id alone', () => {
|
||||
expect(toProviderModelId('eu.anthropic.claude-sonnet-5', 'bedrock')).toBe(
|
||||
'eu.anthropic.claude-sonnet-5'
|
||||
)
|
||||
expect(toProviderModelId('anthropic.claude-sonnet-5', 'bedrock')).toBe(
|
||||
'anthropic.claude-sonnet-5'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to the environment-resolved provider', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-example'
|
||||
expect(toProviderModelId('claude-sonnet-5')).toBe('claude-sonnet-5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiCredentialPrefix', () => {
|
||||
it('returns only the non-secret Anthropic key prefix', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-SECRETSECRETSECRET'
|
||||
const prefix = aiCredentialPrefix()
|
||||
expect(prefix).toBe('sk-ant-api03')
|
||||
expect(prefix).not.toContain('SECRET')
|
||||
})
|
||||
|
||||
it('returns only the AWS access-key-id class prefix', () => {
|
||||
process.env.AWS_ACCESS_KEY_ID = 'AKIASECRETSECRET'
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'secret'
|
||||
const prefix = aiCredentialPrefix()
|
||||
expect(prefix).toBe('AKIA')
|
||||
expect(prefix).not.toContain('SECRET')
|
||||
})
|
||||
|
||||
it('returns null when nothing is configured', () => {
|
||||
expect(aiCredentialPrefix()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import AnthropicBedrock from '@anthropic-ai/bedrock-sdk'
|
||||
|
||||
/**
|
||||
* Which backend Claude traffic goes to.
|
||||
*
|
||||
* Hosted runs on AWS Bedrock: keeping inference inside eu-north-1 is a
|
||||
* deliberate BFL/GDPR posture for Swedish accounting data, not an
|
||||
* implementation detail. Self-hosted deployments generally have no AWS
|
||||
* account at all, so they get the direct Anthropic API with a plain
|
||||
* ANTHROPIC_API_KEY.
|
||||
*
|
||||
* See https://github.com/erp-mafia/accounted/issues/1406.
|
||||
*/
|
||||
export type AiProvider = 'bedrock' | 'anthropic'
|
||||
|
||||
export type AiClient = Anthropic | AnthropicBedrock
|
||||
|
||||
/**
|
||||
* Resolve the provider from the environment, or null when neither backend has
|
||||
* usable credentials.
|
||||
*
|
||||
* Precedence is deliberate:
|
||||
*
|
||||
* 1. `AI_PROVIDER` wins when set. The escape hatch for a deployment that has
|
||||
* both credential sets and needs to say which one it means.
|
||||
* 2. Static AWS keys mean Bedrock. This is what keeps hosted byte-identical:
|
||||
* an operator who adds an Anthropic key for a side experiment must not
|
||||
* silently move production inference out of eu-north-1.
|
||||
* 3. Otherwise an Anthropic key means the direct API. This is the
|
||||
* self-hosted path.
|
||||
* 4. Otherwise Bedrock without static keys, so the AWS credential provider
|
||||
* chain (instance profile, IRSA, EKS pod identity) still resolves on
|
||||
* hosted infrastructure that injects credentials rather than setting env
|
||||
* vars. `hasAiCredentials()` reports false here: we cannot see the chain
|
||||
* from this side, so callers that need a cheap pre-flight treat it as
|
||||
* unconfigured rather than paying a request to find out.
|
||||
*/
|
||||
export function resolveAiProvider(): AiProvider {
|
||||
const explicit = (process.env.AI_PROVIDER ?? '').trim().toLowerCase()
|
||||
if (explicit === 'bedrock' || explicit === 'anthropic') return explicit
|
||||
|
||||
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) return 'bedrock'
|
||||
if (process.env.ANTHROPIC_API_KEY) return 'anthropic'
|
||||
return 'bedrock'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this deployment has credentials we can see from the environment.
|
||||
*
|
||||
* Used by call sites that must degrade quietly rather than throw: document
|
||||
* extraction returns an empty result instead of failing an upload. Returns
|
||||
* false for the AWS provider chain (case 4 above) because it is not visible
|
||||
* here; that path was already treated the same way before the direct-API
|
||||
* option existed.
|
||||
*/
|
||||
export function hasAiCredentials(): boolean {
|
||||
return resolveAiProvider() === 'anthropic'
|
||||
? !!process.env.ANTHROPIC_API_KEY
|
||||
: !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a client for the resolved provider. Both expose the same
|
||||
* `messages.create` / `messages.stream` surface, which is all this codebase
|
||||
* uses of either SDK.
|
||||
*/
|
||||
export function createAiClient(): AiClient {
|
||||
if (resolveAiProvider() === 'anthropic') {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
// Omit the key when unset so the SDK resolves it itself and fails at call
|
||||
// time: throwing here would take down every route that merely imports a
|
||||
// module touching AI.
|
||||
return apiKey ? new Anthropic({ apiKey }) : new Anthropic()
|
||||
}
|
||||
|
||||
const awsRegion = process.env.AWS_REGION || 'eu-north-1'
|
||||
const awsAccessKey = process.env.AWS_ACCESS_KEY_ID
|
||||
const awsSecretKey = process.env.AWS_SECRET_ACCESS_KEY
|
||||
// When both static keys are present, pass them. Otherwise omit them so the
|
||||
// SDK falls back to the AWS credential provider chain. The two-overload SDK
|
||||
// refuses a mix.
|
||||
return awsAccessKey && awsSecretKey
|
||||
? new AnthropicBedrock({ awsRegion, awsAccessKey, awsSecretKey })
|
||||
: new AnthropicBedrock({ awsRegion })
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a bare Anthropic model id to the form the resolved provider expects.
|
||||
*
|
||||
* Bedrock needs the `eu.` inference-profile prefix: a bare
|
||||
* `anthropic.claude-sonnet-5` is rejected because on-demand throughput
|
||||
* requires the cross-region profile. The direct API takes the bare id and
|
||||
* rejects both prefixes.
|
||||
*
|
||||
* Ids that already carry a provider prefix pass through untouched, so an
|
||||
* operator-supplied override in either form keeps working.
|
||||
*/
|
||||
export function toProviderModelId(bareModelId: string, provider = resolveAiProvider()): string {
|
||||
if (provider === 'anthropic') return bareModelId
|
||||
if (bareModelId.startsWith('eu.') || bareModelId.startsWith('anthropic.')) return bareModelId
|
||||
return `eu.anthropic.${bareModelId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-secret identification of the configured credential, for startup logs.
|
||||
* Anthropic keys carry a public prefix (`sk-ant-api03` for a standard API key,
|
||||
* `sk-ant-oat` for an OAuth token); AWS access key ids carry `AKIA` for a
|
||||
* long-term IAM user key and `ASIA` for an STS/role credential. Never returns
|
||||
* any part of a secret.
|
||||
*/
|
||||
export function aiCredentialPrefix(): string | null {
|
||||
if (resolveAiProvider() === 'anthropic') {
|
||||
return process.env.ANTHROPIC_API_KEY?.slice(0, 12) ?? null
|
||||
}
|
||||
return process.env.AWS_ACCESS_KEY_ID?.slice(0, 4) ?? null
|
||||
}
|
||||
+1
-1
@@ -113,7 +113,7 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"icon": "MessageCircle",
|
||||
"dataPattern": "both",
|
||||
"description": "Läser kvitton och fakturor med AI och fyller i leverantör, belopp, moms och datum automatiskt",
|
||||
"longDescription": "Lyssnar på document.uploaded-händelser och kör Sonnet 4.6 via AWS Bedrock på varje uppladdat kvitto eller faktura (PDF eller bild). De extraherade fälten skrivs till document_attachments.extracted_data så att den specialiserade bokföringsassistenten kan föreslå rätt BAS-konto utan att fråga användaren om sådant som redan står på underlaget. Hoppar över dokument som redan extraherats av andra extensions (t.ex. invoice-inbox) för att undvika dubbla AI-anrop.",
|
||||
"longDescription": "Lyssnar på document.uploaded-händelser och kör Claude på varje uppladdat kvitto eller faktura (PDF eller bild), via AWS Bedrock eller Anthropics API beroende på vilka nycklar som är satta. De extraherade fälten skrivs till document_attachments.extracted_data så att den specialiserade bokföringsassistenten kan föreslå rätt BAS-konto utan att fråga användaren om sådant som redan står på underlaget. Hoppar över dokument som redan extraherats av andra extensions (t.ex. invoice-inbox) för att undvika dubbla AI-anrop.",
|
||||
"readsCoreTables": [
|
||||
"document_attachments",
|
||||
"invoice_inbox_items"
|
||||
|
||||
@@ -9,10 +9,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { UncertainPair } from '../adjudicate'
|
||||
|
||||
const mockCreate = vi.fn()
|
||||
vi.mock('@anthropic-ai/bedrock-sdk', () => ({
|
||||
default: class {
|
||||
messages = { create: (...args: unknown[]) => mockCreate(...args) }
|
||||
},
|
||||
vi.mock('@/lib/ai/provider', () => ({
|
||||
createAiClient: () => ({ messages: { create: (...args: unknown[]) => mockCreate(...args) } }),
|
||||
toProviderModelId: (id: string) => id,
|
||||
}))
|
||||
|
||||
import { adjudicate } from '../adjudicate'
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockCreate = vi.fn()
|
||||
vi.mock('@anthropic-ai/bedrock-sdk', () => ({
|
||||
default: class {
|
||||
messages = { create: (...args: unknown[]) => mockCreate(...args) }
|
||||
},
|
||||
vi.mock('@/lib/ai/provider', () => ({
|
||||
createAiClient: () => ({ messages: { create: (...args: unknown[]) => mockCreate(...args) } }),
|
||||
toProviderModelId: (id: string) => id,
|
||||
}))
|
||||
|
||||
import { extractMailDocuments, type CandidateForReview } from '../mail-intelligence'
|
||||
|
||||
@@ -23,16 +23,17 @@
|
||||
* human approves, carrying the reason so the approval is checking an argument
|
||||
* rather than trusting a verdict.
|
||||
*/
|
||||
import AnthropicBedrock from '@anthropic-ai/bedrock-sdk'
|
||||
import { z } from 'zod'
|
||||
import { createAiClient, toProviderModelId, type AiClient } from '@/lib/ai/provider'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('receipt-hunt-adjudicate')
|
||||
|
||||
const MODEL =
|
||||
const MODEL = toProviderModelId(
|
||||
process.env.RECEIPT_HUNT_MODEL_ID ||
|
||||
process.env.BEDROCK_MODEL_ID ||
|
||||
'eu.anthropic.claude-sonnet-5'
|
||||
process.env.BEDROCK_MODEL_ID ||
|
||||
'claude-sonnet-5'
|
||||
)
|
||||
|
||||
export interface UncertainPair {
|
||||
/** Stable handle for this pair, opaque to the model beyond matching it back. */
|
||||
@@ -134,8 +135,8 @@ kan väga.
|
||||
|
||||
reason: en kort mening på svenska om varför paret hör ihop eller inte.`
|
||||
|
||||
function client(): AnthropicBedrock {
|
||||
return new AnthropicBedrock({ awsRegion: process.env.AWS_REGION })
|
||||
function client(): AiClient {
|
||||
return createAiClient()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
* afterwards by the same deterministic amount-and-merchant matcher that scores
|
||||
* every other underlag, so mail and Underlag get one matcher rather than two.
|
||||
*/
|
||||
import AnthropicBedrock from '@anthropic-ai/bedrock-sdk'
|
||||
import { z } from 'zod'
|
||||
import { createAiClient, toProviderModelId, type AiClient } from '@/lib/ai/provider'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('receipt-hunt-intelligence')
|
||||
@@ -30,10 +30,11 @@ const log = createLogger('receipt-hunt-intelligence')
|
||||
/**
|
||||
* Overridable so ops can move the hunt off the default without a deploy.
|
||||
*/
|
||||
const MODEL =
|
||||
const MODEL = toProviderModelId(
|
||||
process.env.RECEIPT_HUNT_MODEL_ID ||
|
||||
process.env.BEDROCK_MODEL_ID ||
|
||||
'eu.anthropic.claude-sonnet-5'
|
||||
process.env.BEDROCK_MODEL_ID ||
|
||||
'claude-sonnet-5'
|
||||
)
|
||||
|
||||
export interface CandidateForReview {
|
||||
messageId: string
|
||||
@@ -139,8 +140,8 @@ is_receipt=false och lämna resten null. Ta hellre med en osäker faktura än
|
||||
missa ett kvitto: en handling utan matchande belopp faller bort av sig själv
|
||||
senare.`
|
||||
|
||||
function client(): AnthropicBedrock {
|
||||
return new AnthropicBedrock({ awsRegion: process.env.AWS_REGION })
|
||||
function client(): AiClient {
|
||||
return createAiClient()
|
||||
}
|
||||
|
||||
async function ask(
|
||||
|
||||
Generated
+1
@@ -10,6 +10,7 @@
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "0.29.1",
|
||||
"@anthropic-ai/sdk": "0.95.0",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.7",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "0.29.1",
|
||||
"@anthropic-ai/sdk": "0.95.0",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.7",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Smoke test for the configured AI backend, against the real API.
|
||||
*
|
||||
* Unit tests cover which provider and model id get resolved from the
|
||||
* environment; they cannot tell you whether the resulting request is one the
|
||||
* backend accepts. This script sends real traffic over all three shapes the
|
||||
* app actually uses, so a credential or parameter problem surfaces here rather
|
||||
* than in front of a user:
|
||||
*
|
||||
* 1. A plain `messages.create` on both agent model ids.
|
||||
* 2a. A streamed turn with a tool, the shape the chat loop sends.
|
||||
* 2b. Adaptive thinking, an effort level and a cached system prompt: the
|
||||
* rest of that parameter set, probed separately because one turn cannot
|
||||
* falsify both tool use and thinking at once (see the note there).
|
||||
* 3. Document extraction end to end, when given a file.
|
||||
*
|
||||
* Works against whichever backend lib/ai/provider.ts resolves (AWS Bedrock or
|
||||
* the direct Anthropic API), so it doubles as the acceptance check when
|
||||
* switching between them.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/smoke-ai.ts
|
||||
* npx tsx scripts/smoke-ai.ts ./some-receipt.pdf # also runs step 3
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv'
|
||||
config({ path: '.env.local' })
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, extname } from 'node:path'
|
||||
import {
|
||||
aiCredentialPrefix,
|
||||
hasAiCredentials,
|
||||
resolveAiProvider,
|
||||
} from '../lib/ai/provider'
|
||||
|
||||
type ComposerModule = typeof import('../lib/agent/composer/client')
|
||||
type ExtractionModule = typeof import('../extensions/general/invoice-inbox/lib/extract-invoice-fields')
|
||||
|
||||
let getAnthropic: ComposerModule['getAnthropic']
|
||||
let EFFORT_DEEP: ComposerModule['EFFORT_DEEP']
|
||||
let MAX_TOKENS_DEEP: ComposerModule['MAX_TOKENS_DEEP']
|
||||
let OPUS_MODEL: ComposerModule['OPUS_MODEL']
|
||||
let SONNET_MODEL: ComposerModule['SONNET_MODEL']
|
||||
let extractInvoiceFields: ExtractionModule['extractInvoiceFields']
|
||||
|
||||
let failures = 0
|
||||
|
||||
function fail(step: string, err: unknown): void {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(` x ${step}: ${message}`)
|
||||
failures++
|
||||
}
|
||||
|
||||
/** Step 1: the simplest possible request, once per agent model id. */
|
||||
async function ping(model: string): Promise<void> {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const resp = await getAnthropic().messages.create({
|
||||
model,
|
||||
max_tokens: 10,
|
||||
messages: [{ role: 'user', content: 'Säg "hej" på svenska.' }],
|
||||
})
|
||||
const text = resp.content
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => (b as { type: 'text'; text: string }).text)
|
||||
.join('')
|
||||
console.log(` ok ${model}: ${Date.now() - start}ms: "${text.trim()}"`)
|
||||
} catch (err) {
|
||||
fail(model, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: the chat loop's parameter set.
|
||||
*
|
||||
* Split in two because one turn cannot falsify both things at once. A question
|
||||
* that needs a tool gets answered by calling the tool, and adaptive thinking
|
||||
* correctly declines to reason about it, so a zero thinking-block count there
|
||||
* means nothing. Each probe therefore asks for one behaviour and nothing else.
|
||||
*/
|
||||
async function streamingWithTool(): Promise<void> {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const stream = getAnthropic().messages.stream({
|
||||
model: SONNET_MODEL,
|
||||
max_tokens: MAX_TOKENS_DEEP,
|
||||
system: 'Du är en svensk redovisningsassistent. Använd verktyget när du behöver ett kontosaldo.',
|
||||
tools: [
|
||||
{
|
||||
name: 'get_account_balance',
|
||||
description: 'Hämtar saldot för ett BAS-konto i innevarande räkenskapsår.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
account: { type: 'string', description: 'BAS-kontonummer, till exempel 1930.' },
|
||||
},
|
||||
required: ['account'],
|
||||
},
|
||||
},
|
||||
],
|
||||
messages: [{ role: 'user', content: 'Vad är saldot på konto 1930?' }],
|
||||
})
|
||||
|
||||
let deltas = 0
|
||||
stream.on('text', () => {
|
||||
deltas++
|
||||
})
|
||||
const message = await stream.finalMessage()
|
||||
const toolUse = message.content.filter((b) => b.type === 'tool_use').length
|
||||
|
||||
console.log(
|
||||
` ok verktyg+streaming: ${Date.now() - start}ms, stop=${message.stop_reason}, ` +
|
||||
`textdeltan=${deltas}, tool_use=${toolUse}`
|
||||
)
|
||||
if (toolUse === 0) {
|
||||
console.warn(' varning: inget tool_use-block, verktyget kan ha ignorerats')
|
||||
}
|
||||
} catch (err) {
|
||||
fail('verktyg+streaming', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2b: adaptive thinking, effort and prompt caching.
|
||||
*
|
||||
* The question needs several dependent steps (reverse charge, then a partial
|
||||
* deduction, then which boxes move), because adaptive thinking is supposed to
|
||||
* skip reasoning it does not need: asking something easy cannot distinguish
|
||||
* "declined to think" from "parameter ignored".
|
||||
*
|
||||
* The system prompt is padded past the 1024-token minimum cacheable prefix.
|
||||
* Below it the API caches nothing and reports no error, so a short prompt
|
||||
* makes the cache counters read zero no matter whether caching works.
|
||||
*/
|
||||
async function thinkingTurn(): Promise<void> {
|
||||
const start = Date.now()
|
||||
const filler = 'Svara alltid på svenska och hänvisa till BAS-konton med nummer. '.repeat(120)
|
||||
try {
|
||||
const stream = getAnthropic().messages.stream({
|
||||
model: SONNET_MODEL,
|
||||
max_tokens: MAX_TOKENS_DEEP,
|
||||
thinking: { type: 'adaptive', display: 'summarized' },
|
||||
output_config: { effort: EFFORT_DEEP },
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Du är en svensk redovisningsassistent. ${filler}`,
|
||||
cache_control: { type: 'ephemeral', ttl: '1h' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'Ett svenskt momsregistrerat bolag köper en konsulttjänst från Tyskland för 10 000 kr. ' +
|
||||
'Bolaget har blandad verksamhet med 60 procent avdragsrätt. Hur bokförs affären, ' +
|
||||
'och vilka rutor i momsdeklarationen påverkas?',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const message = await stream.finalMessage()
|
||||
|
||||
const thinkingBlocks = message.content.filter((b) => b.type === 'thinking')
|
||||
const thinkingChars = thinkingBlocks
|
||||
.map((b) => (b as { type: 'thinking'; thinking: string }).thinking?.length ?? 0)
|
||||
.reduce((a, b) => a + b, 0)
|
||||
const cacheWrite = message.usage.cache_creation_input_tokens ?? 0
|
||||
const cacheRead = message.usage.cache_read_input_tokens ?? 0
|
||||
|
||||
console.log(
|
||||
` ok thinking+cache: ${Date.now() - start}ms, stop=${message.stop_reason}, ` +
|
||||
`thinking-block=${thinkingBlocks.length}, thinking-tecken=${thinkingChars}, ` +
|
||||
`cache write/read=${cacheWrite}/${cacheRead}`
|
||||
)
|
||||
if (thinkingBlocks.length === 0) {
|
||||
console.warn(
|
||||
' varning: inget thinking-block pa en fraga som kraver flera steg. ' +
|
||||
'Chattens "Tankte…"-block skulle da aldrig fyllas.'
|
||||
)
|
||||
} else if (thinkingChars === 0) {
|
||||
console.warn(
|
||||
' varning: thinking-block utan text, sa display:"summarized" slog inte igenom.'
|
||||
)
|
||||
}
|
||||
if (cacheWrite === 0 && cacheRead === 0) {
|
||||
console.warn(' varning: ingenting cachat trots prefix over minimigransen')
|
||||
}
|
||||
} catch (err) {
|
||||
fail('thinking+cache', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 3: the real extraction path, including prompt, media block and JSON parse. */
|
||||
async function extraction(path: string): Promise<void> {
|
||||
const start = Date.now()
|
||||
const mimeByExt: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
}
|
||||
const mimeType = mimeByExt[extname(path).toLowerCase()]
|
||||
if (!mimeType) {
|
||||
fail('extraction', `okänd filändelse för ${path}, stöds: ${Object.keys(mimeByExt).join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await readFile(path)
|
||||
const { data, rawText } = await extractInvoiceFields({
|
||||
buffer,
|
||||
mimeType,
|
||||
fileName: basename(path),
|
||||
})
|
||||
// extractInvoiceFields never throws: a null rawText means the call was
|
||||
// skipped or the reply did not parse, which is exactly the silent failure
|
||||
// this script exists to make loud.
|
||||
if (!rawText) {
|
||||
fail('extraction', 'tomt resultat (nycklar saknas, filtyp stöds inte, eller JSON-parsen föll)')
|
||||
return
|
||||
}
|
||||
// documentKind arrived with the receipt-aware extraction work. Read it
|
||||
// defensively so this script still compiles against a checkout from
|
||||
// before that landed: the whole point of it is to be runnable anywhere
|
||||
// the app runs, including an older self-hosted deployment.
|
||||
const kind = (data as { documentKind?: string | null }).documentKind ?? '-'
|
||||
console.log(
|
||||
` ok extraction: ${Date.now() - start}ms, leverantör="${data.supplier.name ?? '-'}", ` +
|
||||
`nummer=${data.invoice.invoiceNumber ?? '-'}, datum=${data.invoice.invoiceDate ?? '-'}, ` +
|
||||
`typ=${kind}`
|
||||
)
|
||||
} catch (err) {
|
||||
fail('extraction', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// These modules resolve their model ids at import time. Load them only after
|
||||
// dotenv has populated the environment so .env.local provider and model
|
||||
// overrides are exercised by the smoke test.
|
||||
const [composer, extractionModule] = await Promise.all([
|
||||
import('../lib/agent/composer/client'),
|
||||
import('../extensions/general/invoice-inbox/lib/extract-invoice-fields'),
|
||||
])
|
||||
getAnthropic = composer.getAnthropic
|
||||
EFFORT_DEEP = composer.EFFORT_DEEP
|
||||
MAX_TOKENS_DEEP = composer.MAX_TOKENS_DEEP
|
||||
OPUS_MODEL = composer.OPUS_MODEL
|
||||
SONNET_MODEL = composer.SONNET_MODEL
|
||||
extractInvoiceFields = extractionModule.extractInvoiceFields
|
||||
|
||||
const provider = resolveAiProvider()
|
||||
const explicitProvider = (process.env.AI_PROVIDER ?? '').trim().toLowerCase()
|
||||
console.log(`Leverantör: ${provider}`)
|
||||
console.log(`Nyckel: ${hasAiCredentials() ? `${aiCredentialPrefix()}…` : 'SAKNAS'}`)
|
||||
if (provider === 'bedrock') console.log(`Region: ${process.env.AWS_REGION || 'eu-north-1'}`)
|
||||
console.log(`Modeller: ${SONNET_MODEL} / ${OPUS_MODEL}`)
|
||||
|
||||
if (!hasAiCredentials() && explicitProvider !== 'bedrock') {
|
||||
console.error(
|
||||
'\nInga synliga nycklar. Sätt ANTHROPIC_API_KEY, eller AWS_ACCESS_KEY_ID +\n' +
|
||||
'AWS_SECRET_ACCESS_KEY för Bedrock. (Bedrock via instansprofil/IRSA syns\n' +
|
||||
'inte härifrån: kör i så fall vidare med AI_PROVIDER=bedrock.)'
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log('\n1. Enkla anrop, båda modellerna')
|
||||
await ping(SONNET_MODEL)
|
||||
if (OPUS_MODEL !== SONNET_MODEL) await ping(OPUS_MODEL)
|
||||
|
||||
console.log('\n2a. Streaming med verktyg')
|
||||
await streamingWithTool()
|
||||
|
||||
console.log('\n2b. Adaptive thinking, effort och prompt-cache')
|
||||
await thinkingTurn()
|
||||
|
||||
const path = process.argv[2]
|
||||
console.log('\n3. Dokumenttolkning')
|
||||
if (path) await extraction(path)
|
||||
else console.log(' - skipping, no file given (npx tsx scripts/smoke-ai.ts <file>)')
|
||||
|
||||
console.log(failures === 0 ? '\nAllt grönt.' : `\n${failures} steg föll.`)
|
||||
if (failures > 0) process.exitCode = 1
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Smoke test: send a 1-token request to Bedrock with both the Opus and
|
||||
* Sonnet model ids the agent uses. Confirms AWS creds + region work and
|
||||
* the models are enabled on the account before we exercise the full chat
|
||||
* loop with real user data.
|
||||
*
|
||||
* Usage: npx tsx scripts/smoke-bedrock.ts
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv'
|
||||
config({ path: '.env.local' })
|
||||
|
||||
import { getAnthropic, OPUS_MODEL, SONNET_MODEL } from '../lib/agent/composer/client'
|
||||
|
||||
async function ping(model: string): Promise<void> {
|
||||
const client = getAnthropic()
|
||||
const start = Date.now()
|
||||
try {
|
||||
const resp = await client.messages.create({
|
||||
model,
|
||||
max_tokens: 10,
|
||||
messages: [{ role: 'user', content: 'Säg "hej" på svenska.' }],
|
||||
})
|
||||
const text = resp.content
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => (b as { type: 'text'; text: string }).text)
|
||||
.join('')
|
||||
console.log(` ✓ ${model}: ${Date.now() - start}ms: "${text.trim()}"`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(` ✗ ${model}: ${message}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Region: ${process.env.AWS_REGION || 'eu-north-1'}`)
|
||||
console.log('Pinging Bedrock for both agent models…\n')
|
||||
await ping(SONNET_MODEL)
|
||||
await ping(OPUS_MODEL)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user