5c9edbde3c
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
163 lines
4.6 KiB
TypeScript
163 lines
4.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { createLlmService } from '../llmService.js';
|
|
|
|
describe('llmService', () => {
|
|
let originalFetch: typeof globalThis.fetch;
|
|
|
|
beforeEach(() => {
|
|
originalFetch = globalThis.fetch;
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
delete process.env.OPENROUTER_API_KEY;
|
|
delete process.env.LLM_MODEL;
|
|
delete process.env.LLM_FALLBACK_MODEL;
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
function setupEnv(opts?: { fallback?: string }) {
|
|
process.env.OPENROUTER_API_KEY = 'test-key';
|
|
process.env.LLM_MODEL = 'free/model:free';
|
|
if (opts?.fallback) {
|
|
process.env.LLM_FALLBACK_MODEL = opts.fallback;
|
|
}
|
|
}
|
|
|
|
function mockFetchOk(content: string) {
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
choices: [{ message: { content } }],
|
|
}),
|
|
});
|
|
}
|
|
|
|
function mockFetch429(resetAt?: number) {
|
|
const metadata = resetAt != null
|
|
? { headers: { 'X-RateLimit-Reset': String(resetAt) } }
|
|
: undefined;
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 429,
|
|
statusText: 'Too Many Requests',
|
|
json: () => Promise.resolve({
|
|
error: { code: 429, message: 'Rate limit exceeded', metadata },
|
|
}),
|
|
});
|
|
}
|
|
|
|
it('returns null when LLM is disabled', async () => {
|
|
delete process.env.OPENROUTER_API_KEY;
|
|
const service = createLlmService();
|
|
const result = await service.generate('backstory', { npcName: 'Test' });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('generates text using primary model', async () => {
|
|
setupEnv();
|
|
mockFetchOk('A brave warrior.');
|
|
|
|
const service = createLlmService();
|
|
const result = await service.generate('backstory', {
|
|
npcName: 'Brynn',
|
|
stats: 'STR:15',
|
|
});
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
expect(result).toBe('A brave warrior.');
|
|
});
|
|
|
|
it('switches to fallback model on 429', async () => {
|
|
setupEnv({ fallback: 'paid/model' });
|
|
const resetAt = Date.now() + 3600000; // 1 hour from now
|
|
|
|
// First call hits 429
|
|
mockFetch429(resetAt);
|
|
const service = createLlmService();
|
|
const result1 = await service.generate('backstory', {
|
|
npcName: 'Brynn',
|
|
stats: 'STR:15',
|
|
});
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(result1).toBeNull(); // The 429 request itself returns null
|
|
|
|
// Second call should use fallback model
|
|
mockFetchOk('Fallback response.');
|
|
const result2 = await service.generate('backstory', {
|
|
npcName: 'Brynn',
|
|
stats: 'STR:15',
|
|
});
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(result2).toBe('Fallback response.');
|
|
|
|
// Verify the model sent was the fallback
|
|
const body = JSON.parse(
|
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body,
|
|
);
|
|
expect(body.model).toBe('paid/model');
|
|
});
|
|
|
|
it('switches back to primary model after reset time', async () => {
|
|
setupEnv({ fallback: 'paid/model' });
|
|
const resetAt = Date.now() + 5000; // 5 seconds from now
|
|
|
|
// Hit 429
|
|
mockFetch429(resetAt);
|
|
const service = createLlmService();
|
|
await service.generate('backstory', { npcName: 'X', stats: 'S:1' });
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
expect(service.activeModel()).toBe('paid/model');
|
|
|
|
// Advance past reset time
|
|
await vi.advanceTimersByTimeAsync(6000);
|
|
|
|
expect(service.activeModel()).toBe('free/model:free');
|
|
});
|
|
|
|
it('returns null on 429 when no fallback model configured', async () => {
|
|
setupEnv(); // no fallback
|
|
mockFetch429(Date.now() + 3600000);
|
|
|
|
const service = createLlmService();
|
|
const result = await service.generate('backstory', {
|
|
npcName: 'X',
|
|
stats: 'S:1',
|
|
});
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('tracks usage per model', async () => {
|
|
setupEnv({ fallback: 'paid/model' });
|
|
mockFetchOk('ok');
|
|
|
|
const service = createLlmService();
|
|
await service.generate('backstory', { npcName: 'X', stats: 'S:1' });
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
const stats = service.usageStats();
|
|
expect(stats['free/model:free']?.total).toBe(1);
|
|
});
|
|
|
|
it('exposes queue depth and clear', async () => {
|
|
setupEnv();
|
|
mockFetchOk('ok');
|
|
|
|
const service = createLlmService();
|
|
expect(service.queueDepth()).toBe(0);
|
|
service.clear();
|
|
});
|
|
|
|
it('exposes activeModel', () => {
|
|
setupEnv({ fallback: 'paid/model' });
|
|
mockFetchOk('ok');
|
|
|
|
const service = createLlmService();
|
|
expect(service.activeModel()).toBe('free/model:free');
|
|
});
|
|
});
|