UNPKG

ai-functions

Version:

Core AI primitives for building intelligent applications

430 lines (375 loc) 13.4 kB
/** * OpenAI Batch API Adapter * * Implements batch processing using OpenAI's Batch API: * - 50% cost discount * - 24-hour turnaround * - Up to 50,000 requests per batch * * Plus a flex adapter that processes items concurrently for faster turnaround * at a similar discount. * * This file is a small adapter on top of the BatchProvider port (`./provider.js`). * * @see https://platform.openai.com/docs/guides/batch * * @packageDocumentation */ import { schema as convertSchema } from '../schema.js' import { failedResult, pollUntilComplete, processConcurrently, registerBatchAdapter, registerFlexAdapter, tryParseJson, zodToJsonSchema, type BatchAdapter, type BatchItem, type BatchJob, type BatchQueueOptions, type BatchResult, type BatchStatus, type BatchSubmitResult, type FlexAdapter, } from './provider.js' // ============================================================================ // Provider-specific types // ============================================================================ interface OpenAIBatchRequest { custom_id: string method: 'POST' url: '/v1/chat/completions' body: { model: string messages: Array<{ role: string; content: string }> response_format?: { type: 'json_schema'; json_schema: { name: string; schema: unknown } } max_tokens?: number temperature?: number } } interface OpenAIBatchResponse { id: string custom_id: string response: { status_code: number body: { id: string choices: Array<{ message: { content: string } }> usage: { prompt_tokens: number completion_tokens: number total_tokens: number } } } | null error: { code: string message: string } | null } interface OpenAIBatch { id: string object: 'batch' endpoint: string errors: null | { object: string; data: Array<{ code: string; message: string; line: number }> } input_file_id: string completion_window: string status: string output_file_id: string | null error_file_id: string | null created_at: number in_progress_at: number | null expires_at: number | null finalizing_at: number | null completed_at: number | null failed_at: number | null expired_at: number | null cancelling_at: number | null cancelled_at: number | null request_counts: { total: number completed: number failed: number } metadata: Record<string, string> | null } // ============================================================================ // OpenAI client // ============================================================================ let openaiApiKey: string | undefined let openaiBaseUrl = 'https://api.openai.com/v1' /** Configure the OpenAI client. */ export function configureOpenAI(options: { apiKey?: string; baseUrl?: string }): void { if (options.apiKey) openaiApiKey = options.apiKey if (options.baseUrl) openaiBaseUrl = options.baseUrl } function getApiKey(): string { const key = openaiApiKey || process.env['OPENAI_API_KEY'] if (!key) { throw new Error('OpenAI API key not configured. Set OPENAI_API_KEY or call configureOpenAI()') } return key } async function openaiRequest<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> { const response = await fetch(`${openaiBaseUrl}${path}`, { method, headers: { Authorization: `Bearer ${getApiKey()}`, 'Content-Type': 'application/json', }, ...(body !== undefined && { body: JSON.stringify(body) }), }) if (!response.ok) { const error = await response.text() throw new Error(`OpenAI API error: ${response.status} ${error}`) } return response.json() } async function uploadFile(content: string, purpose: string): Promise<{ id: string }> { const formData = new FormData() formData.append('purpose', purpose) formData.append('file', new Blob([content], { type: 'application/jsonl' }), 'batch.jsonl') const response = await fetch(`${openaiBaseUrl}/files`, { method: 'POST', headers: { Authorization: `Bearer ${getApiKey()}` }, body: formData, }) if (!response.ok) { const error = await response.text() throw new Error(`OpenAI file upload error: ${response.status} ${error}`) } return response.json() } async function downloadFile(fileId: string): Promise<string> { const response = await fetch(`${openaiBaseUrl}/files/${fileId}/content`, { headers: { Authorization: `Bearer ${getApiKey()}` }, }) if (!response.ok) { const error = await response.text() throw new Error(`OpenAI file download error: ${response.status} ${error}`) } return response.text() } function mapStatus(status: string): BatchStatus { const statusMap: Record<string, BatchStatus> = { validating: 'validating', in_progress: 'in_progress', finalizing: 'finalizing', completed: 'completed', failed: 'failed', expired: 'expired', cancelling: 'cancelling', cancelled: 'cancelled', } return statusMap[status] || 'pending' } // ============================================================================ // OpenAI batch adapter (BatchProvider port) // ============================================================================ const TERMINAL_FOR_OPENAI: ReadonlySet<BatchStatus> = new Set(['completed', 'failed']) const THROW_FOR_OPENAI: ReadonlySet<BatchStatus> = new Set(['cancelled', 'expired']) const openaiAdapter: BatchAdapter = { async submit(items: BatchItem[], options: BatchQueueOptions): Promise<BatchSubmitResult> { const model = options.model || 'gpt-4o' const requests: OpenAIBatchRequest[] = items.map((item) => { const request: OpenAIBatchRequest = { custom_id: item.id, method: 'POST', url: '/v1/chat/completions', body: { model, messages: [ ...(item.options?.system ? [{ role: 'system', content: item.options.system }] : []), { role: 'user', content: item.prompt }, ], ...(item.options?.maxTokens !== undefined && { max_tokens: item.options.maxTokens }), ...(item.options?.temperature !== undefined && { temperature: item.options.temperature, }), }, } if (item.schema) { const zodSchema = convertSchema(item.schema) request.body.response_format = { type: 'json_schema', json_schema: { name: 'response', schema: zodToJsonSchema(zodSchema) }, } } return request }) const jsonlContent = requests.map((r) => JSON.stringify(r)).join('\n') const inputFile = await uploadFile(jsonlContent, 'batch') const batch = await openaiRequest<OpenAIBatch>('POST', '/batches', { input_file_id: inputFile.id, endpoint: '/v1/chat/completions', completion_window: '24h', metadata: options.metadata, }) const job: BatchJob = { id: batch.id, provider: 'openai', status: mapStatus(batch.status), totalItems: items.length, completedItems: 0, failedItems: 0, createdAt: new Date(batch.created_at * 1000), ...(batch.expires_at && { expiresAt: new Date(batch.expires_at * 1000) }), ...(options.webhookUrl !== undefined && { webhookUrl: options.webhookUrl }), inputFileId: batch.input_file_id, } const completion = this.waitForCompletion(batch.id) return { job, completion } }, async getStatus(batchId: string): Promise<BatchJob> { const batch = await openaiRequest<OpenAIBatch>('GET', `/batches/${batchId}`) return { id: batch.id, provider: 'openai', status: mapStatus(batch.status), totalItems: batch.request_counts.total, completedItems: batch.request_counts.completed, failedItems: batch.request_counts.failed, createdAt: new Date(batch.created_at * 1000), ...(batch.in_progress_at && { startedAt: new Date(batch.in_progress_at * 1000) }), ...(batch.completed_at && { completedAt: new Date(batch.completed_at * 1000) }), ...(batch.expires_at && { expiresAt: new Date(batch.expires_at * 1000) }), inputFileId: batch.input_file_id, ...(batch.output_file_id && { outputFileId: batch.output_file_id }), ...(batch.error_file_id && { errorFileId: batch.error_file_id }), } }, async cancel(batchId: string): Promise<void> { await openaiRequest('POST', `/batches/${batchId}/cancel`) }, async getResults(batchId: string): Promise<BatchResult[]> { const status = await this.getStatus(batchId) if (status.status !== 'completed' && status.status !== 'failed') { throw new Error(`Batch not complete. Status: ${status.status}`) } const results: BatchResult[] = [] if (status.outputFileId) { const lines = (await downloadFile(status.outputFileId)).trim().split('\n') for (const line of lines) { const response: OpenAIBatchResponse = JSON.parse(line) if (response.error) { results.push({ id: response.custom_id, customId: response.custom_id, status: 'failed', error: response.error.message, }) } else if (response.response) { const content = response.response.body.choices[0]?.message?.content results.push({ id: response.custom_id, customId: response.custom_id, status: 'completed', result: tryParseJson(content), usage: { promptTokens: response.response.body.usage.prompt_tokens, completionTokens: response.response.body.usage.completion_tokens, totalTokens: response.response.body.usage.total_tokens, }, }) } } } if (status.errorFileId) { const lines = (await downloadFile(status.errorFileId)).trim().split('\n') for (const line of lines) { const response: OpenAIBatchResponse = JSON.parse(line) results.push({ id: response.custom_id, customId: response.custom_id, status: 'failed', error: response.error?.message || 'Unknown error', }) } } return results }, async waitForCompletion(batchId: string, pollInterval = 5000): Promise<BatchResult[]> { return pollUntilComplete(this, batchId, { pollInterval, fetchResultsOn: TERMINAL_FOR_OPENAI, throwOn: THROW_FOR_OPENAI, }) }, } // ============================================================================ // OpenAI flex adapter (FlexAdapter port) // ============================================================================ /** * Flex processing uses concurrent requests for faster turnaround than batch * (minutes vs 24h) at a similar discount. Ideal for 5–500 items. * * As of 2026, OpenAI doesn't expose a dedicated "flex" tier API, so this * adapter implements concurrent direct chat completions as a middle ground. */ const openaiFlexAdapter: FlexAdapter = { async submitFlex(items: BatchItem[], options: { model?: string }): Promise<BatchResult[]> { const model = options.model || 'gpt-4o' return processConcurrently(items, (item) => processOpenAIItem(item, model), { concurrency: 10, }) }, } /** Process a single item via OpenAI Chat Completions API. */ async function processOpenAIItem(item: BatchItem, model: string): Promise<BatchResult> { const messages: Array<{ role: string; content: string }> = [] if (item.options?.system) { messages.push({ role: 'system', content: item.options.system }) } messages.push({ role: 'user', content: item.prompt }) const body: Record<string, unknown> = { model, messages, max_tokens: item.options?.maxTokens, temperature: item.options?.temperature, } if (item.schema) { const zodSchema = convertSchema(item.schema) body['response_format'] = { type: 'json_schema', json_schema: { name: 'response', schema: zodToJsonSchema(zodSchema) }, } } const response = await fetch(`${openaiBaseUrl}/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${getApiKey()}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }) if (!response.ok) { const error = await response.text() throw new Error(`OpenAI API error: ${response.status} ${error}`) } const data = (await response.json()) as { choices: Array<{ message: { content: string } }> usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } } const content = data.choices[0]?.message?.content return { id: item.id, customId: item.id, status: 'completed', result: tryParseJson(content, !!item.schema), usage: { promptTokens: data.usage.prompt_tokens, completionTokens: data.usage.completion_tokens, totalTokens: data.usage.total_tokens, }, } } // `failedResult` re-imported only to keep the import surface stable for tests // that may use it. The processConcurrently helper handles failures internally. void failedResult // ============================================================================ // Register adapters // ============================================================================ registerBatchAdapter('openai', openaiAdapter) registerFlexAdapter('openai', openaiFlexAdapter) export { openaiAdapter, openaiFlexAdapter }