neura-sdk
Version:
TypeScript SDK for interacting with the Neura AI API
349 lines (303 loc) • 10.6 kB
text/typescript
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('::');
}
}