inital commit
This commit is contained in:
@@ -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 };
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
});
|
||||
Reference in New Issue
Block a user