import Anthropic from '@anthropic-ai/sdk'; import { resolveSecret } from './secret.js'; export const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6'; /** * Build an Anthropic client, resolving the API key via the secret resolver. * Key reference can be overridden with ANTHROPIC_API_KEY_REF env var. */ export function getAnthropicClient() { const key = resolveSecret(process.env.ANTHROPIC_API_KEY_REF || 'env:ANTHROPIC_API_KEY'); if (!key) { throw new Error( 'Anthropic API key not configured (set ANTHROPIC_API_KEY or ANTHROPIC_API_KEY_REF)' ); } return new Anthropic({ apiKey: key }); } /** * Factory that returns a callModel function bound to a specific client + model. * * @param {object} opts * @param {object} opts.client - Anthropic client (or fake for tests) * @param {string} [opts.model] - Model ID (default: DEFAULT_MODEL) * @param {number} [opts.maxTokens] - max_tokens (default: 1024) * @returns {Function} callModel({ system, messages, tools, onTextDelta }) * * callModel returns a stable shape: * { text: string, toolUses: [{id, name, input}], stopReason: string, usage: object } * * Text deltas are streamed via onTextDelta(chunk) as they arrive. * Tool-use blocks are collected from the final assembled message. */ export function makeCallModel({ client, model = DEFAULT_MODEL, maxTokens = 1024 }) { return async function callModel({ system, messages, tools, onTextDelta }) { // client.messages.stream returns a MessageStream: AsyncIterable // with a finalMessage() method that resolves to the assembled Message. const stream = client.messages.stream({ model, max_tokens: maxTokens, ...(system !== undefined && { system }), messages, ...(tools !== undefined && tools !== null && { tools }), }); // Iterate events; fire onTextDelta for text_delta events only. // RawContentBlockDeltaEvent: { type: 'content_block_delta', index, delta } // TextDelta: { type: 'text_delta', text } for await (const event of stream) { if ( event.type === 'content_block_delta' && event.delta?.type === 'text_delta' ) { onTextDelta?.(event.delta.text); } } // finalMessage() resolves to the fully assembled Message object. const final = await stream.finalMessage(); // Collect text from all text blocks (typically just one, but join defensively). const text = final.content .filter((b) => b.type === 'text') .map((b) => b.text) .join(''); // Collect tool_use blocks, normalising to a stable {id, name, input} shape. const toolUses = final.content .filter((b) => b.type === 'tool_use') .map((b) => ({ id: b.id, name: b.name, input: b.input })); return { text, toolUses, stopReason: final.stop_reason, usage: final.usage, }; }; }