diff --git a/server/src/llm/__tests__/openRouterClient.test.ts b/server/src/llm/__tests__/openRouterClient.test.ts new file mode 100644 index 0000000..860864d --- /dev/null +++ b/server/src/llm/__tests__/openRouterClient.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createOpenRouterClient } from '../openRouterClient.js'; +import type { LlmConfig } from '../../config/llmConfig.js'; + +const mockConfig: LlmConfig = { + apiKey: 'test-key', + model: 'arcee-ai/trinity-large-preview:free', + maxTokens: 200, + temperature: 0.8, + requestsPerMinute: 60, + timeoutMs: 5000, + enabled: true, +}; + +describe('openRouterClient', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('sends correct request to OpenRouter API', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + choices: [{ message: { content: 'Hello world' } }], + }), + }); + + const client = createOpenRouterClient(mockConfig); + const result = await client.complete({ + system: 'You are helpful.', + user: 'Say hello.', + }); + + expect(result).toBe('Hello world'); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://openrouter.ai/api/v1/chat/completions', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Authorization': 'Bearer test-key', + 'Content-Type': 'application/json', + }), + }), + ); + + const body = JSON.parse( + (globalThis.fetch as ReturnType).mock.calls[0][1].body, + ); + expect(body.model).toBe('arcee-ai/trinity-large-preview:free'); + expect(body.max_tokens).toBe(200); + expect(body.temperature).toBe(0.8); + expect(body.messages).toEqual([ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Say hello.' }, + ]); + }); + + it('returns null on HTTP error', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + }); + + const client = createOpenRouterClient(mockConfig); + const result = await client.complete({ + system: 'sys', + user: 'usr', + }); + + expect(result).toBeNull(); + }); + + it('returns null on network error', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + const client = createOpenRouterClient(mockConfig); + const result = await client.complete({ + system: 'sys', + user: 'usr', + }); + + expect(result).toBeNull(); + }); + + it('returns null when response has no choices', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ choices: [] }), + }); + + const client = createOpenRouterClient(mockConfig); + const result = await client.complete({ + system: 'sys', + user: 'usr', + }); + + expect(result).toBeNull(); + }); + + it('allows per-request maxTokens and temperature override', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + choices: [{ message: { content: 'ok' } }], + }), + }); + + const client = createOpenRouterClient(mockConfig); + await client.complete({ + system: 'sys', + user: 'usr', + maxTokens: 50, + temperature: 0.2, + }); + + const body = JSON.parse( + (globalThis.fetch as ReturnType).mock.calls[0][1].body, + ); + expect(body.max_tokens).toBe(50); + expect(body.temperature).toBe(0.2); + }); +}); diff --git a/server/src/llm/openRouterClient.ts b/server/src/llm/openRouterClient.ts new file mode 100644 index 0000000..4050b88 --- /dev/null +++ b/server/src/llm/openRouterClient.ts @@ -0,0 +1,51 @@ +import type { LlmConfig } from '../config/llmConfig.js'; +import type { RenderedPrompt } from './promptTemplate.js'; + +const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; + +export interface CompletionRequest extends RenderedPrompt { + maxTokens?: number; + temperature?: number; +} + +export interface OpenRouterClient { + complete(request: CompletionRequest): Promise; +} + +export function createOpenRouterClient(config: LlmConfig): OpenRouterClient { + return { + async complete(request: CompletionRequest): Promise { + try { + const response = await fetch(OPENROUTER_URL, { + method: 'POST', + signal: AbortSignal.timeout(config.timeoutMs), + headers: { + 'Authorization': `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: config.model, + max_tokens: request.maxTokens ?? config.maxTokens, + temperature: request.temperature ?? config.temperature, + messages: [ + { role: 'system', content: request.system }, + { role: 'user', content: request.user }, + ], + }), + }); + + if (!response.ok) { + console.warn(`OpenRouter API error: ${response.status} ${response.statusText}`); + return null; + } + + const data = await response.json(); + const content = data?.choices?.[0]?.message?.content; + return content ?? null; + } catch (error) { + console.warn('OpenRouter request failed:', (error as Error).message); + return null; + } + }, + }; +}