UNPKG

@hivetechs/hive-ai

Version:

Real-time streaming AI consensus platform with HTTP+SSE MCP integration for Claude Code, VS Code, Cursor, and Windsurf - powered by OpenRouter's unified API

308 lines (307 loc) 13.4 kB
/** * OpenRouter Streaming Client * * Advanced streaming client for real-time consensus with live token streaming, * progress updates, and cancellation support. Built on top of the bulletproof * error handling system from Phase 2C. */ import { structuredLogger } from './structured-logger.js'; import { globalErrorHandler } from './error-handling.js'; import { globalHealthMonitor } from './health-monitor.js'; export class OpenRouterStreamingClient { constructor(apiKey) { this.apiKey = apiKey; this.abortController = null; this.currentStreamId = null; if (!apiKey.startsWith('sk-or-')) { throw new Error('Invalid OpenRouter API key format. Key must start with sk-or-'); } } /** * Stream chat completions with real-time token output */ async streamChatCompletion(model, messages, options = {}, callbacks = {}) { const requestId = `stream-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const [provider, modelName] = model.includes('/') ? model.split('/') : ['openrouter', model]; // Add model to health monitoring globalHealthMonitor.addTestModel(model); // Log stream start structuredLogger.info('Starting streaming request', { provider, model: modelName, requestId, messageCount: messages.length, options }); return await globalErrorHandler.executeWithFallback(async (fallbackProvider, fallbackModel) => { const fullModel = fallbackProvider === 'openrouter' ? fallbackModel : `${fallbackProvider}/${fallbackModel}`; return await this.performStreamingRequest(fullModel, messages, options, callbacks, requestId); }, provider, modelName, 'streaming', requestId).then(result => result.result); } /** * Perform the actual streaming request */ async performStreamingRequest(model, messages, options, callbacks, requestId) { // Create abort controller for cancellation this.abortController = new AbortController(); this.currentStreamId = requestId; const requestBody = { model, messages, stream: true, temperature: options.temperature ?? 0.7, max_tokens: options.max_tokens ?? 4000, top_p: options.top_p ?? 1, frequency_penalty: options.frequency_penalty ?? 0, presence_penalty: options.presence_penalty ?? 0 }; const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}`, 'HTTP-Referer': 'https://hivetechs.io', 'X-Title': 'Hive.AI Streaming Consensus' }; try { callbacks.onStart?.(); const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers, body: JSON.stringify(requestBody), signal: this.abortController.signal }); if (!response.ok) { const errorText = await response.text(); let errorMessage = `OpenRouter streaming error (${response.status})`; try { const errorData = JSON.parse(errorText); if (errorData.error?.message) { errorMessage = errorData.error.message; } } catch { if (response.status === 401) { errorMessage = 'Invalid OpenRouter API key for streaming'; } else if (response.status === 402) { errorMessage = 'Insufficient credits for streaming request'; } else if (response.status === 404) { errorMessage = `Model "${model}" does not support streaming`; } else if (response.status === 429) { errorMessage = 'Rate limit exceeded for streaming requests'; } } throw new Error(errorMessage); } if (!response.body) { throw new Error('No response body received for streaming request'); } return await this.processStreamingResponse(response.body, callbacks, requestId, model); } catch (error) { if (error.name === 'AbortError') { structuredLogger.info('Streaming request cancelled', { requestId, model }); throw new Error('Streaming request was cancelled'); } structuredLogger.error('Streaming request failed', { requestId, model }, error); callbacks.onError?.(error); throw error; } finally { this.abortController = null; this.currentStreamId = null; } } /** * Process the streaming response */ async processStreamingResponse(responseBody, callbacks, requestId, model) { const reader = responseBody.getReader(); const decoder = new TextDecoder(); let buffer = ''; let fullContent = ''; let tokenCount = 0; let streamId = ''; let finalUsage = undefined; try { while (true) { const { done, value } = await reader.read(); if (done) { break; } // Decode the chunk const chunk = decoder.decode(value, { stream: true }); buffer += chunk; // Process complete lines const lines = buffer.split('\n'); buffer = lines.pop() || ''; // Keep incomplete line in buffer for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine === '' || trimmedLine === 'data: [DONE]') { continue; } if (trimmedLine.startsWith('data: ')) { const jsonData = trimmedLine.slice(6); try { const parsed = JSON.parse(jsonData); if (parsed.id) { streamId = parsed.id; } // Handle content chunks if (parsed.choices?.[0]?.delta?.content) { const content = parsed.choices[0].delta.content; fullContent += content; // More accurate token counting - estimate tokens from content length // This is approximate but much better than incrementing by 1 const contentTokens = Math.max(1, Math.round(content.length / 4)); // ~4 chars per token average tokenCount += contentTokens; // Call chunk callback callbacks.onChunk?.(content, fullContent); // Use dynamic percentage based on stage and content // Different stages have different completion patterns const baseProgress = Math.min(90, Math.log(tokenCount + 1) * 20); const randomVariation = Math.random() * 15 + 5; // 5-20% variation const percentage = Math.min(95, baseProgress + randomVariation); callbacks.onProgress?.({ tokens: tokenCount, percentage }); structuredLogger.debug('Streaming chunk received', { requestId, model, chunkLength: content.length, chunkTokens: contentTokens, totalLength: fullContent.length, totalTokens: tokenCount }); } // Handle completion if (parsed.choices?.[0]?.finish_reason) { // Send final 100% progress update callbacks.onProgress?.({ tokens: tokenCount, estimatedTotal: tokenCount, percentage: 100 }); structuredLogger.info('Streaming completed', { requestId, model, finishReason: parsed.choices[0].finish_reason, totalTokens: tokenCount, contentLength: fullContent.length }); } // Capture usage information if (parsed.usage) { finalUsage = parsed.usage; } } catch (parseError) { structuredLogger.warn('Failed to parse streaming chunk', { requestId, model, chunk: jsonData.substring(0, 100) }); } } } } // Use actual token count from finalUsage if available, otherwise use estimated count const actualTokenCount = finalUsage?.total_tokens || tokenCount; // Create final response const response = { id: streamId || requestId, model, content: fullContent, usage: finalUsage }; // Update token count with actual usage if available if (finalUsage?.total_tokens && finalUsage.total_tokens !== tokenCount) { structuredLogger.info('Updated token count with actual usage', { requestId, model, estimatedTokens: tokenCount, actualTokens: finalUsage.total_tokens, difference: finalUsage.total_tokens - tokenCount }); } // Call completion callback callbacks.onComplete?.(response); structuredLogger.info('Streaming request completed successfully', { requestId, model, contentLength: fullContent.length, tokens: tokenCount, usage: finalUsage }); return response; } finally { reader.releaseLock(); } } /** * Cancel the current streaming request */ cancelStream() { if (this.abortController && this.currentStreamId) { const streamId = this.currentStreamId; structuredLogger.info('Cancelling streaming request', { requestId: streamId }); this.abortController.abort(); return true; } return false; } /** * Check if a stream is currently active */ isStreaming() { return this.abortController !== null && this.currentStreamId !== null; } /** * Get current stream ID */ getCurrentStreamId() { return this.currentStreamId; } } /** * Factory function to create streaming client */ export async function createStreamingClient() { const { getOpenRouterApiKey } = await import('../storage/unified-database.js'); // Use Windows-aware fallback logic const apiKey = process.env.OPENROUTER_API_KEY || await getOpenRouterApiKey(); if (!apiKey) { console.error('[Windows Debug] No API key found for streaming client'); throw new Error('No auth credentials found. Please run: hive quickstart'); } return new OpenRouterStreamingClient(apiKey); } /** * Test streaming connectivity */ export async function testStreamingConnection(model) { try { const client = await createStreamingClient(); const testModel = model || 'openai/gpt-4o-mini'; const testMessages = [ { role: 'user', content: 'Say "streaming test successful" and stop.' } ]; let received = ''; const response = await client.streamChatCompletion(testModel, testMessages, { max_tokens: 10 }, { onChunk: (chunk, total) => { received = total; } }); return received.toLowerCase().includes('streaming test successful') || response.content.toLowerCase().includes('streaming test successful'); } catch (error) { structuredLogger.error('Streaming connection test failed', {}, error); return false; } }