UNPKG

ai-functions

Version:

Core AI primitives for building intelligent applications

299 lines 12 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, } from './provider.js'; // ============================================================================ // OpenAI client // ============================================================================ let openaiApiKey; let openaiBaseUrl = 'https://api.openai.com/v1'; /** Configure the OpenAI client. */ export function configureOpenAI(options) { if (options.apiKey) openaiApiKey = options.apiKey; if (options.baseUrl) openaiBaseUrl = options.baseUrl; } function getApiKey() { 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(method, path, body) { 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, purpose) { 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) { 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) { const statusMap = { 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 = new Set(['completed', 'failed']); const THROW_FOR_OPENAI = new Set(['cancelled', 'expired']); const openaiAdapter = { async submit(items, options) { const model = options.model || 'gpt-4o'; const requests = items.map((item) => { const request = { 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('POST', '/batches', { input_file_id: inputFile.id, endpoint: '/v1/chat/completions', completion_window: '24h', metadata: options.metadata, }); const job = { 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) { const batch = await openaiRequest('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) { await openaiRequest('POST', `/batches/${batchId}/cancel`); }, async getResults(batchId) { const status = await this.getStatus(batchId); if (status.status !== 'completed' && status.status !== 'failed') { throw new Error(`Batch not complete. Status: ${status.status}`); } const results = []; if (status.outputFileId) { const lines = (await downloadFile(status.outputFileId)).trim().split('\n'); for (const line of lines) { const response = 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 = 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, pollInterval = 5000) { 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 = { async submitFlex(items, options) { 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, model) { const messages = []; if (item.options?.system) { messages.push({ role: 'system', content: item.options.system }); } messages.push({ role: 'user', content: item.prompt }); const body = { 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()); 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 }; //# sourceMappingURL=openai.js.map