inital commit

This commit is contained in:
2026-07-21 01:06:08 -05:00
commit fafc502aeb
10 changed files with 3336 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
keys.txt
npm-debug.log*
.DS_Store
coverage/
+51
View File
@@ -0,0 +1,51 @@
# OpenCode Go Proxy
A small, unauthenticated OpenAI-compatible proxy for OpenCode Go.
## Setup
Requires Node.js 18 or newer.
```sh
npm install
```
Create `keys.txt` in the project root. Put one OpenCode Go API key on each line. Blank lines and comments are ignored; inline comments are supported.
```text
go-first-key
go-second-key # optional comment
# disabled-key
```
Start the proxy:
```sh
npm start
```
The proxy listens at `http://localhost:4005/oai/v1` and `http://localhost:4005/ant/v1`. It does not require authentication from clients. Keys are selected round-robin for each request and are sent upstream as Bearer tokens. If an upstream request fails, the proxy tries each remaining key once before returning the final failure response.
## Endpoints
```sh
curl http://localhost:4005/oai/v1/models
```
```sh
curl http://localhost:4005/oai/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"kimi-k3","messages":[{"role":"user","content":"Hello"}]}'
```
Streaming requests are supported with `"stream":true` and are passed through as Server-Sent Events. The model list is fetched from OpenCode Go, so it can include models whose upstream transport is not OpenAI Chat Completions.
The Anthropic-compatible adapter exposes `POST /ant/v1/messages` and `GET /ant/v1/models`. Messages, tools, JSON responses, and streaming events are translated to and from OpenAI Chat Completions. Anthropic clients should use their normal Messages API request format and can use the OpenCode model IDs returned by the models endpoint.
For Claude Code, either set `ANTHROPIC_BASE_URL=http://localhost:4005/ant` or use the displayed `http://localhost:4005/ant/v1` value; both forms are supported.
## Tests
```sh
npm test
```
+2657
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "opencode-go-proxy",
"version": "1.0.0",
"private": true,
"description": "A small OpenAI-compatible proxy for OpenCode Go.",
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "vitest run"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"express": "^5.1.0"
},
"devDependencies": {
"supertest": "^7.1.1",
"vitest": "^3.2.4"
}
}
+194
View File
@@ -0,0 +1,194 @@
const SUPPORTED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
function anthropicError(message, type = 'invalid_request_error') {
const error = new Error(message);
error.status = 400;
error.body = { type: 'error', error: { type, message } };
return error;
}
function textBlock(block) {
if (typeof block === 'string') return block;
if (block?.type === 'text' && typeof block.text === 'string') return block.text;
throw anthropicError(`Unsupported content block type: ${block?.type || 'unknown'}`);
}
function imageBlock(block) {
const source = block?.source;
if (block?.type !== 'image' || source?.type !== 'base64' || !SUPPORTED_IMAGE_TYPES.has(source.media_type)) {
throw anthropicError('Only base64 JPEG, PNG, GIF, and WebP image blocks are supported');
}
return { type: 'image_url', image_url: { url: `data:${source.media_type};base64,${source.data}` } };
}
function toOpenAIContent(content) {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) throw anthropicError('Message content must be a string or array of content blocks');
return content.map((block) => {
if (block?.type === 'text') return { type: 'text', text: textBlock(block) };
if (block?.type === 'image') return imageBlock(block);
throw anthropicError(`Unsupported content block type: ${block?.type || 'unknown'}`);
});
}
function toOpenAITool(tool) {
if (!tool || tool.type === 'custom' || !tool.name || !tool.input_schema) {
throw anthropicError('Tools must include a name and input_schema');
}
return {
type: 'function',
function: { name: tool.name, description: tool.description, parameters: tool.input_schema },
};
}
export function translateAnthropicRequest(body = {}) {
if (!body.model) throw anthropicError('model is required');
if (!Number.isFinite(body.max_tokens)) throw anthropicError('max_tokens is required');
if (!Array.isArray(body.messages)) throw anthropicError('messages must be an array');
const messages = [];
if (body.system !== undefined) {
messages.push({ role: 'system', content: toOpenAIContent(body.system) });
}
for (const message of body.messages) {
if (message?.role === 'system' || message?.role === 'developer') {
messages.push({ role: 'system', content: toOpenAIContent(message.content) });
continue;
}
if (message?.role === 'tool') {
messages.push({ role: 'tool', tool_call_id: message.tool_call_id || message.tool_use_id, content: toOpenAIContent(message.content || '') });
continue;
}
if (!['user', 'assistant'].includes(message?.role)) throw anthropicError(`Unsupported message role: ${message?.role || 'missing'}`);
if (!Array.isArray(message.content)) {
messages.push({ role: message.role, content: toOpenAIContent(message.content) });
continue;
}
const ordinary = message.content.filter((block) => block?.type !== 'tool_result' && block?.type !== 'tool_use' && block?.type !== 'thinking');
const thinking = message.content.filter((block) => block?.type === 'thinking');
if (thinking.length && message.role !== 'assistant') throw anthropicError('thinking blocks are only valid in assistant messages');
if (ordinary.length || thinking.length) {
const converted = { role: message.role, content: ordinary.length ? toOpenAIContent(ordinary) : null };
if (thinking.length) {
if (thinking.some((block) => typeof block.thinking !== 'string')) throw anthropicError('thinking blocks must include thinking text');
converted.reasoning_content = thinking.map((block) => block.thinking).join('');
}
messages.push(converted);
}
for (const block of message.content) {
if (block.type === 'tool_result') {
if (!block.tool_use_id) throw anthropicError('tool_result must include tool_use_id');
messages.push({ role: 'tool', tool_call_id: block.tool_use_id, content: toOpenAIContent(block.content || '') });
} else if (block.type === 'tool_use') {
if (!block.id || !block.name) throw anthropicError('tool_use must include id and name');
const assistant = messages.at(-1)?.role === 'assistant' ? messages.at(-1) : null;
if (assistant) {
assistant.tool_calls = assistant.tool_calls || [];
assistant.tool_calls.push({ id: block.id, type: 'function', function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) } });
} else {
messages.push({ role: 'assistant', content: null, tool_calls: [{ id: block.id, type: 'function', function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) } }] });
}
}
}
}
const result = {
model: body.model,
messages,
max_tokens: body.max_tokens,
};
for (const field of ['temperature', 'top_p']) if (body[field] !== undefined) result[field] = body[field];
if (body.stop_sequences !== undefined) result.stop = body.stop_sequences;
if (body.stream !== undefined) result.stream = body.stream;
if (body.tools !== undefined) result.tools = body.tools.map(toOpenAITool);
if (body.tool_choice !== undefined) {
if (body.tool_choice.type === 'auto') result.tool_choice = 'auto';
else if (body.tool_choice.type === 'any') result.tool_choice = 'required';
else if (body.tool_choice.type === 'tool' && body.tool_choice.name) result.tool_choice = { type: 'function', function: { name: body.tool_choice.name } };
else throw anthropicError('Unsupported tool_choice');
}
return result;
}
function usageOf(usage = {}) {
return { input_tokens: usage.prompt_tokens ?? 0, output_tokens: usage.completion_tokens ?? 0 };
}
export function translateAnthropicResponse(payload, requestedModel) {
const choice = payload.choices?.[0] || {};
const message = choice.message || {};
const content = [];
if (message.reasoning_content) content.push({ type: 'thinking', thinking: message.reasoning_content });
if (message.content) content.push({ type: 'text', text: message.content });
for (const call of message.tool_calls || []) {
let input = {};
try { input = JSON.parse(call.function?.arguments || '{}'); } catch { input = {}; }
content.push({ type: 'tool_use', id: call.id, name: call.function?.name, input });
}
const finish = choice.finish_reason;
return {
id: payload.id || `msg_${Date.now()}`,
type: 'message', role: 'assistant', model: payload.model || requestedModel,
content, stop_reason: finish === 'tool_calls' ? 'tool_use' : finish === 'length' ? 'max_tokens' : finish === 'stop' ? 'end_turn' : null,
stop_sequence: null, usage: usageOf(payload.usage),
};
}
export function translateAnthropicModels(payload) {
return {
object: 'list',
data: (payload.data || []).map((model) => ({
id: model.id, display_name: model.name || model.id, created_at: new Date((model.created || 0) * 1000 || Date.now()).toISOString(), type: 'model',
})),
has_more: false, first_id: payload.data?.[0]?.id, last_id: payload.data?.at(-1)?.id,
};
}
function event(type, data) { return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; }
export function translateOpenAIChunk(chunk, state) {
const choice = chunk.choices?.[0];
if (!choice) return '';
const delta = choice.delta || {};
let output = '';
if (!state.started) {
state.started = true;
output += event('message_start', { type: 'message_start', message: { id: state.id, type: 'message', role: 'assistant', model: state.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } });
}
if (delta.reasoning_content) {
if (!state.reasoningStarted) {
state.reasoningStarted = true;
state.reasoningIndex = state.nextBlockIndex++;
output += event('content_block_start', { type: 'content_block_start', index: state.reasoningIndex, content_block: { type: 'thinking', thinking: '' } });
}
output += event('content_block_delta', { type: 'content_block_delta', index: state.reasoningIndex, delta: { type: 'thinking_delta', thinking: delta.reasoning_content } });
}
if (delta.content) {
if (!state.contentStarted) {
state.contentStarted = true;
state.contentIndex = state.nextBlockIndex++;
output += event('content_block_start', { type: 'content_block_start', index: state.contentIndex, content_block: { type: 'text', text: '' } });
}
output += event('content_block_delta', { type: 'content_block_delta', index: state.contentIndex, delta: { type: 'text_delta', text: delta.content } });
}
if (delta.tool_calls?.length) {
const call = delta.tool_calls[0];
if (!state.toolStarted) {
state.toolStarted = true;
state.toolIndex = state.nextBlockIndex++;
output += event('content_block_start', { type: 'content_block_start', index: state.toolIndex, content_block: { type: 'tool_use', id: call.id || 'tool_use', name: call.function?.name || '', input: {} } });
}
if (call.function?.arguments) output += event('content_block_delta', { type: 'content_block_delta', index: state.toolIndex, delta: { type: 'input_json_delta', partial_json: call.function.arguments } });
}
if (choice.finish_reason) {
for (const index of [state.reasoningIndex, state.contentIndex, state.toolIndex]) {
if (index !== undefined) output += event('content_block_stop', { type: 'content_block_stop', index });
}
output += event('message_delta', { type: 'message_delta', delta: { stop_reason: choice.finish_reason === 'tool_calls' ? 'tool_use' : choice.finish_reason === 'length' ? 'max_tokens' : 'end_turn', stop_sequence: null }, usage: { output_tokens: chunk.usage?.completion_tokens || 0 } });
output += event('message_stop', { type: 'message_stop' });
}
return output;
}
export { anthropicError, event };
+34
View File
@@ -0,0 +1,34 @@
import fs from 'node:fs/promises';
export function parseKeys(contents) {
return contents
.split(/\r?\n/)
.map((line) => line.replace(/#.*/, '').trim())
.filter(Boolean);
}
export async function loadKeys(path = new URL('../keys.txt', import.meta.url)) {
const contents = await fs.readFile(path, 'utf8');
const keys = parseKeys(contents);
if (keys.length === 0) {
throw new Error(`No usable OpenCode Go API keys found in ${path.pathname ?? path}`);
}
return keys;
}
export function createKeyRotator(keys) {
if (!Array.isArray(keys) || keys.length === 0) {
throw new Error('At least one OpenCode Go API key is required');
}
let index = 0;
return {
next() {
const key = keys[index];
index = (index + 1) % keys.length;
return key;
},
};
}
+160
View File
@@ -0,0 +1,160 @@
import { Readable } from 'node:stream';
import express from 'express';
import { createKeyRotator } from './key-store.js';
import {
anthropicError,
translateAnthropicModels,
translateAnthropicRequest,
translateAnthropicResponse,
translateOpenAIChunk,
} from './anthropic.js';
export const DEFAULT_UPSTREAM_BASE_URL = 'https://opencode.ai/zen/go/v1';
function copyResponseHeaders(upstream, response) {
const contentType = upstream.headers.get('content-type');
const cacheControl = upstream.headers.get('cache-control');
if (contentType) response.set('content-type', contentType);
if (cacheControl) response.set('cache-control', cacheControl);
}
async function forwardError(upstream, response) {
copyResponseHeaders(upstream, response);
const body = await upstream.text();
response.status(upstream.status).send(body || upstream.statusText);
}
export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBaseUrl = DEFAULT_UPSTREAM_BASE_URL } = {}) {
const rotator = createKeyRotator(keys);
const app = express();
app.use(express.json({ limit: '10mb' }));
async function fetchWithKeyRetries(url, options = {}) {
let lastError;
for (let attempt = 0; attempt < keys.length; attempt += 1) {
try {
const upstream = await fetchImpl(url, {
...options,
headers: {
...options.headers,
authorization: `Bearer ${rotator.next()}`,
},
});
if (upstream.ok || attempt === keys.length - 1) return upstream;
lastError = new Error(`OpenCode Go returned ${upstream.status}`);
} catch (error) {
lastError = error;
if (attempt === keys.length - 1) throw error;
}
}
throw lastError;
}
app.get('/oai/v1/models', async (_request, response) => {
try {
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/models`, {
headers: { accept: 'application/json' },
});
if (!upstream.ok) return forwardError(upstream, response);
copyResponseHeaders(upstream, response);
response.status(upstream.status).send(await upstream.text());
} catch (error) {
response.status(502).json({ error: { message: `Unable to reach OpenCode Go: ${error.message}`, type: 'upstream_error' } });
}
});
app.get(['/ant/v1/models', '/ant/v1/v1/models'], async (_request, response) => {
try {
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/models`, { headers: { accept: 'application/json' } });
if (!upstream.ok) return forwardError(upstream, response);
const payload = JSON.parse(await upstream.text());
response.type('application/json').send(JSON.stringify(translateAnthropicModels(payload)));
} catch (error) {
response.status(502).json({ type: 'error', error: { type: 'upstream_error', message: `Unable to reach OpenCode Go: ${error.message}` } });
}
});
app.post('/oai/v1/chat/completions', async (request, response) => {
try {
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: request.body?.stream ? 'text/event-stream' : 'application/json',
},
body: JSON.stringify(request.body),
});
if (!upstream.ok) return forwardError(upstream, response);
copyResponseHeaders(upstream, response);
if (request.body?.stream && upstream.body) {
response.status(upstream.status);
Readable.fromWeb(upstream.body).pipe(response);
return;
}
response.status(upstream.status).send(await upstream.text());
} catch (error) {
if (!response.headersSent) {
response.status(502).json({ error: { message: `Unable to reach OpenCode Go: ${error.message}`, type: 'upstream_error' } });
}
}
});
app.post(['/ant/v1/messages', '/ant/v1/v1/messages'], async (request, response) => {
let translated;
try {
translated = translateAnthropicRequest(request.body);
} catch (error) {
if (error.body) return response.status(error.status || 400).json(error.body);
return response.status(400).json({ type: 'error', error: { type: 'invalid_request_error', message: error.message } });
}
try {
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: translated.stream ? 'text/event-stream' : 'application/json' },
body: JSON.stringify(translated),
});
if (!upstream.ok) return forwardError(upstream, response);
if (!translated.stream) {
const payload = JSON.parse(await upstream.text());
return response.type('application/json').send(JSON.stringify(translateAnthropicResponse(payload, translated.model)));
}
if (!upstream.body) return response.status(502).json({ type: 'error', error: { type: 'upstream_error', message: 'Upstream returned no streaming body' } });
response.status(upstream.status).type('text/event-stream');
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const state = { id: `msg_${Date.now()}`, model: translated.model, nextBlockIndex: 0 };
const writeEvents = (text) => {
for (const eventText of text.split('\n\n')) {
if (!eventText.trim()) continue;
const data = eventText.split('\n').find((line) => line.startsWith('data:'))?.slice(5).trim();
if (!data || data === '[DONE]') continue;
try { response.write(translateOpenAIChunk(JSON.parse(data), state)); } catch { /* ignore malformed upstream event */ }
}
};
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
writeEvents(parts.join('\n\n'));
if (done) break;
}
writeEvents(buffer);
response.end();
} catch (error) {
if (!response.headersSent) response.status(502).json({ type: 'error', error: { type: 'upstream_error', message: `Unable to reach OpenCode Go: ${error.message}` } });
else response.end();
}
});
return app;
}
+13
View File
@@ -0,0 +1,13 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createProxyApp } from './proxy.js';
import { loadKeys } from './key-store.js';
const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const keys = await loadKeys(path.join(projectRoot, 'keys.txt'));
const app = createProxyApp({ keys });
const port = Number(process.env.PORT || 4005);
app.listen(port, '127.0.0.1', () => {
console.log(`OpenCode Go proxy listening at http://localhost:${port}/oai/v1`);
});
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { createKeyRotator, parseKeys } from '../src/key-store.js';
describe('key store', () => {
it('parses keys, blank lines, and comments', () => {
expect(parseKeys(' first \n\nsecond # note\n# ignored\n')).toEqual(['first', 'second']);
});
it('rotates keys round-robin', () => {
const rotator = createKeyRotator(['a', 'b']);
expect([rotator.next(), rotator.next(), rotator.next()]).toEqual(['a', 'b', 'a']);
});
});
+187
View File
@@ -0,0 +1,187 @@
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('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',
});
});
});