/** * Internal framework-agnostic SSE streaming client for @geepers/chat. * * Handles Server-Sent Events streaming for multi-provider LLM chat APIs. * Parses SSE data lines, extracts content from various provider response formats, * and provides callbacks for streaming chunks and completion. * * @example * ```ts * import { ChatCore } from '@geepers/chat'; * * const chat = new ChatCore({ gateway: 'https://api.example.com/v1' }); * await chat.send('Hello!'); * ``` */ export interface SSEChatOptions { /** API endpoint URL for chat completions */ apiUrl: string; /** API key sent as X-API-Key header */ apiKey?: string; /** Additional headers to include in requests */ headers?: Record; } export interface ChatMessage { role: 'system' | 'user' | 'assistant'; content: string; } export interface SendOptions { /** LLM provider name */ provider: string; /** Model identifier */ model: string; /** Conversation messages */ messages: ChatMessage[]; /** Called with each text chunk as it streams in */ onChunk?: (chunk: string, accumulated: string) => void; /** Called when streaming is complete */ onDone?: (fullText: string) => void; /** Called on error */ onError?: (error: Error) => void; /** Optional AbortSignal for cancellation */ signal?: AbortSignal; /** Additional body parameters to include in the request */ extraParams?: Record; } /** * Extract content from various SSE JSON formats used by different providers. * Handles OpenAI, Anthropic, xAI, and custom gateway formats. */ function extractContent(parsed: Record): string { // Direct content field (custom gateways) if (typeof parsed.content === 'string') return parsed.content; // OpenAI format: choices[0].delta.content const choices = parsed.choices as Array> | undefined; if (choices?.[0]) { const delta = choices[0].delta as Record | undefined; if (delta?.content) return delta.content; } // Anthropic format: message.content or content_block.text const message = parsed.message as Record | undefined; if (message?.content) return message.content; const contentBlock = parsed.content_block as Record | undefined; if (contentBlock?.text) return contentBlock.text; // Delta text (Anthropic streaming) const delta = parsed.delta as Record | undefined; if (delta?.text) return delta.text; return ''; } export class SSEChat { private apiUrl: string; private apiKey?: string; private headers: Record; constructor(options: SSEChatOptions) { this.apiUrl = options.apiUrl; this.apiKey = options.apiKey; this.headers = options.headers ?? {}; } /** Update the API key at runtime */ setApiKey(key: string): void { this.apiKey = key; } /** * Send a chat message and stream the response via SSE. * Returns a promise that resolves with the full response text. */ async send(options: SendOptions): Promise { const { provider, model, messages, onChunk, onDone, onError, signal, extraParams } = options; const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', ...this.headers, }; if (this.apiKey) { headers['X-API-Key'] = this.apiKey; } let fullContent = ''; try { const response = await fetch(this.apiUrl, { method: 'POST', headers, body: JSON.stringify({ provider, model, messages, stream: true, ...extraParams, }), signal, }); if (!response.ok) { const errorText = await response.text(); const error = new Error( response.status === 401 ? 'Invalid API key' : response.status === 429 ? 'Rate limit exceeded' : `HTTP ${response.status}: ${errorText}` ); onError?.(error); throw error; } const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { if (line.trim() === '' || !line.startsWith('data: ')) continue; const data = line.slice(6); if (data === '[DONE]') continue; try { const parsed = JSON.parse(data) as Record; const chunk = extractContent(parsed); if (chunk) { fullContent += chunk; onChunk?.(chunk, fullContent); } } catch { // Skip malformed JSON lines } } } onDone?.(fullContent); return fullContent; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { onDone?.(fullContent); return fullContent; } const err = error instanceof Error ? error : new Error(String(error)); onError?.(err); throw err; } } } export default SSEChat;