llm-credit-sdk
Version:
SDK for estimating and reconciling LLM token costs using a credit system.
258 lines • 9.86 kB
JavaScript
;
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