Skip to main content
Glama

Nexus MCP Server

openrouter.ts14.1 kB
import type { ChatCompletionRequest, ChatCompletionResponse, ChatCompletionChunk, OpenRouterError, PerplexityModelId, } from '../types/openrouter.js'; export interface OpenRouterClientConfig { apiKey: string; baseUrl?: string; timeout?: number; userAgent?: string; maxRetries?: number; retryDelay?: number; } export interface RetryConfig { maxRetries: number; retryDelay: number; retryableStatusCodes: number[]; } export class OpenRouterClient { private readonly apiKey: string; private readonly baseUrl: string; private readonly timeout: number; private readonly defaultHeaders: Record<string, string>; private readonly retryConfig: RetryConfig; constructor(config: OpenRouterClientConfig) { if (!config.apiKey) { throw new Error('OpenRouter API key is required'); } this.apiKey = config.apiKey; this.baseUrl = config.baseUrl || 'https://openrouter.ai/api/v1'; this.timeout = config.timeout || 30000; // 30 seconds default this.retryConfig = { maxRetries: config.maxRetries || 3, retryDelay: config.retryDelay || 1000, // 1 second default retryableStatusCodes: [429, 500, 502, 503, 504], }; this.defaultHeaders = { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', 'User-Agent': config.userAgent || 'nexus-mcp/1.0.0', 'HTTP-Referer': 'https://github.com/adawalli/nexus', 'X-Title': 'Nexus Search MCP', }; } /** * Validate API key format */ private validateApiKey(): boolean { return this.apiKey.startsWith('sk-or-') && this.apiKey.length > 10; } /** * Sleep for a given amount of time */ private sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Calculate exponential backoff delay */ private calculateBackoffDelay(attempt: number, baseDelay: number): number { return baseDelay * Math.pow(2, attempt) + Math.random() * 1000; // Add jitter } /** * Check if an error is retryable */ private isRetryableError(error: OpenRouterApiError): boolean { return this.retryConfig.retryableStatusCodes.includes(error.statusCode); } /** * Make authenticated HTTP request to OpenRouter API with retry logic */ async makeRequest<T>( endpoint: string, options: RequestInit = {} ): Promise<T> { if (!this.validateApiKey()) { throw new AuthenticationError('Invalid OpenRouter API key format'); } let lastError: Error | null = null; for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) { const url = `${this.baseUrl}${endpoint}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(url, { ...options, headers: { ...this.defaultHeaders, ...options.headers, }, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const errorData: OpenRouterError = await response .json() .catch(() => ({ error: { code: response.status, message: response.statusText, type: 'http_error', }, })); let apiError: OpenRouterApiError; // Create specific error types based on status code if (response.status === 401) { apiError = new AuthenticationError( errorData.error.message, response.status, errorData.error.code ); } else if (response.status === 429) { // Extract retry-after header if available const retryAfter = response.headers.get('retry-after'); apiError = new RateLimitError( errorData.error.message, retryAfter ? parseInt(retryAfter, 10) : undefined, response.status, errorData.error.code ); } else if (response.status >= 500) { apiError = new ServerError( errorData.error.message, response.status, errorData.error.code ); } else if (response.status >= 400) { apiError = new ClientError( errorData.error.message, response.status, errorData.error.type, errorData.error.code ); } else { apiError = new OpenRouterApiError( errorData.error.message, response.status, errorData.error.type, errorData.error.code ); } // Don't retry non-retryable errors or on the last attempt if ( !this.isRetryableError(apiError) || attempt === this.retryConfig.maxRetries ) { throw apiError; } lastError = apiError; const delay = this.calculateBackoffDelay( attempt, this.retryConfig.retryDelay ); await this.sleep(delay); continue; } return await response.json(); } catch (error) { clearTimeout(timeoutId); if (error instanceof OpenRouterApiError) { // This error was already handled above throw error; } if (error instanceof Error && error.name === 'AbortError') { const timeoutError = new Error( `Request timeout after ${this.timeout}ms` ); // Don't retry timeout errors on the last attempt if (attempt === this.retryConfig.maxRetries) { throw timeoutError; } lastError = timeoutError; const delay = this.calculateBackoffDelay( attempt, this.retryConfig.retryDelay ); await this.sleep(delay); continue; } const networkError = new Error( `Network error: ${error instanceof Error ? error.message : 'Unknown error'}` ); // Don't retry network errors on the last attempt if (attempt === this.retryConfig.maxRetries) { throw networkError; } lastError = networkError; const delay = this.calculateBackoffDelay( attempt, this.retryConfig.retryDelay ); await this.sleep(delay); } } // This should never be reached, but just in case throw lastError || new Error('Request failed after all retry attempts'); } /** * Set custom headers for requests */ setHeaders(headers: Record<string, string>): void { Object.assign(this.defaultHeaders, headers); } /** * Get current headers */ getHeaders(): Record<string, string> { return { ...this.defaultHeaders }; } /** * Test API connection and authentication */ async testConnection(): Promise<boolean> { try { await this.makeRequest('/models'); return true; } catch { return false; } } /** * Create a chat completion using the OpenRouter API */ async chatCompletions( request: ChatCompletionRequest ): Promise<ChatCompletionResponse> { // Default to Perplexity Sonar model if not specified or use a fallback const defaultModel: PerplexityModelId = 'perplexity/sonar'; const payload: ChatCompletionRequest = { ...request, model: request.model || defaultModel, // Ensure we don't accidentally enable streaming for non-streaming requests stream: request.stream || false, }; // Validate required fields if (!payload.messages || payload.messages.length === 0) { throw new Error('Messages array is required and cannot be empty'); } return await this.makeRequest<ChatCompletionResponse>('/chat/completions', { method: 'POST', body: JSON.stringify(payload), }); } /** * Create a streaming chat completion using the OpenRouter API * Returns an async generator that yields chat completion chunks */ async *chatCompletionsStream( request: ChatCompletionRequest ): AsyncGenerator<ChatCompletionChunk, void, unknown> { const defaultModel: PerplexityModelId = 'perplexity/sonar'; const payload: ChatCompletionRequest = { ...request, model: request.model || defaultModel, stream: true, }; // Validate required fields if (!payload.messages || payload.messages.length === 0) { throw new Error('Messages array is required and cannot be empty'); } if (!this.validateApiKey()) { throw new AuthenticationError('Invalid OpenRouter API key format'); } const url = `${this.baseUrl}/chat/completions`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(url, { method: 'POST', headers: { ...this.defaultHeaders, Accept: 'text/event-stream', }, body: JSON.stringify(payload), signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const errorData: OpenRouterError = await response.json().catch(() => ({ error: { code: response.status, message: response.statusText, type: 'http_error', }, })); // Create specific error types for streaming requests too if (response.status === 401) { throw new AuthenticationError( errorData.error.message, response.status, errorData.error.code ); } else if (response.status === 429) { const retryAfter = response.headers.get('retry-after'); throw new RateLimitError( errorData.error.message, retryAfter ? parseInt(retryAfter, 10) : undefined, response.status, errorData.error.code ); } else if (response.status >= 500) { throw new ServerError( errorData.error.message, response.status, errorData.error.code ); } else if (response.status >= 400) { throw new ClientError( errorData.error.message, response.status, errorData.error.type, errorData.error.code ); } else { throw new OpenRouterApiError( errorData.error.message, response.status, errorData.error.type, errorData.error.code ); } } if (!response.body) { throw new Error('No response body for streaming request'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); try { while (true) { const { done, value } = await reader.read(); if (done) { break; } const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n'); for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine === '' || !trimmedLine.startsWith('data: ')) { continue; } const data = trimmedLine.slice(6); // Remove 'data: ' prefix if (data === '[DONE]') { return; } try { const parsed: ChatCompletionChunk = JSON.parse(data); yield parsed; } catch { // Log parsing errors but continue processing // Note: Using minimal logging to avoid stdout contamination continue; } } } } finally { reader.releaseLock(); } } catch (error) { clearTimeout(timeoutId); if (error instanceof OpenRouterApiError) { throw error; } if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Request timeout after ${this.timeout}ms`); } throw new Error( `Network error: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } } /** * Base error class for OpenRouter API errors */ export class OpenRouterApiError extends Error { constructor( message: string, public readonly statusCode: number, public readonly type: string, public readonly code: number ) { super(message); this.name = 'OpenRouterApiError'; } isRateLimitError(): boolean { return this.statusCode === 429 || this.type === 'rate_limit_error'; } isAuthenticationError(): boolean { return this.statusCode === 401 || this.type === 'authentication_error'; } isServerError(): boolean { return this.statusCode >= 500; } } /** * Authentication error class */ export class AuthenticationError extends OpenRouterApiError { constructor(message: string, statusCode: number = 401, code: number = 401) { super(message, statusCode, 'authentication_error', code); this.name = 'AuthenticationError'; } } /** * Rate limit error class */ export class RateLimitError extends OpenRouterApiError { constructor( message: string, public readonly retryAfter?: number, statusCode: number = 429, code: number = 429 ) { super(message, statusCode, 'rate_limit_error', code); this.name = 'RateLimitError'; } } /** * Server error class for 5xx status codes */ export class ServerError extends OpenRouterApiError { constructor(message: string, statusCode: number, code: number) { super(message, statusCode, 'server_error', code); this.name = 'ServerError'; } } /** * Generic API error class for 4xx client errors */ export class ClientError extends OpenRouterApiError { constructor(message: string, statusCode: number, type: string, code: number) { super(message, statusCode, type, code); this.name = 'ClientError'; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/adawalli/nexus'

If you have feedback or need assistance with the MCP directory API, please join our Discord server