feat(llm): add OpenRouter API client with error handling and timeout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 16:21:26 +00:00
parent 5f5bf24d64
commit cf403250e0
2 changed files with 180 additions and 0 deletions
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0][1].body,
);
expect(body.max_tokens).toBe(50);
expect(body.temperature).toBe(0.2);
});
});
+51
View File
@@ -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<string | null>;
}
export function createOpenRouterClient(config: LlmConfig): OpenRouterClient {
return {
async complete(request: CompletionRequest): Promise<string | null> {
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;
}
},
};
}