import { Logger } from '@nestjs/common'; import OpenAI from 'openai'; import type { CompletionEvent, CompletionParams, IProviderAdapter, ModelInfo, ProviderHealth, } from '@mosaicstack/types'; const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; interface OpenRouterModel { id: string; name?: string; context_length?: number; top_provider?: { max_completion_tokens?: number; }; pricing?: { prompt?: string | number; completion?: string | number; }; architecture?: { input_modalities?: string[]; }; } interface OpenRouterModelsResponse { data?: OpenRouterModel[]; } /** * OpenRouter provider adapter. * * Routes completions through OpenRouter's OpenAI-compatible API. * Configuration is driven by the OPENROUTER_API_KEY environment variable. */ export class OpenRouterAdapter implements IProviderAdapter { readonly name = 'openrouter'; private readonly logger = new Logger(OpenRouterAdapter.name); private client: OpenAI | null = null; private registeredModels: ModelInfo[] = []; async register(): Promise { const apiKey = process.env['OPENROUTER_API_KEY']; if (!apiKey) { this.logger.debug('Skipping OpenRouter provider registration: OPENROUTER_API_KEY not set'); return; } this.client = new OpenAI({ apiKey, baseURL: OPENROUTER_BASE_URL, defaultHeaders: { 'HTTP-Referer': 'https://mosaic.ai', 'X-Title': 'Mosaic', }, }); try { this.registeredModels = await this.fetchModels(apiKey); this.logger.log(`OpenRouter provider registered with ${this.registeredModels.length} models`); } catch (err) { this.logger.warn( `OpenRouter model discovery failed: ${err instanceof Error ? err.message : String(err)}. Registering with empty model list.`, ); this.registeredModels = []; } } listModels(): ModelInfo[] { return this.registeredModels; } async healthCheck(): Promise { const apiKey = process.env['OPENROUTER_API_KEY']; if (!apiKey) { return { status: 'down', lastChecked: new Date().toISOString(), error: 'OPENROUTER_API_KEY not configured', }; } const start = Date.now(); try { const res = await fetch(`${OPENROUTER_BASE_URL}/models`, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', }, signal: AbortSignal.timeout(5000), }); const latencyMs = Date.now() - start; if (!res.ok) { return { status: 'degraded', latencyMs, lastChecked: new Date().toISOString(), error: `HTTP ${res.status}`, }; } return { status: 'healthy', latencyMs, lastChecked: new Date().toISOString() }; } catch (err) { const latencyMs = Date.now() - start; const error = err instanceof Error ? err.message : String(err); return { status: 'down', latencyMs, lastChecked: new Date().toISOString(), error }; } } /** * Stream a completion through OpenRouter's OpenAI-compatible API. */ async *createCompletion(params: CompletionParams): AsyncIterable { if (!this.client) { throw new Error('OpenRouterAdapter is not initialized. Ensure OPENROUTER_API_KEY is set.'); } const stream = await this.client.chat.completions.create({ model: params.model, messages: params.messages.map((m) => ({ role: m.role, content: m.content })), temperature: params.temperature, max_tokens: params.maxTokens, stream: true, }); let inputTokens = 0; let outputTokens = 0; for await (const chunk of stream) { const choice = chunk.choices[0]; if (!choice) continue; const delta = choice.delta; if (delta.content) { yield { type: 'text_delta', content: delta.content }; } if (choice.finish_reason === 'stop') { const usage = (chunk as { usage?: { prompt_tokens?: number; completion_tokens?: number } }) .usage; if (usage) { inputTokens = usage.prompt_tokens ?? 0; outputTokens = usage.completion_tokens ?? 0; } } } yield { type: 'done', usage: { inputTokens, outputTokens }, }; } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- private async fetchModels(apiKey: string): Promise { const res = await fetch(`${OPENROUTER_BASE_URL}/models`, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', }, signal: AbortSignal.timeout(10000), }); if (!res.ok) { throw new Error(`OpenRouter models endpoint returned HTTP ${res.status}`); } const json = (await res.json()) as OpenRouterModelsResponse; const data = json.data ?? []; return data.map((model): ModelInfo => { const inputPrice = model.pricing?.prompt ? parseFloat(String(model.pricing.prompt)) * 1000 : 0; const outputPrice = model.pricing?.completion ? parseFloat(String(model.pricing.completion)) * 1000 : 0; const inputModalities = model.architecture?.input_modalities ?? ['text']; const inputTypes = inputModalities.includes('image') ? (['text', 'image'] as const) : (['text'] as const); return { id: model.id, provider: 'openrouter', name: model.name ?? model.id, reasoning: false, contextWindow: model.context_length ?? 4096, maxTokens: model.top_provider?.max_completion_tokens ?? 4096, inputTypes: [...inputTypes], cost: { input: inputPrice, output: outputPrice, cacheRead: 0, cacheWrite: 0, }, }; }); } }