converse-mcp-server
Version:
Converse MCP Server - Converse with other LLMs with chat and consensus tools
1,086 lines (1,005 loc) • 35.3 kB
JavaScript
/**
* OpenAI Provider
*
* Provider implementation for OpenAI GPT models using the official OpenAI SDK v5.
* Implements the unified interface: async invoke(messages, options) => { content, stop_reason, rawResponse }
*/
import OpenAI from 'openai';
import { debugLog, debugError } from '../utils/console.js';
// Define supported models with their capabilities
const SUPPORTED_MODELS = {
'gpt-5.6-sol': {
modelName: 'gpt-5.6-sol',
friendlyName: 'OpenAI (GPT-5.6 Sol)',
contextWindow: 1000000,
maxOutputTokens: 128000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
supportsNoneReasoningEffort: true,
timeout: 10800000, // 3 hours
description:
'Flagship GPT-5.6 model (1M context, 128K output) - Frontier reasoning, coding, agentic workflows. Most token-efficient flagship',
aliases: [
'gpt-5.6',
'gpt5.6',
'gpt 5.6',
'gpt-5',
'gpt5',
'gpt 5',
'sol',
'gpt-5.6sol',
'gpt 5.6 sol',
],
},
'gpt-5.6-terra': {
modelName: 'gpt-5.6-terra',
friendlyName: 'OpenAI (GPT-5.6 Terra)',
contextWindow: 400000,
maxOutputTokens: 128000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
supportsNoneReasoningEffort: true,
timeout: 5400000, // 90 minutes
description:
'Lower-cost GPT-5.6 (400K context, 128K output) - Performance competitive with GPT-5.5 at half the flagship price',
aliases: ['gpt5.6-terra', 'gpt-5.6terra', 'gpt 5.6 terra', 'terra'],
},
'gpt-5.6-luna': {
modelName: 'gpt-5.6-luna',
friendlyName: 'OpenAI (GPT-5.6 Luna)',
contextWindow: 400000,
maxOutputTokens: 128000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
supportsNoneReasoningEffort: true,
timeout: 1800000, // 30 minutes
description:
'Fastest, most affordable GPT-5.6 (400K context, 128K output) - High-volume, latency-sensitive workloads',
aliases: ['gpt5.6-luna', 'gpt-5.6luna', 'gpt 5.6 luna', 'luna'],
},
'gpt-5.4': {
modelName: 'gpt-5.4',
friendlyName: 'OpenAI (GPT-5.4)',
contextWindow: 1000000,
maxOutputTokens: 128000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
supportsNoneReasoningEffort: true,
timeout: 10800000, // 3 hours
description:
'Latest flagship model (1M context, 128K output) - Superior reasoning, coding, agentic workflows, computer use. Most token-efficient reasoning model',
aliases: [
'gpt5.4',
'gpt 5.4',
],
},
'gpt-5-mini': {
modelName: 'gpt-5-mini',
friendlyName: 'OpenAI (GPT-5-mini)',
contextWindow: 400000,
maxOutputTokens: 128000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
timeout: 5400000, // 90 minutes
description:
'Faster, cost-efficient GPT-5 (400K context, 128K output) - Well-defined tasks, precise prompts',
aliases: ['gpt5-mini', 'gpt-5mini', 'gpt 5 mini', 'gpt-5-mini-2025-08-07'],
},
'gpt-5-nano': {
modelName: 'gpt-5-nano',
friendlyName: 'OpenAI (GPT-5-nano)',
contextWindow: 400000,
maxOutputTokens: 128000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: false, // GPT-5-nano doesn't support web search
supportsResponsesAPI: true,
timeout: 1800000, // 30 minutes
description:
'Fastest, most cost-efficient GPT-5 (400K context, 128K output) - Summarization, classification',
aliases: ['gpt5-nano', 'gpt-5nano', 'gpt 5 nano', 'gpt-5-nano-2025-08-07'],
},
'gpt-5.4-mini': {
modelName: 'gpt-5.4-mini',
friendlyName: 'OpenAI (GPT-5.4 mini)',
contextWindow: 400000,
maxOutputTokens: 128000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
timeout: 5400000, // 90 minutes
description:
'Fast, efficient GPT-5.4 (400K context, 128K output) - Coding, subagents, computer use, tool use. 2x faster than GPT-5 mini',
aliases: [
'gpt5.4-mini',
'gpt-5.4mini',
'gpt 5.4 mini',
'gpt-5.4-mini-2025-08-07',
],
},
'gpt-5.4-nano': {
modelName: 'gpt-5.4-nano',
friendlyName: 'OpenAI (GPT-5.4 nano)',
contextWindow: 400000,
maxOutputTokens: 128000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: false,
supportsResponsesAPI: true,
timeout: 1800000, // 30 minutes
description:
'Smallest, cheapest GPT-5.4 (400K context, 128K output) - Classification, data extraction, ranking, coding subagents',
aliases: [
'gpt5.4-nano',
'gpt-5.4nano',
'gpt 5.4 nano',
'gpt-5.4-nano-2025-08-07',
],
},
'gpt-5.4-pro': {
modelName: 'gpt-5.4-pro',
friendlyName: 'OpenAI (GPT-5.4 Pro)',
contextWindow: 1000000,
maxOutputTokens: 272000,
supportsStreaming: false, // GPT-5 Pro doesn't support streaming
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
supportsDeepResearch: false,
timeout: 10800000, // 180 minutes
description:
'Maximum performance reasoning model (1M context, 272K output) - Most complex tasks, extended compute time (EXPENSIVE)',
aliases: [
'gpt-5-pro',
'gpt5-pro',
'gpt-5pro',
'gpt 5 pro',
'gpt-5 pro',
],
},
o3: {
modelName: 'o3',
friendlyName: 'OpenAI (O3)',
contextWindow: 200000,
maxOutputTokens: 100000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
timeout: 1800000, // 30 minutes
description:
'Strong reasoning (200K context) - Logical problems, code generation, systematic analysis',
aliases: ['o3-2025-01-31'],
},
'o3-pro-2025-06-10': {
modelName: 'o3-pro-2025-06-10',
friendlyName: 'OpenAI (O3-Pro)',
contextWindow: 200000,
maxOutputTokens: 100000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
timeout: 10800000, // 180 minutes
description:
'Professional-grade reasoning (200K context) - EXTREMELY EXPENSIVE: Only for the most complex problems',
aliases: ['o3-pro', 'o3pro', 'o3 pro'],
},
'o4-mini': {
modelName: 'o4-mini',
friendlyName: 'OpenAI (O4-mini)',
contextWindow: 200000,
maxOutputTokens: 100000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
timeout: 540000, // 9 minutes
description:
'Latest reasoning model (200K context) - Optimized for shorter contexts, rapid reasoning',
aliases: ['o4mini', 'o4', 'o4 mini', 'o4-mini-2025-01-30'],
},
'gpt-4.1-2025-04-14': {
modelName: 'gpt-4.1-2025-04-14',
friendlyName: 'OpenAI (GPT-4.1)',
contextWindow: 1000000,
maxOutputTokens: 32768,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
timeout: 900000,
description:
'GPT-4.1 (1M context) - Advanced reasoning model with large context window',
aliases: ['gpt4.1', 'gpt-4.1', 'gpt 4.1', 'gpt-4.1-latest'],
},
'o3-deep-research-2025-06-26': {
modelName: 'o3-deep-research-2025-06-26',
friendlyName: 'OpenAI (O3 Deep Research)',
contextWindow: 200000,
maxOutputTokens: 100000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
supportsDeepResearch: true,
timeout: 21600000, // 360 minutes for deep research
description:
'Deep research model (200K context) - In-depth synthesis, comprehensive reports, multi-source analysis (30-90 min runtime)',
aliases: [
'o3-deep-research',
'o3-research',
'o3 deep research',
'deep-research-o3',
],
},
'o4-mini-deep-research-2025-06-26': {
modelName: 'o4-mini-deep-research-2025-06-26',
friendlyName: 'OpenAI (O4-mini Deep Research)',
contextWindow: 200000,
maxOutputTokens: 100000,
supportsStreaming: true,
supportsImages: true,
supportsWebSearch: true,
supportsResponsesAPI: true,
supportsDeepResearch: true,
timeout: 10800000, // 180 minutes for faster deep research
description:
'Fast deep research model (200K context) - Lightweight research, faster results, latency-sensitive analysis (15-60 min runtime)',
aliases: [
'o4-mini-deep-research',
'o4-mini-research',
'o4-research',
'o4 mini deep research',
'deep-research-o4-mini',
'o4-deep-research',
],
},
};
/**
* Custom error class for OpenAI provider errors
*/
class OpenAIProviderError extends Error {
constructor(message, code, originalError = null) {
super(message);
this.name = 'OpenAIProviderError';
this.code = code;
this.originalError = originalError;
}
}
/**
* Resolve model name to canonical form, including aliases
*/
function resolveModelName(modelName) {
const modelNameLower = modelName.toLowerCase();
// Check exact matches first
for (const [supportedModel] of Object.entries(SUPPORTED_MODELS)) {
if (supportedModel.toLowerCase() === modelNameLower) {
return supportedModel;
}
}
// Check aliases
for (const [supportedModel, config] of Object.entries(SUPPORTED_MODELS)) {
if (config.aliases) {
for (const alias of config.aliases) {
if (alias.toLowerCase() === modelNameLower) {
return supportedModel;
}
}
}
}
// Return as-is if not found (let OpenAI API handle unknown models)
return modelName;
}
/**
* Resolve the reasoning effort actually sent to the API for a given model.
* GPT-5 Pro models only accept 'high'. GPT-5.6 models dropped 'minimal'
* (supported efforts: none, low, medium, high, xhigh, max), so 'minimal'
* maps to the closest supported value.
*/
function resolveReasoningEffort(resolvedModel, reasoningEffort) {
if (resolvedModel.endsWith('-pro') && resolvedModel.startsWith('gpt-5')) {
return 'high';
}
if (resolvedModel.startsWith('gpt-5.6') && reasoningEffort === 'minimal') {
return 'low';
}
return reasoningEffort;
}
/**
* Validate OpenAI API key format
*/
function validateApiKey(apiKey) {
if (!apiKey || typeof apiKey !== 'string') {
return false;
}
// OpenAI API keys typically start with 'sk-' and are at least 20 characters
return apiKey.startsWith('sk-') && apiKey.length >= 20;
}
/**
* Convert messages to OpenAI format, handling both Responses API and Chat Completions API
*/
function convertMessages(messages, useResponsesAPI = false) {
if (!Array.isArray(messages)) {
throw new OpenAIProviderError(
'Messages must be an array',
'INVALID_MESSAGES',
);
}
return messages.map((msg, index) => {
if (!msg || typeof msg !== 'object') {
throw new OpenAIProviderError(
`Message at index ${index} must be an object`,
'INVALID_MESSAGE',
);
}
const { role, content } = msg;
if (!role || !['system', 'user', 'assistant'].includes(role)) {
throw new OpenAIProviderError(
`Invalid role "${role}" at message index ${index}`,
'INVALID_ROLE',
);
}
if (!content) {
throw new OpenAIProviderError(
`Message content is required at index ${index}`,
'MISSING_CONTENT',
);
}
// Handle complex content structure (array with text and images)
if (Array.isArray(content)) {
debugLog(
`[OpenAI] Processing complex content array with ${content.length} items for ${useResponsesAPI ? 'Responses API' : 'Chat Completions API'}`,
);
if (useResponsesAPI) {
// Convert to Responses API format
const convertedContent = [];
for (const item of content) {
if (item.type === 'text') {
convertedContent.push({
type: 'input_text',
text: item.text,
});
} else if (item.type === 'image' && item.source) {
// Convert Anthropic/Claude format to OpenAI Responses API format
const imageUrl = `data:${item.source.media_type};base64,${item.source.data}`;
debugLog(
`[OpenAI] Converting image for Responses API: ${item.source.media_type}, data length: ${item.source.data.length}`,
);
convertedContent.push({
type: 'input_image',
image_url: imageUrl,
});
}
}
return { role, content: convertedContent };
} else {
// Convert to Chat Completions API format
const convertedContent = [];
for (const item of content) {
if (item.type === 'text') {
convertedContent.push({
type: 'text',
text: item.text,
});
} else if (item.type === 'image' && item.source) {
// Convert Anthropic/Claude format to OpenAI Chat Completions format
const imageUrl = `data:${item.source.media_type};base64,${item.source.data}`;
debugLog(
`[OpenAI] Converting image for Chat Completions API: ${item.source.media_type}, data length: ${item.source.data.length}`,
);
convertedContent.push({
type: 'image_url',
image_url: {
url: imageUrl,
detail: 'high',
},
});
}
}
return { role, content: convertedContent };
}
}
// Simple string content
return { role, content };
});
}
/**
* Main OpenAI provider implementation
*/
export const openaiProvider = {
/**
* Unified provider interface: invoke messages with options
* @param {Array} messages - Array of message objects with role and content
* @param {Object} options - Configuration options
* @returns {Object|AsyncGenerator} - { content, stop_reason, rawResponse } or AsyncGenerator when stream=true
*/
async invoke(messages, options = {}) {
const {
model = 'gpt-5.6',
maxTokens = null,
stream = false,
reasoning_effort = 'medium',
signal,
config,
// Filter out options not meant for the API
continuation_id, // eslint-disable-line no-unused-vars
continuationStore, // eslint-disable-line no-unused-vars
...otherOptions
} = options;
// Validate API key
if (!config?.apiKeys?.openai) {
throw new OpenAIProviderError(
'OpenAI API key not configured',
'MISSING_API_KEY',
);
}
if (!validateApiKey(config.apiKeys.openai)) {
throw new OpenAIProviderError(
'Invalid OpenAI API key format',
'INVALID_API_KEY',
);
}
// Initialize OpenAI client
const openai = new OpenAI({
apiKey: config.apiKeys.openai,
});
// Resolve model name
const resolvedModel = resolveModelName(model);
const modelConfig = SUPPORTED_MODELS[resolvedModel] || {};
// Always use Responses API since all OpenAI models support it
// Only fallback to Chat Completions API if Responses API is explicitly not supported
const shouldUseResponsesAPI = modelConfig.supportsResponsesAPI !== false;
// Convert and validate messages
const openaiMessages = convertMessages(messages, shouldUseResponsesAPI);
// Build request payload based on API type
let requestPayload;
if (shouldUseResponsesAPI) {
// Build Responses API payload
requestPayload = {
model: resolvedModel,
input: openaiMessages,
stream,
...otherOptions,
};
// Attach web search where the model supports it; the model decides
// per-request whether to actually search.
if (modelConfig.supportsWebSearch) {
// Use web_search_preview tool for all models in Responses API
requestPayload.tools = [{ type: 'web_search_preview' }];
}
// Add reasoning effort for thinking models (o3 series and GPT-5 family)
if (
(resolvedModel.startsWith('o3') || resolvedModel.startsWith('gpt-5')) &&
reasoning_effort
) {
requestPayload.reasoning = {
effort: resolveReasoningEffort(resolvedModel, reasoning_effort),
summary: 'auto', // Enable reasoning summaries
};
}
} else {
// Build Chat Completions API payload
const { reasoning_effort: _unused, ...cleanOptions } = otherOptions;
requestPayload = {
model: resolvedModel,
messages: openaiMessages,
stream,
...cleanOptions,
};
// Add reasoning effort for thinking models (o3 series and GPT-5 family)
if (
(resolvedModel.startsWith('o3') || resolvedModel.startsWith('gpt-5')) &&
reasoning_effort
) {
requestPayload.reasoning_effort = resolveReasoningEffort(
resolvedModel,
reasoning_effort,
);
}
}
// Add max tokens if specified (both APIs)
if (maxTokens) {
if (shouldUseResponsesAPI) {
requestPayload.max_output_tokens = Math.min(
maxTokens,
modelConfig.maxOutputTokens || 100000,
);
} else {
requestPayload.max_tokens = Math.min(
maxTokens,
modelConfig.maxOutputTokens || 100000,
);
}
}
// Add usage reporting for streaming mode
if (stream && !shouldUseResponsesAPI) {
requestPayload.stream_options = { include_usage: true };
}
// If streaming is requested and model doesn't support it, fall back to non-streaming
if (stream && modelConfig.supportsStreaming === false) {
debugLog(
`[OpenAI] Model ${resolvedModel} doesn't support streaming, falling back to non-streaming mode`,
);
requestPayload.stream = false;
}
// Handle streaming requests
if (stream && requestPayload.stream !== false) {
return this._createStreamingGenerator(
openai,
requestPayload,
shouldUseResponsesAPI,
resolvedModel,
modelConfig,
signal,
);
}
try {
const apiType = shouldUseResponsesAPI
? 'Responses API'
: 'Chat Completions API';
const searchInfo = modelConfig.supportsWebSearch
? ' (with web search)'
: '';
debugLog(
`[OpenAI] Calling ${resolvedModel} via ${apiType} with ${openaiMessages.length} messages${searchInfo}`,
);
const startTime = Date.now();
// Check if already aborted before making request
if (signal?.aborted) {
throw new Error(`Request aborted: ${signal.reason || 'Cancelled'}`);
}
// Make the API call based on API type
let response;
const requestOptions = signal ? { signal } : {};
if (shouldUseResponsesAPI) {
response = await openai.responses.create(requestPayload, requestOptions);
} else {
response = await openai.chat.completions.create(requestPayload, requestOptions);
}
const responseTime = Date.now() - startTime;
debugLog(`[OpenAI] Response received in ${responseTime}ms`);
// Extract response data based on API type
let content, stopReason, usage;
if (shouldUseResponsesAPI) {
// Handle Responses API response format
let reasoningSummary = null;
if (response.output) {
// New format with output array (includes reasoning summaries)
const messageOutput = response.output.find(
(item) => item.type === 'message',
);
const reasoningOutput = response.output.find(
(item) => item.type === 'reasoning',
);
if (!messageOutput || !messageOutput.content) {
throw new OpenAIProviderError(
'No message content in Responses API response',
'NO_RESPONSE_CONTENT',
);
}
// Extract content from message output
const textContent = messageOutput.content.find(
(item) => item.type === 'output_text',
);
if (!textContent) {
throw new OpenAIProviderError(
'No text content in message output',
'NO_RESPONSE_CONTENT',
);
}
content = textContent.text;
// Extract reasoning summary if available
if (reasoningOutput && reasoningOutput.summary) {
const summaryText = reasoningOutput.summary.find(
(item) => item.type === 'summary_text',
);
if (summaryText) {
reasoningSummary = summaryText.text;
}
}
} else if (response.output_text) {
// Legacy format
content = response.output_text;
} else {
throw new OpenAIProviderError(
'No output in Responses API response',
'NO_RESPONSE_CONTENT',
);
}
stopReason = response.status || 'stop';
usage = response.usage || {};
// Store reasoning summary in metadata
if (reasoningSummary) {
usage.reasoning_summary = reasoningSummary;
debugLog(
`[OpenAI] Found reasoning summary: ${reasoningSummary.substring(0, 100)}...`,
);
} else {
debugLog('[OpenAI] No reasoning summary found in response');
debugLog(
'[OpenAI] Response structure:',
JSON.stringify(response, null, 2).substring(0, 500),
);
}
} else {
// Handle Chat Completions API response format
const choice = response.choices[0];
if (!choice) {
throw new OpenAIProviderError(
'No response choice received from OpenAI',
'NO_RESPONSE_CHOICE',
);
}
content = choice.message?.content;
if (!content) {
throw new OpenAIProviderError(
'No content in response from OpenAI',
'NO_RESPONSE_CONTENT',
);
}
stopReason = choice.finish_reason || 'stop';
usage = response.usage || {};
}
// Web search is available whenever the model supports it
const webSearchUsed = !!modelConfig.supportsWebSearch;
const webSearchType = webSearchUsed ? 'web_search_preview' : null;
// Return unified response format
return {
content,
stop_reason: stopReason,
rawResponse: response,
metadata: {
model: response.model || resolvedModel,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
total_tokens: usage.total_tokens || 0,
},
response_time_ms: responseTime,
finish_reason: stopReason,
provider: 'openai',
api_type: apiType,
web_search_used: webSearchUsed,
web_search_type: webSearchType,
},
};
} catch (error) {
debugError('[OpenAI] Error during API call:', error);
// Handle specific OpenAI errors
if (error.code === 'insufficient_quota') {
throw new OpenAIProviderError(
'OpenAI API quota exceeded',
'QUOTA_EXCEEDED',
error,
);
} else if (error.code === 'invalid_api_key') {
throw new OpenAIProviderError(
'Invalid OpenAI API key',
'INVALID_API_KEY',
error,
);
} else if (error.code === 'model_not_found') {
throw new OpenAIProviderError(
`Model ${resolvedModel} not found`,
'MODEL_NOT_FOUND',
error,
);
} else if (error.code === 'context_length_exceeded') {
throw new OpenAIProviderError(
'Context length exceeded for model',
'CONTEXT_LENGTH_EXCEEDED',
error,
);
} else if (error.type === 'invalid_request_error') {
throw new OpenAIProviderError(
`Invalid request: ${error.message}`,
'INVALID_REQUEST',
error,
);
} else if (error.type === 'rate_limit_error') {
throw new OpenAIProviderError(
'OpenAI rate limit exceeded',
'RATE_LIMIT_EXCEEDED',
error,
);
}
// Generic error handling
throw new OpenAIProviderError(
`OpenAI API error: ${error.message || 'Unknown error'}`,
'API_ERROR',
error,
);
}
},
/**
* Create streaming generator for OpenAI responses
* @private
* @param {OpenAI} openai - OpenAI client instance
* @param {Object} requestPayload - Request payload
* @param {boolean} shouldUseResponsesAPI - Whether to use Responses API
* @param {string} resolvedModel - Resolved model name
* @param {Object} modelConfig - Model configuration
* @returns {AsyncGenerator} - Streaming generator yielding events
*/
async *_createStreamingGenerator(
openai,
requestPayload,
shouldUseResponsesAPI,
resolvedModel,
modelConfig,
signal,
) {
const apiType = shouldUseResponsesAPI
? 'Responses API'
: 'Chat Completions API';
const searchInfo = modelConfig.supportsWebSearch
? ' (with web search)'
: '';
debugLog(
`[OpenAI] Starting streaming for ${resolvedModel} via ${apiType} with ${requestPayload.input?.length || requestPayload.messages?.length} messages${searchInfo}`,
);
const startTime = Date.now();
let totalContent = '';
let totalReasoningSummary = '';
let lastUsage = null;
let finishReason = null;
let finalModel = resolvedModel;
try {
// Check if already aborted before starting
if (signal?.aborted) {
throw new Error(`Request aborted: ${signal.reason || 'Cancelled'}`);
}
// Yield start event
yield {
type: 'start',
timestamp: new Date().toISOString(),
model: resolvedModel,
provider: 'openai',
api_type: apiType,
};
// Create stream based on API type
let stream;
const requestOptions = signal ? { signal } : {};
if (shouldUseResponsesAPI) {
stream = await openai.responses.create(requestPayload, requestOptions);
} else {
stream = await openai.chat.completions.create(requestPayload, requestOptions);
}
// Process stream chunks
for await (const chunk of stream) {
try {
// Check for cancellation during stream processing
if (signal?.aborted) {
debugLog(
`[OpenAI] Stream aborted during processing: ${signal.reason || 'Cancelled'}`,
);
break;
}
if (shouldUseResponsesAPI) {
// Handle Responses API streaming format
if (chunk.type === 'response.output_text.delta') {
const content = chunk.delta || '';
if (content) {
totalContent += content;
yield {
type: 'delta',
content,
timestamp: new Date().toISOString(),
};
}
} else if (chunk.type === 'response.reasoning_summary_part.added') {
// Event 1: reasoning summary part added (usually empty initially)
debugLog('[OpenAI] *** REASONING PART ADDED');
} else if (chunk.type === 'response.reasoning_summary_part.done') {
// Event 2: reasoning summary part completed with full text
const summaryText = chunk.part?.text || '';
if (summaryText) {
totalReasoningSummary = summaryText;
debugLog(
`[OpenAI] *** REASONING PART DONE: "${summaryText.substring(0, 100)}..."`,
);
yield {
type: 'reasoning_summary',
content: totalReasoningSummary,
timestamp: new Date().toISOString(),
};
}
} else if (chunk.type === 'response.reasoning_summary_text.delta') {
// Event 3: reasoning summary text delta (streaming pieces)
const summaryDelta = chunk.delta || '';
if (summaryDelta) {
totalReasoningSummary += summaryDelta;
debugLog(
`[OpenAI] *** REASONING TEXT DELTA: "${summaryDelta}"`,
);
yield {
type: 'reasoning_summary',
content: totalReasoningSummary,
timestamp: new Date().toISOString(),
};
}
} else if (chunk.type === 'response.reasoning_summary_text.done') {
// Event 4: reasoning summary text completed with full text
const fullSummary = chunk.text || totalReasoningSummary;
if (fullSummary) {
totalReasoningSummary = fullSummary;
debugLog(
`[OpenAI] *** REASONING TEXT DONE: "${fullSummary.substring(0, 100)}..."`,
);
yield {
type: 'reasoning_summary',
content: fullSummary,
timestamp: new Date().toISOString(),
};
}
} else if (chunk.type === 'response.completed') {
finishReason = chunk.response?.status || 'stop';
finalModel = chunk.response?.model || resolvedModel;
if (chunk.response?.usage) {
lastUsage = chunk.response.usage;
}
}
} else {
// Handle Chat Completions API streaming format
const choice = chunk.choices?.[0];
if (choice) {
const content = choice.delta?.content || '';
if (content) {
totalContent += content;
yield {
type: 'delta',
content,
timestamp: new Date().toISOString(),
};
}
if (choice.finish_reason) {
finishReason = choice.finish_reason;
}
}
// Handle usage information (typically in final chunk)
if (chunk.usage) {
lastUsage = chunk.usage;
}
// Update model if provided
if (chunk.model) {
finalModel = chunk.model;
}
}
} catch (chunkError) {
debugError('[OpenAI] Error processing stream chunk:', chunkError);
yield {
type: 'error',
error: {
message: `Chunk processing error: ${chunkError.message}`,
code: 'CHUNK_PROCESSING_ERROR',
recoverable: true,
},
timestamp: new Date().toISOString(),
};
}
}
const responseTime = Date.now() - startTime;
debugLog(`[OpenAI] Streaming completed in ${responseTime}ms`);
// Yield usage information if available
if (lastUsage) {
yield {
type: 'usage',
usage: {
input_tokens:
lastUsage.prompt_tokens || lastUsage.input_tokens || 0,
output_tokens:
lastUsage.completion_tokens || lastUsage.output_tokens || 0,
total_tokens: lastUsage.total_tokens || 0,
},
timestamp: new Date().toISOString(),
};
}
// Web search is available whenever the model supports it
const webSearchUsed = !!modelConfig.supportsWebSearch;
const webSearchType = webSearchUsed ? 'web_search_preview' : null;
// Yield end event with final metadata
yield {
type: 'end',
content: totalContent,
stop_reason: finishReason || 'stop',
metadata: {
model: finalModel,
usage: {
input_tokens:
lastUsage?.prompt_tokens || lastUsage?.input_tokens || 0,
output_tokens:
lastUsage?.completion_tokens || lastUsage?.output_tokens || 0,
total_tokens: lastUsage?.total_tokens || 0,
},
response_time_ms: responseTime,
finish_reason: finishReason || 'stop',
provider: 'openai',
api_type: apiType,
web_search_used: webSearchUsed,
web_search_type: webSearchType,
reasoning_summary: totalReasoningSummary || null,
},
timestamp: new Date().toISOString(),
};
} catch (error) {
debugError('[OpenAI] Streaming error:', error);
// Handle specific OpenAI errors in streaming context
let errorCode = 'STREAMING_ERROR';
let errorMessage = `OpenAI streaming error: ${error.message || 'Unknown error'}`;
let recoverable = false;
if (error.code === 'insufficient_quota') {
errorCode = 'QUOTA_EXCEEDED';
errorMessage = 'OpenAI API quota exceeded';
} else if (error.code === 'invalid_api_key') {
errorCode = 'INVALID_API_KEY';
errorMessage = 'Invalid OpenAI API key';
} else if (error.code === 'model_not_found') {
errorCode = 'MODEL_NOT_FOUND';
errorMessage = `Model ${resolvedModel} not found`;
} else if (error.code === 'context_length_exceeded') {
errorCode = 'CONTEXT_LENGTH_EXCEEDED';
errorMessage = 'Context length exceeded for model';
} else if (error.type === 'rate_limit_error') {
errorCode = 'RATE_LIMIT_EXCEEDED';
errorMessage = 'OpenAI rate limit exceeded';
recoverable = true;
}
yield {
type: 'error',
error: {
message: errorMessage,
code: errorCode,
recoverable,
originalError: error,
},
timestamp: new Date().toISOString(),
};
// Re-throw the error to maintain existing error handling behavior
throw new OpenAIProviderError(errorMessage, errorCode, error);
}
},
/**
* Validate configuration for OpenAI provider
* @param {Object} config - Configuration object
* @returns {boolean} - True if configuration is valid
*/
validateConfig(config) {
return !!(config?.apiKeys?.openai && validateApiKey(config.apiKeys.openai));
},
/**
* Check if provider is available with current configuration
* @param {Object} config - Configuration object
* @returns {boolean} - True if provider is available
*/
isAvailable(config) {
return this.validateConfig(config);
},
/**
* Get supported models
* @returns {Object} - Map of supported models and their configurations
*/
getSupportedModels() {
return SUPPORTED_MODELS;
},
/**
* Get model configuration
* @param {string} modelName - Model name
* @returns {Object|null} - Model configuration or null if not found
*/
getModelConfig(modelName) {
const resolved = resolveModelName(modelName);
return SUPPORTED_MODELS[resolved] || null;
},
};