Files
accounted/scripts/smoke-ai.ts
T
Mattsson 3829b6add3 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>
2026-08-13 15:45:24 +02:00

297 lines
11 KiB
TypeScript

#!/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)
})