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', }); }); });