UNPKG

neura-sdk

Version:

TypeScript SDK for interacting with the Neura AI API

349 lines (303 loc) 10.6 kB
import { EventEmitter } from 'events'; import pRetry from 'p-retry'; import pThrottle from 'p-throttle'; import { CompletionsRequestOptions, NeuraResponse, NeuraSDKConfig, NeuraStream, ProcessingError, StreamChunk, } from '../types'; import { MemoryCache } from '../utils/cache'; const DEFAULT_CONFIG: Partial<NeuraSDKConfig> = { maxRetries: 3, retryDelay: 1000, requestsPerMinute: 60, enableCache: true, cacheTTL: 5 * 60 * 1000, // 5 minutes }; export class NeuraSDK { private baseUrl: string; private apiKey: string; private maxRetries: number; private retryDelay: number; private cache: MemoryCache | null; private throttle: <T>(fn: (...args: any[]) => Promise<T>) => (...args: any[]) => Promise<T>; constructor(config: NeuraSDKConfig) { this.baseUrl = config.baseUrl.endsWith('/') ? config.baseUrl.slice(0, -1) : config.baseUrl; this.apiKey = config.apiKey; this.maxRetries = config.maxRetries ?? DEFAULT_CONFIG.maxRetries!; this.retryDelay = config.retryDelay ?? DEFAULT_CONFIG.retryDelay!; // Initialize cache if enabled this.cache = config.enableCache ?? DEFAULT_CONFIG.enableCache ? new MemoryCache(config.cacheTTL ?? DEFAULT_CONFIG.cacheTTL) : null; // Initialize rate limiter const requestsPerMinute = config.requestsPerMinute ?? DEFAULT_CONFIG.requestsPerMinute!; this.throttle = pThrottle({ limit: requestsPerMinute, interval: 60 * 1000, // 1 minute in milliseconds }); } /** * Create a chat completion * @param options Request options * @returns Promise with the completion response */ async createCompletion(options: CompletionsRequestOptions): Promise<NeuraResponse> { // Check cache first if enabled and not streaming if (this.cache && !options.stream) { const cacheKey = this.getCacheKey(options); const cachedResponse = this.cache.get<NeuraResponse>(cacheKey); if (cachedResponse) { return cachedResponse; } } // Apply throttling and retry logic const throttledRequest = this.throttle(async () => { return await pRetry( async () => { const response = await this.makeRequest(options); return response; }, { retries: this.maxRetries, minTimeout: this.retryDelay, onFailedAttempt: (error) => { console.warn( `Request failed (attempt ${error.attemptNumber}/${error.retriesLeft + error.attemptNumber}): ${error.message}` ); }, } ); }); const response = await throttledRequest(); // Cache the response if caching is enabled and it's not an error if (this.cache && !options.stream && !('error' in response)) { const cacheKey = this.getCacheKey(options); this.cache.set(cacheKey, response); } return response; } /** * Create a streaming chat completion * @param options Request options * @returns EventEmitter that emits chunks, done, and error events */ createCompletionStream(options: CompletionsRequestOptions): NeuraStream { const eventEmitter = new EventEmitter() as NeuraStream; // Force stream to true const streamOptions = { ...options, stream: true }; // Apply throttling const throttledRequest = this.throttle(async () => { try { await this.processStream(streamOptions, eventEmitter); } catch (error) { eventEmitter.emit('error', error instanceof Error ? error : new Error(String(error))); } }); // Start the stream processing throttledRequest().catch((error) => { eventEmitter.emit('error', error instanceof Error ? error : new Error(String(error))); }); return eventEmitter; } /** * Clear the cache */ clearCache(): void { this.cache?.clear(); } /** * Make a request to the API * @param options Request options * @returns Promise with the response * @private */ private async makeRequest(options: CompletionsRequestOptions): Promise<NeuraResponse> { const endpoint = `${this.baseUrl}/v1/chat/completions`; // Prepare the request body const body: Record<string, any> = { messages: options.messages, }; if (options.sessionId) body.session_id = options.sessionId; if (options.userId) body.user_id = options.userId; if (options.stream !== undefined) body.stream = options.stream; if (options.reasoningFormat) body.reasoning_format = options.reasoningFormat; // Handle file data (convert Buffer to base64 if needed) if (options.fileData) { if (Buffer.isBuffer(options.fileData)) { body.file_data = options.fileData.toString('base64'); } else { body.file_data = options.fileData; } } try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}`, }, body: JSON.stringify(body), }); // Check for non-2xx responses if (!response.ok) { const errorData = await response.json(); return errorData as ProcessingError; } // Parse the response const data = await response.json(); return data as NeuraResponse; } catch (error) { throw new Error(`Failed to create completion: ${error instanceof Error ? error.message : String(error)}`); } } /** * Process a streaming response * @param options Request options * @param emitter EventEmitter to emit events * @private */ private async processStream( options: CompletionsRequestOptions, emitter: NeuraStream ): Promise<void> { const endpoint = `${this.baseUrl}/v1/chat/completions`; // Prepare the request body const body: Record<string, any> = { messages: options.messages, stream: true, }; if (options.sessionId) body.session_id = options.sessionId; if (options.userId) body.user_id = options.userId; if (options.reasoningFormat) body.reasoning_format = options.reasoningFormat; // Handle file data (convert Buffer to base64 if needed) if (options.fileData) { if (Buffer.isBuffer(options.fileData)) { body.file_data = options.fileData.toString('base64'); } else { body.file_data = options.fileData; } } // Make the request with retry logic await pRetry( async () => { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}`, }, body: JSON.stringify(body), }); // Check for non-2xx responses if (!response.ok) { const errorData = await response.json(); emitter.emit('error', errorData as ProcessingError); throw new Error(`API returned ${response.status}: ${JSON.stringify(errorData)}`); } // Process the SSE stream const reader = response.body?.getReader(); if (!reader) { const error = new Error('Response body is null'); emitter.emit('error', error); throw error; } const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) { break; } // Decode the chunk and add it to our buffer buffer += decoder.decode(value, { stream: true }); // Process complete SSE events in the buffer const events = buffer.split('\n\n'); buffer = events.pop() || ''; // Keep the last incomplete event in the buffer for (const event of events) { if (!event.trim()) continue; // Parse the SSE data const lines = event.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); // Check if it's the [DONE] marker if (data.includes('[DONE]')) { emitter.emit('done'); continue; } try { const parsed = JSON.parse(data); emitter.emit('chunk', parsed as StreamChunk); } catch (e) { console.error('Failed to parse SSE data:', data); } } } } } // Emit any remaining data if (buffer.trim()) { const lines = buffer.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); emitter.emit('chunk', data as StreamChunk); } catch (e) { console.error('Failed to parse remaining SSE data:', line); } } } } emitter.emit('done'); }, { retries: this.maxRetries, minTimeout: this.retryDelay, onFailedAttempt: (error) => { console.warn( `Stream request failed (attempt ${error.attemptNumber}/${error.retriesLeft + error.attemptNumber}): ${error.message}` ); }, } ); } /** * Generate a cache key for the request * @param options Request options * @returns Cache key * @private */ private getCacheKey(options: CompletionsRequestOptions): string { // Create a deterministic cache key from the request options const keyParts = [ options.messages, options.sessionId || '', options.userId || '', options.reasoningFormat || '', ]; // We don't include file data in the cache key as it could be large // Instead, we use a hash of the file data if present if (options.fileData) { if (Buffer.isBuffer(options.fileData)) { // Simple hash for the buffer const hash = Buffer.from(options.fileData).reduce( (hash, byte) => (hash * 31 + byte) & 0xFFFFFFFF, 0 ).toString(16); keyParts.push(hash); } else { // For base64 strings, use the first 20 chars as a fingerprint keyParts.push(options.fileData.substring(0, 20)); } } return keyParts.join('::'); } }