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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
fbf47649f2
commit
c7a75d069d
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user