-
Notifications
You must be signed in to change notification settings - Fork 3
ADR 031 Outbound LLM Provider Integration
Accepted
Cornerstone is adding auto-itemization of construction invoices via OCR text analysis (Story #1546, #1547). The system extracts line items (description, quantity, unit, price, VAT) from raw OCR text without vendor-locking to any single LLM provider.
Multiple providers expose the OpenAI chat-completions API schema:
- OpenAI (GPT-4 Turbo, GPT-4o) — ~$0.01–$0.03 per invoice
- Anthropic (Claude 3 Opus) — ~$0.015 per invoice via Claude API or OpenRouter
- Google Gemini (Flash 1.5) — ~$0.001 per invoice, extremely cost-effective
- Open-source / self-hosted (Ollama, OpenRouter, LiteLLM proxy) — variable costs, no external API calls
- Azure OpenAI — enterprise compliance, OpenAI-compatible endpoint
Choosing a single vendor (e.g., "we only support OpenAI") would force users into that ecosystem and lock out cost-conscious deployments and air-gapped installations. The solution must support any OpenAI-compatible gateway.
Implement BudgetExtractionService with a single createOpenAICompatibleProvider(config) factory that:
- POSTs to
{LLM_BASE_URL}/chat/completionswithAuthorization: Bearer {LLM_API_KEY} - Sends the configured
{LLM_MODEL}andresponse_format: { type: 'json_object' } - Enforces a request timeout via
LLM_REQUEST_TIMEOUT_MS(default 30 seconds) - Returns fully-typed
ExtractedLine[]objects
Three optional env vars enable the feature:
-
LLM_BASE_URL— base URL of the LLM API (e.g.,https://api.openai.com/v1) -
LLM_API_KEY— bearer token for authentication -
LLM_MODEL— model identifier (e.g.,gpt-4-turbo,claude-3-opus-20240229) -
LLM_REQUEST_TIMEOUT_MS— request timeout in ms (default: 30000)
Feature is disabled by default. GET /api/config returns autoItemizeEnabled: false unless all three core env vars are set. The client uses this flag to conditionally show auto-itemize UI buttons.
API key is never exposed. LLM_API_KEY is not returned to the client; only the boolean autoItemizeEnabled flag is. Error responses do not echo the request body (which could contain the prompt with vendor names, amounts, and invoice context).
What is sent to the LLM provider:
- OCR text (raw text extracted from the PDF, 1–5 KB typical)
- Invoice metadata hints (vendor name, total amount, date, locale)
What is NOT sent:
- Binary PDF files or images (OCR happens on-host via Paperless)
- User-identifying information beyond what appears on the invoice itself (no account IDs, user emails, etc.)
- API keys or credentials
The system prompt scopes extraction to "German construction-invoice line items" and emphasizes JSON-only output, preventing prompt injection and PII leakage via LLM responses.
Three distinct LLM-related error codes:
| Error Code | HTTP | Scenario |
|---|---|---|
LLM_UNREACHABLE |
502 | Network timeout, DNS failure, connection refused |
LLM_INVALID_RESPONSE |
502 | LLM returned non-JSON or invalid schema |
LLM_UPSTREAM_ERROR |
502 | LLM returned non-200 (e.g., 429 rate-limit, 500) |
LLM_NOT_CONFIGURED |
503 | Feature is disabled (env vars not set) |
Error messages are sanitized — no API response bodies are echoed (they may contain the LLM's interpretation of the prompt). Only the HTTP status code is included for diagnosis.
-
API Key Storage: Kept in environment variables, never logged (config logging excludes
llmApiKey). Shared with the LLM provider only via HTTPAuthorization: Bearerheader, never in URL or query params. -
Request Timeout: Prevents hanging requests due to LLM provider outages. Default 30 seconds is appropriate for turn-based chat (GPT-4, Claude) and slightly generous for fast models (Gemini Flash). Users can tune via
LLM_REQUEST_TIMEOUT_MS. -
Response Validation: Strict structural validation rejects any response not matching
{ lines: ExtractedLine[] }schema. Confidence scores are 0–1; all monetary amounts are validated as finite numbers. - Prompt Injection: System prompt is immutable, user prompt is template-based (OCR text is concatenated, not interpolated into JSON or shell commands).
-
Failure Safety: If LLM is unreachable or returns invalid data, the extraction returns
LLM_UNREACHABLE/LLM_INVALID_RESPONSEand stops. The invoice is not partially itemized. The user is prompted to retry or manually itemize.
-
Provider-Specific Clients (OpenAI SDK, Anthropic SDK, etc.)
- Rejected: Requires shipping N client libraries, complicating dependency management. Locking users into one vendor contradicts the goal of flexibility. Future providers require code changes.
-
Separate Python Service with Native Libraries
-
Rejected: Adds deployment complexity (Flask/FastAPI service, Docker Compose). Doubles the attack surface. No benefit over HTTP client in Node.js which is already built-in (
fetch). Python-specific dependencies add build-time issues.
-
Rejected: Adds deployment complexity (Flask/FastAPI service, Docker Compose). Doubles the attack surface. No benefit over HTTP client in Node.js which is already built-in (
-
Client-Side LLM Calls via CORS Proxy
- Rejected: Exposes API key to the browser (major security flaw). CORS proxies are brittle. No server-side audit trail of LLM usage. Violates principle of least privilege.
-
Custom NLP/Regex Extraction Engine
- Rejected: Building a German invoice parser is a multi-month project. LLMs handle variance in formatting, currency notation, VAT, and unit conventions out-of-the-box. Maintenance burden is orders of magnitude higher.
- Zero Vendor Lock-In: Users pick their provider based on cost, compliance, or performance.
- Default-Off Security Posture: LLM integration is opt-in via environment variables. Users who don't set them get a working system without external dependencies.
- Cost Control: Users on tight budgets can use Gemini Flash (~$0.001/invoice) or self-hosted Ollama (free, local). Enterprise customers can use Azure OpenAI for compliance.
- Future-Proof: New OpenAI-compatible providers (Deepseek, Mistral, etc.) work without code changes.
- Reduced Blast Radius: If the LLM provider is compromised or unreachable, the system degrades gracefully (extraction returns a 502/503, invoices remain itemizable manually).
- Model-Specific Behavior: Some models are better at German invoices than others. Gemini Flash may miss small detail lines; Claude 3 Opus is more thorough but slower. Users must experiment.
- Cost Tracking: No built-in metering or cost-tracking dashboard. Users must monitor their LLM provider's usage dashboard separately.
- Rate Limiting: If the user's LLM provider rate-limits, extraction fails. Retry is on the user.
- Latency Variation: Extraction latency depends on the chosen provider and model. GPT-4 Turbo: ~10–30s per invoice. Gemini Flash: ~2–5s. Expected and documented.
- ADR-015 (Paperless-ngx Integration): OCR happens on-host via Paperless. The LLM extracts structure from Paperless' OCR output.
- ADR-009 (Error Handling): Typed error codes and HTTP status codes follow the established error-handling convention.
-
ADR-030 (Orphan Budget Lines): Auto-itemized lines land as orphans (no parent work item) with
origin: 'auto', distinct from manual itemizations.
- ✅ Bearer token in
Authorizationheader, not in URL or body - ✅ API key never logged or echoed in error responses
- ✅ Response body not echoed in error messages (prevents PII leakage via LLM inference)
- ✅ Strict schema validation prevents injection via malformed JSON
- ✅ Request timeout prevents DoS via hanging connections
- ✅ Default-off (requires explicit env var configuration)
- ✅ No browser-side API key exposure
- ✅ No client-side configuration of LLM endpoint (server-only)