UNPKG

converse-mcp-server

Version:

Converse MCP Server - Converse with other LLMs with chat and consensus tools

1,134 lines (1,030 loc) 34.1 kB
/** * ProviderStreamNormalizer - Unified streaming interface for all LLM providers * * Normalizes provider-specific streaming formats into a consistent event structure: * - start: Stream initialization event * - delta: Text content chunk event * - usage: Token usage statistics event * - end: Stream completion event with final metadata * - error: Error event with recovery information * * This normalizer enables seamless provider switching and uniform async processing * across all supported LLM providers (OpenAI, Google, XAI, Anthropic, Mistral, DeepSeek, OpenRouter). */ import { debugLog, debugError } from '../utils/console.js'; /** * Unified event types for normalized streaming */ const EVENT_TYPES = { START: 'start', DELTA: 'delta', USAGE: 'usage', END: 'end', ERROR: 'error', REASONING_SUMMARY: 'reasoning_summary', REASONING: 'reasoning', }; /** * ProviderStreamNormalizer class * Converts provider-specific AsyncGenerators into standardized event streams */ class ProviderStreamNormalizer { constructor() { // Provider normalizer registry this.normalizers = { openai: this.normalizeOpenAIStream.bind(this), xai: this.normalizeXAIStream.bind(this), google: this.normalizeGoogleStream.bind(this), anthropic: this.normalizeAnthropicStream.bind(this), mistral: this.normalizeMistralStream.bind(this), deepseek: this.normalizeDeepSeekStream.bind(this), openrouter: this.normalizeOpenRouterStream.bind(this), codex: this.normalizeCodexStream.bind(this), copilot: this.normalizePassthroughStream.bind(this), claude: this.normalizePassthroughStream.bind(this), 'gemini-cli': this.normalizePassthroughStream.bind(this), }; } /** * Main normalization method - routes to provider-specific normalizer * @param {string} provider - Provider name (e.g., 'openai', 'google') * @param {AsyncGenerator} stream - Provider's raw streaming generator * @param {Object} context - Additional context (model, requestId, etc.) * @returns {AsyncGenerator} - Normalized event stream */ async *normalize(provider, stream, context = {}) { const normalizedProvider = provider.toLowerCase(); if (!this.normalizers[normalizedProvider]) { throw new Error( `Unsupported provider for streaming normalization: ${provider}`, ); } debugLog( `[StreamNormalizer] Normalizing ${provider} stream for model ${context.model || 'unknown'}`, ); try { // Delegate to provider-specific normalizer yield* this.normalizers[normalizedProvider](stream, context); } catch (error) { debugError( `[StreamNormalizer] Error during ${provider} stream normalization:`, error, ); // Yield error event before re-throwing yield this.createErrorEvent(error, provider); throw error; } } /** * Normalize OpenAI streaming format * Handles both Chat Completions API and Responses API formats */ async *normalizeOpenAIStream(stream, context) { const provider = 'openai'; const model = context.model || 'unknown'; const startTime = Date.now(); let hasStarted = false; let accumulatedContent = ''; let accumulatedUsage = null; let finishReason = null; try { for await (const event of stream) { // Handle start event if (event.type === 'start' && !hasStarted) { hasStarted = true; yield this.createStartEvent(provider, model, event); continue; } // Handle delta events if (event.type === 'delta') { accumulatedContent += event.content || ''; yield this.createDeltaEvent(event.content || '', provider, model); continue; } // Handle usage events if (event.type === 'usage') { accumulatedUsage = event.usage; yield this.createUsageEvent(event.usage, provider, model); continue; } // Handle reasoning summary events (OpenAI reasoning models) if (event.type === 'reasoning_summary') { yield this.createReasoningSummaryEvent( event.content, provider, model, ); continue; } // Handle end event if (event.type === 'end') { finishReason = event.stop_reason || event.metadata?.finish_reason; yield this.createEndEvent( { content: event.content || accumulatedContent, stopReason: finishReason, usage: event.metadata?.usage || accumulatedUsage, responseTime: Date.now() - startTime, metadata: event.metadata, }, provider, model, ); continue; } // Handle error events if (event.type === 'error') { yield this.createErrorEvent( event.error, provider, event.error?.recoverable, ); continue; } } } catch (error) { debugError('[StreamNormalizer] OpenAI stream error:', error); yield this.createErrorEvent(error, provider); throw error; } } /** * Normalize XAI streaming format (OpenAI-compatible) */ async *normalizeXAIStream(stream, context) { const provider = 'xai'; const model = context.model || 'unknown'; const startTime = Date.now(); let hasStarted = false; let accumulatedContent = ''; let accumulatedReasoning = ''; let accumulatedUsage = null; let finishReason = null; const searchMetadata = {}; try { for await (const event of stream) { // Handle start event if (event.type === 'start' && !hasStarted) { hasStarted = true; yield this.createStartEvent(provider, model, event); continue; } // Handle delta events if (event.type === 'delta') { accumulatedContent += event.content || ''; yield this.createDeltaEvent(event.content || '', provider, model); continue; } // Handle reasoning/thinking events (kept separate from answer text) if (event.type === 'thinking') { accumulatedReasoning += event.content || ''; yield this.createReasoningEvent(event.content || '', provider, model); continue; } // Handle usage events (with XAI-specific search metadata) if (event.type === 'usage') { accumulatedUsage = event.usage; if (event.usage.search_sources_used) { searchMetadata.searchSourcesUsed = event.usage.search_sources_used; searchMetadata.searchCostEstimate = event.usage.search_cost_estimate; } yield this.createUsageEvent(event.usage, provider, model); continue; } // Handle end event if (event.type === 'end') { finishReason = event.stop_reason || event.metadata?.finish_reason; const endMetadata = { ...event.metadata, ...searchMetadata, ...(accumulatedReasoning && { reasoning: accumulatedReasoning }), }; yield this.createEndEvent( { content: event.content || accumulatedContent, stopReason: finishReason, usage: event.metadata?.usage || accumulatedUsage, responseTime: Date.now() - startTime, metadata: endMetadata, }, provider, model, ); continue; } // Handle error events if (event.type === 'error') { yield this.createErrorEvent( event.error, provider, event.error?.recoverable, ); continue; } } } catch (error) { debugError('[StreamNormalizer] XAI stream error:', error); yield this.createErrorEvent(error, provider); throw error; } } /** * Normalize Google GenAI streaming format */ async *normalizeGoogleStream(stream, context) { const provider = 'google'; const model = context.model || 'unknown'; const startTime = Date.now(); let hasStarted = false; let accumulatedContent = ''; let accumulatedUsage = null; let finishReason = null; let searchMetadata = {}; try { for await (const event of stream) { // Handle start event if (event.type === 'start' && !hasStarted) { hasStarted = true; yield this.createStartEvent(provider, model, event); continue; } // Handle delta events if (event.type === 'delta') { accumulatedContent += event.content || ''; yield this.createDeltaEvent(event.content || '', provider, model); continue; } // Handle usage events (with potential grounding metadata) if (event.type === 'usage') { accumulatedUsage = event.usage; if (event.groundingMetadata) { searchMetadata = event.groundingMetadata; } yield this.createUsageEvent(event.usage, provider, model); continue; } // Handle end event if (event.type === 'end') { finishReason = event.stop_reason || event.metadata?.finish_reason; const endMetadata = { ...event.metadata, ...searchMetadata, }; yield this.createEndEvent( { content: event.content || accumulatedContent, stopReason: finishReason, usage: event.metadata?.usage || accumulatedUsage, responseTime: Date.now() - startTime, metadata: endMetadata, }, provider, model, ); continue; } // Handle error events if (event.type === 'error') { yield this.createErrorEvent( event.error, provider, event.error?.recoverable, ); continue; } } } catch (error) { debugError('[StreamNormalizer] Google stream error:', error); yield this.createErrorEvent(error, provider); throw error; } } /** * Normalize Anthropic streaming format */ async *normalizeAnthropicStream(stream, context) { const provider = 'anthropic'; const model = context.model || 'unknown'; const startTime = Date.now(); let hasStarted = false; let accumulatedContent = ''; let accumulatedUsage = null; let finishReason = null; const thinkingMetadata = {}; try { for await (const event of stream) { // Handle start event if (event.type === 'start' && !hasStarted) { hasStarted = true; yield this.createStartEvent(provider, model, event); continue; } // Handle delta events (including thinking tokens) if (event.type === 'delta') { accumulatedContent += event.content || ''; yield this.createDeltaEvent(event.content || '', provider, model, { isThinking: event.isThinking || false, }); continue; } // Handle usage events (with Anthropic-specific cache and thinking tokens) if (event.type === 'usage') { accumulatedUsage = event.usage; if (event.usage.thinking_tokens) { thinkingMetadata.thinkingTokens = event.usage.thinking_tokens; } if ( event.usage.cache_creation_input_tokens || event.usage.cache_read_input_tokens ) { thinkingMetadata.cacheUsage = { creation: event.usage.cache_creation_input_tokens || 0, read: event.usage.cache_read_input_tokens || 0, }; } yield this.createUsageEvent(event.usage, provider, model); continue; } // Handle end event if (event.type === 'end') { finishReason = event.stop_reason || event.metadata?.finish_reason; const endMetadata = { ...event.metadata, ...thinkingMetadata, }; yield this.createEndEvent( { content: event.content || accumulatedContent, stopReason: finishReason, usage: event.metadata?.usage || accumulatedUsage, responseTime: Date.now() - startTime, metadata: endMetadata, }, provider, model, ); continue; } // Handle error events if (event.type === 'error') { yield this.createErrorEvent( event.error, provider, event.error?.recoverable, ); continue; } } } catch (error) { debugError('[StreamNormalizer] Anthropic stream error:', error); yield this.createErrorEvent(error, provider); throw error; } } /** * Normalize Mistral streaming format */ async *normalizeMistralStream(stream, context) { const provider = 'mistral'; const model = context.model || 'unknown'; const startTime = Date.now(); let hasStarted = false; let accumulatedContent = ''; let accumulatedReasoning = ''; let accumulatedUsage = null; let finishReason = null; try { for await (const event of stream) { // Handle start event if (event.type === 'start' && !hasStarted) { hasStarted = true; yield this.createStartEvent(provider, model, event); continue; } // Handle delta events if (event.type === 'delta') { accumulatedContent += event.content || ''; yield this.createDeltaEvent(event.content || '', provider, model); continue; } // Handle reasoning/thinking events (kept separate from answer text) if (event.type === 'thinking') { accumulatedReasoning += event.content || ''; yield this.createReasoningEvent(event.content || '', provider, model); continue; } // Handle usage events if (event.type === 'usage') { accumulatedUsage = event.usage; yield this.createUsageEvent(event.usage, provider, model); continue; } // Handle end event if (event.type === 'end') { finishReason = event.stop_reason || event.metadata?.finish_reason; const endMetadata = { ...event.metadata, ...(accumulatedReasoning && { reasoning: accumulatedReasoning }), }; yield this.createEndEvent( { content: event.content || accumulatedContent, stopReason: finishReason, usage: event.metadata?.usage || accumulatedUsage, responseTime: Date.now() - startTime, metadata: endMetadata, }, provider, model, ); continue; } // Handle error events if (event.type === 'error') { yield this.createErrorEvent( event.error, provider, event.error?.recoverable, ); continue; } } } catch (error) { debugError('[StreamNormalizer] Mistral stream error:', error); yield this.createErrorEvent(error, provider); throw error; } } /** * Normalize DeepSeek streaming format */ async *normalizeDeepSeekStream(stream, context) { const provider = 'deepseek'; const model = context.model || 'unknown'; const startTime = Date.now(); let hasStarted = false; let accumulatedContent = ''; let accumulatedReasoning = ''; let accumulatedUsage = null; let finishReason = null; const reasoningMetadata = {}; try { for await (const event of stream) { // Handle start event if (event.type === 'start' && !hasStarted) { hasStarted = true; yield this.createStartEvent(provider, model, event); continue; } // Handle delta events (including reasoning tokens for DeepSeek-R1) if (event.type === 'delta') { accumulatedContent += event.content || ''; yield this.createDeltaEvent(event.content || '', provider, model, { isReasoning: event.isReasoning || false, }); continue; } // Handle reasoning/thinking events (kept separate from answer text) if (event.type === 'thinking') { accumulatedReasoning += event.content || ''; yield this.createReasoningEvent(event.content || '', provider, model); continue; } // Handle usage events (with DeepSeek-specific reasoning tokens) if (event.type === 'usage') { accumulatedUsage = event.usage; if (event.usage.reasoning_tokens) { reasoningMetadata.reasoningTokens = event.usage.reasoning_tokens; } yield this.createUsageEvent(event.usage, provider, model); continue; } // Handle end event if (event.type === 'end') { finishReason = event.stop_reason || event.metadata?.finish_reason; const endMetadata = { ...event.metadata, ...reasoningMetadata, ...(accumulatedReasoning && { reasoning: accumulatedReasoning }), }; yield this.createEndEvent( { content: event.content || accumulatedContent, stopReason: finishReason, usage: event.metadata?.usage || accumulatedUsage, responseTime: Date.now() - startTime, metadata: endMetadata, }, provider, model, ); continue; } // Handle error events if (event.type === 'error') { yield this.createErrorEvent( event.error, provider, event.error?.recoverable, ); continue; } } } catch (error) { debugError('[StreamNormalizer] DeepSeek stream error:', error); yield this.createErrorEvent(error, provider); throw error; } } /** * Normalize OpenRouter streaming format */ async *normalizeOpenRouterStream(stream, context) { const provider = 'openrouter'; const model = context.model || 'unknown'; const startTime = Date.now(); let hasStarted = false; let accumulatedContent = ''; let accumulatedReasoning = ''; let accumulatedUsage = null; let finishReason = null; let routingMetadata = {}; try { for await (const event of stream) { // Handle start event if (event.type === 'start' && !hasStarted) { hasStarted = true; yield this.createStartEvent(provider, model, event); continue; } // Handle delta events if (event.type === 'delta') { accumulatedContent += event.content || ''; yield this.createDeltaEvent(event.content || '', provider, model); continue; } // Handle reasoning/thinking events (kept separate from answer text) if (event.type === 'thinking') { accumulatedReasoning += event.content || ''; yield this.createReasoningEvent(event.content || '', provider, model); continue; } // Handle usage events (with OpenRouter-specific routing metadata) if (event.type === 'usage') { accumulatedUsage = event.usage; if (event.routingInfo) { routingMetadata = event.routingInfo; } yield this.createUsageEvent(event.usage, provider, model); continue; } // Handle end event if (event.type === 'end') { finishReason = event.stop_reason || event.metadata?.finish_reason; const endMetadata = { ...event.metadata, ...routingMetadata, ...(accumulatedReasoning && { reasoning: accumulatedReasoning }), }; yield this.createEndEvent( { content: event.content || accumulatedContent, stopReason: finishReason, usage: event.metadata?.usage || accumulatedUsage, responseTime: Date.now() - startTime, metadata: endMetadata, }, provider, model, ); continue; } // Handle error events if (event.type === 'error') { yield this.createErrorEvent( event.error, provider, event.error?.recoverable, ); continue; } } } catch (error) { debugError('[StreamNormalizer] OpenRouter stream error:', error); yield this.createErrorEvent(error, provider); throw error; } } /** * Passthrough normalizer for providers that yield pre-normalized events * (copilot, claude, gemini-cli). These providers emit start/delta/usage/end * events directly — wrap them in the standard createXxxEvent() format. */ async *normalizePassthroughStream(stream, context) { const provider = context.provider || 'unknown'; const model = context.model || 'unknown'; const startTime = Date.now(); let accumulatedContent = ''; let accumulatedUsage = null; try { for await (const event of stream) { switch (event.type) { case 'start': yield this.createStartEvent(provider, model, event); break; case 'delta': accumulatedContent += event.data?.textDelta || ''; yield this.createDeltaEvent( event.data?.textDelta || '', provider, model, ); break; case 'usage': accumulatedUsage = event.usage || event.data?.usage; if (accumulatedUsage) { yield this.createUsageEvent(accumulatedUsage, provider, model); } break; case 'end': yield this.createEndEvent( { content: accumulatedContent, stopReason: event.stop_reason || event.finish_reason || 'stop', usage: accumulatedUsage, responseTime: Date.now() - startTime, metadata: event.metadata || {}, }, provider, model, ); break; case 'error': yield this.createErrorEvent( event.error || new Error('Unknown streaming error'), provider, ); break; default: debugLog( `[StreamNormalizer] Passthrough: unknown event type '${event.type}'`, ); break; } } } catch (error) { debugError('[StreamNormalizer] Passthrough stream error:', error); yield this.createErrorEvent(error, provider); throw error; } } /** * Create standardized start event */ createStartEvent(provider, model, originalEvent = {}) { return { type: EVENT_TYPES.START, provider, model, timestamp: Date.now(), data: { requestId: originalEvent.requestId || `${provider}-${Date.now()}`, estimatedTokens: originalEvent.estimatedTokens || null, ...originalEvent.data, }, }; } /** * Create standardized delta event */ createDeltaEvent(textDelta, provider, model, metadata = {}) { return { type: EVENT_TYPES.DELTA, provider, model, timestamp: Date.now(), data: { textDelta, role: 'assistant', index: 0, ...metadata, }, }; } /** * Create standardized usage event */ createUsageEvent(usage, provider, model) { return { type: EVENT_TYPES.USAGE, provider, model, timestamp: Date.now(), data: { usage: { inputTokens: usage.input_tokens || usage.prompt_tokens || 0, outputTokens: usage.output_tokens || usage.completion_tokens || 0, totalTokens: usage.total_tokens || 0, // Preserve provider-specific usage fields ...usage, }, }, }; } /** * Create standardized streamed-reasoning event. Distinct from a delta so * downstream consumers keep reasoning separate from the visible answer. */ createReasoningEvent(content, provider, model) { return { type: EVENT_TYPES.REASONING, provider, model, timestamp: Date.now(), data: { content: content || '', role: 'assistant', isReasoning: true, }, }; } /** * Create standardized reasoning summary event */ createReasoningSummaryEvent(content, provider, model) { return { type: EVENT_TYPES.REASONING_SUMMARY, provider, model, timestamp: Date.now(), data: { content: content || '', role: 'assistant', isReasoningSummary: true, }, }; } /** * Create standardized end event */ createEndEvent(params, provider, model) { return { type: EVENT_TYPES.END, provider, model, timestamp: Date.now(), data: { content: params.content, stopReason: params.stopReason || 'stop', usage: { inputTokens: params.usage?.input_tokens || params.usage?.prompt_tokens || 0, outputTokens: params.usage?.output_tokens || params.usage?.completion_tokens || 0, totalTokens: params.usage?.total_tokens || 0, ...params.usage, }, responseTimeMs: params.responseTime, metadata: params.metadata || {}, }, }; } /** * Create standardized error event */ createErrorEvent(error, provider, recoverable = false) { // Determine if error is recoverable based on error type const isRecoverable = recoverable || error.code === 'RATE_LIMIT_EXCEEDED' || error.code === 'CHUNK_PROCESSING_ERROR' || error.code === 'TIMEOUT' || error.recoverable === true; return { type: EVENT_TYPES.ERROR, provider, model: 'unknown', timestamp: Date.now(), data: { error: { message: error.message || 'Unknown streaming error', code: error.code || 'STREAMING_ERROR', recoverable: isRecoverable, originalError: error, }, }, }; } /** * Normalize Codex streaming format * Handles Codex SDK events: thread.started, turn.started, item.completed, turn.completed * * Event mapping: * - thread.started → start event (capture thread_id) * - item.completed (type: agent_message) → delta events (user-facing content) * - item.completed (type: reasoning) → ignored (internal reasoning) * - turn.completed → end event (includes usage) * - Unknown events → logged at debug level, preserved in metadata */ async *normalizeCodexStream(stream, context) { const provider = 'codex'; const model = context.model || 'codex'; const startTime = Date.now(); let accumulatedContent = ''; let finalUsage = null; let threadId = null; let hasEnded = false; // Track if we've already sent an end event try { for await (const event of stream) { switch (event?.type) { case 'thread.started': // Thread initialization - capture thread ID for continuation threadId = event.thread_id; yield this.createStartEvent(provider, model, { threadId }); break; case 'turn.started': // Turn execution begins - informational only, no event needed debugLog('[Codex] Turn started'); break; case 'item.completed': // Item completion - filter by item type if (event.item?.type === 'agent_message') { // User-facing response content const text = event.item.text || ''; accumulatedContent += text; yield this.createDeltaEvent(text, provider, model); } else if (event.item?.type === 'reasoning') { // Internal reasoning - log for debugging but don't send to user debugLog('[Codex] Reasoning item completed', { id: event.item.id, text: event.item.text?.substring(0, 100), // Log first 100 chars }); } else { // Unknown item type - log and preserve debugLog('[Codex] Unknown item type', { type: event.item?.type, event, }); } break; case 'turn.completed': // Turn execution completed - final event with usage finalUsage = event.usage; hasEnded = true; yield this.createEndEvent( { content: accumulatedContent, stopReason: 'stop', usage: finalUsage ? { input_tokens: finalUsage.input_tokens || 0, output_tokens: finalUsage.output_tokens || 0, total_tokens: (finalUsage.input_tokens || 0) + (finalUsage.output_tokens || 0), cached_input_tokens: finalUsage.cached_input_tokens || 0, } : null, responseTime: Date.now() - startTime, metadata: { threadId }, }, provider, model, ); // Exit the generator after turn completion - no more events expected return; case 'turn.failed': // Turn failed with error hasEnded = true; debugError('[Codex] Turn failed', event.error); yield this.createErrorEvent( new Error(event.error?.message || 'Turn failed'), provider, ); // Exit the generator after turn failure return; case 'error': // Fatal error from stream hasEnded = true; debugError('[Codex] Stream error', event.message); yield this.createErrorEvent( new Error(event.message || 'Stream error'), provider, ); // Exit the generator after fatal error return; case 'item.started': case 'item.updated': // Item progress events - log for debugging debugLog('[Codex] Item event', { type: event.type, itemType: event.item?.type, }); break; default: // Unknown event type - log at debug level but don't crash debugLog('[Codex] Unknown event type', { type: event?.type, event, }); break; } } // If the stream ended naturally without a turn.completed event, // yield a final end event with whatever content we accumulated if (!hasEnded) { debugLog('[Codex] Stream ended naturally without turn.completed event'); yield this.createEndEvent( { content: accumulatedContent, stopReason: 'stop', usage: finalUsage, responseTime: Date.now() - startTime, metadata: { threadId }, }, provider, model, ); } } catch (error) { debugError('[Codex] Stream normalization error:', error); yield this.createErrorEvent(error, provider); throw error; } } /** * Validate that a stream produces valid normalized events * Used primarily for testing */ async validateStream(stream) { const events = []; let hasStart = false; let hasEnd = false; let errorCount = 0; try { for await (const event of stream) { events.push(event); // Validate event structure if (!event.type || !event.provider || !event.timestamp) { throw new Error(`Invalid event structure: ${JSON.stringify(event)}`); } // Track event types if (event.type === EVENT_TYPES.START) hasStart = true; if (event.type === EVENT_TYPES.END) hasEnd = true; if (event.type === EVENT_TYPES.ERROR) errorCount++; // Validate event data based on type switch (event.type) { case EVENT_TYPES.START: if (!event.data?.requestId) { throw new Error('Start event missing requestId'); } break; case EVENT_TYPES.DELTA: if (event.data?.textDelta === undefined) { throw new Error('Delta event missing textDelta'); } break; case EVENT_TYPES.USAGE: if (!event.data?.usage) { throw new Error('Usage event missing usage data'); } break; case EVENT_TYPES.END: if (event.data?.content === undefined || !event.data?.stopReason) { throw new Error('End event missing required fields'); } break; case EVENT_TYPES.ERROR: if (!event.data?.error?.message || !event.data?.error?.code) { throw new Error('Error event missing required error fields'); } break; } } return { valid: hasStart && hasEnd, events, hasStart, hasEnd, errorCount, totalEvents: events.length, }; } catch (error) { return { valid: false, error: error.message, events, hasStart, hasEnd, errorCount, totalEvents: events.length, }; } } } // Create and export singleton instance const providerStreamNormalizer = new ProviderStreamNormalizer(); export default providerStreamNormalizer; // Named exports for testing export { ProviderStreamNormalizer, EVENT_TYPES };