feat(ai): job-shaped AI service with OpenAI-compatible backend, extraction-first; stop extracting every inbox document twice (#1740)

* feat(ai): job-shaped AI service with OpenAI-compatible backend, extraction-first; stop extracting every inbox document twice

Sovereign plan WS1 PR1 (#1406 Tier 2, extraction-first, aligned with the
AI surface audit).

lib/ai grows a job-shaped service (generateText / generateStructured /
extractFromDocument; no streaming members yet, see plan rule R3):
- services/anthropic-family delegates to the existing createAiClient()
  and sends the exact request literals the inbox extractor sent before
  (request-shape tests deep-equal them), so hosted Bedrock stays
  byte-identical.
- services/openai-compatible talks to any chat-completions endpoint
  (BYO Swedish provider) via Vercel AI SDK 6.x, exact-pinned and
  guarded: images as parts, PDFs rasterized with poppler (AI_PDF_MODE)
  or sent natively, AI_VISION / AI_STRICT_JSON declared, honest skips
  (ai_no_vision, pdf_rasterizer_missing) instead of fake failures.
- config.ts: AI_PROVIDER/AI_BASE_URL/AI_API_KEY/AI_MODEL and per-tier
  AI_*_MODEL with the legacy BEDROCK_* names kept as the same overrides;
  getAiStatus() is the single source of truth for "is AI wired up".
- provider.ts: openai-compatible in the auto-detect chain (after Bedrock
  and the direct API); createAiClient() refuses it loudly.

Document extraction moves onto the service and gets the audit's fixes:
- Inbox documents were extracted TWICE (pipeline A ran inside
  uploadDocument() before the inbox row existed, so its dedupe branch
  never fired; 3 707 + 1 666 calls / 30 d). The inbox now declares
  extractionOwner on the upload, the extension yields, and the inbox
  mirrors its single outcome onto document_attachments from every
  writer (sync, deferred, attach, retry, MCP).
- Every "no extraction will ever happen" outcome is stamped
  (skipped:no_ai_entitlement / ai_unconfigured / system_generated /
  ...); the status route maps the quiet ones to 'disabled' on the first
  poll instead of a 30 s client timeout. Prod showed 309 of the 327
  never-extracted uploads were the paywall working silently.
- Self-generated documents (our own invoice PDFs, payout files) are no
  longer OCR'd.
- Agent invoke answers 503 ai_unconfigured when the deployment has no
  assistant backend, distinct from the paywall.

Guard: new direct-ai-client antipattern check (shrink-only allowlist of
the pre-abstraction SDK callers) plus exact pins for @anthropic-ai/sdk,
ai and @ai-sdk/openai-compatible.

Verified: 15 958 unit tests green, guards, lint ratchet, typecheck, and a
live smoke against hosted Bedrock through the new service (ping, streamed
tool turn, thinking+cache, PDF extraction).

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

* feat(ai): make AI_API_KEY optional for OpenAI-compatible endpoints (keyless local model servers)

A local model server (llama.cpp's server, Ollama /v1, LM Studio, vLLM)
usually has no auth. Before, the OpenAI-compatible backend required both
AI_BASE_URL and AI_API_KEY to count as configured, so running Accounted on a
local model meant setting a meaningless placeholder key.

- resolveAiProvider / hasAiCredentials: a base URL alone is now enough.
- services/openai-compatible: only send Authorization: Bearer when AI_API_KEY
  is set, so a keyless server is never handed an empty bearer; a hosted
  provider that needs a key still sets it.
- Docs (SELF-HOSTING Option 3: local-model example, key marked optional),
  DECISIONS.

Verified: with no AI_API_KEY, just AI_BASE_URL + AI_MODEL, getAiStatus()
reports configured=true / provider=openai-compatible (live). lib/ai suite
71 green; tsc, guards, lint clean. Bedrock/Anthropic logic unchanged.

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-20 19:39:08 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent fbf47649f2
commit c7a75d069d
37 changed files with 2865 additions and 335 deletions
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Drive the service against a fake Bedrock client: the point of these tests
// is the REQUEST SHAPE. Hosted stays byte-identical only if the service sends
// exactly the literals the call sites sent before the abstraction existed.
const mockCreate = vi.fn()
vi.mock('@anthropic-ai/bedrock-sdk', () => {
class FakeBedrock {
messages = { create: mockCreate }
}
return { default: FakeBedrock }
})
import { readAiConfig } from '../config'
import { createAnthropicFamilyService, buildAnthropicDocumentContent } from '../services/anthropic-family'
const ENV = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'ANTHROPIC_API_KEY', 'AI_PROVIDER', 'AI_BASE_URL', 'AI_API_KEY', 'BEDROCK_MODEL_ID', 'AI_EXTRACTION_MODEL', 'AI_MODEL'] as const
let saved: Record<string, string | undefined> = {}
beforeEach(() => {
vi.clearAllMocks()
saved = {}
for (const k of ENV) {
saved[k] = process.env[k]
delete process.env[k]
}
process.env.AWS_ACCESS_KEY_ID = 'AKIAEXAMPLE'
process.env.AWS_SECRET_ACCESS_KEY = 'secret'
mockCreate.mockResolvedValue({
content: [{ type: 'text', text: '{"ok":true}' }],
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 7, cache_creation_input_tokens: 0 },
})
})
afterEach(() => {
for (const k of ENV) {
if (saved[k] === undefined) delete process.env[k]
else process.env[k] = saved[k]
}
})
const SYSTEM = 'You extract fields.'
const INSTRUCTION = 'Extract the fields per the schema. JSON only.'
describe('extractFromDocument request shape (hosted regression net)', () => {
it('sends a PDF exactly as the inbox extractor did: cached system block, document part, instruction', async () => {
const svc = createAnthropicFamilyService(readAiConfig())
const pdf = Buffer.from('%PDF-1.4')
const result = await svc.extractFromDocument({
document: { kind: 'pdf', data: pdf, fileName: 'faktura.pdf' },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 8192,
})
expect(result.ok).toBe(true)
expect(mockCreate).toHaveBeenCalledTimes(1)
expect(mockCreate.mock.calls[0][0]).toEqual({
model: 'eu.anthropic.claude-sonnet-5',
max_tokens: 8192,
system: [{ type: 'text', text: SYSTEM, cache_control: { type: 'ephemeral' } }],
messages: [
{
role: 'user',
content: [
{
type: 'document',
source: { type: 'base64', media_type: 'application/pdf', data: pdf.toString('base64') },
},
{ type: 'text', text: INSTRUCTION },
],
},
],
})
})
it('sends an image as a base64 image block with its media type', async () => {
const svc = createAnthropicFamilyService(readAiConfig())
const jpeg = Buffer.from('JPEG')
await svc.extractFromDocument({
document: { kind: 'image', data: jpeg, mediaType: 'image/jpeg' },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 8192,
})
expect(mockCreate.mock.calls[0][0].messages[0].content).toEqual([
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: jpeg.toString('base64') } },
{ type: 'text', text: INSTRUCTION },
])
})
it('sends converted HTML as two text blocks', async () => {
const svc = createAnthropicFamilyService(readAiConfig())
await svc.extractFromDocument({
document: { kind: 'text', text: 'The document is an HTML email invoice, converted to plain text:\n\nTotal 100' },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 8192,
})
expect(mockCreate.mock.calls[0][0].messages[0].content).toEqual([
{ type: 'text', text: 'The document is an HTML email invoice, converted to plain text:\n\nTotal 100' },
{ type: 'text', text: INSTRUCTION },
])
})
it('returns the text, model and usage', async () => {
const svc = createAnthropicFamilyService(readAiConfig())
const result = await svc.extractFromDocument({
document: { kind: 'text', text: 'x' },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 100,
})
expect(result).toEqual({
ok: true,
text: '{"ok":true}',
model: 'eu.anthropic.claude-sonnet-5',
usage: { inputTokens: 10, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 7 },
})
})
it('honours the extraction tier override (legacy BEDROCK_MODEL_ID)', async () => {
process.env.BEDROCK_MODEL_ID = 'claude-sonnet-4-6'
const svc = createAnthropicFamilyService(readAiConfig())
await svc.extractFromDocument({ document: { kind: 'text', text: 'x' }, system: SYSTEM, instruction: INSTRUCTION, maxTokens: 1 })
expect(mockCreate.mock.calls[0][0].model).toBe('eu.anthropic.claude-sonnet-4-6')
})
it('skips without a call when the deployment has no credentials', async () => {
delete process.env.AWS_ACCESS_KEY_ID
delete process.env.AWS_SECRET_ACCESS_KEY
const svc = createAnthropicFamilyService(readAiConfig())
const result = await svc.extractFromDocument({ document: { kind: 'text', text: 'x' }, system: SYSTEM, instruction: INSTRUCTION, maxTokens: 1 })
expect(result).toEqual({ ok: false, skipped: 'ai_unconfigured' })
expect(mockCreate).not.toHaveBeenCalled()
})
})
describe('generateText / generateStructured', () => {
it('generateText sends a plain user message with an optional system string', async () => {
const svc = createAnthropicFamilyService(readAiConfig())
const result = await svc.generateText({ tier: 'assistant', system: 'S', prompt: 'Hej', maxTokens: 50 })
expect(mockCreate.mock.calls[0][0]).toEqual({
model: 'eu.anthropic.claude-sonnet-5',
max_tokens: 50,
system: 'S',
messages: [{ role: 'user', content: 'Hej' }],
})
expect(result.text).toBe('{"ok":true}')
})
it('generateStructured forces one named tool and returns its input', async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: 'tool_use', id: 't1', name: 'verdict', input: { paired: true } }],
usage: { input_tokens: 1, output_tokens: 1 },
})
const svc = createAnthropicFamilyService(readAiConfig())
const schema = { type: 'object', properties: { paired: { type: 'boolean' } }, required: ['paired'] }
const result = await svc.generateStructured({
tier: 'heavy',
prompt: 'Decide',
maxTokens: 200,
schema: { name: 'verdict', description: 'd', jsonSchema: schema },
})
expect(mockCreate.mock.calls[0][0]).toEqual({
model: 'eu.anthropic.claude-sonnet-5',
max_tokens: 200,
tools: [{ name: 'verdict', description: 'd', input_schema: schema }],
tool_choice: { type: 'tool', name: 'verdict' },
messages: [{ role: 'user', content: 'Decide' }],
})
expect(result.value).toEqual({ paired: true })
})
it('generateStructured throws when the model ignored the forced tool', async () => {
mockCreate.mockResolvedValueOnce({ content: [{ type: 'text', text: 'nope' }], usage: {} })
const svc = createAnthropicFamilyService(readAiConfig())
await expect(
svc.generateStructured({ tier: 'assistant', prompt: 'x', maxTokens: 10, schema: { name: 'v', jsonSchema: {} } })
).rejects.toThrow(/forced tool/)
})
})
describe('buildAnthropicDocumentContent', () => {
it('is a pure function of the document and instruction', () => {
expect(buildAnthropicDocumentContent({ kind: 'text', text: 'a' }, 'b')).toEqual([
{ type: 'text', text: 'a' },
{ type: 'text', text: 'b' },
])
})
})
+226
View File
@@ -0,0 +1,226 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getAiStatus, readAiConfig, resolveTierModel } from '../config'
import { createAiClient, AiProviderUnsupportedError, hasAiCredentials, resolveAiProvider, toProviderModelId } from '../provider'
const KEYS = [
'AI_PROVIDER',
'AI_BASE_URL',
'AI_API_KEY',
'AI_MODEL',
'AI_ASSISTANT_MODEL',
'AI_HEAVY_MODEL',
'AI_EXTRACTION_MODEL',
'AI_EXTRACTION_MAX_TOKENS',
'AI_VISION',
'AI_STRICT_JSON',
'AI_PDF_MODE',
'AI_PDF_MAX_PAGES',
'ANTHROPIC_API_KEY',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_REGION',
'BEDROCK_MODEL_ID',
'BEDROCK_SONNET_MODEL_ID',
'BEDROCK_OPUS_MODEL_ID',
'BEDROCK_MAX_TOKENS',
] as const
let saved: Partial<Record<(typeof KEYS)[number], string | undefined>> = {}
beforeEach(() => {
saved = {}
for (const k of KEYS) {
saved[k] = process.env[k]
delete process.env[k]
}
})
afterEach(() => {
for (const k of KEYS) {
if (saved[k] === undefined) delete process.env[k]
else process.env[k] = saved[k]
}
})
function hosted() {
process.env.AWS_ACCESS_KEY_ID = 'AKIAEXAMPLE'
process.env.AWS_SECRET_ACCESS_KEY = 'secret'
}
function byo(model = 'google/gemma-4-31B-it') {
process.env.AI_BASE_URL = 'https://api.berget.ai/v1'
process.env.AI_API_KEY = 'sk-berget-example'
if (model) process.env.AI_MODEL = model
}
describe('provider resolution with an OpenAI-compatible endpoint', () => {
it('auto-detects AI_BASE_URL + AI_API_KEY when no Anthropic-family credentials exist', () => {
byo()
expect(resolveAiProvider()).toBe('openai-compatible')
expect(hasAiCredentials()).toBe(true)
})
// Hosted stays hosted: a stray BYO pair must never move inference.
it('keeps Bedrock ahead of a BYO endpoint when static AWS keys are present', () => {
hosted()
byo()
expect(resolveAiProvider()).toBe('bedrock')
})
it('keeps the direct Anthropic API ahead of a BYO endpoint', () => {
process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-example'
byo()
expect(resolveAiProvider()).toBe('anthropic')
})
it('honours AI_PROVIDER=openai-compatible as the explicit escape hatch', () => {
hosted()
byo()
process.env.AI_PROVIDER = 'openai-compatible'
expect(resolveAiProvider()).toBe('openai-compatible')
})
// AI_API_KEY is optional: a local OpenAI-compatible server usually has no
// auth, so a base URL alone counts as configured. A key is added only when
// the endpoint requires one.
it('counts a base URL alone as credentials (keyless local server)', () => {
process.env.AI_PROVIDER = 'openai-compatible'
process.env.AI_BASE_URL = 'http://localhost:11434/v1'
expect(hasAiCredentials()).toBe(true)
})
it('is not configured with no base URL at all', () => {
process.env.AI_PROVIDER = 'openai-compatible'
expect(hasAiCredentials()).toBe(false)
})
it('passes bare model ids through untouched', () => {
expect(toProviderModelId('google/gemma-4-31B-it', 'openai-compatible')).toBe('google/gemma-4-31B-it')
})
// The chat loop and the other direct SDK callers cannot run here: fail
// loudly at construction rather than with an opaque error at call time.
it('refuses to build an Anthropic client for it', () => {
byo()
expect(() => createAiClient()).toThrow(AiProviderUnsupportedError)
})
})
describe('resolveTierModel precedence', () => {
it('defaults every tier to Claude on the Anthropic family', () => {
hosted()
expect(resolveTierModel('assistant')).toBe('claude-sonnet-5')
expect(resolveTierModel('heavy')).toBe('claude-sonnet-5')
expect(resolveTierModel('extraction')).toBe('claude-sonnet-5')
})
// Hosted operators set BEDROCK_* today; those must keep winning over the
// new generic AI_MODEL so a migration to the new names is a no-op until
// someone deliberately moves.
it('lets the legacy Bedrock tier variables beat AI_MODEL, and AI_<TIER>_MODEL beat both', () => {
hosted()
process.env.AI_MODEL = 'generic'
process.env.BEDROCK_MODEL_ID = 'legacy-extraction'
process.env.BEDROCK_SONNET_MODEL_ID = 'legacy-assistant'
process.env.BEDROCK_OPUS_MODEL_ID = 'legacy-heavy'
expect(resolveTierModel('extraction')).toBe('legacy-extraction')
expect(resolveTierModel('assistant')).toBe('legacy-assistant')
expect(resolveTierModel('heavy')).toBe('legacy-heavy')
process.env.AI_EXTRACTION_MODEL = 'specific-extraction'
expect(resolveTierModel('extraction')).toBe('specific-extraction')
})
it('has no default on an OpenAI-compatible endpoint', () => {
byo('')
expect(resolveTierModel('extraction')).toBeNull()
process.env.AI_MODEL = 'x'
expect(resolveTierModel('extraction')).toBe('x')
process.env.AI_EXTRACTION_MODEL = 'y'
expect(resolveTierModel('extraction')).toBe('y')
expect(resolveTierModel('assistant')).toBe('x')
})
})
describe('readAiConfig', () => {
it('reads the extraction output cap from the new name, then the legacy name, then 8192', () => {
hosted()
expect(readAiConfig().extractionMaxTokens).toBe(8192)
process.env.BEDROCK_MAX_TOKENS = '4000'
expect(readAiConfig().extractionMaxTokens).toBe(4000)
process.env.AI_EXTRACTION_MAX_TOKENS = '12000'
expect(readAiConfig().extractionMaxTokens).toBe(12000)
})
// A deliberate `0` is an invalid configuration, not "disable": fall back.
it('ignores non-positive or garbage token caps', () => {
hosted()
process.env.AI_EXTRACTION_MAX_TOKENS = '0'
expect(readAiConfig().extractionMaxTokens).toBe(8192)
process.env.AI_EXTRACTION_MAX_TOKENS = 'lots'
expect(readAiConfig().extractionMaxTokens).toBe(8192)
})
it('rasterizes PDFs by default on OpenAI-compatible endpoints and reads them natively on Claude', () => {
byo()
expect(readAiConfig().pdfMode).toBe('rasterize')
process.env.AI_PDF_MODE = 'native'
expect(readAiConfig().pdfMode).toBe('native')
})
it('reads PDFs natively on the Anthropic family regardless', () => {
hosted()
expect(readAiConfig().pdfMode).toBe('native')
})
it('parses the boolean flags with sane defaults', () => {
byo()
expect(readAiConfig().vision).toBe(true)
expect(readAiConfig().strictJson).toBe(false)
process.env.AI_VISION = 'false'
process.env.AI_STRICT_JSON = '1'
expect(readAiConfig().vision).toBe(false)
expect(readAiConfig().strictJson).toBe(true)
})
})
describe('getAiStatus', () => {
it('is unconfigured with a reason when nothing is set', () => {
const s = getAiStatus()
expect(s.configured).toBe(false)
expect(s.reason).toBe('no_credentials')
expect(s.assistantAvailable).toBe(false)
})
it('is configured and assistant-capable on hosted', () => {
hosted()
const s = getAiStatus()
expect(s.provider).toBe('bedrock')
expect(s.configured).toBe(true)
expect(s.assistantAvailable).toBe(true)
expect(s.capabilities.pdfNative).toBe(true)
expect(s.models.extraction).toBe('eu.anthropic.claude-sonnet-5')
})
it('needs a model id on an OpenAI-compatible endpoint before it counts as configured', () => {
byo('')
const s = getAiStatus()
expect(s.configured).toBe(false)
expect(s.reason).toBe('no_model')
})
// Extraction and single-call jobs run; the chat loop does not (yet).
it('is configured but not assistant-capable on an OpenAI-compatible endpoint', () => {
byo()
const s = getAiStatus()
expect(s.provider).toBe('openai-compatible')
expect(s.configured).toBe(true)
expect(s.assistantAvailable).toBe(false)
expect(s.capabilities.pdfNative).toBe(false)
expect(s.capabilities.imageInput).toBe(true)
expect(s.models.extraction).toBe('google/gemma-4-31B-it')
})
it('reports AI_VISION=false as no image input', () => {
byo()
process.env.AI_VISION = 'false'
expect(getAiStatus().capabilities.imageInput).toBe(false)
})
})
+242
View File
@@ -0,0 +1,242 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { MockLanguageModelV3 } from 'ai/test'
// Replace the provider factory with one that hands out a mock model, then
// run the REAL `generateText` from the AI SDK through it: the assertions see
// the prompt the SDK would put on the wire (image parts, file parts, system),
// which is what an OpenAI-compatible endpoint receives.
const doGenerate = vi.fn()
const createdWith = vi.fn()
vi.mock('@ai-sdk/openai-compatible', () => ({
createOpenAICompatible: (settings: unknown) => {
createdWith(settings)
const factory = (modelId: string) =>
new MockLanguageModelV3({
modelId,
doGenerate: async (options: unknown) => doGenerate(options),
})
return factory
},
}))
const rasterizeMock = vi.fn()
vi.mock('../rasterize-pdf', () => ({
rasterizePdf: (...args: unknown[]) => rasterizeMock(...args),
}))
import { readAiConfig } from '../config'
import { createOpenAICompatibleService } from '../services/openai-compatible'
const ENV = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'ANTHROPIC_API_KEY', 'AI_PROVIDER', 'AI_BASE_URL', 'AI_API_KEY', 'AI_MODEL', 'AI_EXTRACTION_MODEL', 'AI_VISION', 'AI_STRICT_JSON', 'AI_PDF_MODE', 'AI_PDF_MAX_PAGES'] as const
let saved: Record<string, string | undefined> = {}
function textResponse(text: string) {
return {
content: [{ type: 'text', text }],
finishReason: { unified: 'stop', raw: undefined },
usage: {
inputTokens: { total: 42, noCache: 40, cacheRead: 2, cacheWrite: undefined },
outputTokens: { total: 7, text: 7, reasoning: undefined },
},
warnings: [],
}
}
beforeEach(() => {
vi.clearAllMocks()
saved = {}
for (const k of ENV) {
saved[k] = process.env[k]
delete process.env[k]
}
process.env.AI_BASE_URL = 'https://api.berget.ai/v1'
process.env.AI_API_KEY = 'sk-berget-example'
process.env.AI_MODEL = 'google/gemma-4-31B-it'
doGenerate.mockResolvedValue(textResponse('{"supplier":"x"}'))
})
afterEach(() => {
for (const k of ENV) {
if (saved[k] === undefined) delete process.env[k]
else process.env[k] = saved[k]
}
})
const SYSTEM = 'You extract fields.'
const INSTRUCTION = 'Extract the fields per the schema. JSON only.'
function promptOf(call = 0) {
return doGenerate.mock.calls[call][0].prompt as Array<{ role: string; content: unknown }>
}
describe('createOpenAICompatibleService', () => {
it('builds the provider from AI_BASE_URL / AI_API_KEY', () => {
createOpenAICompatibleService(readAiConfig())
expect(createdWith).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: 'https://api.berget.ai/v1', apiKey: 'sk-berget-example', supportsStructuredOutputs: false })
)
})
it('reports capabilities from the config: rasterized PDFs, vision on, no strict JSON by default', () => {
const svc = createOpenAICompatibleService(readAiConfig())
expect(svc.capabilities).toEqual({
pdfNative: false,
imageInput: true,
toolUse: true,
forcedToolChoice: false,
strictJsonSchema: false,
})
expect(svc.modelFor('extraction')).toBe('google/gemma-4-31B-it')
})
it('generateText sends system + prompt and maps usage', async () => {
const svc = createOpenAICompatibleService(readAiConfig())
const result = await svc.generateText({ tier: 'assistant', system: 'S', prompt: 'Hej', maxTokens: 50 })
const prompt = promptOf()
expect(prompt[0]).toEqual({ role: 'system', content: 'S' })
expect(prompt[1].role).toBe('user')
expect(doGenerate.mock.calls[0][0].maxOutputTokens).toBe(50)
expect(result).toEqual({
text: '{"supplier":"x"}',
model: 'google/gemma-4-31B-it',
usage: { inputTokens: 42, outputTokens: 7, cacheCreationInputTokens: null, cacheReadInputTokens: 2 },
})
})
it('extractFromDocument sends an image as an image part followed by the instruction', async () => {
const svc = createOpenAICompatibleService(readAiConfig())
const jpeg = Buffer.from('JPEG')
const result = await svc.extractFromDocument({
document: { kind: 'image', data: jpeg, mediaType: 'image/jpeg' },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 8192,
})
expect(result.ok).toBe(true)
const prompt = promptOf()
expect(prompt[0]).toEqual({ role: 'system', content: SYSTEM })
const user = prompt[1].content as Array<{ type: string; mediaType?: string; text?: string }>
expect(user[0].type).toBe('file')
expect(user[0].mediaType).toBe('image/jpeg')
expect(user[1]).toEqual({ type: 'text', text: INSTRUCTION })
})
it('extractFromDocument sends plain text as two text parts (works on text-only models)', async () => {
process.env.AI_VISION = 'false'
const svc = createOpenAICompatibleService(readAiConfig())
const result = await svc.extractFromDocument({
document: { kind: 'text', text: 'Total 100' },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 100,
})
expect(result.ok).toBe(true)
const user = promptOf()[1].content as Array<{ type: string; text?: string }>
expect(user).toEqual([
{ type: 'text', text: 'Total 100' },
{ type: 'text', text: INSTRUCTION },
])
})
it('rasterizes PDFs by default and sends one image part per page', async () => {
rasterizeMock.mockResolvedValue({
ok: true,
pages: [Buffer.from('p1'), Buffer.from('p2')],
mediaType: 'image/png',
pageCount: 2,
})
const svc = createOpenAICompatibleService(readAiConfig())
const result = await svc.extractFromDocument({
document: { kind: 'pdf', data: Buffer.from('%PDF'), fileName: 'f.pdf' },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 100,
})
expect(rasterizeMock).toHaveBeenCalledWith(expect.any(Buffer), { maxPages: 4 })
expect(result).toMatchObject({ ok: true, pagesRasterized: 2 })
const user = promptOf()[1].content as Array<{ type: string; mediaType?: string }>
expect(user.map((p) => p.type)).toEqual(['file', 'file', 'text'])
expect(user[0].mediaType).toBe('image/png')
})
it('sends the PDF as a native file part when AI_PDF_MODE=native', async () => {
process.env.AI_PDF_MODE = 'native'
const svc = createOpenAICompatibleService(readAiConfig())
await svc.extractFromDocument({
document: { kind: 'pdf', data: Buffer.from('%PDF'), fileName: 'f.pdf' },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 100,
})
expect(rasterizeMock).not.toHaveBeenCalled()
const user = promptOf()[1].content as Array<{ type: string; mediaType?: string; filename?: string }>
expect(user[0]).toMatchObject({ type: 'file', mediaType: 'application/pdf', filename: 'f.pdf' })
})
// Honest skips, never fake failures: the caller stamps the reason.
it('skips images and PDFs when AI_VISION=false', async () => {
process.env.AI_VISION = 'false'
const svc = createOpenAICompatibleService(readAiConfig())
const image = await svc.extractFromDocument({
document: { kind: 'image', data: Buffer.from('x'), mediaType: 'image/png' },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 1,
})
const pdf = await svc.extractFromDocument({
document: { kind: 'pdf', data: Buffer.from('%PDF') },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 1,
})
expect(image).toEqual({ ok: false, skipped: 'ai_no_vision' })
expect(pdf).toEqual({ ok: false, skipped: 'ai_no_vision' })
expect(doGenerate).not.toHaveBeenCalled()
})
it('skips with pdf_rasterizer_missing when poppler is not installed', async () => {
rasterizeMock.mockResolvedValue({ ok: false, reason: 'rasterizer_missing' })
const svc = createOpenAICompatibleService(readAiConfig())
const result = await svc.extractFromDocument({
document: { kind: 'pdf', data: Buffer.from('%PDF') },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 1,
})
expect(result).toEqual({ ok: false, skipped: 'pdf_rasterizer_missing' })
expect(doGenerate).not.toHaveBeenCalled()
})
it('skips with ai_unconfigured when the model is missing', async () => {
delete process.env.AI_MODEL
const svc = createOpenAICompatibleService(readAiConfig())
const result = await svc.extractFromDocument({
document: { kind: 'text', text: 'x' },
system: SYSTEM,
instruction: INSTRUCTION,
maxTokens: 1,
})
expect(result).toEqual({ ok: false, skipped: 'ai_unconfigured' })
})
it('generateStructured without strict JSON embeds the schema and parses the first JSON object', async () => {
doGenerate.mockResolvedValueOnce(textResponse('Here you go:\n```json\n{"paired": true}\n```'))
const svc = createOpenAICompatibleService(readAiConfig())
const result = await svc.generateStructured({
tier: 'heavy',
prompt: 'Decide',
maxTokens: 100,
schema: { name: 'verdict', description: 'pairing verdict', jsonSchema: { type: 'object' } },
})
expect(result.value).toEqual({ paired: true })
const system = promptOf()[0].content as string
expect(system).toContain('JSON Schema')
expect(system).toContain('pairing verdict')
})
it('turns on structured outputs at the provider when AI_STRICT_JSON=true', () => {
process.env.AI_STRICT_JSON = 'true'
const svc = createOpenAICompatibleService(readAiConfig())
expect(createdWith).toHaveBeenLastCalledWith(expect.objectContaining({ supportsStructuredOutputs: true }))
expect(svc.capabilities.strictJsonSchema).toBe(true)
})
})
+7
View File
@@ -11,6 +11,8 @@ import {
const AI_ENV_KEYS = [
'AI_PROVIDER',
'AI_BASE_URL',
'AI_API_KEY',
'ANTHROPIC_API_KEY',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
@@ -100,6 +102,11 @@ describe('hasAiCredentials', () => {
expect(hasAiCredentials()).toBe(false)
})
it('is true for an OpenAI-compatible base URL with no key (keyless local server)', () => {
process.env.AI_BASE_URL = 'http://localhost:11434/v1'
expect(hasAiCredentials()).toBe(true)
})
it('is true for an Anthropic key', () => {
process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-example'
expect(hasAiCredentials()).toBe(true)
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { writeFile } from 'node:fs/promises'
// Fake pdftoppm: the module shells out with (-r dpi -png -f 1 -l N input prefix)
// and reads back <prefix>-<n>.png. The fake writes those files itself, so the
// read-back, ordering and cleanup paths run for real on a temp dir.
const execFileMock = vi.fn()
vi.mock('node:child_process', () => ({
execFile: (...args: unknown[]) => execFileMock(...args),
}))
import { rasterizePdf } from '../rasterize-pdf'
type Cb = (err: Error | null, out?: { stdout: string; stderr: string }) => void
beforeEach(() => {
vi.clearAllMocks()
})
describe('rasterizePdf', () => {
it('renders the first pages in order and cleans up', async () => {
execFileMock.mockImplementation((bin: string, args: string[], _opts: unknown, cb: Cb) => {
expect(bin).toBe('pdftoppm')
expect(args.slice(0, 6)).toEqual(['-r', '110', '-png', '-f', '1', '-l'])
const prefix = args[args.length - 1]
void (async () => {
// Two pages, written out of order to prove numeric sorting.
await writeFile(`${prefix}-2.png`, Buffer.from('PAGE2'))
await writeFile(`${prefix}-1.png`, Buffer.from('PAGE1'))
cb(null, { stdout: '', stderr: '' })
})()
})
const result = await rasterizePdf(Buffer.from('%PDF'), { maxPages: 4 })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.pageCount).toBe(2)
expect(result.pages.map((p) => p.toString())).toEqual(['PAGE1', 'PAGE2'])
expect(result.mediaType).toBe('image/png')
const args = execFileMock.mock.calls[0][1] as string[]
expect(args[6]).toBe('4') // -l maxPages
})
it('reports a missing binary as rasterizer_missing', async () => {
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Cb) => {
const err = Object.assign(new Error('spawn pdftoppm ENOENT'), { code: 'ENOENT' })
cb(err)
})
const result = await rasterizePdf(Buffer.from('%PDF'), { maxPages: 4 })
expect(result).toEqual({ ok: false, reason: 'rasterizer_missing' })
})
it('reports any other failure as failed with the message', async () => {
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Cb) => {
cb(new Error('Syntax Error: Couldn\'t read xref table'))
})
const result = await rasterizePdf(Buffer.from('not a pdf'), { maxPages: 2 })
expect(result).toMatchObject({ ok: false, reason: 'failed' })
})
it('treats a run that produced no pages as failed', async () => {
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Cb) => {
cb(null, { stdout: '', stderr: '' })
})
const result = await rasterizePdf(Buffer.from('%PDF'), { maxPages: 1 })
expect(result).toMatchObject({ ok: false, reason: 'failed' })
})
it('honours a binary override', async () => {
execFileMock.mockImplementation((bin: string, _args: string[], _opts: unknown, cb: Cb) => {
expect(bin).toBe('/opt/poppler/bin/pdftoppm')
cb(new Error('x'))
})
await rasterizePdf(Buffer.from('%PDF'), { maxPages: 1, binary: '/opt/poppler/bin/pdftoppm' })
expect(execFileMock).toHaveBeenCalled()
})
})
+180
View File
@@ -0,0 +1,180 @@
import {
hasAiCredentials,
resolveAiProvider,
toProviderModelId,
type AiProvider,
} from './provider'
import type { AiCapabilities, AiPdfMode, AiStatus, AiTier } from './types'
/**
* Environment parsing for the AI layer. Read on every call (cheap string
* reads) so tests and the smoke script see env changes; the service cache in
* lib/ai/index.ts keys on the resolved config, not on this module's state.
*
* Variables (all optional; legacy BEDROCK_* names keep working):
*
* AI_PROVIDER bedrock | anthropic | openai-compatible (else auto-detect)
* AI_BASE_URL OpenAI-compatible endpoint (Swedish provider or a local model)
* AI_API_KEY optional: only when that endpoint requires auth
* AI_MODEL default model id for every tier
* AI_ASSISTANT_MODEL per-tier overrides (fallbacks: BEDROCK_SONNET_MODEL_ID,
* AI_HEAVY_MODEL BEDROCK_OPUS_MODEL_ID, BEDROCK_MODEL_ID respectively)
* AI_EXTRACTION_MODEL
* AI_EXTRACTION_MAX_TOKENS output cap for document extraction (fallback BEDROCK_MAX_TOKENS, then 8192)
* AI_VISION OpenAI-compatible only: the configured models accept images (default true)
* AI_STRICT_JSON OpenAI-compatible only: use response_format json_schema (default false)
* AI_PDF_MODE auto | native | rasterize (auto = native on Claude, rasterize elsewhere)
* AI_PDF_MAX_PAGES pages rasterized per PDF (default 4)
*/
export interface ResolvedAiConfig {
provider: AiProvider
/** Credentials present (and, for OpenAI-compatible, a model id). */
configured: boolean
reason: AiStatus['reason']
baseUrl: string | null
apiKey: string | null
/** Bare model ids per tier (null only when OpenAI-compatible has none configured). */
models: Record<AiTier, string | null>
extractionMaxTokens: number
vision: boolean
strictJson: boolean
pdfMode: AiPdfMode
pdfMaxPages: number
}
const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-5'
const DEFAULT_EXTRACTION_MAX_TOKENS = 8192
const DEFAULT_PDF_MAX_PAGES = 4
function env(name: string): string | null {
const v = process.env[name]
if (v === undefined) return null
const trimmed = v.trim()
return trimmed.length > 0 ? trimmed : null
}
function envBool(name: string, fallback: boolean): boolean {
const v = env(name)?.toLowerCase()
if (v === null || v === undefined) return fallback
if (v === 'true' || v === '1' || v === 'yes' || v === 'on') return true
if (v === 'false' || v === '0' || v === 'no' || v === 'off') return false
return fallback
}
// Use the env value only if it's a positive number: `||` would also fall back
// on a deliberate `0`, masking what is really an invalid configuration rather
// than the intent to disable.
function envPositiveInt(name: string): number | null {
const raw = env(name)
if (raw === null) return null
const parsed = Number(raw)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
}
const LEGACY_TIER_VARS: Record<AiTier, string> = {
assistant: 'BEDROCK_SONNET_MODEL_ID',
heavy: 'BEDROCK_OPUS_MODEL_ID',
extraction: 'BEDROCK_MODEL_ID',
}
const TIER_VARS: Record<AiTier, string> = {
assistant: 'AI_ASSISTANT_MODEL',
heavy: 'AI_HEAVY_MODEL',
extraction: 'AI_EXTRACTION_MODEL',
}
/**
* Model id for a tier, most specific wins: AI_<TIER>_MODEL, then the legacy
* Bedrock-era tier variable, then AI_MODEL, then the Claude default on the
* Anthropic family. An OpenAI-compatible endpoint has no sane default model,
* so the result is null there and the status reports `no_model`.
*/
export function resolveTierModel(tier: AiTier, provider: AiProvider = resolveAiProvider()): string | null {
const specific = env(TIER_VARS[tier]) ?? env(LEGACY_TIER_VARS[tier]) ?? env('AI_MODEL')
if (specific) return specific
return provider === 'openai-compatible' ? null : DEFAULT_CLAUDE_MODEL
}
export function readAiConfig(): ResolvedAiConfig {
const provider = resolveAiProvider()
const models: Record<AiTier, string | null> = {
assistant: resolveTierModel('assistant', provider),
heavy: resolveTierModel('heavy', provider),
extraction: resolveTierModel('extraction', provider),
}
const credentials = hasAiCredentials()
const isOpenAiCompatible = provider === 'openai-compatible'
const hasModels = !isOpenAiCompatible || Object.values(models).every((m) => m !== null)
const pdfModeRaw = env('AI_PDF_MODE')?.toLowerCase()
const pdfMode: AiPdfMode =
pdfModeRaw === 'native' || pdfModeRaw === 'rasterize'
? pdfModeRaw
: isOpenAiCompatible
? 'rasterize'
: 'native'
return {
provider,
configured: credentials && hasModels,
reason: !credentials ? 'no_credentials' : !hasModels ? 'no_model' : 'ok',
baseUrl: isOpenAiCompatible ? env('AI_BASE_URL') : null,
apiKey: isOpenAiCompatible ? env('AI_API_KEY') : null,
models,
extractionMaxTokens:
envPositiveInt('AI_EXTRACTION_MAX_TOKENS') ??
envPositiveInt('BEDROCK_MAX_TOKENS') ??
DEFAULT_EXTRACTION_MAX_TOKENS,
vision: envBool('AI_VISION', true),
strictJson: envBool('AI_STRICT_JSON', false),
pdfMode,
pdfMaxPages: envPositiveInt('AI_PDF_MAX_PAGES') ?? DEFAULT_PDF_MAX_PAGES,
}
}
export function capabilitiesFor(cfg: ResolvedAiConfig): AiCapabilities {
if (cfg.provider === 'openai-compatible') {
return {
pdfNative: cfg.pdfMode === 'native',
imageInput: cfg.vision,
toolUse: true,
forcedToolChoice: false,
strictJsonSchema: cfg.strictJson,
}
}
return {
pdfNative: true,
imageInput: true,
toolUse: true,
forcedToolChoice: true,
strictJsonSchema: false,
}
}
/**
* Single source of truth for "is AI wired up here, and for what". Cheap: env
* reads only, no network. Drives the extraction fail-fast path (a document is
* stamped `skipped:ai_unconfigured` instead of waiting 30 s for nothing), the
* status route, agent routes (503 instead of a stream that dies), and the
* smoke script.
*/
export function getAiStatus(): AiStatus {
const cfg = readAiConfig()
const capabilities = capabilitiesFor(cfg)
const models: Record<AiTier, string | null> = {
assistant: cfg.models.assistant ? toProviderModelId(cfg.models.assistant, cfg.provider) : null,
heavy: cfg.models.heavy ? toProviderModelId(cfg.models.heavy, cfg.provider) : null,
extraction: cfg.models.extraction ? toProviderModelId(cfg.models.extraction, cfg.provider) : null,
}
return {
provider: cfg.provider,
configured: cfg.configured,
reason: cfg.reason,
capabilities,
models,
pdfMode: cfg.pdfMode,
// The chat loop still speaks the Anthropic messages surface directly.
assistantAvailable: cfg.configured && cfg.provider !== 'openai-compatible',
}
}
+66
View File
@@ -0,0 +1,66 @@
import { getAiStatus, readAiConfig, type ResolvedAiConfig } from './config'
import { createAnthropicFamilyService } from './services/anthropic-family'
import { createOpenAICompatibleService } from './services/openai-compatible'
import type { AiService } from './types'
export type {
AiCapabilities,
AiDocumentInput,
AiImageMediaType,
AiPdfMode,
AiProviderKind,
AiService,
AiStatus,
AiTier,
AiUsage,
ExtractFromDocumentRequest,
ExtractFromDocumentResult,
ExtractionSkipReason,
GenerateStructuredRequest,
GenerateStructuredResult,
GenerateTextRequest,
GenerateTextResult,
} from './types'
export { getAiStatus, readAiConfig, resolveTierModel } from './config'
export { extractJsonObject } from './json'
// One service per resolved configuration. Keyed on the non-secret parts of
// the config plus credential presence, so a changed environment (tests, the
// smoke script) gets a fresh service while a long-lived process reuses one.
let cached: { key: string; service: AiService } | null = null
function cacheKey(cfg: ResolvedAiConfig): string {
return JSON.stringify({
provider: cfg.provider,
configured: cfg.configured,
baseUrl: cfg.baseUrl,
hasKey: !!cfg.apiKey,
models: cfg.models,
vision: cfg.vision,
strictJson: cfg.strictJson,
pdfMode: cfg.pdfMode,
pdfMaxPages: cfg.pdfMaxPages,
})
}
/**
* The AI service for this deployment. Never throws on construction: an
* unconfigured deployment still gets a service whose extractFromDocument
* answers `skipped: ai_unconfigured`, so upload paths degrade quietly.
*/
export function getAiService(): AiService {
const cfg = readAiConfig()
const key = cacheKey(cfg)
if (cached && cached.key === key) return cached.service
const service =
cfg.provider === 'openai-compatible'
? createOpenAICompatibleService(cfg)
: createAnthropicFamilyService(cfg)
cached = { key, service }
return service
}
/** Tests only: drop the cached service so the next call re-reads the environment. */
export function resetAiServiceForTests(): void {
cached = null
}
+55
View File
@@ -0,0 +1,55 @@
// Bounds for the candidate scan below. Real model output is already capped by
// the caller's max_tokens (roughly 33 KB of text at 8192 tokens), so genuine
// responses never come near these; they exist so pathological or adversarially
// brace-laden text cannot make the scan quadratic (compliance review A.8.28).
// Oversized or exhausted inputs fall through to the raw text and land in the
// caller's existing parse-failure path.
const MAX_SCAN_INPUT_LENGTH = 256 * 1024
const MAX_CANDIDATE_ATTEMPTS = 50
/**
* Models intermittently wrap a JSON answer in markdown fences (```json ... ```)
* or add prose around it despite a JSON-only instruction. Scan for balanced
* top-level '{'..'}' candidates (string- and escape-aware, so braces inside
* JSON string values don't end a candidate early) and return the first one
* JSON.parse accepts; prose braces around the object form unparseable
* candidates and are skipped. Returns the input unchanged when no candidate
* parses, so the caller's parse-failure path handles prose-only refusals.
* Schema validation downstream still rejects well-formed-but-wrong JSON.
*/
export function extractJsonObject(raw: string): string {
if (raw.length > MAX_SCAN_INPUT_LENGTH) return raw
let attempts = 0
let start = raw.indexOf('{')
while (start !== -1 && attempts < MAX_CANDIDATE_ATTEMPTS) {
attempts++
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < raw.length; i++) {
const ch = raw[i]
if (inString) {
if (escaped) escaped = false
else if (ch === '\\') escaped = true
else if (ch === '"') inString = false
} else if (ch === '"') {
inString = true
} else if (ch === '{') {
depth++
} else if (ch === '}') {
depth--
if (depth === 0) {
const candidate = raw.slice(start, i + 1)
try {
JSON.parse(candidate)
return candidate
} catch {
break
}
}
}
}
start = raw.indexOf('{', start + 1)
}
return raw
}
+58 -13
View File
@@ -2,17 +2,43 @@ import Anthropic from '@anthropic-ai/sdk'
import AnthropicBedrock from '@anthropic-ai/bedrock-sdk'
/**
* Which backend Claude traffic goes to.
* Which backend AI 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.
* ANTHROPIC_API_KEY, or any OpenAI-compatible endpoint (the Swedish inference
* providers a sovereign self-host points at, or a local model) via
* AI_BASE_URL (+ AI_API_KEY when the endpoint requires auth).
*
* The two Anthropic-family backends share the `messages.create/stream`
* surface this factory hands out. The OpenAI-compatible backend does not: it
* is reachable only through the job-shaped service in lib/ai (getAiService),
* and createAiClient() refuses it loudly rather than returning a client that
* would fail at call time.
*
* See https://github.com/erp-mafia/accounted/issues/1406.
*/
export type AiProvider = 'bedrock' | 'anthropic'
export type AiProvider = 'bedrock' | 'anthropic' | 'openai-compatible'
/**
* Thrown by createAiClient() when the resolved backend has no Anthropic
* messages surface. Surfaces that still call the SDK directly (the chat loop,
* composer, receipt hunt, WhatsApp interpreter) are unavailable on such a
* backend until they move onto getAiService(); routes check
* getAiStatus().assistantAvailable first so users get a 503 instead of this.
*/
export class AiProviderUnsupportedError extends Error {
readonly provider: AiProvider
constructor(provider: AiProvider) {
super(
`AI provider "${provider}" has no Anthropic messages surface; use getAiService() from @/lib/ai instead of createAiClient()`
)
this.name = 'AiProviderUnsupportedError'
this.provider = provider
}
}
export type AiClient = Anthropic | AnthropicBedrock
@@ -29,6 +55,8 @@ export type AiClient = Anthropic | AnthropicBedrock
* silently move production inference out of eu-north-1.
* 3. Otherwise an Anthropic key means the direct API. This is the
* self-hosted path.
* 3b. Otherwise an OpenAI-compatible base URL + key means that endpoint.
* This is the sovereign self-hosted path (BYO Swedish provider).
* 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
@@ -38,10 +66,13 @@ export type AiClient = Anthropic | AnthropicBedrock
*/
export function resolveAiProvider(): AiProvider {
const explicit = (process.env.AI_PROVIDER ?? '').trim().toLowerCase()
if (explicit === 'bedrock' || explicit === 'anthropic') return explicit
if (explicit === 'bedrock' || explicit === 'anthropic' || explicit === 'openai-compatible') {
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'
if (process.env.AI_BASE_URL) return 'openai-compatible'
return 'bedrock'
}
@@ -55,18 +86,28 @@ export function resolveAiProvider(): AiProvider {
* 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)
const provider = resolveAiProvider()
if (provider === 'anthropic') return !!process.env.ANTHROPIC_API_KEY
// AI_API_KEY is optional: a local OpenAI-compatible server (llama.cpp,
// Ollama, LM Studio, vLLM) usually has no auth, so a base URL alone counts
// as configured. When a hosted Swedish provider needs a key, the operator
// sets AI_API_KEY and the service sends it as a Bearer token.
if (provider === 'openai-compatible') return !!process.env.AI_BASE_URL
return !!(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.
* Build a client for the resolved Anthropic-family provider. Both expose the
* same `messages.create` / `messages.stream` surface, which is all the direct
* SDK callers in this codebase use. Throws AiProviderUnsupportedError for the
* OpenAI-compatible backend: see the class doc. New code should not call this
* at all; go through getAiService() from @/lib/ai (antipattern guard
* direct-ai-client enforces that outside lib/ai/services).
*/
export function createAiClient(): AiClient {
if (resolveAiProvider() === 'anthropic') {
const provider = resolveAiProvider()
if (provider === 'openai-compatible') throw new AiProviderUnsupportedError(provider)
if (provider === '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
@@ -97,7 +138,7 @@ export function createAiClient(): AiClient {
* operator-supplied override in either form keeps working.
*/
export function toProviderModelId(bareModelId: string, provider = resolveAiProvider()): string {
if (provider === 'anthropic') return bareModelId
if (provider === 'anthropic' || provider === 'openai-compatible') return bareModelId
if (bareModelId.startsWith('eu.') || bareModelId.startsWith('anthropic.')) return bareModelId
return `eu.anthropic.${bareModelId}`
}
@@ -110,8 +151,12 @@ export function toProviderModelId(bareModelId: string, provider = resolveAiProvi
* any part of a secret.
*/
export function aiCredentialPrefix(): string | null {
if (resolveAiProvider() === 'anthropic') {
const provider = resolveAiProvider()
if (provider === 'anthropic') {
return process.env.ANTHROPIC_API_KEY?.slice(0, 12) ?? null
}
// OpenAI-compatible keys have no standard public prefix, so there is no
// non-secret slice to log: identify the endpoint instead.
if (provider === 'openai-compatible') return null
return process.env.AWS_ACCESS_KEY_ID?.slice(0, 4) ?? null
}
+88
View File
@@ -0,0 +1,88 @@
import { execFile } from 'node:child_process'
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
/**
* PDF -> page images for backends that cannot read PDF bytes natively (most
* OpenAI-compatible endpoints: the OpenAI `file` content part is not part of
* the de-facto chat-completions standard the Swedish providers implement).
*
* Uses poppler's `pdftoppm`, a system binary the self-host Docker image
* installs (hosted runs Claude on Bedrock, which reads PDFs natively and
* never needs this). A missing binary is a configuration state, not a crash:
* callers stamp `skipped:pdf_rasterizer_missing` and move on.
*
* Rejected alternatives: pdfjs-dist + @napi-rs/canvas (two new npm
* dependencies, memory spikes on large scans, dead weight on hosted).
*/
export type RasterizePdfResult =
| { ok: true; pages: Buffer[]; mediaType: 'image/png'; pageCount: number }
| { ok: false; reason: 'rasterizer_missing' | 'failed'; error?: string }
export interface RasterizePdfOptions {
/** Pages rendered from the start of the document; invoice data sits on the first page(s). */
maxPages: number
/** Render resolution. 110 dpi keeps an A4 page under 1000x1300 px: readable, cheap in tokens. */
dpi?: number
/** Binary name or path; overridable for tests and unusual installs. */
binary?: string
timeoutMs?: number
}
const DEFAULT_DPI = 110
const DEFAULT_TIMEOUT_MS = 60_000
function pageNumberOf(fileName: string): number {
// pdftoppm names pages <prefix>-1.png, <prefix>-01.png, <prefix>-001.png
// depending on the page count's digit width. Sort by the numeric suffix.
const match = /-(\d+)\.png$/.exec(fileName)
return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER
}
export async function rasterizePdf(
pdf: Buffer,
opts: RasterizePdfOptions
): Promise<RasterizePdfResult> {
const binary = opts.binary ?? process.env.AI_PDF_RASTERIZER_BIN ?? 'pdftoppm'
const dpi = opts.dpi ?? DEFAULT_DPI
const maxPages = Math.max(1, Math.floor(opts.maxPages))
let dir: string | null = null
try {
dir = await mkdtemp(join(tmpdir(), 'accounted-pdf-'))
const input = join(dir, 'input.pdf')
const prefix = join(dir, 'page')
await writeFile(input, pdf)
await execFileAsync(
binary,
['-r', String(dpi), '-png', '-f', '1', '-l', String(maxPages), input, prefix],
{ timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, maxBuffer: 1024 * 1024 }
)
const names = (await readdir(dir))
.filter((n) => n.startsWith('page-') && n.endsWith('.png'))
.sort((a, b) => pageNumberOf(a) - pageNumberOf(b))
if (names.length === 0) {
return { ok: false, reason: 'failed', error: 'pdftoppm produced no pages' }
}
const pages: Buffer[] = []
for (const name of names) pages.push(await readFile(join(dir, name)))
return { ok: true, pages, mediaType: 'image/png', pageCount: pages.length }
} catch (err) {
const code = (err as { code?: string } | null)?.code
if (code === 'ENOENT') return { ok: false, reason: 'rasterizer_missing' }
return {
ok: false,
reason: 'failed',
error: err instanceof Error ? err.message : String(err),
}
} finally {
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => undefined)
}
}
+151
View File
@@ -0,0 +1,151 @@
import type Anthropic from '@anthropic-ai/sdk'
import { createAiClient, toProviderModelId, type AiClient } from '../provider'
import type { ResolvedAiConfig } from '../config'
import { capabilitiesFor } from '../config'
import type {
AiDocumentInput,
AiService,
AiTier,
AiUsage,
ExtractFromDocumentRequest,
ExtractFromDocumentResult,
GenerateStructuredRequest,
GenerateStructuredResult,
GenerateTextRequest,
GenerateTextResult,
} from '../types'
/**
* The Anthropic family (AWS Bedrock and the direct API) behind the job-shaped
* interface. Delegates to the existing client factory and builds the EXACT
* request literals the call sites built before the abstraction existed, so
* hosted stays byte-identical on the wire: the request-shape tests in
* lib/ai/__tests__ deep-equal those literals.
*/
type MessageCreateParams = Anthropic.Messages.MessageCreateParamsNonStreaming
function usageOf(resp: { usage?: unknown }): AiUsage {
const usage = (resp.usage ?? {}) as {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number | null
cache_read_input_tokens?: number | null
}
return {
inputTokens: usage.input_tokens ?? null,
outputTokens: usage.output_tokens ?? null,
cacheCreationInputTokens: usage.cache_creation_input_tokens ?? null,
cacheReadInputTokens: usage.cache_read_input_tokens ?? null,
}
}
function textOf(resp: { content: Array<{ type: string; text?: string }> }): string {
return resp.content
.flatMap((b) => (b.type === 'text' && typeof b.text === 'string' ? [b.text] : []))
.join('')
.trim()
}
/** User content blocks for one document + trailing instruction. Same literals the inbox extractor sent. */
export function buildAnthropicDocumentContent(
document: AiDocumentInput,
instruction: string
): Anthropic.Messages.ContentBlockParam[] {
const tail = { type: 'text' as const, text: instruction }
if (document.kind === 'text') {
return [{ type: 'text' as const, text: document.text }, tail]
}
const base64 = document.data.toString('base64')
if (document.kind === 'pdf') {
return [
{
type: 'document' as const,
source: { type: 'base64' as const, media_type: 'application/pdf' as const, data: base64 },
},
tail,
]
}
return [
{
type: 'image' as const,
source: { type: 'base64' as const, media_type: document.mediaType, data: base64 },
},
tail,
]
}
export function createAnthropicFamilyService(cfg: ResolvedAiConfig): AiService {
let client: AiClient | null = null
const getClient = (): AiClient => {
if (!client) client = createAiClient()
return client
}
const modelFor = (tier: AiTier): string =>
// The Anthropic family always has a model (Claude default), so the null
// branch is unreachable; keep the fallback so the type stays honest.
toProviderModelId(cfg.models[tier] ?? 'claude-sonnet-5', cfg.provider)
return {
provider: cfg.provider,
capabilities: capabilitiesFor(cfg),
modelFor,
async generateText(req: GenerateTextRequest): Promise<GenerateTextResult> {
const model = modelFor(req.tier)
const params: MessageCreateParams = {
model,
max_tokens: req.maxTokens,
...(req.system ? { system: req.system } : {}),
messages: [{ role: 'user', content: req.prompt }],
}
const resp = await getClient().messages.create(params)
return { text: textOf(resp), model, usage: usageOf(resp) }
},
async generateStructured(req: GenerateStructuredRequest): Promise<GenerateStructuredResult> {
const model = modelFor(req.tier)
// Forced tool choice is the reliable way to get schema-shaped output
// from Claude (mirrors receipt-hunt's adjudicate/mail-intelligence).
const params: MessageCreateParams = {
model,
max_tokens: req.maxTokens,
...(req.system ? { system: req.system } : {}),
tools: [
{
name: req.schema.name,
...(req.schema.description ? { description: req.schema.description } : {}),
input_schema: req.schema.jsonSchema as Anthropic.Messages.Tool['input_schema'],
},
],
tool_choice: { type: 'tool', name: req.schema.name },
messages: [{ role: 'user', content: req.prompt }],
}
const resp = await getClient().messages.create(params)
const toolUse = resp.content.find(
(b): b is Anthropic.Messages.ToolUseBlock => b.type === 'tool_use' && b.name === req.schema.name
)
if (!toolUse) {
throw new Error(`Model answered without the forced tool "${req.schema.name}"`)
}
return { value: toolUse.input, model, usage: usageOf(resp) }
},
async extractFromDocument(req: ExtractFromDocumentRequest): Promise<ExtractFromDocumentResult> {
if (!cfg.configured) return { ok: false, skipped: 'ai_unconfigured' }
const model = modelFor('extraction')
// The system prompt is byte-stable per deploy and a few KB: marking it
// ephemeral lets the backend reuse the prompt cache on rapid sequential
// extractions (a user uploading a stack of receipts within minutes).
// Bedrock honours the short default TTL; the direct API the same.
const params: MessageCreateParams = {
model,
max_tokens: req.maxTokens,
system: [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }],
messages: [{ role: 'user', content: buildAnthropicDocumentContent(req.document, req.instruction) }],
}
const resp = await getClient().messages.create(params)
return { ok: true, text: textOf(resp), model, usage: usageOf(resp) }
},
}
}
+198
View File
@@ -0,0 +1,198 @@
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { generateText, jsonSchema, Output, type ModelMessage, type UserContent } from 'ai'
import { capabilitiesFor, type ResolvedAiConfig } from '../config'
import { extractJsonObject } from '../json'
import { rasterizePdf } from '../rasterize-pdf'
import type {
AiDocumentInput,
AiService,
AiTier,
AiUsage,
ExtractFromDocumentRequest,
ExtractFromDocumentResult,
ExtractionSkipReason,
GenerateStructuredRequest,
GenerateStructuredResult,
GenerateTextRequest,
GenerateTextResult,
} from '../types'
/**
* Any endpoint speaking the OpenAI chat-completions API, through the Vercel
* AI SDK's openai-compatible provider. This is the sovereign self-host path:
* the operator points AI_BASE_URL + AI_API_KEY at a Swedish inference
* provider (Berget AI, evroc, ...) and names the models per tier.
*
* Scope discipline: the AI SDK is used ONLY here. The hosted Bedrock /
* direct-API path stays on the Anthropic SDK (services/anthropic-family.ts)
* and no call site imports `ai` directly (antipattern guard direct-ai-client).
*
* Provider quirks this has to absorb, by design choice:
* - PDFs: most such endpoints have no PDF part; the default is to rasterize
* the first pages with poppler (AI_PDF_MODE=rasterize). Operators whose
* provider accepts the OpenAI `file` part can set AI_PDF_MODE=native.
* - Vision: AI_VISION=false declares a text-only model; images and PDFs are
* then skipped honestly (`ai_no_vision`) instead of failing with a 400.
* HTML mail invoices arrive as text and extract on every model.
* - JSON: the default is JSON-in-prose plus the caller's extraction + Zod,
* which works everywhere; AI_STRICT_JSON=true opts into response_format
* json_schema for providers that enforce it.
*/
export function createOpenAICompatibleService(cfg: ResolvedAiConfig): AiService {
const provider = createOpenAICompatible({
name: 'accounted-byo',
baseURL: cfg.baseUrl ?? '',
// Only send a key when one is configured: a keyless local server would
// reject or ignore an empty Bearer, and omitting it means no auth header.
...(cfg.apiKey ? { apiKey: cfg.apiKey } : {}),
supportsStructuredOutputs: cfg.strictJson,
})
const capabilities = capabilitiesFor(cfg)
const modelFor = (tier: AiTier): string => {
const id = cfg.models[tier]
if (!id) throw new Error(`No AI model configured for tier "${tier}" (set AI_MODEL or AI_${tier.toUpperCase()}_MODEL)`)
return id
}
function usageOf(result: { usage: { inputTokens?: number; outputTokens?: number; inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } } }): AiUsage {
const u = result.usage
return {
inputTokens: u.inputTokens ?? null,
outputTokens: u.outputTokens ?? null,
cacheCreationInputTokens: u.inputTokenDetails?.cacheWriteTokens ?? null,
cacheReadInputTokens: u.inputTokenDetails?.cacheReadTokens ?? null,
}
}
async function buildUserContent(
document: AiDocumentInput,
instruction: string
): Promise<
| { ok: true; content: UserContent; pagesRasterized?: number }
| { ok: false; skipped: ExtractionSkipReason }
> {
const tail = { type: 'text' as const, text: instruction }
if (document.kind === 'text') {
return { ok: true, content: [{ type: 'text', text: document.text }, tail] }
}
if (!capabilities.imageInput) return { ok: false, skipped: 'ai_no_vision' }
if (document.kind === 'image') {
return {
ok: true,
content: [{ type: 'image', image: document.data, mediaType: document.mediaType }, tail],
}
}
// PDF
if (capabilities.pdfNative) {
return {
ok: true,
content: [
{
type: 'file',
data: document.data,
mediaType: 'application/pdf',
...(document.fileName ? { filename: document.fileName } : {}),
},
tail,
],
}
}
const raster = await rasterizePdf(document.data, { maxPages: cfg.pdfMaxPages })
if (!raster.ok) {
return {
ok: false,
skipped: raster.reason === 'rasterizer_missing' ? 'pdf_rasterizer_missing' : 'pdf_rasterize_failed',
}
}
return {
ok: true,
pagesRasterized: raster.pageCount,
content: [
...raster.pages.map((page) => ({ type: 'image' as const, image: page, mediaType: raster.mediaType })),
tail,
],
}
}
return {
provider: cfg.provider,
capabilities,
modelFor,
async generateText(req: GenerateTextRequest): Promise<GenerateTextResult> {
const model = modelFor(req.tier)
const result = await generateText({
model: provider(model),
...(req.system ? { system: req.system } : {}),
prompt: req.prompt,
maxOutputTokens: req.maxTokens,
})
return { text: result.text.trim(), model, usage: usageOf(result) }
},
async generateStructured(req: GenerateStructuredRequest): Promise<GenerateStructuredResult> {
const model = modelFor(req.tier)
if (cfg.strictJson) {
const result = await generateText({
model: provider(model),
...(req.system ? { system: req.system } : {}),
prompt: req.prompt,
maxOutputTokens: req.maxTokens,
output: Output.object({ schema: jsonSchema<Record<string, unknown>>(req.schema.jsonSchema) }),
})
return { value: result.output, model, usage: usageOf(result) }
}
// Prose JSON: ask for the shape in the prompt, then pull the first
// parseable object out of whatever the model wrapped it in.
const schemaHint =
`Answer with ONLY a single JSON object${req.schema.description ? ` (${req.schema.description})` : ''}` +
` matching this JSON Schema, no prose, no markdown fences:\n${JSON.stringify(req.schema.jsonSchema)}`
const result = await generateText({
model: provider(model),
system: req.system ? `${req.system}\n\n${schemaHint}` : schemaHint,
prompt: req.prompt,
maxOutputTokens: req.maxTokens,
})
const value: unknown = JSON.parse(extractJsonObject(result.text))
return { value, model, usage: usageOf(result) }
},
async extractFromDocument(req: ExtractFromDocumentRequest): Promise<ExtractFromDocumentResult> {
if (!cfg.configured) return { ok: false, skipped: 'ai_unconfigured' }
const model = modelFor('extraction')
const built = await buildUserContent(req.document, req.instruction)
if (!built.ok) return { ok: false, skipped: built.skipped }
const messages: ModelMessage[] = [{ role: 'user', content: built.content }]
if (cfg.strictJson && req.jsonSchema) {
const result = await generateText({
model: provider(model),
system: req.system,
messages,
maxOutputTokens: req.maxTokens,
output: Output.object({ schema: jsonSchema<Record<string, unknown>>(req.jsonSchema) }),
})
return {
ok: true,
text: JSON.stringify(result.output),
model,
usage: usageOf(result),
...(built.pagesRasterized ? { pagesRasterized: built.pagesRasterized } : {}),
}
}
const result = await generateText({
model: provider(model),
system: req.system,
messages,
maxOutputTokens: req.maxTokens,
})
return {
ok: true,
text: result.text.trim(),
model,
usage: usageOf(result),
...(built.pagesRasterized ? { pagesRasterized: built.pagesRasterized } : {}),
}
},
}
}
+135
View File
@@ -0,0 +1,135 @@
// Job-shaped AI service interface.
//
// Call sites describe WHAT they need (a text answer, a schema-shaped object,
// the fields read out of a document) rather than HOW a particular backend is
// spoken to. The Anthropic-family service (Bedrock and the direct API) keeps
// hosted byte-identical by delegating to the existing client factory in
// lib/ai/provider.ts; the OpenAI-compatible service talks to any endpoint
// that implements the chat-completions API (the Swedish inference providers a
// sovereign self-host points at) through the Vercel AI SDK.
//
// Streaming members (the chat loop) are deliberately absent until the chat
// runtime decision is taken: see the Sovereign plan, alignment rule R3.
export type AiProviderKind = 'bedrock' | 'anthropic' | 'openai-compatible'
/**
* Model tiers. `assistant` is the conversational/default tier, `heavy` the
* deep-reasoning tier (supplier-invoice review, VAT review, bokslut), and
* `extraction` the document-reading tier (a vision model on OpenAI-compatible
* endpoints; Claude reads PDFs natively).
*/
export type AiTier = 'assistant' | 'heavy' | 'extraction'
export interface AiCapabilities {
/** PDF bytes can be sent as a native document part, no rasterization. */
pdfNative: boolean
/** Images (and therefore scanned receipts) can be read at all. */
imageInput: boolean
toolUse: boolean
/** The backend can be forced to answer with one named tool. */
forcedToolChoice: boolean
/** The backend enforces a JSON schema on the output (response_format). */
strictJsonSchema: boolean
}
export type AiImageMediaType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
export type AiDocumentInput =
| { kind: 'pdf'; data: Buffer; fileName?: string }
| { kind: 'image'; data: Buffer; mediaType: AiImageMediaType }
/** Plain text already extracted by the caller (HTML mail invoices). Works on every model, vision or not. */
| { kind: 'text'; text: string }
export interface AiUsage {
inputTokens: number | null
outputTokens: number | null
cacheCreationInputTokens: number | null
cacheReadInputTokens: number | null
}
export interface GenerateTextRequest {
tier: AiTier
system?: string
prompt: string
maxTokens: number
}
export interface GenerateTextResult {
text: string
model: string
usage: AiUsage
}
export interface GenerateStructuredRequest {
tier: AiTier
system?: string
prompt: string
maxTokens: number
schema: {
name: string
description?: string
/** JSON Schema (draft-07 subset) for the expected object. Hand-maintained by the caller. */
jsonSchema: Record<string, unknown>
}
}
export interface GenerateStructuredResult {
/** The model's object, NOT validated: callers run their own Zod parse. */
value: unknown
model: string
usage: AiUsage
}
export interface ExtractFromDocumentRequest {
document: AiDocumentInput
/** Byte-stable system prompt. The Anthropic-family service marks it as a prompt-cache breakpoint. */
system: string
/** Trailing user instruction placed after the document part(s). */
instruction: string
maxTokens: number
/**
* Optional JSON schema for the answer. Used only when strict JSON mode is
* on AND the backend supports it; otherwise the model answers in prose and
* the caller's JSON extraction + Zod parse do the work (works everywhere).
*/
jsonSchema?: Record<string, unknown>
}
export type ExtractionSkipReason =
| 'ai_unconfigured'
| 'ai_no_vision'
| 'pdf_rasterizer_missing'
| 'pdf_rasterize_failed'
export type ExtractFromDocumentResult =
| { ok: true; text: string; model: string; usage: AiUsage; pagesRasterized?: number }
| { ok: false; skipped: ExtractionSkipReason }
export interface AiService {
readonly provider: AiProviderKind
readonly capabilities: AiCapabilities
/** Provider-form model id for a tier (Bedrock inference-profile prefix applied, etc.). */
modelFor(tier: AiTier): string
generateText(req: GenerateTextRequest): Promise<GenerateTextResult>
generateStructured(req: GenerateStructuredRequest): Promise<GenerateStructuredResult>
extractFromDocument(req: ExtractFromDocumentRequest): Promise<ExtractFromDocumentResult>
}
export type AiPdfMode = 'native' | 'rasterize'
export interface AiStatus {
provider: AiProviderKind
/** Credentials AND (for OpenAI-compatible) a model id are present. */
configured: boolean
reason: 'ok' | 'no_credentials' | 'no_model'
capabilities: AiCapabilities
models: Record<AiTier, string | null>
pdfMode: AiPdfMode
/**
* Whether the in-app assistant (chat loop) can run. The loop still speaks
* the Anthropic messages surface directly, so it needs the Anthropic family
* until its streaming port lands; extraction and single-call jobs do not.
*/
assistantAvailable: boolean
}
+21 -3
View File
@@ -503,7 +503,8 @@ export async function completePendingDocumentUpload(
uploadId: string,
fileName: string,
mimeType: string,
now: number = Date.now()
now: number = Date.now(),
options: { extractionOwner?: 'invoice-inbox' } = {}
): Promise<CompletedPendingDocumentUpload> {
const serviceClient = createServiceClientNoCookies()
const storage = serviceClient.storage.from(DOCUMENTS_BUCKET)
@@ -594,7 +595,12 @@ export async function completePendingDocumentUpload(
const document = data as DocumentAttachment
await eventBus.emit({
type: 'document.uploaded',
payload: { document, userId, companyId },
payload: {
document,
userId,
companyId,
...(options.extractionOwner ? { extractionOwner: options.extractionOwner } : {}),
},
})
return { document, buffer }
@@ -646,6 +652,13 @@ export async function uploadDocument(
* WhatsApp intake precedent: the loser stores a copy, nothing corrupts.
*/
dedupeByContent?: boolean
/**
* Who runs AI extraction on this document. The invoice inbox extracts
* the documents it ingests itself (and mirrors the result onto the
* document row), so it declares ownership here and the
* document-extraction extension yields. Default: the extension extracts.
*/
extractionOwner?: 'invoice-inbox'
} = {}
): Promise<DocumentAttachment & { deduplicated?: boolean }> {
await ensureDocumentsBucket()
@@ -775,7 +788,12 @@ export async function uploadDocument(
await eventBus.emit({
type: 'document.uploaded',
payload: { document: result, userId, companyId },
payload: {
document: result,
userId,
companyId,
...(metadata.extractionOwner ? { extractionOwner: metadata.extractionOwner } : {}),
},
})
return result
+4 -1
View File
@@ -26,7 +26,10 @@ export type CoreEvent =
| { type: 'journal_entry.reversed'; payload: { originalEntry: JournalEntry; reversalEntry: JournalEntry; userId: string; companyId: string } }
| { type: 'journal_entry.deleted'; payload: { entryId: string; voucherSeries: string; voucherNumber: number; userId: string; companyId: string } }
// Documents
| { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string; companyId: string } }
// extractionOwner: set by the invoice inbox on documents it extracts itself,
// so the document-extraction extension yields instead of racing it (the
// inbox row does not exist yet when this event fires inside uploadDocument).
| { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string; companyId: string; extractionOwner?: 'invoice-inbox' } }
| { type: 'document.accessed'; payload: { document: { id: string; file_name: string }; userId: string; companyId: string } }
| { type: 'document.deleted'; payload: { document: { id: string; file_name: string }; userId: string; companyId: string } }
// Invoicing
+9 -5
View File
@@ -6,11 +6,15 @@ import { useEffect, useState } from 'react'
// pipeline completes, fails, or times out. Returns the derived status the
// upload UI binds to.
//
// "Disabled" semantics: if the document-extraction extension isn't enabled
// (the column stays NULL forever), we don't know server-side. Instead we
// stop polling after EXTRACTION_TIMEOUT_MS and bubble status='disabled' so
// the UI can quietly fall back ("Uppladdat" without an AI hint): no scary
// error for a feature the customer didn't pay for.
// "Disabled" semantics: the server answers 'disabled' as soon as it knows
// no extraction will happen (AI not configured on this deployment, company
// not entitled, self-generated document: see the route), so the UI can
// quietly fall back ("Uppladdat" without an AI hint) on the first poll: no
// scary error for a feature the customer didn't pay for, and no 30 s hang
// on a self-host without an AI key. The client-side timeout stays as the
// last resort for the one case the server cannot see: the
// document-extraction extension switched off entirely (the column stays
// NULL forever).
//
// Reasonable timeout: typical extraction takes 2-8s on Sonnet via Bedrock.
// 30s is generous and keeps the UX responsive on flaky links.