UNPKG

ai-functions

Version:

Core AI primitives for building intelligent applications

311 lines (270 loc) 9.22 kB
/** * Anthropic Message Batches API Adapter * * Implements batch processing using Anthropic's Message Batches API: * - 50% cost discount * - 24-hour turnaround * - Up to 10,000 requests per batch * * This file is a small adapter on top of the BatchProvider port (`./provider.js`). * It owns only the Anthropic-specific request/response shapes and HTTP calls; * shared concerns (polling, JSON-Schema conversion, JSON parsing) live in the port. * * @see https://docs.anthropic.com/en/docs/build-with-claude/message-batches * * @packageDocumentation */ import { schema as convertSchema } from '../schema.js' import { pollUntilComplete, registerBatchAdapter, tryParseJson, zodToJsonSchema, type BatchAdapter, type BatchItem, type BatchJob, type BatchQueueOptions, type BatchResult, type BatchStatus, type BatchSubmitResult, } from './provider.js' // ============================================================================ // Provider-specific types // ============================================================================ interface AnthropicBatchRequest { custom_id: string params: { model: string max_tokens: number messages: Array<{ role: string; content: string }> system?: string temperature?: number tool_choice?: { type: 'tool'; name: string } tools?: Array<{ name: string description: string input_schema: Record<string, unknown> }> } } interface AnthropicBatchResult { custom_id: string result: { type: 'succeeded' | 'errored' | 'canceled' | 'expired' message?: { id: string content: Array<{ type: 'text' | 'tool_use' text?: string name?: string input?: unknown }> usage: { input_tokens: number output_tokens: number } } error?: { type: string message: string } } } interface AnthropicBatch { id: string type: 'message_batch' processing_status: 'in_progress' | 'ended' request_counts: { processing: number succeeded: number errored: number canceled: number expired: number } ended_at: string | null created_at: string expires_at: string cancel_initiated_at: string | null results_url: string | null } // ============================================================================ // Anthropic client // ============================================================================ let anthropicApiKey: string | undefined let anthropicBaseUrl = 'https://api.anthropic.com/v1' const ANTHROPIC_VERSION = '2023-06-01' const ANTHROPIC_BETA = 'message-batches-2024-09-24' /** Configure the Anthropic client. */ export function configureAnthropic(options: { apiKey?: string; baseUrl?: string }): void { if (options.apiKey) anthropicApiKey = options.apiKey if (options.baseUrl) anthropicBaseUrl = options.baseUrl } function getApiKey(): string { const key = anthropicApiKey || process.env['ANTHROPIC_API_KEY'] if (!key) { throw new Error( 'Anthropic API key not configured. Set ANTHROPIC_API_KEY or call configureAnthropic()' ) } return key } function anthropicHeaders(): Record<string, string> { return { 'x-api-key': getApiKey(), 'anthropic-version': ANTHROPIC_VERSION, 'anthropic-beta': ANTHROPIC_BETA, 'Content-Type': 'application/json', } } async function anthropicRequest<T>( method: 'GET' | 'POST', path: string, body?: unknown ): Promise<T> { const response = await fetch(`${anthropicBaseUrl}${path}`, { method, headers: anthropicHeaders(), ...(body !== undefined && { body: JSON.stringify(body) }), }) if (!response.ok) { const error = await response.text() throw new Error(`Anthropic API error: ${response.status} ${error}`) } return response.json() } function mapStatus(batch: AnthropicBatch): BatchStatus { if (batch.cancel_initiated_at) { return batch.processing_status === 'ended' ? 'cancelled' : 'cancelling' } if (batch.processing_status === 'ended') { return 'completed' } return 'in_progress' } function toBatchJob(batch: AnthropicBatch, totalItemsHint?: number): BatchJob { const totalFromCounts = batch.request_counts.processing + batch.request_counts.succeeded + batch.request_counts.errored + batch.request_counts.canceled + batch.request_counts.expired return { id: batch.id, provider: 'anthropic', status: mapStatus(batch), totalItems: totalItemsHint ?? totalFromCounts, completedItems: batch.request_counts.succeeded, failedItems: batch.request_counts.errored + batch.request_counts.expired + batch.request_counts.canceled, createdAt: new Date(batch.created_at), ...(batch.ended_at && { completedAt: new Date(batch.ended_at) }), expiresAt: new Date(batch.expires_at), } } // ============================================================================ // Anthropic adapter (BatchProvider port) // ============================================================================ const anthropicAdapter: BatchAdapter = { async submit(items: BatchItem[], options: BatchQueueOptions): Promise<BatchSubmitResult> { const model = options.model || 'claude-sonnet-4-20250514' const maxTokens = 4096 const requests: AnthropicBatchRequest[] = items.map((item) => { const request: AnthropicBatchRequest = { custom_id: item.id, params: { model, max_tokens: item.options?.maxTokens || maxTokens, messages: [{ role: 'user', content: item.prompt }], ...(item.options?.system !== undefined && { system: item.options.system }), ...(item.options?.temperature !== undefined && { temperature: item.options.temperature, }), }, } if (item.schema) { const zodSchema = convertSchema(item.schema) request.params.tools = [ { name: 'structured_response', description: 'Generate a structured response matching the schema', input_schema: zodToJsonSchema(zodSchema), }, ] request.params.tool_choice = { type: 'tool', name: 'structured_response' } } return request }) const batch = await anthropicRequest<AnthropicBatch>('POST', '/messages/batches', { requests, }) const job = toBatchJob(batch, items.length) if (options.webhookUrl !== undefined) { job.webhookUrl = options.webhookUrl } const completion = this.waitForCompletion(batch.id) return { job, completion } }, async getStatus(batchId: string): Promise<BatchJob> { const batch = await anthropicRequest<AnthropicBatch>('GET', `/messages/batches/${batchId}`) return toBatchJob(batch) }, async cancel(batchId: string): Promise<void> { await anthropicRequest('POST', `/messages/batches/${batchId}/cancel`) }, async getResults(batchId: string): Promise<BatchResult[]> { const status = await this.getStatus(batchId) if (status.status !== 'completed' && status.status !== 'cancelled') { throw new Error(`Batch not complete. Status: ${status.status}`) } // Anthropic returns results via a signed URL on the batch object. const batch = await anthropicRequest<AnthropicBatch>('GET', `/messages/batches/${batchId}`) if (!batch.results_url) { throw new Error('No results URL available') } const response = await fetch(batch.results_url, { headers: anthropicHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch results: ${response.status}`) } const lines = (await response.text()).trim().split('\n') return lines.map(parseAnthropicResult) }, async waitForCompletion(batchId: string, pollInterval = 5000): Promise<BatchResult[]> { return pollUntilComplete(this, batchId, { pollInterval }) }, } function parseAnthropicResult(line: string): BatchResult { const result: AnthropicBatchResult = JSON.parse(line) if (result.result.type === 'succeeded' && result.result.message) { const message = result.result.message const toolUse = message.content.find((c) => c.type === 'tool_use') const textContent = message.content.find((c) => c.type === 'text') let extractedResult: unknown if (toolUse?.input) { extractedResult = toolUse.input } else if (textContent?.text) { extractedResult = tryParseJson(textContent.text) } return { id: result.custom_id, customId: result.custom_id, status: 'completed', result: extractedResult, usage: { promptTokens: message.usage.input_tokens, completionTokens: message.usage.output_tokens, totalTokens: message.usage.input_tokens + message.usage.output_tokens, }, } } return { id: result.custom_id, customId: result.custom_id, status: 'failed', error: result.result.error?.message || `Request ${result.result.type}`, } } // ============================================================================ // Register adapter // ============================================================================ registerBatchAdapter('anthropic', anthropicAdapter) export { anthropicAdapter }