Skip to content
Merged
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
42 changes: 38 additions & 4 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ function imagePart(url) {
}

function transform(oaiBody) {
if (!oaiBody || typeof oaiBody !== 'object' || Array.isArray(oaiBody)) {
throw new Error('body must be a JSON object');
}
if (oaiBody.messages !== undefined && !Array.isArray(oaiBody.messages)) {
throw new Error('messages must be an array');
}
const model = oaiBody.model || 'deepseek/deepseek-v4-pro';
let systemText = '';
const messages = [];
Expand Down Expand Up @@ -186,13 +192,19 @@ function errorText(evt) {
return evt.error?.message || evt.message || JSON.stringify(evt.error ?? evt);
}

const MAX_EVENT_BUFFER_BYTES = 10 * 1024 * 1024;

// Reads NDJSON lines off the upstream response and hands each parsed event to
// onEvent. Buffers partial lines across chunks without a size cap.
// onEvent. Buffers partial lines across chunks with a size cap.
Comment on lines +195 to +198
function readEvents(proxyRes, onEvent, onEnd) {
let buf = '';
proxyRes.setEncoding('utf8');
proxyRes.on('data', chunk => {
buf += chunk;
if (buf.length > MAX_EVENT_BUFFER_BYTES) {
proxyRes.destroy(new Error('upstream NDJSON event line exceeded 10MB limit'));
return;
Comment on lines +204 to +206
}
let nl;
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim();
Expand Down Expand Up @@ -333,7 +345,12 @@ function handleUpstreamResponse(proxyRes, res, model, isStream, t0) {
proxyRes.on('error', e => {
logErr(`[upstream stream] ${netError(e)}`);
if (!res.headersSent) sendError(res, 502, netError(e));
else if (!res.writableEnded) res.end();
else if (!res.writableEnded) {
if (isStream) {
res.write(`data: ${JSON.stringify({ error: { message: netError(e), type: 'upstream_error' } })}\n\n`);
}
res.end();
}
});
}

Expand Down Expand Up @@ -363,9 +380,17 @@ function handleRequest(req, res) {
if (res.headersSent) return;
const body = Buffer.concat(chunks).toString('utf8');
let oai; try { oai = JSON.parse(body); } catch { sendError(res, 400, 'Invalid JSON', 'invalid_request_error'); return; }
if (!oai || typeof oai !== 'object' || Array.isArray(oai)) {
sendError(res, 400, 'Request body must be a JSON object', 'invalid_request_error');
return;
}
if (oai.messages !== undefined && !Array.isArray(oai.messages)) {
sendError(res, 400, 'messages must be an array', 'invalid_request_error');
return;
}
const model = oai.model || '-', isStream = oai.stream === true;
const t0 = Date.now();
logReq(`${model} | ${req.socket.remoteAddress || '-'} | ${isStream ? 'stream' : 'sync'} | ${size} bytes`);
logReq(`${model} | ${req.socket?.remoteAddress || '-'} | ${isStream ? 'stream' : 'sync'} | ${size} bytes`);

let upstream;
try { upstream = transform(oai); } catch (e) { sendError(res, 400, `transform error: ${e.message}`, 'invalid_request_error'); return; }
Expand All @@ -392,9 +417,18 @@ function handleRequest(req, res) {
});
}

module.exports = { transform, toOpenAIUsage, toFinishReason };
module.exports = { transform, toOpenAIUsage, toFinishReason, handleRequest };

if (require.main === module) {
process.on('uncaughtException', err => {
logErr(`uncaughtException: ${err && err.stack ? err.stack : err}`);
process.exit(1);
});
process.on('unhandledRejection', reason => {
logErr(`unhandledRejection: ${reason && reason.stack ? reason.stack : reason}`);
process.exit(1);
});

log(`proxy-commandcode | listening :${PORT} | upstream ${HOST}${PATH} | CC ${ccVersion}${PINNED_VERSION ? ' (pinned)' : ''} | debug ${DEBUG ? 'on' : 'off'}`);
refreshVersion();
setInterval(refreshVersion, VERSION_REFRESH_MS).unref();
Expand Down
52 changes: 51 additions & 1 deletion test/transform.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { transform, toOpenAIUsage, toFinishReason } = require('../server.js');
const { transform, toOpenAIUsage, toFinishReason, handleRequest } = require('../server.js');
const { EventEmitter } = require('node:events');

test('builds the strict envelope and always streams upstream', () => {
const body = JSON.parse(transform({ model: 'zai-org/GLM-5.2', stream: false, max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] }));
Expand Down Expand Up @@ -68,3 +69,52 @@ test('finish reasons', () => {
assert.equal(toFinishReason('stop', true), 'tool_calls');
assert.equal(toFinishReason(undefined, false), 'stop');
});

test('transform rejects null, arrays, and non-array messages', () => {
assert.throws(() => transform(null), /body must be a JSON object/);
assert.throws(() => transform([]), /body must be a JSON object/);
assert.throws(() => transform({ messages: 'not-an-array' }), /messages must be an array/);
});

test('handleRequest rejects null body with 400', async () => {
const req = new EventEmitter();
req.method = 'POST';
req.url = '/v1/chat/completions';
req.headers = {};

let status = null;
let responseData = '';
const res = {
writeHead(code, headers) { status = code; },
end(data) { responseData = data; },
};

handleRequest(req, res);
req.emit('data', Buffer.from('null'));
req.emit('end');

assert.equal(status, 400);
assert.match(responseData, /Request body must be a JSON object/);
});

test('handleRequest rejects non-array messages with 400', async () => {
const req = new EventEmitter();
req.method = 'POST';
req.url = '/v1/chat/completions';
req.headers = {};

let status = null;
let responseData = '';
const res = {
writeHead(code, headers) { status = code; },
end(data) { responseData = data; },
};

handleRequest(req, res);
req.emit('data', Buffer.from(JSON.stringify({ messages: 'invalid' })));
req.emit('end');

assert.equal(status, 400);
assert.match(responseData, /messages must be an array/);
});

Loading