The /chat assistant (audit Option A / rip) shipped in #1759 reading only the company name + entity type, so it answered "jag har ingen bokföringsdata" to every figures question ("vad är min största utgiftspost?"). It now behaves like an MCP client: it answers over a bounded, READ-only tool loop across the same MCP read tools the old streaming assistant had, plus an always-on company snapshot as the backstop. Provider-agnostic by construction, so it still runs on a local model: - lib/ai generateText gains optional `tools` + `maxSteps`. The OpenAI-compatible service forwards them to the Vercel AI SDK (stopWhen: stepCountIs), which runs the loop; the Anthropic-family service hand-rolls a small loop against messages.create. Kept on the raw Anthropic SDK: no new deps, and the no-tools path is byte-identical, so hosted extraction/composer/etc. are unchanged. - lib/agent/ask/ledger-tools.ts: the read slice of general.help's whitelist (income statement, VAT, ledgers, query_journal, reskontror, lists…) from agentToolRegistry, dispatched with the agent_chat actor run-turn uses. Write/ staging + memory-write tools are excluded; readOnlyHint/destructiveHint are re-checked. Empty in a core-only build → snapshot-only, graceful. - lib/agent/ask/snapshot.ts: a compact company_settings + deadlines block so a model that can't/won't call tools still answers status questions. Never carries figures (those come from the live tools). - ask-service attaches tools + snapshot when a userId is present and uses a tool-aware system prompt; the route calls ensureInitialized() so the registry is populated and threads userId/conversationId through. Works on Bedrock and on any local model with function-calling (Qwen). Tests: the anthropic hand-rolled loop (tool call → result → answer, is_error handling, step-budget forced answer), openai tool forwarding, the read-only adapter filter, the snapshot format, and the ask-service wiring. 457 agent+ai tests green, lint/guards clean. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
68 lines
2.1 KiB
TypeScript
68 lines
2.1 KiB
TypeScript
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,
|
|
AiToolDef,
|
|
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
|
|
}
|