UNPKG

@just-every/ensemble

Version:

LLM provider abstraction layer with unified streaming interface

1,121 lines (1,120 loc) 59.9 kB
import { BaseModelProvider } from './base_provider.js'; import OpenAI, { toFile } from 'openai'; import { costTracker } from '../utils/cost_tracker.js'; import { log_llm_request, log_llm_response, log_llm_error } from '../utils/llm_logger.js'; import { isPaused } from '../utils/pause_controller.js'; import { appendMessageWithImage, normalizeImageDataUrl, resizeAndSplitForOpenAI } from '../utils/image_utils.js'; import { bufferDelta, flushBufferedDeltas } from '../utils/delta_buffer.js'; import { createCitationTracker, formatCitation, generateFootnotes } from '../utils/citation_tracker.js'; import { hasEventHandler } from '../utils/event_controller.js'; const BROWSER_WIDTH = 1024; const BROWSER_HEIGHT = 1536; function requiresReasoning(modelName) { if (modelName.startsWith('o')) return true; if (modelName.startsWith('gpt-')) { const match = modelName.match(/^gpt-(\d+)/); if (match) { const version = parseInt(match[1], 10); return version >= 5; } } return false; } function processSchemaForOpenAI(schema, originalProperties) { const processedSchema = JSON.parse(JSON.stringify(schema)); const processSchemaRecursively = (schema) => { if (!schema || typeof schema !== 'object') return; if (schema.optional === true) { delete schema.optional; } if (Array.isArray(schema.oneOf)) { schema.anyOf = schema.oneOf; delete schema.oneOf; } const unsupportedKeywords = [ 'minimum', 'maximum', 'minItems', 'maxItems', 'minLength', 'maxLength', 'pattern', 'format', 'multipleOf', 'patternProperties', 'unevaluatedProperties', 'propertyNames', 'minProperties', 'maxProperties', 'unevaluatedItems', 'contains', 'minContains', 'maxContains', 'uniqueItems', 'default', ]; unsupportedKeywords.forEach(keyword => { if (schema[keyword] !== undefined) { delete schema[keyword]; } }); const isObject = schema.type === 'object' || (schema.type === undefined && schema.properties !== undefined); for (const key of ['anyOf', 'allOf']) { if (Array.isArray(schema[key])) { schema[key].forEach((variantSchema) => processSchemaRecursively(variantSchema)); } } if (isObject && schema.properties) { for (const propName in schema.properties) { processSchemaRecursively(schema.properties[propName]); } } if (schema.type === 'array' && schema.items !== undefined) { if (Array.isArray(schema.items)) { schema.items.forEach((itemSchema) => processSchemaRecursively(itemSchema)); } else if (typeof schema.items === 'object') { processSchemaRecursively(schema.items); } } if (isObject) { schema.additionalProperties = false; if (schema.properties) { const currentRequired = Object.keys(schema.properties); if (currentRequired.length > 0) { schema.required = currentRequired; } else { delete schema.required; } } else { delete schema.required; } } }; processSchemaRecursively(processedSchema); if (originalProperties) { const topLevelRequired = []; for (const propName in originalProperties) { if (!originalProperties[propName].optional) { topLevelRequired.push(propName); } } if (topLevelRequired.length > 0) { processedSchema.required = topLevelRequired; } else { delete processedSchema.required; } } if (processedSchema.properties && processedSchema.additionalProperties === undefined) { processedSchema.additionalProperties = false; } return processedSchema; } async function resolveAsyncEnums(params) { if (!params || typeof params !== 'object') { return params; } const resolved = { ...params }; if (resolved.properties) { const resolvedProps = {}; for (const [key, value] of Object.entries(resolved.properties)) { if (value && typeof value === 'object') { const propCopy = { ...value }; if (typeof propCopy.enum === 'function') { try { const enumValue = await propCopy.enum(); if (Array.isArray(enumValue) && enumValue.length > 0) { propCopy.enum = enumValue; } else { delete propCopy.enum; } } catch { delete propCopy.enum; } } resolvedProps[key] = await resolveAsyncEnums(propCopy); } else { resolvedProps[key] = value; } } resolved.properties = resolvedProps; } return resolved; } async function convertToOpenAITools(requestParams, tools) { requestParams.tools = await Promise.all(tools.map(async (tool) => { if (tool.definition.function.name === 'openai_web_search') { delete requestParams.reasoning; return { type: 'web_search_preview', search_context_size: 'high', }; } const resolvedParams = await resolveAsyncEnums(tool.definition.function.parameters); const originalToolProperties = resolvedParams.properties; const paramSchema = processSchemaForOpenAI(resolvedParams, originalToolProperties); return { type: 'function', name: tool.definition.function.name, description: tool.definition.function.description, parameters: paramSchema, strict: true, }; })); if (requestParams.model === 'computer-use-preview') { requestParams.tools.push({ type: 'computer_use_preview', display_width: BROWSER_WIDTH, display_height: BROWSER_HEIGHT, environment: 'browser', }); } requestParams.truncation = 'auto'; return requestParams; } async function addImagesToInput(input, images, source) { for (const [image_id, imageData] of Object.entries(images)) { try { const processedImages = await resizeAndSplitForOpenAI(imageData); const messageContent = []; if (processedImages.length === 1) { messageContent.push({ type: 'input_text', text: `[image #${image_id}] from the ${source}`, }); } else { messageContent.push({ type: 'input_text', text: `[image #${image_id}] from the ${source} (split into ${processedImages.length} parts, each up to 768px high)`, }); } for (const imageSegment of processedImages) { messageContent.push({ type: 'input_image', image_url: imageSegment, detail: 'high', }); } input.push({ type: 'message', role: 'user', content: messageContent, }); } catch (error) { console.error(`Error processing image ${image_id}:`, error); input.push({ type: 'message', role: 'user', content: [ { type: 'input_text', text: `This is [image #${image_id}] from the ${source} (raw image)`, }, { type: 'input_image', image_url: imageData, detail: 'high', }, ], }); } } return input; } async function convertContentPartsToOpenAI(content) { if (!Array.isArray(content)) return content; const parts = []; for (const item of content) { if (item.type === 'input_text') { parts.push(item); } else if (item.type === 'input_image' || item.type === 'image') { const normalized = normalizeImageDataUrl({ data: 'data' in item ? item.data : undefined, image_url: 'image_url' in item ? item.image_url : undefined, url: 'url' in item ? item.url : undefined, mime_type: 'mime_type' in item ? item.mime_type : undefined, }); const imageUrl = normalized.dataUrl || normalized.url || ('image_url' in item ? item.image_url : ''); if (!imageUrl) continue; if (imageUrl.startsWith('data:')) { const processedImages = await resizeAndSplitForOpenAI(imageUrl); for (const processedImage of processedImages) { parts.push({ type: 'input_image', image_url: processedImage, detail: item.detail || 'auto', }); } } else { parts.push({ type: 'input_image', image_url: imageUrl, detail: item.detail || 'auto', }); } } } return parts; } export class OpenAIProvider extends BaseModelProvider { _client; apiKey; constructor(apiKey) { super('openai'); this.apiKey = apiKey; } get client() { if (!this._client) { const apiKey = this.apiKey || process.env.OPENAI_API_KEY; if (!apiKey) { throw new Error('Failed to initialize OpenAI client. Make sure OPENAI_API_KEY is set.'); } this._client = new OpenAI({ apiKey: apiKey, }); } return this._client; } async createEmbedding(input, model, agent, opts) { const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; let finalRequestId = requestId; try { const options = { model, input: input, encoding_format: 'float', }; if (opts?.dimensions) { options.dimensions = opts.dimensions; } const loggedRequestId = log_llm_request(agent.agent_id || 'default', 'openai', model, options, new Date(), requestId, agent.tags); finalRequestId = loggedRequestId; const response = await this.client.embeddings.create(options); const inputTokens = response.usage?.prompt_tokens || (typeof input === 'string' ? Math.ceil(input.length / 4) : input.reduce((sum, text) => sum + Math.ceil(text.length / 4), 0)); costTracker.addUsage({ model, input_tokens: inputTokens, output_tokens: 0, metadata: { dimensions: response.data[0]?.embedding.length || options.dimensions, }, }); log_llm_response(finalRequestId, response); if (Array.isArray(input) && input.length > 1) { return response.data.map(item => item.embedding); } else { return response.data[0].embedding; } } catch (error) { log_llm_error(finalRequestId, error); console.error('[OpenAI] Error generating embedding:', error); throw error; } } async createImage(prompt, model, agent, opts) { const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; let finalRequestId = requestId; try { model = model || 'gpt-image-1.5'; const number_of_images = opts?.n || 1; let quality = 'auto'; if (opts?.quality === 'standard') quality = 'medium'; else if (opts?.quality === 'hd') quality = 'high'; else if (opts?.quality === 'low' || opts?.quality === 'medium' || opts?.quality === 'high') { quality = opts.quality; } let size = 'auto'; if (opts?.size === 'square' || opts?.size === '1024x1024') { size = '1024x1024'; } else if (opts?.size === 'landscape' || opts?.size === '1536x1024') { size = '1536x1024'; } else if (opts?.size === 'portrait' || opts?.size === '1024x1536') { size = '1024x1536'; } const background = opts?.background || 'auto'; const input_fidelity = opts?.input_fidelity; const source_images = opts?.source_images; console.log(`[OpenAI] Generating ${number_of_images} image(s) with model ${model}, prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`); let response; if (source_images) { console.log('[OpenAI] Using images.edit with source_images'); const imageArray = Array.isArray(source_images) ? source_images : [source_images]; const imageFiles = []; for (let i = 0; i < imageArray.length; i++) { const sourceImg = imageArray[i]; let imageFile; let imageData; let metadata; if (typeof sourceImg === 'object' && 'data' in sourceImg) { imageData = sourceImg.data; metadata = sourceImg.metadata; } else { imageData = sourceImg; } let filename = `image_${i}.png`; if (metadata) { const parts = []; if (metadata.category) parts.push(metadata.category); if (metadata.title) parts.push(metadata.title.replace(/[^a-zA-Z0-9-_]/g, '_')); if (metadata.id) parts.push(`id${metadata.id}`); if (parts.length > 0) { filename = `${parts.join('_')}.png`; } } if (imageData.startsWith('http://') || imageData.startsWith('https://')) { const imageResponse = await fetch(imageData); const imageBuffer = await imageResponse.arrayBuffer(); const ct = imageResponse.headers.get('content-type') || 'image/png'; imageFile = await toFile(new Uint8Array(imageBuffer), filename, { type: ct, }); } else { let base64Data = imageData; let mime = 'image/png'; if (imageData.startsWith('data:')) { const m = /^data:([^;]+);base64,(.+)$/i.exec(imageData); if (m) { mime = m[1] || 'image/png'; base64Data = m[2]; } else { base64Data = imageData.split(',')[1]; } } const binaryData = Buffer.from(base64Data, 'base64'); imageFile = await toFile(new Uint8Array(binaryData), filename, { type: mime, }); } imageFiles.push(imageFile); } let maskFile; if (opts?.mask) { let maskBase64 = opts.mask; if (opts.mask.startsWith('data:')) { maskBase64 = opts.mask.split(',')[1]; } const maskBinary = Buffer.from(maskBase64, 'base64'); maskFile = await toFile(new Uint8Array(maskBinary), 'mask.png', { type: 'image/png', }); } const editParams = { model, prompt, image: imageFiles, n: number_of_images, background, quality, size, moderation: 'low', output_format: 'png', }; if (input_fidelity) { editParams.input_fidelity = input_fidelity; } if (maskFile) { editParams.mask = maskFile; } const loggedRequestId = log_llm_request(agent.agent_id || 'default', 'openai', model, { ...editParams, imageArray }, new Date(), requestId, agent.tags); finalRequestId = loggedRequestId; response = await this.client.images.edit(editParams); } else { const generateParams = { model, prompt, n: number_of_images, background, quality, size, moderation: 'low', output_format: 'png', }; const loggedRequestId = log_llm_request(agent.agent_id || 'default', 'openai', model, generateParams, new Date(), requestId, agent.tags); finalRequestId = loggedRequestId; response = await this.client.images.generate(generateParams); } if (response.data && response.data.length > 0) { const perImageCost = this.getImageCost(model, quality, size); const totalCost = perImageCost * response.data.length; costTracker.addUsage({ model, image_count: response.data.length, cost: totalCost, request_id: opts?.request_id, metadata: { quality, size, cost_per_image: perImageCost, is_edit: !!source_images, }, }); } const imageDataUrls = response.data.map(item => { const imageData = item?.b64_json; if (!imageData) { throw new Error('No image data returned from OpenAI'); } return `data:image/png;base64,${imageData}`; }); if (imageDataUrls.length === 0) { throw new Error('No images returned from OpenAI'); } log_llm_response(finalRequestId, response); return imageDataUrls; } catch (error) { log_llm_error(finalRequestId, error); console.error('[OpenAI] Error generating image:', error); throw error; } } getImageCost(model, quality, size) { const isLarge = size === '1536x1024' || size === '1024x1536'; const q = quality === 'auto' ? 'medium' : quality; if (model === 'gpt-image-1.5') { if (q === 'high') return isLarge ? 0.2 : 0.133; if (q === 'low') return isLarge ? 0.013 : 0.009; return isLarge ? 0.05 : 0.034; } if (model === 'gpt-image-1') { if (q === 'high') return isLarge ? 0.167 : 0.103; if (q === 'low') return isLarge ? 0.011 : 0.007; return isLarge ? 0.042 : 0.026; } if (model === 'gpt-image-1-mini') { if (q === 'high') return isLarge ? 0.02 : 0.015; if (q === 'low') return isLarge ? 0.006 : 0.005; return isLarge ? 0.015 : 0.011; } return 0.04; } async createVoice(text, model, agent, opts) { const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; let finalRequestId = requestId; try { const voice = opts?.voice || 'alloy'; const speed = opts?.speed || 1.0; let response_format = opts?.response_format || 'mp3'; if (response_format.includes('pcm')) { response_format = 'pcm'; } if (response_format.includes('mp3')) { response_format = 'mp3'; } let instructions = opts?.instructions || undefined; if (opts?.affect) { instructions = `Sound ${opts.affect}${instructions ? ' and ' + instructions : ''}`; } const speechRequest = { model, input: text, instructions, voice, speed, response_format: response_format, }; const loggedRequestId = log_llm_request(agent.agent_id || 'default', 'openai', model, speechRequest, new Date(), requestId, agent.tags); finalRequestId = loggedRequestId; const response = await this.client.audio.speech.create(speechRequest); const characterCount = text.length; const costPerThousandChars = model === 'tts-1-hd' ? 0.03 : 0.015; const cost = (characterCount / 1000) * costPerThousandChars; costTracker.addUsage({ model, cost, metadata: { character_count: characterCount, voice, format: response_format, }, }); log_llm_response(finalRequestId, { status: 'success', headers: response.headers, body_type: opts?.stream ? 'ReadableStream' : 'ArrayBuffer', content_type: response.headers?.['content-type'] || 'audio/mpeg', }); if (opts?.stream) { const nodeStream = response.body; return new ReadableStream({ async start(controller) { for await (const chunk of nodeStream) { controller.enqueue(new Uint8Array(chunk)); } controller.close(); }, }); } else { const buffer = await response.arrayBuffer(); return buffer; } } catch (error) { log_llm_error(finalRequestId, error); console.error('[OpenAI] Error generating speech:', error); throw error; } } async *createResponseStream(messages, model, agent, requestId) { const { getToolsFromAgent } = await import('../utils/agent.js'); const tools = agent ? await getToolsFromAgent(agent) : []; const settings = agent?.modelSettings; try { let input = []; const pendingFunctionOutputs = []; let currentReasoningId = null; for (const messageFull of messages) { let message = { ...messageFull }; const originalModel = message.model; const allowedMessageProps = [ 'type', 'role', 'content', 'status', 'id', 'name', 'thinking_id', 'signature', 'arguments', 'call_id', 'output', 'images', 'image_detail', 'image_url', 'detail', 'action', 'command', 'env', 'timeout_ms', 'user', 'working_directory', 'server_label', 'tools', 'error', 'approval_request_id', 'approve', 'reason', 'acknowledged_safety_checks', 'annotations', 'input_schema', 'description', 'result', 'generated', ]; Object.keys(message).forEach(key => { if (!allowedMessageProps.includes(key)) { delete message[key]; } }); if (message.type === 'thinking') { if (requiresReasoning(model) && message.thinking_id && model === originalModel) { const match = message.thinking_id.match(/^(rs_[A-Za-z0-9]+)-(\d)$/); if (match) { const reasoningId = match[1]; const summaryIndex = parseInt(match[2], 10); currentReasoningId = reasoningId; const summaryText = typeof message.content === 'string' ? message.content : JSON.stringify(message.content); const summaryEntry = { type: 'summary_text', text: summaryText, }; const existingIndex = input.findIndex((item) => item.type === 'reasoning' && item.id === reasoningId); if (existingIndex !== -1) { const existingItem = input[existingIndex]; if (!existingItem.summary) { existingItem.summary = []; } existingItem.summary[summaryIndex] = summaryEntry; input[existingIndex] = existingItem; } else { const newItem = { type: 'reasoning', id: reasoningId, summary: [], }; newItem.summary[summaryIndex] = summaryEntry; input.push(newItem); } continue; } } input.push({ type: 'message', role: 'user', content: 'Thinking: ' + message.content, status: message.status || 'completed', }); continue; } if (message.type === 'function_call') { const isAssociatedWithReasoning = currentReasoningId && message.id && message.id.startsWith('fc_') && model === originalModel; if (message.id && (!message.id.startsWith('fc_') || model !== originalModel)) { const { id, ...rest } = message; message = rest; if (!message.call_id && id) { message.call_id = id; } } message.status = message.status || 'completed'; input.push(message); if (!isAssociatedWithReasoning && pendingFunctionOutputs.length > 0) { for (const output of pendingFunctionOutputs) { input = await appendMessageWithImage(model, input, output.messageToAdd, 'output', addImagesToInput, output.description); } pendingFunctionOutputs.length = 0; currentReasoningId = null; } continue; } if (message.type === 'function_call_output') { const { name, id, ...messageToAdd } = message; if (currentReasoningId && requiresReasoning(model)) { pendingFunctionOutputs.push({ messageToAdd, description: `function call output of ${message.name}`, }); } else { input = await appendMessageWithImage(model, input, messageToAdd, 'output', addImagesToInput, `function call output of ${message.name}`); } continue; } if ((message.type ?? 'message') === 'message' && 'content' in message) { if (pendingFunctionOutputs.length > 0) { for (const output of pendingFunctionOutputs) { input = await appendMessageWithImage(model, input, output.messageToAdd, 'output', addImagesToInput, output.description); } pendingFunctionOutputs.length = 0; currentReasoningId = null; } if ('id' in message && message.id && (!message.id.startsWith('msg_') || model !== originalModel)) { const { id, ...rest } = message; message = rest; } if (Array.isArray(message.content)) { const convertedContent = await convertContentPartsToOpenAI(message.content); input.push({ ...message, type: 'message', content: convertedContent, }); } else { input = await appendMessageWithImage(model, input, { ...message, type: 'message' }, 'content', addImagesToInput); } continue; } } if (pendingFunctionOutputs.length > 0) { for (const output of pendingFunctionOutputs) { input = await appendMessageWithImage(model, input, output.messageToAdd, 'output', addImagesToInput, output.description); } } if (input.length === 0) { input.push({ type: 'message', role: 'user', content: 'Please proceed.', }); } let requestParams = { model, stream: true, user: 'magi', input, }; const isO3Model = (m) => m === 'o3' || m.startsWith('o3-'); const isGpt5Family = (m) => m.startsWith('gpt-5'); const isGpt51Or52 = (m) => m.startsWith('gpt-5.1') || m.startsWith('gpt-5.2'); const getDefaultReasoningEffort = (m) => { if (m === 'gpt-5.2-pro' || m === 'gpt-5-pro') return 'high'; if (m.startsWith('gpt-5.2') || m.startsWith('gpt-5.1')) return 'none'; if (m.startsWith('gpt-5')) return 'medium'; if (m.startsWith('o')) return 'high'; return undefined; }; const REASONING_EFFORT_CONFIGS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']; let requestedReasoningEffort; for (const effort of REASONING_EFFORT_CONFIGS) { const suffix = `-${effort}`; if (model.endsWith(suffix)) { requestedReasoningEffort = effort; model = model.slice(0, -suffix.length); requestParams.model = model; break; } } const defaultEffort = getDefaultReasoningEffort(model); const effectiveEffort = requestedReasoningEffort ?? defaultEffort; if (requestedReasoningEffort) { if (requestedReasoningEffort === 'none') { requestParams.reasoning = { effort: 'none' }; } else { requestParams.reasoning = { effort: requestedReasoningEffort, summary: 'auto', }; } } else if (requiresReasoning(model)) { if (defaultEffort && defaultEffort !== 'none') { requestParams.reasoning = { effort: defaultEffort, summary: 'auto', }; } } if (!isO3Model(model)) { if (settings?.temperature !== undefined) { requestParams.temperature = settings.temperature; } if (settings?.top_p !== undefined) { requestParams.top_p = settings.top_p; } } if (isGpt5Family(model)) { const allowSamplingParams = isGpt51Or52(model) && effectiveEffort === 'none'; if (!allowSamplingParams) { delete requestParams.temperature; delete requestParams.top_p; } } if (settings?.tool_choice) { if (typeof settings.tool_choice === 'object' && settings.tool_choice?.type === 'function' && settings.tool_choice?.function?.name) { requestParams.tool_choice = { type: settings.tool_choice.type, name: settings.tool_choice.function.name, }; } else if (typeof settings.tool_choice === 'string') { requestParams.tool_choice = settings.tool_choice; } } if (settings?.json_schema?.schema) { const { schema, ...wrapperWithoutSchema } = settings.json_schema; requestParams.text = { format: { ...wrapperWithoutSchema, schema: processSchemaForOpenAI(schema), }, }; } if (settings?.verbosity) { requestParams.verbosity = settings.verbosity; } if (settings?.service_tier) { requestParams.service_tier = settings.service_tier; } if (tools && tools.length > 0) { requestParams = await convertToOpenAITools(requestParams, tools); } const loggedRequestId = log_llm_request(agent.agent_id, 'openai', model, requestParams, new Date(), requestId, agent.tags); requestId = loggedRequestId; const { waitWhilePaused } = await import('../utils/pause_controller.js'); await waitWhilePaused(100, agent.abortSignal); const stream = await this.client.responses.create(requestParams); const messagePositions = new Map(); const reasoningPositions = new Map(); const reasoningAggregates = new Map(); const deltaBuffers = new Map(); const citationTracker = createCitationTracker(); const toolCallStates = new Map(); const events = []; try { for await (const event of stream) { events.push(event); if (isPaused()) { await waitWhilePaused(100, agent.abortSignal); } if (event.type === 'response.in_progress') { } else if (event.type === 'response.completed' && event.response?.usage) { const calculatedUsage = costTracker.addUsage({ model, input_tokens: event.response.usage.input_tokens || 0, output_tokens: event.response.usage.output_tokens || 0, cached_tokens: event.response.usage.input_tokens_details?.cached_tokens || 0, metadata: { reasoning_tokens: event.response.usage.output_tokens_details?.reasoning_tokens || 0, }, }); if (!hasEventHandler()) { yield { type: 'cost_update', usage: { ...calculatedUsage, total_tokens: event.response.usage.input_tokens + event.response.usage.output_tokens, }, }; } } else if (event.type === 'response.failed' && event.response?.error) { const errorInfo = event.response.error; log_llm_error(requestId, errorInfo); console.error(`Response ${event.response.id} failed: [${errorInfo.code}] ${errorInfo.message}`); yield { type: 'error', error: `OpenAI response failed: [${errorInfo.code}] ${errorInfo.message}`, }; } else if (event.type === 'response.incomplete' && event.response?.incomplete_details) { const reason = event.response.incomplete_details.reason; log_llm_error(requestId, 'OpenAI response incomplete: ' + reason); console.warn(`Response ${event.response.id} incomplete: ${reason}`); yield { type: 'error', error: 'OpenAI response incomplete: ' + reason, }; } else if (event.type === 'response.output_item.added' && event.item) { if (event.item.type === 'function_call') { if (!toolCallStates.has(event.item.id)) { toolCallStates.set(event.item.id, { id: event.item.id, call_id: event.item.call_id, type: 'function', function: { name: event.item.name || '', arguments: '', }, }); } else { console.warn(`Received output_item.added for already tracked function call ID: ${event.item.id}`); } } } else if (event.type === 'response.output_item.done' && event.item) { if (event.item.type === 'reasoning' && !event.item.summary.length) { yield { type: 'message_complete', content: '', message_id: event.item.id + '-0', thinking_content: '', }; } } else if (event.type === 'response.content_part.added' && event.part) { } else if (event.type === 'response.content_part.done' && event.part) { } else if (event.type === 'response.output_text.delta' && event.delta) { const itemId = event.item_id; let position = messagePositions.get(itemId) ?? 0; for (const ev of bufferDelta(deltaBuffers, itemId, event.delta, content => ({ type: 'message_delta', content, message_id: itemId, order: position++, }))) { yield ev; } messagePositions.set(itemId, position); } else if (event.type === 'response.output_text.annotation.added' && event.annotation) { const eventData = event; if (eventData.annotation?.type === 'url_citation' && eventData.annotation.url) { const marker = formatCitation(citationTracker, { title: eventData.annotation.title || eventData.annotation.url, url: eventData.annotation.url, }); let position = messagePositions.get(eventData.item_id) ?? 0; yield { type: 'message_delta', content: marker, message_id: eventData.item_id, order: position++, }; messagePositions.set(eventData.item_id, position); } else { } } else if (event.type === 'response.output_text.done' && event.text !== undefined) { const itemId = event.item_id; let finalText = event.text; if (citationTracker.citations.size > 0) { const footnotes = generateFootnotes(citationTracker); finalText += footnotes; } yield { type: 'message_complete', content: finalText, message_id: itemId, }; messagePositions.delete(itemId); } else if (event.type === 'response.refusal.delta' && event.delta) { } else if (event.type === 'response.refusal.done' && event.refusal) { log_llm_error(requestId, 'OpenAI refusal error: ' + event.refusal); console.error(`Refusal for item ${event.item_id}: ${event.refusal}`); yield { type: 'error', error: 'OpenAI refusal error: ' + event.refusal, }; } else if (event.type === 'response.function_call_arguments.delta' && event.delta) { const currentCall = toolCallStates.get(event.item_id); if (currentCall) { currentCall.function.arguments += event.delta; } else { console.warn(`Received function_call_arguments.delta for unknown item_id: ${event.item_id}`); } } else if (event.type === 'response.function_call_arguments.done' && event.arguments !== undefined) { const currentCall = toolCallStates.get(event.item_id); if (currentCall) { currentCall.function.arguments = event.arguments; yield { type: 'tool_start', tool_call: currentCall, }; toolCallStates.delete(event.item_id); } else { console.warn(`Received function_call_arguments.done for unknown or already yielded item_id: ${event.item_id}`); } } else if (event.type === 'response.file_search_call.in_progress') { } else if (event.type === 'response.file_search_call.searching') { } else if (event.type === 'response.file_search_call.completed') { } else if (event.type === 'response.web_search_call.in_progress') { } else if (event.type === 'response.web_search_call.searching') { } else if (event.type === 'response.web_search_call.completed') { } else if (event.type === 'response.reasoning_summary_part.added') { } else if (event.type === 'response.reasoning_summary_part.done') { } else if (event.type === 'response.reasoning_summary_text.delta' && event.delta) { const itemId = event.item_id + '-' + event.summary_index; let position = reasoningPositions.get(itemId) ?? 0; reasoningAggregates.set(itemId, reasoningAggregates.get(itemId) + event.delta); yield { type: 'message_delta', content: '', message_id: itemId, thinking_content: event.delta, order: position++, }; reasoningPositions.set(itemId, position); } else if (event.type === 'response.reasoning_summary_text.done' && event.text !== undefined) { const itemId = event.item_id + '-' + event.summary_index; const aggregatedThinking = event.text; yield { type: 'message_complete', content: '', message_id: itemId, thinking_content: aggregatedThinking, }; reasoningPositions.delete(itemId); reasoningAggregates.delete(itemId); } else if (event.type === 'error' && event.message) { log_llm_error(requestId, event); console.error(`API Stream Error (${model}): [${event.code || 'N/A'}] ${event.message}`); yield { type: 'error', error: `OpenAI API error (${model}): [${event.code || 'N/A'}] ${event.message}`, }; } } } catch (streamError) { log_llm_error(requestId, streamError); console.error('Error processing response stream:', streamError); yield { type: 'error', error: `OpenAI stream request error (${model}): ${streamError}`, }; } finally { if (toolCallStates.size > 0) { console.warn(`Stream ended with ${toolCallStates.size} incomplete tool call(s).`); for (const [, toolCall] of toolCallStates.entries()) { if (toolCall.function.name) { yield { type: 'tool_start', tool_call: toolCall, }; } } toolCallStates.clear(); } for (const ev of flushBufferedDeltas(deltaBuffers, (id, content) => { let position = messagePositions.get(id) ?? 0; position++; messagePositions.set(id, position); return { type: 'message_delta', content, message_id: id, order: position, }; })) { yield ev; } messagePositions.clear(); log_llm_response(requestId, events); } } catch (error) { log_llm_error(requestId, error); console.error('Error in OpenAI streaming response:', error); yield { type: 'error',