490 lines
26 KiB
JavaScript
490 lines
26 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import { allowedModelsFromEnv, createProxyApp, DEFAULT_ALLOWED_MODELS, unlimitedKeysFromEnv } from '../src/proxy.js';
|
|
|
|
const response = (body, options = {}) => new Response(body, {
|
|
status: options.status ?? 200,
|
|
headers: options.headers ?? { 'content-type': 'application/json' },
|
|
});
|
|
|
|
describe('proxy', () => {
|
|
const originalAllowedModels = process.env.ALLOWED_MODELS;
|
|
|
|
beforeEach(() => {
|
|
process.env.ALLOWED_MODELS = '';
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalAllowedModels === undefined) delete process.env.ALLOWED_MODELS;
|
|
else process.env.ALLOWED_MODELS = originalAllowedModels;
|
|
});
|
|
|
|
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('normalizes max_completion_tokens for OpenCode Go providers', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response('{"id":"completion-1"}'));
|
|
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
|
|
|
await request(app).post('/oai/v1/chat/completions').send({
|
|
model: 'kimi-k3', messages: [{ role: 'user', content: 'Hi' }],
|
|
max_completion_tokens: 4096, stream: true, stream_options: { include_usage: true },
|
|
}).expect(200);
|
|
|
|
expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({
|
|
model: 'kimi-k3', messages: [{ role: 'user', content: 'Hi' }],
|
|
max_tokens: 4096, stream: true, stream_options: { include_usage: true },
|
|
});
|
|
});
|
|
|
|
it('prefers max_tokens when both token limit field names are supplied', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response('{"id":"completion-1"}'));
|
|
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
|
|
|
await request(app).post('/oai/v1/chat/completions').send({
|
|
model: 'm', max_tokens: 2048, max_completion_tokens: 4096,
|
|
}).expect(200);
|
|
|
|
expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({ model: 'm', max_tokens: 2048 });
|
|
});
|
|
|
|
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_completion_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('bypasses rate checks and spend accounting for configured unlimited keys', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({ id: 'c', cost: 1 })));
|
|
const rateLimiter = { check: vi.fn(() => ({ allowed: false, retryAfter: 1, reason: 'rate' })), recordCost: vi.fn() };
|
|
const app = createProxyApp({ keys: ['key'], fetchImpl, rateLimiter, unlimitedKeys: new Set(['unlimited-key']) });
|
|
|
|
await request(app)
|
|
.post('/oai/v1/chat/completions')
|
|
.set('Authorization', 'Bearer unlimited-key')
|
|
.send({ model: 'm' })
|
|
.expect(200);
|
|
|
|
expect(rateLimiter.check).not.toHaveBeenCalled();
|
|
expect(rateLimiter.recordCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('recognizes x-api-key unlimited keys for Anthropic requests', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
|
|
id: 'c', model: 'm', choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], cost: 1,
|
|
})));
|
|
const rateLimiter = { check: vi.fn(() => ({ allowed: false, retryAfter: 1, reason: 'rate' })), recordCost: vi.fn() };
|
|
const app = createProxyApp({ keys: ['key'], fetchImpl, rateLimiter, unlimitedKeys: new Set(['unlimited-key']) });
|
|
|
|
await request(app)
|
|
.post('/ant/v1/messages')
|
|
.set('x-api-key', 'unlimited-key')
|
|
.send({ model: 'm', max_tokens: 10, messages: [{ role: 'user', content: 'Hi' }] })
|
|
.expect(200);
|
|
|
|
expect(rateLimiter.check).not.toHaveBeenCalled();
|
|
expect(rateLimiter.recordCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('validates the unlimited API key environment variable', () => {
|
|
expect(unlimitedKeysFromEnv('["key-a", "key-b"]')).toEqual(new Set(['key-a', 'key-b']));
|
|
expect(() => unlimitedKeysFromEnv('key-a')).toThrow('UNLIMITED_API_KEYS must be valid JSON');
|
|
expect(() => unlimitedKeysFromEnv('[""]')).toThrow('UNLIMITED_API_KEYS must be a JSON array of non-empty strings');
|
|
});
|
|
|
|
it('validates the allowed models environment variable', () => {
|
|
expect(allowedModelsFromEnv(' model-a,model-b ')).toEqual(new Set(['model-a', 'model-b']));
|
|
expect(allowedModelsFromEnv('')).toBeNull();
|
|
expect(() => allowedModelsFromEnv('model-a,')).toThrow('ALLOWED_MODELS must be a comma-separated list of non-empty model IDs');
|
|
});
|
|
|
|
it('uses the built-in allowlist when ALLOWED_MODELS is unset', () => {
|
|
delete process.env.ALLOWED_MODELS;
|
|
expect(allowedModelsFromEnv()).toEqual(new Set(DEFAULT_ALLOWED_MODELS));
|
|
});
|
|
|
|
it('adds the global context limit and filters model listings for restricted clients', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response('{"data":[{"id":"m"},{"id":"hidden"}]}'));
|
|
const app = createProxyApp({ keys: ['key'], fetchImpl, maxContextTokens: 123, allowedModels: new Set(['m']) });
|
|
const result = await request(app).get('/oai/v1/models').expect(200);
|
|
expect(result.body.data).toHaveLength(1);
|
|
expect(result.body.data[0].id).toBe('m');
|
|
expect(result.body.data[0]).toMatchObject({ context_length: 123, context_window: 123, max_context_tokens: 123 });
|
|
});
|
|
|
|
it('enforces allowed models on every inference API and lets unlimited keys bypass it', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
|
|
id: 'c', model: 'blocked', choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], usage: {},
|
|
})));
|
|
const app = createProxyApp({
|
|
keys: ['key'], fetchImpl, allowedModels: new Set(['allowed']), unlimitedKeys: new Set(['unlimited-key']),
|
|
});
|
|
|
|
await request(app).post('/oai/v1/chat/completions').send({ model: 'blocked' }).expect(400, {
|
|
error: { message: 'Model "blocked" is not allowed', type: 'invalid_request_error' },
|
|
});
|
|
await request(app).post('/oai/v1/responses').send({ model: 'blocked', input: 'Hi' }).expect(400, {
|
|
error: { message: 'Model "blocked" is not allowed', type: 'invalid_request_error' },
|
|
});
|
|
await request(app).post('/ant/v1/messages').send({ model: 'blocked', max_tokens: 1, messages: [] }).expect(400, {
|
|
type: 'error', error: { type: 'invalid_request_error', message: 'Model "blocked" is not allowed' },
|
|
});
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
|
|
await request(app)
|
|
.post('/oai/v1/chat/completions')
|
|
.set('Authorization', 'Bearer unlimited-key')
|
|
.send({ model: 'blocked' })
|
|
.expect(200);
|
|
});
|
|
|
|
it('lets unlimited keys see the complete model list', async () => {
|
|
const fetchImpl = vi.fn().mockImplementation(() => response('{"data":[{"id":"allowed"},{"id":"blocked"}]}'));
|
|
const app = createProxyApp({
|
|
keys: ['key'], fetchImpl, allowedModels: new Set(['allowed']), unlimitedKeys: new Set(['unlimited-key']),
|
|
});
|
|
|
|
const restricted = await request(app).get('/ant/v1/models').expect(200);
|
|
expect(restricted.body.data.map((model) => model.id)).toEqual(['allowed']);
|
|
|
|
const unlimited = await request(app).get('/oai/v1/models').set('x-api-key', 'unlimited-key').expect(200);
|
|
expect(unlimited.body.data.map((model) => model.id)).toEqual(['allowed', 'blocked']);
|
|
});
|
|
|
|
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('collects the request, chat history, and complete streamed response', async () => {
|
|
const stream = 'data: {"choices":[{"delta":{"reasoning_content":"Think. ","content":"Hi"}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n';
|
|
const fetchImpl = vi.fn().mockResolvedValue(response(stream, {
|
|
headers: { 'content-type': 'text/event-stream' },
|
|
}));
|
|
const requestLogger = { log: vi.fn() };
|
|
const app = createProxyApp({ keys: ['key'], fetchImpl, requestLogger });
|
|
const body = { model: 'm', messages: [{ role: 'user', content: 'Hi' }], stream: true };
|
|
|
|
await request(app)
|
|
.post('/oai/v1/chat/completions?source=test')
|
|
.set('X-Real-IP', '198.51.100.10')
|
|
.send(body)
|
|
.expect(200);
|
|
|
|
expect(requestLogger.log).toHaveBeenCalledTimes(1);
|
|
expect(requestLogger.log.mock.calls[0][0]).toMatchObject({
|
|
method: 'POST',
|
|
endpoint: '/oai/v1/chat/completions?source=test',
|
|
requestBody: body,
|
|
responseBody: stream,
|
|
responseStatus: 200,
|
|
model: 'm',
|
|
clientIp: '198.51.100.10',
|
|
stream: true,
|
|
trainingData: { messages: [
|
|
{ role: 'user', content: 'Hi' },
|
|
{ role: 'assistant', content: 'Hi', reasoning_content: 'Think. ' },
|
|
] },
|
|
});
|
|
expect(requestLogger.log.mock.calls[0][0].requestHeaders['content-type']).toMatch('application/json');
|
|
expect(requestLogger.log.mock.calls[0][0].responseHeaders['content-type']).toMatch('text/event-stream');
|
|
expect(requestLogger.log.mock.calls[0][0].durationMs).toBeGreaterThanOrEqual(0);
|
|
expect(requestLogger.log.mock.calls[0][0].trainingData.metadata).toMatchObject({
|
|
schema_version: 1, api: 'openai_chat_completions', model: 'm', stream: true,
|
|
tools: [], response_status: 200, response: { finish_reason: 'stop' },
|
|
});
|
|
});
|
|
|
|
it('does not collect requests to non-chat-completion endpoints', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response('{"object":"list","data":[]}'));
|
|
const requestLogger = { log: vi.fn() };
|
|
const app = createProxyApp({ keys: ['key'], fetchImpl, requestLogger });
|
|
|
|
await request(app).get('/missing').expect(404);
|
|
await request(app).get('/oai/v1/models').expect(200);
|
|
|
|
expect(requestLogger.log).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('collects OpenAI Responses and Anthropic Messages requests', async () => {
|
|
const cases = [
|
|
{ endpoint: '/oai/v1/responses', body: { model: 'm', input: 'Hi' } },
|
|
{ endpoint: '/ant/v1/messages', body: { model: 'm', max_tokens: 10, messages: [{ role: 'user', content: 'Hi' }] } },
|
|
{ endpoint: '/ant/v1/v1/messages', body: { model: 'm', max_tokens: 10, messages: [{ role: 'user', content: 'Hi' }] } },
|
|
];
|
|
|
|
for (const { endpoint, body } of cases) {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
|
|
id: 'chatcmpl-response', model: 'm',
|
|
choices: [{ message: { role: 'assistant', content: 'Hello' }, finish_reason: 'stop' }],
|
|
})));
|
|
const requestLogger = { log: vi.fn() };
|
|
const app = createProxyApp({ keys: ['key'], fetchImpl, requestLogger });
|
|
|
|
await request(app).post(endpoint).send(body).expect(200);
|
|
|
|
expect(requestLogger.log).toHaveBeenCalledOnce();
|
|
expect(requestLogger.log.mock.calls[0][0]).toMatchObject({ method: 'POST', endpoint, requestBody: body });
|
|
}
|
|
});
|
|
|
|
it('translates Responses requests and returns a Responses object', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
|
|
id: 'chatcmpl-response', model: 'm',
|
|
choices: [{ message: { role: 'assistant', content: 'Hello from Responses' }, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: 8, completion_tokens: 3 },
|
|
})));
|
|
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
|
const result = await request(app).post('/oai/v1/responses').send({
|
|
model: 'm', instructions: 'Be brief', max_output_tokens: 20,
|
|
input: [{ role: 'user', content: [{ type: 'input_text', text: 'Hello' }] }],
|
|
}).expect(200);
|
|
|
|
expect(result.body).toMatchObject({ object: 'response', status: 'completed', model: 'm' });
|
|
expect(result.body.output[0].content[0]).toMatchObject({ type: 'output_text', text: 'Hello from Responses' });
|
|
expect(result.body.usage).toEqual({ input_tokens: 8, output_tokens: 3, total_tokens: 11 });
|
|
expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({
|
|
model: 'm', max_tokens: 20,
|
|
messages: [{ role: 'system', content: 'Be brief' }, { role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
|
|
});
|
|
});
|
|
|
|
it('translates Responses streaming events', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response([
|
|
'data: {"id":"c1","model":"m","choices":[{"delta":{"role":"assistant","content":"Hi"}}]}',
|
|
'data: {"choices":[{"delta":{},"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('/oai/v1/responses').send({ model: 'm', input: 'Hi', stream: true }).expect(200);
|
|
expect(result.text).toContain('event: response.created');
|
|
expect(result.text).toContain('event: response.output_text.delta');
|
|
expect(result.text).toContain('"delta":"Hi"');
|
|
expect(result.text).toContain('event: response.completed');
|
|
});
|
|
|
|
it('streams tool calls as completed Responses function_call items', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response([
|
|
'data: {"id":"c-tool","model":"m","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-9","function":{"name":"read_file","arguments":"{\\"path\\":"}}]}}]}',
|
|
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"main.py\\"}"}}]}}]}',
|
|
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
|
|
'data: [DONE]', '',
|
|
].join('\n\n'), { headers: { 'content-type': 'text/event-stream' } }));
|
|
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
|
const result = await request(app).post('/oai/v1/responses').send({ model: 'm', input: 'explore', stream: true }).expect(200);
|
|
expect(result.text).toContain('event: response.output_item.added');
|
|
expect(result.text).toContain('"name":"read_file"');
|
|
expect(result.text).toContain('event: response.function_call_arguments.delta');
|
|
expect(result.text).toContain('event: response.function_call_arguments.done');
|
|
expect(result.text).toContain('"arguments":"{\\"path\\":\\"main.py\\"}"');
|
|
expect(result.text).toContain('event: response.output_item.done');
|
|
const completed = result.text.split('\n\n').find((line) => line.startsWith('event: response.completed'));
|
|
expect(completed).toContain('"type":"function_call"');
|
|
expect(completed).toContain('"call_id":"call-9"');
|
|
});
|
|
|
|
it('streams reasoning content as Responses reasoning events', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response([
|
|
'data: {"id":"c-reason","model":"m","choices":[{"delta":{"reasoning_content":"think first "}}]}',
|
|
'data: {"choices":[{"delta":{"reasoning_content":"then answer"}}]}',
|
|
'data: {"choices":[{"delta":{"content":"done"},"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('/oai/v1/responses').send({ model: 'm', input: 'Hi', stream: true }).expect(200);
|
|
expect(result.text).toContain('event: response.reasoning_summary_text.delta');
|
|
expect(result.text).toContain('"delta":"think first "');
|
|
expect(result.text).toContain('"delta":"then answer"');
|
|
expect(result.text).toContain('event: response.reasoning_summary_text.done');
|
|
expect(result.text).toContain('"output_index":1');
|
|
});
|
|
|
|
it('accepts nested Chat Completions function tools from Codex', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
|
|
id: 'chatcmpl-tool', choices: [{ message: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }], usage: {},
|
|
})));
|
|
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
|
await request(app).post('/oai/v1/responses').send({
|
|
model: 'm', input: 'lookup', tools: [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object' } } }],
|
|
}).expect(200);
|
|
expect(JSON.parse(fetchImpl.mock.calls[0][1].body).tools).toEqual([{
|
|
type: 'function', function: { name: 'lookup', parameters: { type: 'object' } },
|
|
}]);
|
|
});
|
|
|
|
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',
|
|
});
|
|
});
|
|
});
|