Add core utility files
This commit is contained in:
167
src/model-cache.ts
Normal file
167
src/model-cache.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* ModelCache - Caches OpenRouter model data to reduce API calls
|
||||||
|
*/
|
||||||
|
export class ModelCache {
|
||||||
|
private static instance: ModelCache;
|
||||||
|
private models: Record<string, any>;
|
||||||
|
private lastFetchTime: number;
|
||||||
|
private cacheExpiryTime: number; // in milliseconds (1 hour = 3600000)
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.models = {};
|
||||||
|
this.lastFetchTime = 0;
|
||||||
|
this.cacheExpiryTime = 3600000; // 1 hour
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*/
|
||||||
|
public static getInstance(): ModelCache {
|
||||||
|
if (!ModelCache.instance) {
|
||||||
|
ModelCache.instance = new ModelCache();
|
||||||
|
}
|
||||||
|
return ModelCache.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the cache is valid
|
||||||
|
*/
|
||||||
|
public isCacheValid(): boolean {
|
||||||
|
return (
|
||||||
|
Object.keys(this.models).length > 0 &&
|
||||||
|
Date.now() - this.lastFetchTime < this.cacheExpiryTime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store all models
|
||||||
|
*/
|
||||||
|
public setModels(models: any[]): void {
|
||||||
|
this.models = {};
|
||||||
|
for (const model of models) {
|
||||||
|
this.models[model.id] = model;
|
||||||
|
}
|
||||||
|
this.lastFetchTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all cached models
|
||||||
|
*/
|
||||||
|
public getAllModels(): any[] {
|
||||||
|
return Object.values(this.models);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific model by ID
|
||||||
|
*/
|
||||||
|
public getModel(modelId: string): any | null {
|
||||||
|
return this.models[modelId] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model exists
|
||||||
|
*/
|
||||||
|
public hasModel(modelId: string): boolean {
|
||||||
|
return !!this.models[modelId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search models based on criteria
|
||||||
|
*/
|
||||||
|
public searchModels(params: {
|
||||||
|
query?: string;
|
||||||
|
provider?: string;
|
||||||
|
minContextLength?: number;
|
||||||
|
maxContextLength?: number;
|
||||||
|
maxPromptPrice?: number;
|
||||||
|
maxCompletionPrice?: number;
|
||||||
|
capabilities?: {
|
||||||
|
functions?: boolean;
|
||||||
|
tools?: boolean;
|
||||||
|
vision?: boolean;
|
||||||
|
json_mode?: boolean;
|
||||||
|
};
|
||||||
|
limit?: number;
|
||||||
|
}): any[] {
|
||||||
|
let results = this.getAllModels();
|
||||||
|
|
||||||
|
// Apply text search
|
||||||
|
if (params.query) {
|
||||||
|
const query = params.query.toLowerCase();
|
||||||
|
results = results.filter((model) =>
|
||||||
|
model.id.toLowerCase().includes(query) ||
|
||||||
|
(model.description && model.description.toLowerCase().includes(query)) ||
|
||||||
|
(model.provider && model.provider.toLowerCase().includes(query))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by provider
|
||||||
|
if (params.provider) {
|
||||||
|
results = results.filter((model) =>
|
||||||
|
model.provider && model.provider.toLowerCase() === params.provider!.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by context length
|
||||||
|
if (params.minContextLength) {
|
||||||
|
results = results.filter(
|
||||||
|
(model) => model.context_length >= params.minContextLength
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.maxContextLength) {
|
||||||
|
results = results.filter(
|
||||||
|
(model) => model.context_length <= params.maxContextLength
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by price
|
||||||
|
if (params.maxPromptPrice) {
|
||||||
|
results = results.filter(
|
||||||
|
(model) =>
|
||||||
|
!model.pricing?.prompt || model.pricing.prompt <= params.maxPromptPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.maxCompletionPrice) {
|
||||||
|
results = results.filter(
|
||||||
|
(model) =>
|
||||||
|
!model.pricing?.completion ||
|
||||||
|
model.pricing.completion <= params.maxCompletionPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by capabilities
|
||||||
|
if (params.capabilities) {
|
||||||
|
if (params.capabilities.functions) {
|
||||||
|
results = results.filter(
|
||||||
|
(model) => model.capabilities?.function_calling
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params.capabilities.tools) {
|
||||||
|
results = results.filter((model) => model.capabilities?.tools);
|
||||||
|
}
|
||||||
|
if (params.capabilities.vision) {
|
||||||
|
results = results.filter((model) => model.capabilities?.vision);
|
||||||
|
}
|
||||||
|
if (params.capabilities.json_mode) {
|
||||||
|
results = results.filter((model) => model.capabilities?.json_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
if (params.limit && params.limit > 0) {
|
||||||
|
results = results.slice(0, params.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the cache
|
||||||
|
*/
|
||||||
|
public resetCache(): void {
|
||||||
|
this.models = {};
|
||||||
|
this.lastFetchTime = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/openrouter-api.ts
Normal file
130
src/openrouter-api.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||||
|
import { McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for interacting with the OpenRouter API
|
||||||
|
*/
|
||||||
|
export class OpenRouterAPIClient {
|
||||||
|
private apiKey: string;
|
||||||
|
private axiosInstance: AxiosInstance;
|
||||||
|
private retryCount: number = 3;
|
||||||
|
private retryDelay: number = 1000; // Initial delay in ms
|
||||||
|
|
||||||
|
constructor(apiKey: string) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.axiosInstance = axios.create({
|
||||||
|
baseURL: 'https://openrouter.ai/api/v1',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': 'https://github.com/stabgan/openrouter-mcp-multimodal',
|
||||||
|
'X-Title': 'OpenRouter MCP Multimodal Server'
|
||||||
|
},
|
||||||
|
timeout: 60000 // 60 seconds timeout
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available models from OpenRouter
|
||||||
|
*/
|
||||||
|
public async getModels(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const response = await this.axiosInstance.get('/models');
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleRequestError(error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a request to the OpenRouter API with retry functionality
|
||||||
|
*/
|
||||||
|
public async request(endpoint: string, method: string, data?: any): Promise<any> {
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
let retries = 0;
|
||||||
|
|
||||||
|
while (retries <= this.retryCount) {
|
||||||
|
try {
|
||||||
|
const response = await this.axiosInstance.request({
|
||||||
|
url: endpoint,
|
||||||
|
method,
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = this.handleRetryableError(error, retries);
|
||||||
|
retries++;
|
||||||
|
|
||||||
|
if (retries <= this.retryCount) {
|
||||||
|
// Exponential backoff with jitter
|
||||||
|
const delay = this.retryDelay * Math.pow(2, retries - 1) * (0.5 + Math.random() * 0.5);
|
||||||
|
console.error(`Retrying in ${Math.round(delay)}ms (${retries}/${this.retryCount})`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, all retries failed
|
||||||
|
throw lastError || new Error('Request failed after multiple retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle retryable errors
|
||||||
|
*/
|
||||||
|
private handleRetryableError(error: any, retryCount: number): Error {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const axiosError = error as AxiosError;
|
||||||
|
|
||||||
|
// Rate limiting (429) or server errors (5xx)
|
||||||
|
if (axiosError.response?.status === 429 || (axiosError.response?.status && axiosError.response.status >= 500)) {
|
||||||
|
console.error(`Request error (retry ${retryCount}): ${axiosError.message}`);
|
||||||
|
if (axiosError.response?.status === 429) {
|
||||||
|
console.error('Rate limit exceeded. Retrying with backoff...');
|
||||||
|
}
|
||||||
|
return new Error(`OpenRouter API error: ${axiosError.response?.status} ${axiosError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other status codes, don't retry
|
||||||
|
if (axiosError.response) {
|
||||||
|
const responseData = axiosError.response.data as any;
|
||||||
|
const message = responseData?.error?.message || axiosError.message;
|
||||||
|
throw new McpError('RequestFailed', `OpenRouter API error: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network errors should be retried
|
||||||
|
console.error(`Network error (retry ${retryCount}): ${error.message}`);
|
||||||
|
return new Error(`Network error: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle request errors
|
||||||
|
*/
|
||||||
|
private handleRequestError(error: any): never {
|
||||||
|
console.error('Error in OpenRouter API request:', error);
|
||||||
|
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const axiosError = error as AxiosError;
|
||||||
|
|
||||||
|
if (axiosError.response) {
|
||||||
|
const status = axiosError.response.status;
|
||||||
|
const responseData = axiosError.response.data as any;
|
||||||
|
const message = responseData?.error?.message || axiosError.message;
|
||||||
|
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
throw new McpError('Unauthorized', `Authentication error: ${message}`);
|
||||||
|
} else if (status === 429) {
|
||||||
|
throw new McpError('RateLimitExceeded', `Rate limit exceeded: ${message}`);
|
||||||
|
} else {
|
||||||
|
throw new McpError('RequestFailed', `OpenRouter API error (${status}): ${message}`);
|
||||||
|
}
|
||||||
|
} else if (axiosError.request) {
|
||||||
|
throw new McpError('NetworkError', `Network error: ${axiosError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new McpError('UnknownError', `Unknown error: ${error.message || 'No error message'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user