/** * Single Anthropic client used by every service module. * * Centralises model selection, retry, timeout/abort, structured-output * parsing, schema validation, and best-effort call telemetry. Callers * import `callLLM` from here β€” they must not reach `/api/anthropic` on * their own. */ import { storage } from './storage'; import { withRetry, RetryableError, parseRetryAfter, isRetryableStatus } from './llmRetry'; import { toolSchemaRegistry } from './llmSchemas'; import { pb } from './pb'; const ANTHROPIC_URL = '/api/anthropic/v1/messages'; const ANTHROPIC_VERSION = '2023-06-01'; const DEFAULT_TIMEOUT_MS = 60_000; const TIER_DEFAULTS = { fast: 'claude-haiku-4-5-20251001', standard: 'claude-sonnet-4-6', reasoning: 'claude-opus-4-7', }; export class LLMHttpError extends Error { constructor(status, statusText, body) { super(`API Error: ${status} ${statusText} - ${typeof body === 'string' ? body : JSON.stringify(body)}`); this.name = 'LLMHttpError'; this.status = status; this.body = body; } } export class LLMTruncatedError extends Error { constructor(task) { super(`LLM response truncated (stop_reason: max_tokens) for task "${task}". Increase max_tokens or shorten the input.`); this.name = 'LLMTruncatedError'; } } export class LLMOutputError extends Error { constructor(message) { super(message); this.name = 'LLMOutputError'; } } export class LLMValidationError extends Error { constructor(task, zodError) { super(`LLM output failed schema validation for task "${task}": ${zodError?.message ?? zodError}`); this.name = 'LLMValidationError'; this.cause = zodError; } } export function resolveModel(tier) { const key = `admin:model:${tier}`; const override = storage.get(key); if (override) return String(override).trim(); if (tier === 'standard') { const legacy = storage.get('admin:model'); if (legacy) return String(legacy).trim(); } return TIER_DEFAULTS[tier] ?? TIER_DEFAULTS.standard; } /** * Extract the outermost balanced JSON value (object or array) from arbitrary * model output. Strips ```json fences first. Brace-matching ignores braces * inside strings; escapes inside strings are skipped. */ export function parseStructuredText(raw) { if (typeof raw !== 'string') throw new LLMOutputError('LLM returned no text.'); let text = raw.trim(); text = text.replace(/```(?:json)?\s*/gi, '').replace(/```/g, ''); for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch !== '{' && ch !== '[') continue; const open = ch; const close = ch === '{' ? '}' : ']'; let depth = 0; let inString = false; for (let j = i; j < text.length; j++) { const c = text[j]; if (inString) { if (c === '\\') { j++; continue; } if (c === '"') inString = false; continue; } if (c === '"') { inString = true; continue; } if (c === open) depth++; else if (c === close) { depth--; if (depth === 0) { const slice = text.slice(i, j + 1); try { return JSON.parse(slice); } catch { break; } } } } } throw new LLMOutputError('No balanced JSON value found in LLM output.'); } function buildMessages({ messages, user }) { if (Array.isArray(messages) && messages.length) return messages; if (typeof user === 'string' && user.length) return [{ role: 'user', content: user }]; throw new Error('callLLM requires either `messages` or `user`.'); } // Telemetry collection is optional. If the migration hasn't been applied on a // given deploy, the first POST returns 404; we then disable further attempts // to keep the console clean and avoid wasted round-trips. let llmCallsDisabled = false; function logLlmCall(record) { if (llmCallsDisabled) return; try { pb.collection('llm_calls').create(record).catch((err) => { if (err?.status === 404) llmCallsDisabled = true; }); } catch { /* collection may not exist yet β€” swallow */ } } function isChatLikeTask(task) { if (!task) return false; return task === 'legacy.chat' || task.startsWith('chat.') || task.startsWith('r42.'); } const SIMULATION_EXTRACTION_GRAPH = { topics: [ { id: 'radicale-transparantie', label: 'Radicale Transparantie', type: 'concept', description: 'De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is.', learning_relevance: 'core' }, { id: 'kennisbeheer', label: 'Kennisbeheer', type: 'process', description: 'Het proces van het vastleggen en ontsluiten van organisatiekennis.', learning_relevance: 'standard' }, { id: 'wekelijkse-sessie', label: 'Wekelijkse Leersessie', type: 'process', description: 'Elke week leren medewerkers via AI-gegenereerde vragen en quizzen.', learning_relevance: 'standard' }, ], relations: [ { source: 'kennisbeheer', target: 'radicale-transparantie', type: 'depends_on' }, { source: 'wekelijkse-sessie', target: 'kennisbeheer', type: 'part_of' }, ], }; const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify(SIMULATION_EXTRACTION_GRAPH); const SIMULATION_CHAT_TEXT = 'Simulatiemodus staat aan β€” vraag een beheerder om Simulation Mode uit te zetten in Admin β†’ Settings om met R42 te chatten.'; const SIMULATION_ARTICLE = { title: 'Voorbeeld leermodule', intro: 'Dit is een simulatie. Schakel Simulation Mode uit om echte content te genereren.', sections: [ { heading: 'Wat dit is', body: 'Dit is een placeholder-sectie die alleen verschijnt wanneer simulatiemodus aan staat. Hij illustreert de structuur van het artikel zonder een echte API-aanroep te doen. Dat is handig voor UI-werk.' }, ], keyTakeaways: ['Simulatiemodus levert geen echte inhoud.', 'Schakel uit voor productie.'], }; const SIMULATION_SLIDE = { title: 'Voorbeeldslide', bullets: ['Eerste punt', 'Tweede punt'], speakerNote: 'Spreker-notitie ter illustratie.', }; const SIMULATION_INFOGRAPHIC = { headline: 'Simulatie', tagline: 'Vervang door echte content', stats: [{ value: '100%', label: 'simulatie', icon: 'πŸ“Š' }], steps: [{ number: 1, title: 'Schakel uit', description: 'Zet simulatiemodus uit in Admin β†’ Settings.', icon: 'πŸ”§' }], quote: 'Een simulatie vertelt niets nieuws.', colorTheme: 'teal', }; const SIMULATION_TOOL_STUBS = { emit_knowledge_graph: SIMULATION_EXTRACTION_GRAPH, emit_handbook_delta: SIMULATION_EXTRACTION_GRAPH, emit_learning_article: { article: SIMULATION_ARTICLE }, emit_learning_slides: { slides: [SIMULATION_SLIDE] }, emit_learning_infographic: { infographic: SIMULATION_INFOGRAPHIC }, emit_learning_all: { article: SIMULATION_ARTICLE, slides: [SIMULATION_SLIDE], infographic: SIMULATION_INFOGRAPHIC }, emit_custom_topic: { label: 'Simulatie onderwerp', type: 'concept', description: 'Een placeholder-onderwerp gegenereerd in simulatiemodus.' }, emit_quiz_questions: { questions: [ { id: 'sim-q1', question: 'Wat doet simulatiemodus?', topicLabel: 'Simulatie', options: ['Echte API-aanroepen', 'Stub-data tonen', 'Niets', 'Crasht de app'], correctIndex: 1, explanation: 'Simulatiemodus retourneert vaste stub-data zonder de API te raken.', }, ], }, emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] }, set_intro: { intro: 'Bijgewerkte intro (simulatie).' }, }; function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) { return { text, toolUses, stopReason, usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, requestId: null, model: 'simulation', durationMs: 400, }; } async function simulatedResponse({ task, toolChoice }) { await new Promise((r) => setTimeout(r, 400)); if (toolChoice?.type === 'tool' && SIMULATION_TOOL_STUBS[toolChoice.name]) { return stubResponse({ stopReason: 'tool_use', toolUses: [{ name: toolChoice.name, input: SIMULATION_TOOL_STUBS[toolChoice.name] }], }); } if (isChatLikeTask(task)) { return stubResponse({ text: SIMULATION_CHAT_TEXT }); } return stubResponse({ text: SIMULATION_EXTRACTION_PAYLOAD }); } function linkSignals(userSignal, timeoutSignal) { const controller = new AbortController(); const abort = (reason) => controller.abort(reason); if (userSignal) { if (userSignal.aborted) controller.abort(userSignal.reason); else userSignal.addEventListener('abort', () => abort(userSignal.reason), { once: true }); } if (timeoutSignal) { if (timeoutSignal.aborted) controller.abort(timeoutSignal.reason); else timeoutSignal.addEventListener('abort', () => abort(timeoutSignal.reason), { once: true }); } return controller.signal; } function extractToolUses(content) { if (!Array.isArray(content)) return []; return content .filter((b) => b?.type === 'tool_use') .map((b) => ({ id: b.id, name: b.name, input: b.input })); } function extractText(content) { if (!Array.isArray(content)) return ''; return content .filter((b) => b?.type === 'text' && typeof b.text === 'string') .map((b) => b.text) .join(''); } function validateToolInputs(toolUses, task, toolSchemas) { const registry = { ...toolSchemaRegistry, ...(toolSchemas || {}) }; for (const tu of toolUses) { const schema = registry[tu.name]; if (!schema) continue; const result = schema.safeParse(tu.input); if (!result.success) throw new LLMValidationError(`${task}:${tu.name}`, result.error); tu.input = result.data; } } /** * @typedef {Object} CallLLMOptions * @property {string} task Logging label, e.g. 'extract.source'. * @property {'fast'|'standard'|'reasoning'} [tier='standard'] * @property {string|Array<{type:'text',text:string,cache_control?:{type:'ephemeral'}}>} [system] * @property {Array<{role:'user'|'assistant',content:any}>} [messages] * @property {string} [user] Shorthand for a single user message. * @property {Array} [tools] Anthropic tool definitions. * @property {object} [toolChoice] e.g. { type: 'tool', name: 'emit_knowledge_graph' }. * @property {import('zod').ZodTypeAny} [schema] For textβ†’JSON validation. * @property {Record} [toolSchemas] Overrides for tool_use input validation. * @property {number} [maxTokens=4096] * @property {number} [temperature=0] * @property {number} [timeoutMs=60000] Per-request timeout in ms. Increase for large structured extractions. * @property {AbortSignal} [signal] * @property {{ acquire: (opts?:{signal?:AbortSignal}) => Promise, pauseUntil: (untilMs:number) => void }} [limiter] */ /** * @param {CallLLMOptions} options */ export async function callLLM(options) { const { task, tier = 'standard', system, messages, user, tools, toolChoice, schema, toolSchemas, maxTokens = 4096, temperature = 0, timeoutMs = DEFAULT_TIMEOUT_MS, signal, limiter, } = options; if (!task) throw new Error('callLLM requires a `task` label.'); const useSimulation = storage.get('admin:use_simulation') === true; if (useSimulation) return simulatedResponse({ task, toolChoice }); const model = resolveModel(tier); const messagesPayload = buildMessages({ messages, user }); const body = { model, max_tokens: maxTokens, messages: messagesPayload, }; // Temperature is not supported for reasoning tier models if (tier !== 'reasoning') { body.temperature = temperature; } if (system !== undefined) body.system = system; if (tools && tools.length) body.tools = tools; if (toolChoice) body.tool_choice = toolChoice; const start = Date.now(); let result; try { result = await withRetry( async () => { if (limiter) await limiter.acquire({ signal }); const timeoutCtl = new AbortController(); const timer = setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'AbortError')), timeoutMs); const fetchSignal = linkSignals(signal, timeoutCtl.signal); try { const response = await fetch(ANTHROPIC_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'anthropic-version': ANTHROPIC_VERSION, }, body: JSON.stringify(body), signal: fetchSignal, }); if (!response.ok) { const errBody = await response.json().catch(() => ({})); if (isRetryableStatus(response.status)) { const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After')); if (response.status === 429 && retryAfterMs != null && limiter) { limiter.pauseUntil(Date.now() + retryAfterMs); } throw new RetryableError(response.status, retryAfterMs, `HTTP ${response.status}`); } throw new LLMHttpError(response.status, response.statusText, errBody); } const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('application/json')) { throw new Error('Your session has expired. Please refresh the page and log in again.'); } return await response.json(); } finally { if (timer) clearTimeout(timer); } }, { signal }, ); } catch (err) { logLlmCall({ task, model, tier, duration_ms: Date.now() - start, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_create_tokens: 0, stop_reason: '', ok: false, error_msg: String(err?.message ?? err).slice(0, 500), }); throw err; } const stopReason = result.stop_reason || ''; const toolUses = extractToolUses(result.content); const text = extractText(result.content); const usage = result.usage || {}; const truncationRequiresFailure = stopReason === 'max_tokens' && (Boolean(schema) || Boolean(toolChoice)); logLlmCall({ task, model, tier, duration_ms: Date.now() - start, input_tokens: usage.input_tokens ?? 0, output_tokens: usage.output_tokens ?? 0, cache_read_tokens: usage.cache_read_input_tokens ?? 0, cache_create_tokens: usage.cache_creation_input_tokens ?? 0, stop_reason: stopReason, ok: !truncationRequiresFailure, error_msg: truncationRequiresFailure ? 'max_tokens' : '', }); if (truncationRequiresFailure) throw new LLMTruncatedError(task); if (toolUses.length) validateToolInputs(toolUses, task, toolSchemas); let parsedFromText; if (schema && !toolUses.length) { const value = parseStructuredText(text); const parsed = schema.safeParse(value); if (!parsed.success) throw new LLMValidationError(task, parsed.error); parsedFromText = parsed.data; } return { text, toolUses, stopReason, usage: { input_tokens: usage.input_tokens ?? 0, output_tokens: usage.output_tokens ?? 0, cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, }, requestId: result.id ?? null, model: result.model ?? model, durationMs: Date.now() - start, parsed: parsedFromText, }; }