@mmmbuto/zai-codex-bridge 0.1.12 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/server.js +161 -100
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -18,6 +18,10 @@ const HOST = process.env.HOST || '127.0.0.1';
|
|
|
18
18
|
const ZAI_BASE_URL = process.env.ZAI_BASE_URL || 'https://api.z.ai/api/coding/paas/v4';
|
|
19
19
|
const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
|
|
20
20
|
|
|
21
|
+
// Env toggles for compatibility
|
|
22
|
+
const ALLOW_SYSTEM = process.env.ALLOW_SYSTEM === '1';
|
|
23
|
+
const ALLOW_TOOLS = process.env.ALLOW_TOOLS === '1';
|
|
24
|
+
|
|
21
25
|
/**
|
|
22
26
|
* Logger
|
|
23
27
|
*/
|
|
@@ -42,18 +46,22 @@ function detectFormat(body) {
|
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
|
-
* Flatten content parts to string
|
|
49
|
+
* Flatten content parts to string - supports text, input_text, output_text
|
|
46
50
|
*/
|
|
47
51
|
function flattenContent(content) {
|
|
48
52
|
if (typeof content === 'string') {
|
|
49
53
|
return content;
|
|
50
54
|
}
|
|
51
55
|
if (Array.isArray(content)) {
|
|
52
|
-
const
|
|
53
|
-
.filter(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
const texts = content
|
|
57
|
+
.filter(p =>
|
|
58
|
+
(p && (p.type === 'text' || p.type === 'input_text' || p.type === 'output_text')) && p.text
|
|
59
|
+
)
|
|
60
|
+
.map(p => p.text);
|
|
61
|
+
if (texts.length) return texts.join('\n');
|
|
62
|
+
try { return JSON.stringify(content); } catch { return String(content); }
|
|
56
63
|
}
|
|
64
|
+
if (content == null) return '';
|
|
57
65
|
return String(content);
|
|
58
66
|
}
|
|
59
67
|
|
|
@@ -63,12 +71,22 @@ function flattenContent(content) {
|
|
|
63
71
|
function translateResponsesToChat(request) {
|
|
64
72
|
const messages = [];
|
|
65
73
|
|
|
66
|
-
// Add system message from instructions
|
|
74
|
+
// Add system message from instructions (with ALLOW_SYSTEM toggle)
|
|
67
75
|
if (request.instructions) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
if (ALLOW_SYSTEM) {
|
|
77
|
+
messages.push({
|
|
78
|
+
role: 'system',
|
|
79
|
+
content: request.instructions
|
|
80
|
+
});
|
|
81
|
+
} else {
|
|
82
|
+
// Prepend to first user message for Z.ai compatibility
|
|
83
|
+
const instr = String(request.instructions).trim();
|
|
84
|
+
if (messages.length && messages[0].role === 'user') {
|
|
85
|
+
messages[0].content = `[INSTRUCTIONS]\n${instr}\n[/INSTRUCTIONS]\n\n${messages[0].content || ''}`;
|
|
86
|
+
} else {
|
|
87
|
+
messages.unshift({ role: 'user', content: `[INSTRUCTIONS]\n${instr}\n[/INSTRUCTIONS]` });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
// Handle input: can be string (simple user message) or array (message history)
|
|
@@ -101,13 +119,13 @@ function translateResponsesToChat(request) {
|
|
|
101
119
|
content: flattenContent(item.content)
|
|
102
120
|
};
|
|
103
121
|
|
|
104
|
-
// Handle tool calls if present
|
|
105
|
-
if (item.tool_calls && Array.isArray(item.tool_calls)) {
|
|
122
|
+
// Handle tool calls if present (only if ALLOW_TOOLS)
|
|
123
|
+
if (ALLOW_TOOLS && item.tool_calls && Array.isArray(item.tool_calls)) {
|
|
106
124
|
msg.tool_calls = item.tool_calls;
|
|
107
125
|
}
|
|
108
126
|
|
|
109
|
-
// Handle tool call ID for tool responses
|
|
110
|
-
if (item.tool_call_id) {
|
|
127
|
+
// Handle tool call ID for tool responses (only if ALLOW_TOOLS)
|
|
128
|
+
if (ALLOW_TOOLS && item.tool_call_id) {
|
|
111
129
|
msg.tool_call_id = item.tool_call_id;
|
|
112
130
|
}
|
|
113
131
|
|
|
@@ -140,7 +158,8 @@ function translateResponsesToChat(request) {
|
|
|
140
158
|
chatRequest.top_p = request.top_p;
|
|
141
159
|
}
|
|
142
160
|
|
|
143
|
-
|
|
161
|
+
// Tools handling (only if ALLOW_TOOLS)
|
|
162
|
+
if (ALLOW_TOOLS && request.tools && Array.isArray(request.tools)) {
|
|
144
163
|
// Filter out tools with null or empty function
|
|
145
164
|
chatRequest.tools = request.tools.filter(tool => {
|
|
146
165
|
if (tool.type === 'function') {
|
|
@@ -158,7 +177,7 @@ function translateResponsesToChat(request) {
|
|
|
158
177
|
}
|
|
159
178
|
}
|
|
160
179
|
|
|
161
|
-
if (request.tool_choice) {
|
|
180
|
+
if (ALLOW_TOOLS && request.tool_choice) {
|
|
162
181
|
chatRequest.tool_choice = request.tool_choice;
|
|
163
182
|
}
|
|
164
183
|
|
|
@@ -267,112 +286,136 @@ async function makeUpstreamRequest(path, body, headers) {
|
|
|
267
286
|
/**
|
|
268
287
|
* Handle streaming response from Z.AI
|
|
269
288
|
*/
|
|
270
|
-
async function streamChatToResponses(stream, res, responseId) {
|
|
289
|
+
async function streamChatToResponses(stream, res, responseId, itemId) {
|
|
271
290
|
const decoder = new TextDecoder();
|
|
272
291
|
let buffer = '';
|
|
273
292
|
let chunkCount = 0;
|
|
274
293
|
let deltaCount = 0;
|
|
275
294
|
let lastParsed = null;
|
|
276
|
-
|
|
295
|
+
let didComplete = false;
|
|
277
296
|
|
|
278
297
|
log('debug', 'Starting to process stream');
|
|
279
298
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
299
|
+
// Send initial event to create the output item - using "added" not "add"
|
|
300
|
+
const addEvent = {
|
|
301
|
+
type: 'response.output_item.added',
|
|
302
|
+
item: {
|
|
303
|
+
type: 'message',
|
|
304
|
+
role: 'assistant',
|
|
305
|
+
content: [{ type: 'output_text', text: '' }],
|
|
306
|
+
id: itemId
|
|
307
|
+
},
|
|
308
|
+
output_index: 0,
|
|
309
|
+
response_id: responseId
|
|
310
|
+
};
|
|
311
|
+
res.write(`data: ${JSON.stringify(addEvent)}\n\n`);
|
|
312
|
+
log('debug', 'Sent output_item.added event');
|
|
286
313
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
314
|
+
try {
|
|
315
|
+
for await (const chunk of stream) {
|
|
316
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
317
|
+
const lines = buffer.split('\n');
|
|
318
|
+
buffer = lines.pop() || '';
|
|
319
|
+
|
|
320
|
+
chunkCount++;
|
|
321
|
+
|
|
322
|
+
for (const line of lines) {
|
|
323
|
+
if (!line.trim() || !line.startsWith('data: ')) {
|
|
324
|
+
if (line.trim() && !line.startsWith(':')) {
|
|
325
|
+
log('debug', 'Non-data line:', line.substring(0, 50));
|
|
326
|
+
}
|
|
327
|
+
continue;
|
|
291
328
|
}
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
329
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
// Check for stream end
|
|
299
|
-
if (data === '[DONE]') {
|
|
300
|
-
log('info', `Stream end received - wrote ${deltaCount} deltas total`);
|
|
301
|
-
|
|
302
|
-
// Send response.completed event in OpenAI Responses API format
|
|
303
|
-
const zaiUsage = lastParsed?.usage;
|
|
304
|
-
const completedEvent = {
|
|
305
|
-
type: 'response.completed',
|
|
306
|
-
response: {
|
|
307
|
-
id: responseId,
|
|
308
|
-
status: 'completed',
|
|
309
|
-
output: [{
|
|
310
|
-
type: 'message',
|
|
311
|
-
role: 'assistant',
|
|
312
|
-
content: [{ type: 'output_text', text: '' }]
|
|
313
|
-
}],
|
|
314
|
-
usage: zaiUsage ? {
|
|
315
|
-
input_tokens: zaiUsage.prompt_tokens || 0,
|
|
316
|
-
output_tokens: zaiUsage.completion_tokens || 0,
|
|
317
|
-
total_tokens: zaiUsage.total_tokens || 0
|
|
318
|
-
} : {
|
|
319
|
-
input_tokens: 0,
|
|
320
|
-
output_tokens: 0,
|
|
321
|
-
total_tokens: 0
|
|
322
|
-
}
|
|
323
|
-
},
|
|
324
|
-
sequence_number: deltaCount
|
|
325
|
-
};
|
|
330
|
+
const data = line.slice(6).trim();
|
|
331
|
+
log('debug', 'SSE data:', data.substring(0, 100));
|
|
326
332
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
333
|
+
// Check for stream end
|
|
334
|
+
if (data === '[DONE]') {
|
|
335
|
+
log('info', `Stream end received - wrote ${deltaCount} deltas total`);
|
|
336
|
+
didComplete = true;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
332
339
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
340
|
+
try {
|
|
341
|
+
const parsed = JSON.parse(data);
|
|
342
|
+
lastParsed = parsed;
|
|
343
|
+
log('debug', 'Parsed SSE:', JSON.stringify(parsed).substring(0, 150));
|
|
344
|
+
|
|
345
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
346
|
+
const content = delta?.content || delta?.reasoning_content || '';
|
|
347
|
+
|
|
348
|
+
if (content) {
|
|
349
|
+
deltaCount++;
|
|
350
|
+
log('debug', 'Writing delta:', content.substring(0, 30));
|
|
351
|
+
// OpenAI Responses API format for text delta
|
|
352
|
+
const deltaEvent = {
|
|
353
|
+
type: 'response.output_text.delta',
|
|
354
|
+
delta: content,
|
|
355
|
+
output_index: 0,
|
|
356
|
+
item_id: itemId,
|
|
357
|
+
sequence_number: deltaCount - 1
|
|
358
|
+
};
|
|
359
|
+
res.write(`data: ${JSON.stringify(deltaEvent)}\n\n`);
|
|
360
|
+
}
|
|
361
|
+
} catch (e) {
|
|
362
|
+
log('warn', 'Failed to parse SSE chunk:', e.message, 'data:', data.substring(0, 100));
|
|
353
363
|
}
|
|
354
|
-
} catch (e) {
|
|
355
|
-
log('warn', 'Failed to parse SSE chunk:', e.message, 'data:', data.substring(0, 100));
|
|
356
364
|
}
|
|
357
|
-
}
|
|
358
365
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
366
|
+
if (didComplete) break;
|
|
367
|
+
|
|
368
|
+
if (chunkCount > 1000) {
|
|
369
|
+
log('warn', 'Too many chunks, possible loop');
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
362
372
|
}
|
|
373
|
+
} catch (e) {
|
|
374
|
+
log('error', 'Stream processing error:', e);
|
|
363
375
|
}
|
|
364
376
|
|
|
365
|
-
|
|
377
|
+
// ALWAYS send response.completed event (even if stream ended without [DONE])
|
|
378
|
+
const zaiUsage = lastParsed?.usage;
|
|
379
|
+
const completedEvent = {
|
|
380
|
+
type: 'response.completed',
|
|
381
|
+
response: {
|
|
382
|
+
id: responseId,
|
|
383
|
+
status: 'completed',
|
|
384
|
+
output: [{
|
|
385
|
+
type: 'message',
|
|
386
|
+
role: 'assistant',
|
|
387
|
+
content: [{ type: 'output_text', text: '' }]
|
|
388
|
+
}],
|
|
389
|
+
usage: zaiUsage ? {
|
|
390
|
+
input_tokens: zaiUsage.prompt_tokens || 0,
|
|
391
|
+
output_tokens: zaiUsage.completion_tokens || 0,
|
|
392
|
+
total_tokens: zaiUsage.total_tokens || 0
|
|
393
|
+
} : {
|
|
394
|
+
input_tokens: 0,
|
|
395
|
+
output_tokens: 0,
|
|
396
|
+
total_tokens: 0
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
sequence_number: deltaCount + 1
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
log('info', 'Sending response.completed event');
|
|
403
|
+
res.write(`data: ${JSON.stringify(completedEvent)}\n\n`);
|
|
404
|
+
log('info', `Stream ended - wrote ${deltaCount} deltas total`);
|
|
366
405
|
}
|
|
367
406
|
|
|
368
407
|
/**
|
|
369
408
|
* Handle POST requests
|
|
370
409
|
*/
|
|
371
410
|
async function handlePostRequest(req, res) {
|
|
372
|
-
|
|
411
|
+
// Use normalized pathname instead of raw req.url
|
|
412
|
+
const { pathname: path } = new URL(req.url, 'http://127.0.0.1');
|
|
373
413
|
|
|
374
|
-
//
|
|
375
|
-
|
|
414
|
+
// Handle both /responses and /v1/responses, /chat/completions and /v1/chat/completions
|
|
415
|
+
const isResponses = (path === '/responses' || path === '/v1/responses');
|
|
416
|
+
const isChat = (path === '/chat/completions' || path === '/v1/chat/completions');
|
|
417
|
+
|
|
418
|
+
if (!isResponses && !isChat) {
|
|
376
419
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
377
420
|
res.end(JSON.stringify({ error: 'Not Found', path }));
|
|
378
421
|
return;
|
|
@@ -440,6 +483,7 @@ async function handlePostRequest(req, res) {
|
|
|
440
483
|
// Handle streaming response
|
|
441
484
|
if (upstreamBody.stream) {
|
|
442
485
|
const responseId = 'resp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
486
|
+
const itemId = 'item_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
443
487
|
log('info', 'Starting streaming response');
|
|
444
488
|
res.writeHead(200, {
|
|
445
489
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
@@ -448,7 +492,7 @@ async function handlePostRequest(req, res) {
|
|
|
448
492
|
});
|
|
449
493
|
|
|
450
494
|
try {
|
|
451
|
-
await streamChatToResponses(upstreamResponse.body, res, responseId);
|
|
495
|
+
await streamChatToResponses(upstreamResponse.body, res, responseId, itemId);
|
|
452
496
|
log('info', 'Streaming completed');
|
|
453
497
|
} catch (e) {
|
|
454
498
|
log('error', 'Streaming error:', e);
|
|
@@ -473,16 +517,32 @@ async function handlePostRequest(req, res) {
|
|
|
473
517
|
* Create HTTP server
|
|
474
518
|
*/
|
|
475
519
|
const server = http.createServer(async (req, res) => {
|
|
476
|
-
|
|
520
|
+
// Use normalized pathname
|
|
521
|
+
const { pathname } = new URL(req.url, 'http://127.0.0.1');
|
|
522
|
+
|
|
523
|
+
log('debug', 'Request:', req.method, pathname);
|
|
477
524
|
|
|
478
525
|
// Health check
|
|
479
|
-
if (
|
|
526
|
+
if (pathname === '/health' && req.method === 'GET') {
|
|
480
527
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
481
528
|
res.end(JSON.stringify({ ok: true }));
|
|
482
529
|
return;
|
|
483
530
|
}
|
|
484
531
|
|
|
485
|
-
//
|
|
532
|
+
// Models endpoint (Codex often calls /v1/models)
|
|
533
|
+
if ((pathname === '/v1/models' || pathname === '/models') && req.method === 'GET') {
|
|
534
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
535
|
+
res.end(JSON.stringify({
|
|
536
|
+
object: 'list',
|
|
537
|
+
data: [
|
|
538
|
+
{ id: 'GLM-4.7', object: 'model' },
|
|
539
|
+
{ id: 'glm-4.7', object: 'model' }
|
|
540
|
+
]
|
|
541
|
+
}));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// POST requests
|
|
486
546
|
if (req.method === 'POST') {
|
|
487
547
|
await handlePostRequest(req, res);
|
|
488
548
|
return;
|
|
@@ -500,4 +560,5 @@ server.listen(PORT, HOST, () => {
|
|
|
500
560
|
log('info', `zai-codex-bridge listening on http://${HOST}:${PORT}`);
|
|
501
561
|
log('info', `Proxying to Z.AI at: ${ZAI_BASE_URL}`);
|
|
502
562
|
log('info', `Health check: http://${HOST}:${PORT}/health`);
|
|
563
|
+
log('info', `Models endpoint: http://${HOST}:${PORT}/v1/models`);
|
|
503
564
|
});
|