200 lines
6.4 KiB
TypeScript
200 lines
6.4 KiB
TypeScript
/**
|
|
* Tests for the shared OpenRouter API helper.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { callOpenRouter, parseJsonContent, type JsonSchemaDefinition } from './openrouter.js';
|
|
|
|
// Mock fetch globally
|
|
const originalFetch = global.fetch;
|
|
|
|
const testSchema: JsonSchemaDefinition = {
|
|
name: 'test_schema',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
value: { type: 'string', description: 'A test value' },
|
|
count: { type: 'integer', description: 'A test count' },
|
|
},
|
|
required: ['value', 'count'],
|
|
additionalProperties: false,
|
|
},
|
|
};
|
|
|
|
describe('callOpenRouter', () => {
|
|
beforeEach(() => {
|
|
process.env.OPENROUTER_API_KEY = 'test-api-key';
|
|
global.fetch = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.OPENROUTER_API_KEY;
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('should return error when API key is not set', async () => {
|
|
delete process.env.OPENROUTER_API_KEY;
|
|
|
|
const result = await callOpenRouter({
|
|
model: 'test-model',
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
jsonSchema: testSchema,
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) {
|
|
expect(result.error).toContain('API_KEY');
|
|
}
|
|
});
|
|
|
|
it('should return parsed data on successful response', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: JSON.stringify({ value: 'hello', count: 42 }) } }],
|
|
}),
|
|
} as Response);
|
|
|
|
const result = await callOpenRouter<{ value: string; count: number }>({
|
|
model: 'test-model',
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
jsonSchema: testSchema,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.value).toBe('hello');
|
|
expect(result.data.count).toBe(42);
|
|
}
|
|
});
|
|
|
|
it('should handle API errors gracefully', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
text: async () => 'Internal Server Error',
|
|
} as Response);
|
|
|
|
const result = await callOpenRouter({
|
|
model: 'test-model',
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
jsonSchema: testSchema,
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) {
|
|
expect(result.error).toContain('500');
|
|
}
|
|
});
|
|
|
|
it('should handle empty response content', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: '' } }],
|
|
}),
|
|
} as Response);
|
|
|
|
const result = await callOpenRouter({
|
|
model: 'test-model',
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
jsonSchema: testSchema,
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) {
|
|
expect(result.error).toContain('No content');
|
|
}
|
|
});
|
|
|
|
it('should include json_schema in request body', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: '{"value": "test", "count": 1}' } }],
|
|
}),
|
|
} as Response);
|
|
|
|
await callOpenRouter({
|
|
model: 'test-model',
|
|
messages: [{ role: 'user', content: 'test prompt' }],
|
|
jsonSchema: testSchema,
|
|
});
|
|
|
|
const fetchCall = vi.mocked(global.fetch).mock.calls[0];
|
|
const body = JSON.parse(fetchCall[1]?.body as string);
|
|
|
|
expect(body.response_format.type).toBe('json_schema');
|
|
expect(body.response_format.json_schema.name).toBe('test_schema');
|
|
expect(body.response_format.json_schema.strict).toBe(true);
|
|
});
|
|
|
|
it('should retry on parsing failures when maxRetries is set', async () => {
|
|
let callCount = 0;
|
|
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
callCount++;
|
|
if (callCount < 3) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: 'invalid json' } }],
|
|
}),
|
|
} as Response;
|
|
}
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: '{"value": "success", "count": 3}' } }],
|
|
}),
|
|
} as Response;
|
|
});
|
|
|
|
// Suppress console output during test
|
|
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
|
|
const result = await callOpenRouter<{ value: string; count: number }>({
|
|
model: 'test-model',
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
jsonSchema: testSchema,
|
|
maxRetries: 2,
|
|
retryDelayMs: 10, // Fast retries for tests
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.value).toBe('success');
|
|
}
|
|
expect(callCount).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('parseJsonContent', () => {
|
|
it('should parse clean JSON', () => {
|
|
const result = parseJsonContent<{ foo: string }>('{"foo": "bar"}');
|
|
expect(result.foo).toBe('bar');
|
|
});
|
|
|
|
it('should handle markdown code fences', () => {
|
|
const result = parseJsonContent<{ foo: string }>('```json\n{"foo": "bar"}\n```');
|
|
expect(result.foo).toBe('bar');
|
|
});
|
|
|
|
it('should handle json without language specifier', () => {
|
|
const result = parseJsonContent<{ foo: string }>('```\n{"foo": "bar"}\n```');
|
|
expect(result.foo).toBe('bar');
|
|
});
|
|
|
|
it('should extract JSON from surrounding text', () => {
|
|
const result = parseJsonContent<{ foo: string }>('Here is the result: {"foo": "bar"} as requested.');
|
|
expect(result.foo).toBe('bar');
|
|
});
|
|
|
|
it('should throw on completely invalid content', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
expect(() => parseJsonContent('not json at all')).toThrow();
|
|
});
|
|
});
|