@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
JavaScript
/**
* 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;
}
}