UNPKG

openrouter-model-picker

Version:

Third-party React component for OpenRouter model selection

480 lines (414 loc) 16.1 kB
import { OpenRouterResponse, ModelInfo, OpenRouterModel } from '../types' const DEFAULT_API_ENDPOINT = 'https://openrouter.ai/api/v1/models' const CHAT_COMPLETION_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions' export class ApiClient { private endpoint: string private cache: ModelInfo[] | null = null private cacheTimestamp: number = 0 private readonly CACHE_DURATION = 5 * 60 * 1000 // 5 minutes constructor(endpoint?: string) { this.endpoint = endpoint || DEFAULT_API_ENDPOINT } async fetchModels(forceRefresh = false): Promise<ModelInfo[]> { // Return cached data if still valid if (!forceRefresh && this.cache && Date.now() - this.cacheTimestamp < this.CACHE_DURATION) { return this.cache } try { const headers: Record<string, string> = { 'Content-Type': 'application/json' } const response = await fetch(this.endpoint, { headers }) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } const data: OpenRouterResponse = await response.json() const models = this.filterAvailableModels(data.data).map(model => this.transformModel(model)) // Cache the results this.cache = models this.cacheTimestamp = Date.now() return models } catch (error) { console.error('Failed to fetch models from OpenRouter:', error) // Return cached data if available, otherwise fallback models if (this.cache) { return this.cache } return this.getFallbackModels() } } /** * Test a model with an actual API call */ async testModel(modelId: string, apiKey: string, testMessage: string = "Hello! Please respond with a brief greeting."): Promise<{ success: boolean response?: string error?: string usage?: { prompt_tokens: number completion_tokens: number total_tokens: number } }> { if (!apiKey) { return { success: false, error: 'API key is required for testing models' } } try { const response = await fetch(CHAT_COMPLETION_ENDPOINT, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: modelId, messages: [ { role: 'user', content: testMessage } ], max_tokens: 100, // Keep it short for testing temperature: 0.7 }) }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) return { success: false, error: `HTTP ${response.status}: ${errorData.error?.message || response.statusText}` } } const data = await response.json() return { success: true, response: data.choices?.[0]?.message?.content || 'No response content', usage: data.usage } } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' } } } /** * Filter out models with known availability issues */ private filterAvailableModels(models: OpenRouterModel[]): OpenRouterModel[] { return models.filter(model => { // Filter out models with zero or invalid context length if (!model.context_length || model.context_length <= 0) { console.debug(`Filtered out model ${model.id}: invalid context length (${model.context_length})`) return false } // Enhanced deprecated model detection const isDeprecated = this.isModelDeprecated(model) if (isDeprecated) { console.debug(`Filtered out model ${model.id}: deprecated`) return false } // Filter out models with no capacity limits (often indicates unavailability) if (model.per_request_limits) { const promptTokens = parseInt(model.per_request_limits.prompt_tokens || '0') const completionTokens = parseInt(model.per_request_limits.completion_tokens || '0') if (promptTokens <= 0 && completionTokens <= 0) { console.debug(`Filtered out model ${model.id}: zero capacity limits`) return false } } // Filter out models with invalid pricing (both prompt and completion are undefined/null) if (!model.pricing || (!model.pricing.prompt && !model.pricing.completion)) { console.debug(`Filtered out model ${model.id}: invalid pricing`) return false } // Filter out models with malformed IDs if (!model.id || model.id.trim() === '') { console.debug(`Filtered out model: empty or invalid ID`) return false } // Filter out known problematic model patterns if (this.isKnownProblematicModel(model.id)) { console.debug(`Filtered out model ${model.id}: known problematic model`) return false } return true }) } /** * Check if a model is deprecated using multiple detection strategies */ private isModelDeprecated(model: OpenRouterModel): boolean { // Check instruct_type for deprecated keyword if (model.architecture?.instruct_type?.toLowerCase().includes('deprecated')) { return true } // Check description for deprecation keywords if (model.description) { const description = model.description.toLowerCase() const deprecationKeywords = [ 'deprecated', 'has been deprecated', 'please switch to', 'no longer supported', 'discontinued', 'replaced by' ] if (deprecationKeywords.some(keyword => description.includes(keyword))) { return true } } // Check for specific known deprecated model patterns const deprecatedPatterns = [ /.*-exp-\d{2}-\d{2}$/, // Experimental models with date endings like "exp-03-25" /.*experimental.*$/i, // Models with "experimental" in the name ] if (deprecatedPatterns.some(pattern => pattern.test(model.id))) { // Additional check: if it's an experimental model AND has $0 pricing, it might be deprecated const inputCost = parseFloat(model.pricing?.prompt || '0') const outputCost = parseFloat(model.pricing?.completion || '0') if (inputCost === 0 && outputCost === 0) { return true } } return false } /** * Check for known problematic model IDs that consistently cause issues */ private isKnownProblematicModel(modelId: string): boolean { const problematicModels = [ 'google/gemini-2.5-pro-exp-03-25', // Known deprecated model causing 404s // Add other known problematic models here as they're discovered ] return problematicModels.includes(modelId) } /** * Categorize models into free and paid */ categorizeModels(models: ModelInfo[]): { freeModels: ModelInfo[], paidModels: ModelInfo[] } { const freeModels: ModelInfo[] = [] const paidModels: ModelInfo[] = [] models.forEach(model => { if (model.costTier === 'free') { freeModels.push(model) } else { paidModels.push(model) } }) return { freeModels, paidModels } } private transformModel(model: OpenRouterModel): ModelInfo { const provider = this.extractProvider(model.id) const costTier = this.calculateCostTier(model.pricing) const features = this.extractFeatures(model) return { id: model.id, // CRITICAL: Use exact model ID without modification name: model.name, provider, costTier, description: model.description || 'No description available', features, pricing: { input: parseFloat(model.pricing.prompt) || 0, output: parseFloat(model.pricing.completion) || 0, currency: 'USD' }, context: model.context_length, multimodal: this.isMultimodal(model), reasoning: this.isReasoningModel(model), streamCancel: this.supportsStreamCancellation(model) } } private extractProvider(modelId: string): string { const parts = modelId.split('/') if (parts.length > 1) { const provider = parts[0] // Capitalize first letter return provider.charAt(0).toUpperCase() + provider.slice(1) } return 'Unknown' } private calculateCostTier(pricing: { prompt: string; completion: string }): 'free' | 'low' | 'medium' | 'high' { const inputCost = parseFloat(pricing.prompt) || 0 const outputCost = parseFloat(pricing.completion) || 0 // Both input and output must be 0 for free tier if (inputCost === 0 && outputCost === 0) return 'free' const avgCost = (inputCost + outputCost) / 2 if (avgCost < 0.000001) return 'low' // < $0.001 per 1K tokens if (avgCost < 0.00001) return 'medium' // < $0.01 per 1K tokens return 'high' } private extractFeatures(model: OpenRouterModel): string[] { const features: string[] = [] if (model.architecture.input_modalities?.includes('image') || model.architecture.modality === 'multimodal') { features.push('Vision') } if (this.isReasoningModel(model)) { features.push('Reasoning') } if (this.supportsStreamCancellation(model)) { features.push('Stream Cancel') } if (model.top_provider.is_moderated) { features.push('Moderated') } if (model.context_length > 100000) { features.push('Long Context') } if (model.architecture.instruct_type) { features.push('Instruct') } // Add free indicator for free models const inputCost = parseFloat(model.pricing.prompt) || 0 const outputCost = parseFloat(model.pricing.completion) || 0 if (inputCost === 0 && outputCost === 0) { features.push('Free') } return features } private isMultimodal(model: OpenRouterModel): boolean { return model.architecture.input_modalities?.includes('image') || model.architecture.modality === 'multimodal' || false } private isReasoningModel(model: OpenRouterModel): boolean { const modelId = model.id?.toLowerCase() || ''; // ⚠️ WARNING: This is pattern-based detection, not an official API field! // // The `internal_reasoning` field in the pricing object indicates the COST PER REASONING TOKEN, // not whether the model supports reasoning. A value of "0" means reasoning tokens are free, // while a non-zero value indicates a cost per token. Many non-reasoning models have this field // present with a value of 0 or null. // // This pattern matching approach: // - May miss new reasoning models with different naming patterns // - Requires manual updates when new reasoning models are released // - Is based on OpenRouter's official documentation as of January 2025 // // TODO: Replace with official API field when OpenRouter provides one // Use conservative pattern matching for known reasoning models based on OpenRouter's official docs: // - DeepSeek R1 models (and derived models) // - Gemini Thinking models // - Anthropic reasoning models // - OpenAI o-series (though they don't return reasoning tokens) // - Grok reasoning models // - Other known reasoning patterns const reasoningPatterns = [ /\bo1(-preview|-mini)?\b/i, // OpenAI o1, o1-preview, o1-mini /\bdeepseek.*r1\b/i, // DeepSeek R1 variants /\bgemini.*thinking\b/i, // Gemini Thinking models /\bclaude.*thinking/i, // Claude thinking variants (e.g., claude-3.7-sonnet:thinking) /anthropic.*thinking/i, // Anthropic thinking models (covers anthropic/claude-3.7-sonnet:thinking) /\bgrok.*reasoning\b/i, // Grok reasoning models /\bminimax.*m1\b/i, // MiniMax M1 reasoning models /\bqwen.*r1\b/i, // Qwen R1 reasoning models /reasoning.*model/i, // Generic reasoning model names /thinking.*model/i // Generic thinking model names ]; return reasoningPatterns.some(pattern => pattern.test(modelId)); } private supportsStreamCancellation(model: OpenRouterModel): boolean { const provider = this.extractProvider(model.id).toLowerCase(); // Based on OpenRouter's official documentation for stream cancellation support // https://openrouter.ai/docs/api-reference/streaming#stream-cancellation const supportedProviders = new Set([ 'openai', 'azure', 'anthropic', 'fireworks', 'mancer', 'recursal', 'anyscale', 'lepton', 'octoai', 'novita', 'deepinfra', 'together', 'cohere', 'hyperbolic', 'infermatic', 'avian', 'xai', 'cloudflare', 'sfcompute', 'nineteen', 'liquid', 'friendli', 'chutes', 'deepseek' ]); return supportedProviders.has(provider); } private getFallbackModels(): ModelInfo[] { return [ { id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini', provider: 'OpenAI', costTier: 'low', description: 'Fast and affordable multimodal model', features: ['Vision', 'Fast'], pricing: { input: 0.00015, output: 0.0006, currency: 'USD' }, context: 128000, multimodal: true, reasoning: false, streamCancel: true // OpenAI supports stream cancellation }, { id: 'openai/o1-preview', name: 'o1-preview', provider: 'OpenAI', costTier: 'high', description: 'Advanced reasoning model with enhanced thinking capabilities', features: ['Reasoning', 'Advanced'], pricing: { input: 0.015, output: 0.06, currency: 'USD' }, context: 128000, multimodal: false, reasoning: true, streamCancel: true // OpenAI supports stream cancellation }, { id: 'openai/gpt-4o', name: 'GPT-4o', provider: 'OpenAI', costTier: 'high', description: 'Most capable multimodal model', features: ['Vision', 'Advanced'], pricing: { input: 0.005, output: 0.015, currency: 'USD' }, context: 128000, multimodal: true, reasoning: false, streamCancel: true // OpenAI supports stream cancellation }, { id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', provider: 'DeepSeek', costTier: 'medium', description: 'Advanced reasoning model with step-by-step thinking', features: ['Reasoning', 'Long Context'], pricing: { input: 0.002, output: 0.008, currency: 'USD' }, context: 200000, multimodal: false, reasoning: true, streamCancel: true // DeepSeek supports stream cancellation }, { id: 'anthropic/claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'Anthropic', costTier: 'medium', description: 'Balanced performance and cost', features: ['Long Context'], pricing: { input: 0.003, output: 0.015, currency: 'USD' }, context: 200000, multimodal: false, reasoning: false, streamCancel: true // Anthropic supports stream cancellation }, // Add some free models to fallback { id: 'meta-llama/llama-3.2-3b-instruct:free', name: 'Llama 3.2 3B Instruct (Free)', provider: 'Meta', costTier: 'free', description: 'Free tier access to Llama 3.2 3B', features: ['Free', 'Instruct'], pricing: { input: 0, output: 0, currency: 'USD' }, context: 131072, multimodal: false, reasoning: false, streamCancel: false // Meta/HuggingFace does not support stream cancellation } ] } clearCache(): void { this.cache = null this.cacheTimestamp = 0 } }