Files
opencode-proxy/test/proxy.test.js
T
2026-07-21 01:31:53 -05:00

206 lines
12 KiB
JavaScript

import { describe, expect, it, vi } from 'vitest';
import request from 'supertest';
import { createProxyApp } from '../src/proxy.js';
const response = (body, options = {}) => new Response(body, {
status: options.status ?? 200,
headers: options.headers ?? { 'content-type': 'application/json' },
});
describe('proxy', () => {
it('forwards models and rotates credentials', async () => {
const fetchImpl = vi.fn()
.mockResolvedValueOnce(response('{"object":"list","data":[]}'))
.mockResolvedValueOnce(response('{"object":"list","data":[]}'));
const app = createProxyApp({ keys: ['key-a', 'key-b'], fetchImpl, upstreamBaseUrl: 'https://upstream.test/v1' });
await request(app).get('/oai/v1/models').expect(200);
await request(app).get('/oai/v1/models').expect(200);
expect(fetchImpl.mock.calls[0][1].headers.authorization).toBe('Bearer key-a');
expect(fetchImpl.mock.calls[1][1].headers.authorization).toBe('Bearer key-b');
});
it('tries each key until one succeeds', async () => {
const fetchImpl = vi.fn()
.mockResolvedValueOnce(response('{"error":"bad key"}', { status: 401 }))
.mockResolvedValueOnce(response('{"error":"rate limited"}', { status: 429 }))
.mockResolvedValueOnce(response('{"object":"list","data":[]}'));
const app = createProxyApp({ keys: ['key-a', 'key-b', 'key-c'], fetchImpl });
await request(app).get('/oai/v1/models').expect(200);
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(fetchImpl.mock.calls.map(([_, options]) => options.headers.authorization)).toEqual([
'Bearer key-a',
'Bearer key-b',
'Bearer key-c',
]);
});
it('returns the final failure after all keys are exhausted', async () => {
const fetchImpl = vi.fn()
.mockResolvedValueOnce(response('{"error":"bad key"}', { status: 401 }))
.mockResolvedValueOnce(response('{"error":"still bad"}', { status: 401 }));
const app = createProxyApp({ keys: ['key-a', 'key-b'], fetchImpl });
await request(app).get('/oai/v1/models').expect(401, '{"error":"still bad"}');
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it('forwards non-streaming chat completions', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response('{"id":"completion-1"}'));
const app = createProxyApp({ keys: ['key'], fetchImpl });
const body = { model: 'kimi-k3', messages: [{ role: 'user', content: 'Hi' }] };
const result = await request(app).post('/oai/v1/chat/completions').send(body).expect(200);
expect(result.body).toEqual({ id: 'completion-1' });
expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual(body);
});
it('enforces the configured context and IP limits and records cost', async () => {
const fetchImpl = vi.fn()
.mockResolvedValueOnce(response(JSON.stringify({ id: 'c', cost: 0 })))
.mockResolvedValue(response(JSON.stringify({ id: 'c', cost: 10 })));
const app = createProxyApp({ keys: ['key'], fetchImpl, maxContextTokens: 100 });
await request(app).post('/oai/v1/chat/completions').set('X-Real-IP', '198.51.100.1').send({ model: 'm', max_tokens: 101 }).expect(400);
await request(app).post('/oai/v1/chat/completions').set('X-Real-IP', '198.51.100.1').send({ model: 'm', max_tokens: 1 }).expect(200);
await request(app).post('/oai/v1/chat/completions').set('X-Real-IP', '198.51.100.1').send({ model: 'm', max_tokens: 1 }).expect(200);
await request(app).post('/oai/v1/chat/completions').set('X-Real-IP', '198.51.100.1').send({ model: 'm', max_tokens: 1 }).expect(429);
});
it('adds the global context limit to model listings', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response('{"data":[{"id":"m"}]}'));
const app = createProxyApp({ keys: ['key'], fetchImpl, maxContextTokens: 123 });
const result = await request(app).get('/oai/v1/models').expect(200);
expect(result.body.data[0]).toMatchObject({ context_length: 123, context_window: 123, max_context_tokens: 123 });
});
it('passes through streaming responses', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response('data: {"delta":"Hi"}\n\ndata: [DONE]\n\n', {
headers: { 'content-type': 'text/event-stream' },
}));
const app = createProxyApp({ keys: ['key'], fetchImpl });
const result = await request(app).post('/oai/v1/chat/completions').send({ stream: true }).expect(200);
expect(result.text).toContain('data: [DONE]');
expect(fetchImpl.mock.calls[0][1].headers.accept).toBe('text/event-stream');
});
it('propagates upstream errors', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response('{"error":"bad key"}', { status: 401 }));
const app = createProxyApp({ keys: ['key'], fetchImpl });
await request(app).get('/oai/v1/models').expect(401, '{"error":"bad key"}');
});
it('translates Anthropic messages to chat completions and back', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
id: 'chatcmpl-1', model: 'kimi-k3',
choices: [{ message: { role: 'assistant', content: 'Hello back' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 12, completion_tokens: 4 },
})));
const app = createProxyApp({ keys: ['key'], fetchImpl });
const body = {
model: 'kimi-k3', max_tokens: 100, system: 'Be concise',
messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
};
const result = await request(app).post('/ant/v1/messages').send(body).expect(200);
expect(result.body).toMatchObject({ type: 'message', role: 'assistant', model: 'kimi-k3', stop_reason: 'end_turn' });
expect(result.body.content).toEqual([{ type: 'text', text: 'Hello back' }]);
expect(result.body.usage).toEqual({ input_tokens: 12, output_tokens: 4 });
expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({
model: 'kimi-k3', max_tokens: 100,
messages: [{ role: 'system', content: 'Be concise' }, { role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
});
});
it('translates Anthropic tool requests', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
id: 'chatcmpl-2', choices: [{ message: { role: 'assistant', content: null, tool_calls: [{ id: 'call-1', function: { name: 'lookup', arguments: '{"q":"x"}' } }] }, finish_reason: 'tool_calls' }], usage: {},
})));
const app = createProxyApp({ keys: ['key'], fetchImpl });
const body = { model: 'm', max_tokens: 20, messages: [{ role: 'user', content: 'Find x' }], tools: [{ name: 'lookup', description: 'Look up', input_schema: { type: 'object' } }], tool_choice: { type: 'any' } };
const result = await request(app).post('/ant/v1/messages').send(body).expect(200);
const upstream = JSON.parse(fetchImpl.mock.calls[0][1].body);
expect(upstream.tools[0].function.name).toBe('lookup');
expect(upstream.tool_choice).toBe('required');
expect(result.body.content).toEqual([{ type: 'tool_use', id: 'call-1', name: 'lookup', input: { q: 'x' } }]);
expect(result.body.stop_reason).toBe('tool_use');
});
it('translates Anthropic streaming events', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response('data: {"id":"c1","model":"m","choices":[{"delta":{"role":"assistant","content":"Hi"}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', { headers: { 'content-type': 'text/event-stream' } }));
const app = createProxyApp({ keys: ['key'], fetchImpl });
const result = await request(app).post('/ant/v1/messages').send({ model: 'm', max_tokens: 10, stream: true, messages: [{ role: 'user', content: 'Hi' }] }).expect(200);
expect(result.text).toContain('event: message_start');
expect(result.text).toContain('event: content_block_delta');
expect(result.text).toContain('"text":"Hi"');
expect(result.text).toContain('event: message_stop');
});
it('translates OpenAI reasoning_content into Anthropic thinking events', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response([
'data: {"id":"c2","model":"m","choices":[{"delta":{"role":"assistant","reasoning_content":"thinking "}}]}',
'data: {"choices":[{"delta":{"reasoning_content":"more"}}]}',
'data: {"choices":[{"delta":{"content":"answer"},"finish_reason":"stop"}]}',
'data: [DONE]',
'',
].join('\n\n'), { headers: { 'content-type': 'text/event-stream' } }));
const app = createProxyApp({ keys: ['key'], fetchImpl });
const result = await request(app).post('/ant/v1/messages').send({ model: 'm', max_tokens: 100, stream: true, messages: [{ role: 'user', content: 'Hi' }] }).expect(200);
expect(result.text).toContain('"type":"thinking"');
expect(result.text).toContain('"type":"thinking_delta","thinking":"thinking "');
expect(result.text).toContain('"index":1');
expect(result.text).toContain('"type":"text_delta","text":"answer"');
});
it('returns Anthropic model listings and validates requests', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({ data: [{ id: 'm1', name: 'Model One', created: 1700000000 }] })));
const app = createProxyApp({ keys: ['key'], fetchImpl });
const models = await request(app).get('/ant/v1/models').expect(200);
expect(models.body.data[0]).toMatchObject({ id: 'm1', display_name: 'Model One', type: 'model' });
await request(app).post('/ant/v1/messages').send({ model: 'm', messages: [] }).expect(400, {
type: 'error', error: { type: 'invalid_request_error', message: 'max_tokens is required' },
});
});
it('supports Anthropic SDKs that append /v1 to the configured base URL', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
id: 'chatcmpl-sdk', model: 'm', choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], usage: {},
})));
const app = createProxyApp({ keys: ['key'], fetchImpl });
await request(app).post('/ant/v1/v1/messages').send({ model: 'm', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] }).expect(200);
});
it('normalizes system, developer, and tool message roles', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({ id: 'c', choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], usage: {} })));
const app = createProxyApp({ keys: ['key'], fetchImpl });
await request(app).post('/ant/v1/messages').send({
model: 'm', max_tokens: 100,
messages: [
{ role: 'system', content: 'system text' },
{ role: 'developer', content: 'developer text' },
{ role: 'tool', tool_call_id: 'call-1', content: 'tool result' },
{ role: 'user', content: 'hi' },
],
}).expect(200);
expect(JSON.parse(fetchImpl.mock.calls[0][1].body).messages.map(({ role }) => role)).toEqual(['system', 'system', 'tool', 'user']);
});
it('accepts thinking blocks from prior assistant turns', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({ id: 'c3', choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], usage: {} })));
const app = createProxyApp({ keys: ['key'], fetchImpl });
await request(app).post('/ant/v1/messages').send({
model: 'm', max_tokens: 100,
messages: [
{ role: 'assistant', content: [{ type: 'thinking', thinking: 'prior reasoning', signature: 'opaque' }, { type: 'text', text: 'prior answer' }] },
{ role: 'user', content: 'continue' },
],
}).expect(200);
expect(JSON.parse(fetchImpl.mock.calls[0][1].body).messages[0]).toEqual({
role: 'assistant', content: [{ type: 'text', text: 'prior answer' }], reasoning_content: 'prior reasoning',
});
});
});