converse-mcp-server
Version:
Converse MCP Server - Converse with other LLMs with chat and consensus tools
778 lines (699 loc) • 23.6 kB
JavaScript
/**
* 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'
};
/**
* 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)
};
}
/**
* 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 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 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
};
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 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 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] 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 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 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
};
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 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 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
};
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;
}
}
/**
* 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 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
}
}
};
}
/**
* 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 };