import axios, { AxiosInstance } from 'axios'; import { Message, LLMResponse, ToolDefinition, ToolCall, StreamChunk } from '../types'; import { ProviderError } from '../utils/errors'; import { logger } from '../utils/logger'; interface OpenRouterUsage { prompt_tokens: number; completion_tokens: number; total_tokens: number; } interface OpenRouterMessage { content: string | null; tool_calls?: ToolCall[]; } interface OpenRouterChoice { message: OpenRouterMessage; finish_reason: string; } interface OpenRouterResponse { choices: OpenRouterChoice[]; usage?: OpenRouterUsage; } function handleProviderError(error: unknown): never { logger.error({ error }, 'OpenRouter API error'); if (axios.isAxiosError(error)) { const errorMessage = (error.response?.data as { error?: { message?: string } })?.error?.message; throw new ProviderError(`OpenRouter API error: ${errorMessage || error.message}`); } throw new ProviderError(`OpenRouter API error: ${(error as Error).message}`); } function buildRequestMessages(messages: Message[]): Record[] { return messages.map((m) => ({ role: m.role, content: m.content, ...(m.name && { name: m.name }), ...(m.tool_calls && { tool_calls: m.tool_calls }), ...(m.tool_call_id && { tool_call_id: m.tool_call_id }), })); } /** * Base class for LLM providers */ export abstract class BaseProvider { protected client: AxiosInstance; protected apiKey: string; protected apiBase: string; constructor(apiKey: string, apiBase?: string) { this.apiKey = apiKey; this.apiBase = apiBase || this.getDefaultApiBase(); this.client = axios.create({ baseURL: this.apiBase, headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', }, timeout: 60000, }); } protected abstract getDefaultApiBase(): string; abstract complete( messages: Message[], model: string, temperature?: number, maxTokens?: number, tools?: ToolDefinition[] ): Promise; abstract stream( messages: Message[], model: string, temperature?: number, maxTokens?: number, tools?: ToolDefinition[] ): AsyncGenerator; } /** * OpenRouter provider */ export class OpenRouterProvider extends BaseProvider { protected getDefaultApiBase(): string { return 'https://openrouter.ai/api/v1'; } private buildRequestData( messages: Message[], model: string, temperature: number, maxTokens: number, tools?: ToolDefinition[], stream = false ): Record { const requestData: Record = { model, messages: buildRequestMessages(messages), temperature, max_tokens: maxTokens, stream, }; if (tools && tools.length > 0) { requestData.tools = tools; } return requestData; } async complete( messages: Message[], model: string, temperature = 0.7, maxTokens = 4096, tools?: ToolDefinition[] ): Promise { try { const requestData = this.buildRequestData(messages, model, temperature, maxTokens, tools); const response = await this.client.post('/chat/completions', requestData); const choice = response.data.choices[0]; const message = choice.message; return { content: message.content || '', toolCalls: message.tool_calls, finishReason: choice.finish_reason, usage: response.data.usage ? { promptTokens: response.data.usage.prompt_tokens, completionTokens: response.data.usage.completion_tokens, totalTokens: response.data.usage.total_tokens, } : undefined, }; } catch (error) { handleProviderError(error); } } async *stream( messages: Message[], model: string, temperature = 0.7, maxTokens = 4096, tools?: ToolDefinition[] ): AsyncGenerator { try { const requestData = this.buildRequestData(messages, model, temperature, maxTokens, tools, true); const response = await this.client.post('/chat/completions', requestData, { responseType: 'stream', timeout: 120000, }); let buffer = ''; for await (const chunk of response.data as AsyncIterable) { buffer += chunk.toString(); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith('data: ')) continue; const payload = trimmed.slice(6); if (payload === '[DONE]') { yield { type: 'done' }; return; } try { const parsed = JSON.parse(payload) as { choices?: Array<{ delta?: { content?: string | null; tool_calls?: Array<{ index: number; id?: string; function?: { name?: string; arguments?: string }; }>; }; finish_reason?: string | null; }>; }; const choice = parsed.choices?.[0]; if (!choice) continue; const delta = choice.delta; if (!delta) continue; if (delta.content) { yield { type: 'delta', content: delta.content }; } if (delta.tool_calls && delta.tool_calls.length > 0) { yield { type: 'tool_calls', toolCalls: delta.tool_calls }; } if (choice.finish_reason) { yield { type: 'done', finishReason: choice.finish_reason }; return; } } catch { // Skip malformed JSON lines } } } } catch (error) { handleProviderError(error); } } }