UNPKG

llm-credit-sdk

Version:

SDK for estimating and reconciling LLM token costs using a credit system.

258 lines 9.86 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DashboardClient = void 0; /** * Dashboard client for communicating with the Tokenix Dashboard API * Handles reconciliation log posting and real-time config subscriptions */ class DashboardClient { constructor(config) { this.configSubscription = null; this.pollingInterval = null; this.isSubscribed = false; this.config = this.validateAndNormalizeConfig(config); } /** * Validate and normalize the dashboard configuration */ validateAndNormalizeConfig(config) { if (!config.endpoint) { throw new Error('Dashboard endpoint is required'); } // Ensure endpoint doesn't end with slash const normalizedEndpoint = config.endpoint.replace(/\/$/, ''); // Log the endpoint being used for debugging this.logInfo(`Initializing with endpoint: ${normalizedEndpoint}`); return { ...config, endpoint: normalizedEndpoint }; } /** * Post reconciliation log to dashboard * Includes retry logic and graceful error handling */ async postReconciliation(log) { if (!this.config.endpoint) { throw new Error('Dashboard endpoint not configured'); } const url = `${this.config.endpoint}/api/v1/reconciliation-log`; const payload = { ...log, projectId: log.projectId || this.config.projectId }; await this.retryRequest(async () => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.config.apiKey}`, 'Accept': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal, mode: 'cors' // Explicitly enable CORS }); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); throw new Error(`Dashboard API error: ${response.status} ${response.statusText} - ${errorText}`); } this.logInfo(`Reconciliation data posted successfully for ${payload.model}:${payload.feature}`); } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { throw new Error('Request timeout - check if dashboard endpoint is reachable'); } throw error; } }); } /** * Subscribe to real-time config updates via SSE * Falls back to polling if subscription fails */ subscribeToConfigUpdates(onUpdate) { if (this.isSubscribed) { return; // Already subscribed } this.isSubscribed = true; // Try Server-Sent Events first this.trySSESubscription(onUpdate) .catch(() => { // Fallback to polling this.startPolling(onUpdate); }); } /** * Unsubscribe from config updates and cleanup resources */ unsubscribe() { if (!this.isSubscribed) { return; // Already unsubscribed } this.isSubscribed = false; this.logInfo('Unsubscribing from config updates'); if (this.configSubscription) { if ('close' in this.configSubscription) { this.configSubscription.close(); } this.configSubscription = null; } if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = null; } } /** * Get current config from dashboard (used for polling fallback) */ async getConfig() { if (!this.config.endpoint) { throw new Error('Dashboard endpoint not configured'); } const url = `${this.config.endpoint}/api/v1/config`; return await this.retryRequest(async () => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); try { const response = await fetch(url, { headers: { 'Authorization': `Bearer ${this.config.apiKey}`, 'Accept': 'application/json' }, signal: controller.signal, mode: 'cors' // Explicitly enable CORS }); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); throw new Error(`Dashboard API error: ${response.status} ${response.statusText} - ${errorText}. Check endpoint: ${url}`); } const config = await response.json(); this.logInfo(`Config fetched successfully from dashboard`); return config; } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { throw new Error('Request timeout - check if dashboard endpoint is reachable'); } throw error; } }); } /** * Try Server-Sent Events subscription for real-time updates */ async trySSESubscription(onUpdate) { return new Promise((resolve, reject) => { try { if (typeof EventSource === 'undefined') { reject(new Error('EventSource not available')); return; } const sseUrl = `${this.config.endpoint}/api/v1/config/subscribe`; const eventSource = new EventSource(sseUrl, { headers: { 'Authorization': `Bearer ${this.config.apiKey}` } }); eventSource.onopen = () => { this.configSubscription = eventSource; this.logInfo('SSE subscription established'); resolve(); }; eventSource.onmessage = (event) => { try { const config = JSON.parse(event.data); this.logInfo(`Config update received via SSE - applying new configuration`); onUpdate(config); } catch (error) { this.logError('Failed to parse SSE config update', error); } }; eventSource.onerror = (error) => { this.logError('SSE error', error); reject(new Error('SSE connection failed')); }; // Timeout for connection setTimeout(() => { if (eventSource.readyState === EventSource.CONNECTING) { eventSource.close(); reject(new Error('SSE connection timeout')); } }, 10000); } catch (error) { reject(error); } }); } /** * Start polling for config updates as fallback */ startPolling(onUpdate) { this.logInfo('Starting config polling fallback (30s intervals)'); // Poll every 30 seconds this.pollingInterval = setInterval(async () => { try { const config = await this.getConfig(); this.logInfo('Config updated via polling'); onUpdate(config); } catch (error) { this.logError('Config polling failed', error); } }, 30000); // Initial fetch this.getConfig() .then((config) => { this.logInfo('Initial config loaded via polling'); onUpdate(config); }) .catch((error) => this.logError('Initial config fetch failed', error)); } /** * Retry logic with exponential backoff */ async retryRequest(operation, maxRetries = 3, baseDelay = 500) { let lastError; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await operation(); } catch (error) { lastError = error; if (attempt === maxRetries) { this.logError(`Request failed after ${maxRetries + 1} attempts`, lastError); throw lastError; } // Exponential backoff: 500ms, 1s, 2s const delay = baseDelay * Math.pow(2, attempt); this.logInfo(`Request failed (attempt ${attempt + 1}), retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } } throw lastError; } /** * Log info messages (can be enhanced with proper logger) */ logInfo(message) { console.log(`[DashboardClient] ${message}`); } /** * Log error messages without throwing */ logError(message, error) { console.error(`[DashboardClient] ${message}`, error); } } exports.DashboardClient = DashboardClient; //# sourceMappingURL=dashboardClient.js.map