diff --git a/lib/ai/anthropic.js b/lib/ai/anthropic.js new file mode 100644 index 0000000..fe9b834 --- /dev/null +++ b/lib/ai/anthropic.js @@ -0,0 +1,80 @@ +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, + }; + }; +} diff --git a/tests/ai/anthropic.test.js b/tests/ai/anthropic.test.js new file mode 100644 index 0000000..98ba733 --- /dev/null +++ b/tests/ai/anthropic.test.js @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { makeCallModel } from '../../lib/ai/anthropic.js'; + +// --------------------------------------------------------------------------- +// Fake client whose messages.stream() returns an async-iterable of real-SDK- +// shaped RawMessageStreamEvent objects, plus a finalMessage() method that +// resolves to the assembled Message. Shape matches @anthropic-ai/sdk@0.40.1. +// --------------------------------------------------------------------------- + +function makeFakeStream({ events, finalMsg }) { + // The stream object is both async-iterable and has a finalMessage() method, + // exactly as MessageStream from the real SDK. + return { + [Symbol.asyncIterator]() { + let idx = 0; + return { + async next() { + if (idx < events.length) return { value: events[idx++], done: false }; + return { value: undefined, done: true }; + }, + }; + }, + async finalMessage() { + return finalMsg; + }, + }; +} + +const FAKE_FINAL_MESSAGE = { + content: [ + { type: 'text', text: 'Hello', citations: null }, + { type: 'tool_use', id: 'tu_1', name: 'search', input: { q: 'x' } }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 10, output_tokens: 5 }, +}; + +// Events emitted during streaming: two text deltas that together form 'Hello' +const FAKE_EVENTS = [ + // message_start — no text + { type: 'message_start', message: {} }, + // content_block_start for text block + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + // text deltas + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hel' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'lo' } }, + // content_block_stop for text block + { type: 'content_block_stop', index: 0 }, + // content_block_start for tool_use block + { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'tu_1', name: 'search', input: {} } }, + // input_json delta (not text — should NOT trigger onTextDelta) + { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"q":"x"}' } }, + { type: 'content_block_stop', index: 1 }, + // message_stop + { type: 'message_stop' }, +]; + +const fakeClient = { + messages: { + stream(_params) { + return makeFakeStream({ events: FAKE_EVENTS, finalMsg: FAKE_FINAL_MESSAGE }); + }, + }, +}; + +describe('makeCallModel', () => { + it('streams text deltas and returns stable shape', async () => { + const callModel = makeCallModel({ client: fakeClient, model: 'm', maxTokens: 512 }); + + const deltas = []; + const out = await callModel({ + system: 'You are helpful.', + messages: [{ role: 'user', content: 'Hi' }], + tools: [], + onTextDelta: (chunk) => deltas.push(chunk), + }); + + // Streamed deltas must concatenate to 'Hello' + expect(deltas.join('')).toBe('Hello'); + + // Stable return shape + expect(out.text).toBe('Hello'); + expect(out.toolUses).toHaveLength(1); + expect(out.toolUses[0]).toMatchObject({ id: 'tu_1', name: 'search' }); + expect(out.toolUses[0].input).toEqual({ q: 'x' }); + expect(out.stopReason).toBe('tool_use'); + expect(out.usage).toMatchObject({ input_tokens: 10, output_tokens: 5 }); + }); + + it('works without onTextDelta callback', async () => { + const callModel = makeCallModel({ client: fakeClient, model: 'm' }); + const out = await callModel({ + messages: [{ role: 'user', content: 'Hi' }], + }); + expect(out.text).toBe('Hello'); + expect(out.stopReason).toBe('tool_use'); + }); + + it('returns empty toolUses when no tool_use blocks', async () => { + const textOnlyMsg = { + content: [{ type: 'text', text: 'Just text', citations: null }], + stop_reason: 'end_turn', + usage: { input_tokens: 5, output_tokens: 3 }, + }; + const textOnlyClient = { + messages: { + stream: () => makeFakeStream({ events: [], finalMsg: textOnlyMsg }), + }, + }; + const callModel = makeCallModel({ client: textOnlyClient, model: 'm' }); + const out = await callModel({ messages: [{ role: 'user', content: 'Hi' }] }); + expect(out.text).toBe('Just text'); + expect(out.toolUses).toEqual([]); + expect(out.stopReason).toBe('end_turn'); + }); +});