senangwebs-chatbot
Version:
Lightweight JavaScript library with OpenRouter API integration for AI-powered conversations.
330 lines (289 loc) • 9.19 kB
JavaScript
/**
* OpenRouter API Client
* Handles communication with OpenRouter (OpenAI-compatible) API
* Supports streaming responses and error handling
*/
class OpenRouterAPI {
constructor(config) {
this.apiKey = config.apiKey;
this.baseURL = config.baseURL || "https://openrouter.ai/api/v1";
this.model = config.model || "openai/gpt-3.5-turbo";
this.maxTokens = config.maxTokens || 500;
this.temperature = config.temperature || 0.7;
this.siteName = config.siteName || "SenangWebs Chatbot";
this.siteUrl =
config.siteUrl ||
(typeof window !== "undefined" ? window.location.origin : "");
this.timeout = config.timeout || 30000; // 30 seconds
this.retryAttempts = config.retryAttempts || 2;
this.retryDelay = config.retryDelay || 1000; // 1 second
this.debug = config.debug || false;
this.abortController = null;
this.validateConfig();
}
/**
* Validate configuration
* @throws {Error} if configuration is invalid
*/
validateConfig() {
if (!this.apiKey || this.apiKey.trim() === "") {
throw new Error("OpenRouter API key is required");
}
if (!this.baseURL || !this.baseURL.startsWith("http")) {
throw new Error("Invalid base URL");
}
if (this.maxTokens < 1 || this.maxTokens > 32768) {
console.warn(
"maxTokens should be between 1 and 32768, using default 500"
);
this.maxTokens = 500;
}
if (this.temperature < 0 || this.temperature > 2) {
console.warn("temperature should be between 0 and 2, using default 0.7");
this.temperature = 0.7;
}
}
/**
* Send message to OpenRouter API with streaming support
* @param {Array} messages - Array of message objects {role, content}
* @param {Function} onChunk - Callback for each chunk of streamed data
* @param {Function} onComplete - Callback when streaming is complete
* @param {Function} onError - Callback for errors
* @returns {Promise<Object>} Complete response object
*/
async sendMessage(messages, onChunk, onComplete, onError) {
let attempt = 0;
let lastError = null;
while (attempt <= this.retryAttempts) {
try {
if (this.debug) {
console.log(
`[OpenRouterAPI] Attempt ${attempt + 1}/${this.retryAttempts + 1}`
);
console.log("[OpenRouterAPI] Sending messages:", messages);
}
const response = await this._makeRequest(messages);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw this._handleAPIError(response.status, errorData);
}
// Handle streaming response
const result = await this._handleStreamingResponse(response, onChunk);
if (onComplete) {
onComplete(result);
}
return result;
} catch (error) {
lastError = error;
if (this.debug) {
console.error(
`[OpenRouterAPI] Attempt ${attempt + 1} failed:`,
error
);
}
// Don't retry on certain errors
if (this._shouldNotRetry(error)) {
if (onError) {
onError(error);
}
throw error;
}
attempt++;
// Wait before retry (exponential backoff)
if (attempt <= this.retryAttempts) {
const delay = this.retryDelay * Math.pow(2, attempt - 1);
if (this.debug) {
console.log(`[OpenRouterAPI] Retrying in ${delay}ms...`);
}
await this._sleep(delay);
}
}
}
// All retries failed
if (onError) {
onError(lastError);
}
throw lastError;
}
/**
* Make HTTP request to OpenRouter API
* @private
*/
async _makeRequest(messages) {
this.abortController = new AbortController();
const timeoutId = setTimeout(
() => this.abortController.abort(),
this.timeout
);
try {
const response = await fetch(`${this.baseURL}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": this.siteUrl,
"X-Title": this.siteName,
},
body: JSON.stringify({
model: this.model,
messages: messages,
max_tokens: this.maxTokens,
temperature: this.temperature,
stream: true,
}),
signal: this.abortController.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
} finally {
// Clean up timeout in all cases
clearTimeout(timeoutId);
}
}
/**
* Handle streaming response from API
* @private
*/
async _handleStreamingResponse(response, onChunk) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let fullContent = "";
try {
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() || ""; // Keep incomplete line in buffer
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine === "") continue;
if (trimmedLine === "data: [DONE]") continue;
if (!trimmedLine.startsWith("data: ")) continue;
try {
const data = JSON.parse(trimmedLine.substring(6));
const content = data.choices?.[0]?.delta?.content;
if (content) {
fullContent += content;
if (onChunk) {
onChunk({
content: content,
fullContent: fullContent,
done: false,
});
}
}
// Check if streaming is finished
if (data.choices?.[0]?.finish_reason) {
if (this.debug) {
console.log(
"[OpenRouterAPI] Stream finished:",
data.choices[0].finish_reason
);
}
}
} catch (parseError) {
if (this.debug) {
console.warn(
"[OpenRouterAPI] Failed to parse SSE data:",
trimmedLine,
parseError
);
}
}
}
}
return {
content: fullContent,
model: this.model,
done: true,
};
} catch (error) {
if (error.name === "AbortError") {
throw new Error("Request cancelled by user");
}
throw error;
}
}
/**
* Handle API errors and create meaningful error messages
* @private
*/
_handleAPIError(status, errorData) {
const errorMessage = errorData.error?.message || "Unknown error occurred";
switch (status) {
case 401:
return new Error(
"Invalid API key. Please check your OpenRouter API key."
);
case 403:
return new Error(
"Access forbidden. Please check your API key permissions."
);
case 429:
const error = new Error("Rate limit exceeded. Please try again later.");
error.isRateLimit = true;
return error;
case 500:
case 502:
case 503:
return new Error(
"OpenRouter service is temporarily unavailable. Please try again."
);
case 400:
return new Error(`Bad request: ${errorMessage}`);
default:
return new Error(`API error (${status}): ${errorMessage}`);
}
}
/**
* Check if error should not be retried
* @private
*/
_shouldNotRetry(error) {
// Don't retry on auth errors, bad requests, or user cancellation
if (error.message.includes("Invalid API key")) return true;
if (error.message.includes("Access forbidden")) return true;
if (error.message.includes("Bad request")) return true;
if (error.message.includes("cancelled by user")) return true;
return false;
}
/**
* Cancel ongoing request
*/
cancel() {
if (this.abortController) {
this.abortController.abort();
if (this.debug) {
console.log("[OpenRouterAPI] Request cancelled");
}
}
}
/**
* Get model information
*/
getModelInfo() {
return {
model: this.model,
maxTokens: this.maxTokens,
temperature: this.temperature,
};
}
/**
* Sleep utility for retry delays
* @private
*/
_sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
// Export for use in other modules
if (typeof module !== "undefined" && module.exports) {
module.exports = OpenRouterAPI;
}