Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions packages/broker-proxy/openai-chat-compat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
'use strict';

const crypto = require('crypto');

const CURSOR_MODEL_ALIASES = Object.freeze({
'anthropic:sonnet': 'claude-sonnet-4-6',
'anthropic:opus': 'claude-opus-4-6',
'anthropic:haiku': 'claude-haiku-4-5-20251001'
});

function cursorModel(model) {
const requested = String(model || 'anthropic:sonnet');
if (CURSOR_MODEL_ALIASES[requested]) return CURSOR_MODEL_ALIASES[requested];
if (requested.startsWith('anthropic:claude-')) return requested.slice('anthropic:'.length);
return requested;
}

function contentBlocks(content) {
if (content == null) return [];
if (typeof content === 'string') return content ? [{ type: 'text', text: content }] : [];
if (!Array.isArray(content)) return [{ type: 'text', text: String(content) }];
const out = [];
for (const part of content) {
if (!part || typeof part !== 'object') continue;
if ((part.type === 'text' || part.type === 'input_text') && typeof part.text === 'string') {
out.push({ type: 'text', text: part.text });
} else if (part.type === 'image_url' && part.image_url) {
const url = typeof part.image_url === 'string' ? part.image_url : part.image_url.url;
const data = typeof url === 'string' && url.match(/^data:([^;,]+);base64,(.+)$/s);
if (data) out.push({ type: 'image', source: { type: 'base64', media_type: data[1], data: data[2] } });
else if (typeof url === 'string') out.push({ type: 'image', source: { type: 'url', url } });
}
}
return out;
}

function append(messages, role, blocks) {
if (!blocks.length) return;
const tail = messages[messages.length - 1];
if (tail && tail.role === role) tail.content.push(...blocks);
else messages.push({ role, content: blocks });
}

function chatToAnthropic(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be a JSON object');
if (!Array.isArray(body.messages)) throw new Error('messages must be an array');
if (body.n != null && body.n !== 1) throw new Error('only n=1 is supported');
const system = [];
const messages = [];
for (const message of body.messages) {
if (!message || typeof message !== 'object') continue;
if (message.role === 'system' || message.role === 'developer') {
system.push(...contentBlocks(message.content));
} else if (message.role === 'assistant') {
const blocks = contentBlocks(message.content);
for (const call of message.tool_calls || []) {
if (!call || call.type !== 'function' || !call.function) continue;
let input;
try { input = JSON.parse(call.function.arguments || '{}'); }
catch (_) { throw new Error(`tool call ${call.id || call.function.name || ''} arguments must be valid JSON`); }
blocks.push({ type: 'tool_use', id: call.id || `call_${crypto.randomUUID()}`, name: call.function.name, input });
}
append(messages, 'assistant', blocks);
} else if (message.role === 'tool') {
append(messages, 'user', [{ type: 'tool_result', tool_use_id: message.tool_call_id, content: contentBlocks(message.content) }]);
} else {
append(messages, 'user', contentBlocks(message.content));
}
}
const out = {
model: cursorModel(body.model),
max_tokens: body.max_completion_tokens ?? body.max_tokens ?? 8192,
messages,
stream: body.stream === true
};
if (system.length) out.system = system;
if (body.temperature != null) out.temperature = body.temperature;
if (body.top_p != null) out.top_p = body.top_p;
if (body.stop != null) out.stop_sequences = Array.isArray(body.stop) ? body.stop : [body.stop];
if (Array.isArray(body.tools) && body.tool_choice !== 'none') {
out.tools = body.tools.filter(t => t && t.type === 'function' && t.function && t.function.name).map(t => ({
name: t.function.name,
...(t.function.description ? { description: t.function.description } : {}),
input_schema: t.function.parameters || { type: 'object', properties: {} }
}));
}
const choice = body.tool_choice;
if (choice === 'auto') out.tool_choice = { type: 'auto' };
else if (choice === 'required') out.tool_choice = { type: 'any' };
else if (choice && choice.type === 'function' && choice.function && choice.function.name) out.tool_choice = { type: 'tool', name: choice.function.name };
return out;
}

function finishReason(reason) {
if (reason === 'max_tokens') return 'length';
if (reason === 'tool_use') return 'tool_calls';
return 'stop';
}

function anthropicToChat(body) {
const blocks = Array.isArray(body.content) ? body.content : [];
const text = blocks.filter(b => b && b.type === 'text').map(b => b.text || '').join('');
const calls = blocks.filter(b => b && b.type === 'tool_use').map(b => ({
id: b.id, type: 'function', function: { name: b.name, arguments: JSON.stringify(b.input || {}) }
}));
const sourceUsage = body.usage || {};
const message = { role: 'assistant', content: text || null };
if (calls.length) message.tool_calls = calls;
return {
id: `chatcmpl-${String(body.id || crypto.randomUUID()).replace(/^msg_/, '')}`,
object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: body.model,
choices: [{ index: 0, message, finish_reason: finishReason(body.stop_reason) }],
usage: {
prompt_tokens: sourceUsage.input_tokens || 0,
completion_tokens: sourceUsage.output_tokens || 0,
total_tokens: (sourceUsage.input_tokens || 0) + (sourceUsage.output_tokens || 0),
...(sourceUsage.cache_read_input_tokens != null ? { prompt_tokens_details: { cached_tokens: sourceUsage.cache_read_input_tokens } } : {})
}
};
}

function openAiError(status, body) {
const source = body && body.error ? body.error : body || {};
return { error: { message: source.message || `Claude upstream returned HTTP ${status}`, type: source.type || 'api_error', param: null, code: source.type || null } };
}

function createSseTranslator() {
const state = { id: `chatcmpl-${crypto.randomUUID()}`, model: 'claude', created: Math.floor(Date.now() / 1000), tool: -1, input: 0, output: 0, cached: 0, finish: 'stop', buffer: '', done: false };
const frame = (delta, finish = null, usage) => `data: ${JSON.stringify({ id: state.id, object: 'chat.completion.chunk', created: state.created, model: state.model, choices: usage ? [] : [{ index: 0, delta, finish_reason: finish }], ...(usage ? { usage } : {}) })}\n\n`;
function translate(event) {
if (event.type === 'message_start') {
const message = event.message || {}; const usage = message.usage || {};
state.id = `chatcmpl-${String(message.id || crypto.randomUUID()).replace(/^msg_/, '')}`; state.model = message.model || state.model;
state.input = usage.input_tokens || 0; state.cached = usage.cache_read_input_tokens || 0;
return frame({ role: 'assistant', content: '' });
}
if (event.type === 'content_block_start' && event.content_block && event.content_block.type === 'tool_use') {
state.tool++; const block = event.content_block;
return frame({ tool_calls: [{ index: state.tool, id: block.id, type: 'function', function: { name: block.name, arguments: '' } }] });
}
if (event.type === 'content_block_delta' && event.delta) {
if (event.delta.type === 'text_delta') return frame({ content: event.delta.text || '' });
if (event.delta.type === 'input_json_delta') return frame({ tool_calls: [{ index: state.tool, function: { arguments: event.delta.partial_json || '' } }] });
}
if (event.type === 'message_delta') {
state.finish = finishReason(event.delta && event.delta.stop_reason); state.output = event.usage && event.usage.output_tokens || state.output; return '';
}
if (event.type === 'error') { state.done = true; return `data: ${JSON.stringify(openAiError(502, event))}\n\ndata: [DONE]\n\n`; }
if (event.type === 'message_stop' && !state.done) {
state.done = true;
const usage = { prompt_tokens: state.input, completion_tokens: state.output, total_tokens: state.input + state.output, ...(state.cached ? { prompt_tokens_details: { cached_tokens: state.cached } } : {}) };
return frame({}, state.finish) + frame({}, null, usage) + 'data: [DONE]\n\n';
}
return '';
}
return {
push(chunk) {
state.buffer += chunk.toString('utf8'); let out = ''; let at;
while ((at = state.buffer.indexOf('\n\n')) >= 0) {
const raw = state.buffer.slice(0, at); state.buffer = state.buffer.slice(at + 2);
const line = raw.split('\n').find(l => l.startsWith('data:')); if (!line) continue;
const payload = line.slice(5).trim(); if (!payload || payload === '[DONE]') continue;
try { out += translate(JSON.parse(payload)); } catch (_) {}
}
return out;
},
end() { const out = state.done ? '' : translate({ type: 'message_stop' }); state.buffer = ''; return out; }
};
}

module.exports = { anthropicToChat, chatToAnthropic, createSseTranslator, cursorModel, openAiError };
50 changes: 50 additions & 0 deletions packages/broker-proxy/openai-chat-compat.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { anthropicToChat, chatToAnthropic, createSseTranslator, cursorModel } = require('./openai-chat-compat');

test('maps Cursor-safe Anthropic aliases to upstream Claude models', () => {
assert.equal(cursorModel('anthropic:sonnet'), 'claude-sonnet-4-6');
assert.equal(cursorModel('anthropic:opus'), 'claude-opus-4-6');
assert.equal(cursorModel('anthropic:haiku'), 'claude-haiku-4-5-20251001');
assert.equal(cursorModel('anthropic:claude-sonnet-4-6'), 'claude-sonnet-4-6');
assert.equal(cursorModel('custom-model'), 'custom-model');
});

test('converts Cursor messages and tools to Anthropic', () => {
const out = chatToAnthropic({ model: 'claude-sonnet-4-6', stream: true, messages: [
{ role: 'system', content: 'Use tools.' }, { role: 'user', content: 'Read.' },
{ role: 'assistant', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'read_file', arguments: '{"path":"README.md"}' } }] },
{ role: 'tool', tool_call_id: 'call_1', content: 'pool docs' }
], tools: [{ type: 'function', function: { name: 'read_file', parameters: { type: 'object', properties: { path: { type: 'string' } } } } }] });
assert.equal(out.system[0].text, 'Use tools.');
assert.deepEqual(out.messages[1].content[0], { type: 'tool_use', id: 'call_1', name: 'read_file', input: { path: 'README.md' } });
assert.equal(out.messages[2].content[0].tool_use_id, 'call_1');
assert.equal(out.tools[0].name, 'read_file');
});

test('converts Anthropic JSON tool calls and usage to Chat Completions', () => {
const out = anthropicToChat({ id: 'msg_1', model: 'claude', stop_reason: 'tool_use', content: [{ type: 'tool_use', id: 'toolu_1', name: 'read_file', input: { path: 'README.md' } }], usage: { input_tokens: 10, output_tokens: 3 } });
assert.equal(out.id, 'chatcmpl-1');
assert.equal(out.choices[0].finish_reason, 'tool_calls');
assert.equal(out.choices[0].message.tool_calls[0].function.arguments, '{"path":"README.md"}');
assert.deepEqual(out.usage, { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 });
});

test('converts split Anthropic SSE with tool deltas and final usage', () => {
const t = createSseTranslator();
const raw = [
{ type: 'message_start', message: { id: 'msg_s', model: 'claude', usage: { input_tokens: 12 } } },
{ type: 'content_block_start', content_block: { type: 'tool_use', id: 'toolu_1', name: 'read_file' } },
{ type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '{"path":"README.md"}' } },
{ type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 7 } },
{ type: 'message_stop' }
].map(e => `data: ${JSON.stringify(e)}\n\n`).join('');
const split = Math.floor(raw.length / 2);
const out = t.push(raw.slice(0, split)) + t.push(raw.slice(split)) + t.end();
assert.match(out, /"id":"toolu_1"/);
assert.match(out, /"finish_reason":"tool_calls"/);
assert.match(out, /"prompt_tokens":12/);
assert.match(out, /"completion_tokens":7/);
assert.equal(out.endsWith('data: [DONE]\n\n'), true);
});
42 changes: 38 additions & 4 deletions packages/broker-proxy/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const {
applyClaudeCodeProtocol,
claudeCodeHeaders
} = require('./claude-code-protocol');
const { anthropicToChat, chatToAnthropic, createSseTranslator, openAiError } = require('./openai-chat-compat');

// ─── Defaults ───────────────────────────────────────────────────────────────
const DEFAULT_PORT = 18801;
Expand Down Expand Up @@ -1660,14 +1661,15 @@ function sendUpstreamOnce(config, req, res, body, auth, reqNum, abortSignal, act
return;
}
const lib = upstreamUrl.protocol === 'http:' ? http : https;
const isChat = req.method === 'POST' && req.url.split('?')[0] === '/v1/chat/completions';
let requestModelIsHaiku = false;
try { requestModelIsHaiku = /haiku/i.test(JSON.parse(body.toString('utf8')).model || ''); } catch (_) {}
const headers = buildUpstreamHeaders(req, body.length, auth.accessToken, requestModelIsHaiku);
let responseStarted = false;
let finished = false;
const requestOptions = {
protocol: upstreamUrl.protocol,
path: req.url,
path: isChat ? '/v1/messages' : req.url,
method: req.method,
headers,
timeout: config.upstreamTimeoutMs
Expand Down Expand Up @@ -1742,6 +1744,10 @@ function sendUpstreamOnce(config, req, res, body, auth, reqNum, abortSignal, act
console.error(`[${ts}] #${reqNum} DETECTION! Body: ${body.length}b`);
}
errBody = reverseMap(errBody, config, activeRenames);
if (isChat) {
let parsed; try { parsed = JSON.parse(errBody); } catch (_) { parsed = { message: errBody }; }
errBody = JSON.stringify(openAiError(status, parsed));
}
const nh = { ...upRes.headers };
delete nh['transfer-encoding'];
nh['content-length'] = Buffer.byteLength(errBody);
Expand All @@ -1763,12 +1769,22 @@ function sendUpstreamOnce(config, req, res, body, auth, reqNum, abortSignal, act
const TAIL_SIZE = 64;
const decoder = new StringDecoder('utf8');
const observer = createSseUsageObserver();
const chatTranslator = isChat ? createSseTranslator() : null;
let pending = '';
let buffered = '';
if (!config.bufferSseResponses) res.writeHead(status, sseHeaders);
if (chatTranslator) {
sseHeaders['content-type'] = 'text/event-stream; charset=utf-8';
sseHeaders['cache-control'] = 'no-cache';
res.writeHead(status, sseHeaders);
} else if (!config.bufferSseResponses) res.writeHead(status, sseHeaders);
upRes.on('data', (chunk) => {
const decoded = decoder.write(chunk);
observer.push(decoded);
if (chatTranslator) {
const translated = chatTranslator.push(decoded);
if (translated) res.write(translated);
return;
}
pending += decoded;
if (pending.length > TAIL_SIZE) {
let sliceIdx = pending.length - TAIL_SIZE;
Expand All @@ -1783,7 +1799,11 @@ function sendUpstreamOnce(config, req, res, body, auth, reqNum, abortSignal, act
upRes.on('end', () => {
pending += decoder.end();
const observed = observer.result();
if (config.bufferSseResponses) {
if (chatTranslator) {
const translated = chatTranslator.end();
if (translated) res.write(translated);
res.end();
} else if (config.bufferSseResponses) {
buffered += pending;
if (observed.errorCode) {
const message = observed.errorMessage || `Upstream SSE terminated with ${observed.errorCode}`;
Expand Down Expand Up @@ -1831,6 +1851,7 @@ function sendUpstreamOnce(config, req, res, body, auth, reqNum, abortSignal, act
try {
const parsed = JSON.parse(respBody);
if (typeof parsed.model === 'string' && parsed.model.length > 0) actualModel = parsed.model;
if (isChat) respBody = JSON.stringify(anthropicToChat(parsed));
} catch (_) {}
nh['x-actual-model'] = actualModel;
nh['content-length'] = Buffer.byteLength(respBody);
Expand Down Expand Up @@ -1927,6 +1948,15 @@ function createRequestHandler(config, state) {
let body = Buffer.concat(chunks);
let bodyStr = body.toString('utf8');
const originalSize = bodyStr.length;
const isChat = req.method === 'POST' && req.url.split('?')[0] === '/v1/chat/completions';
if (isChat) {
try { bodyStr = JSON.stringify(chatToAnthropic(JSON.parse(bodyStr))); }
catch (e) {
const errorBody = JSON.stringify(openAiError(400, { type: 'invalid_request_error', message: e.message }));
res.writeHead(400, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(errorBody) });
res.end(errorBody); responseFinished = true; return;
}
}
// Composability: determine which renames the CALLER actually originated,
// from the untouched request body, BEFORE we shape it. Reverse-map will
// only undo these, so native-CC / arbitrary harnesses get their own tool
Expand Down Expand Up @@ -2023,7 +2053,11 @@ function createRequestHandler(config, state) {
// above already reported this exact lease, this is a no-op instead of
// a duplicate report the broker rejects with 404 unknown_lease.
if (result.outcome) await reportBrokerOutcome(config, auth.lease, { ...result.outcome, latencyMs });
const errBody = reverseMap(result.body, config, activeRenames);
let errBody = reverseMap(result.body, config, activeRenames);
if (isChat) {
let parsed; try { parsed = JSON.parse(errBody); } catch (_) { parsed = { message: errBody }; }
errBody = JSON.stringify(openAiError(result.status, parsed));
}
const nh = { ...result.headers };
delete nh['transfer-encoding'];
nh['content-length'] = Buffer.byteLength(errBody);
Expand Down
Loading