@mmmbuto/zai-codex-bridge 0.1.13 → 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 +147 -101
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
|
|
|
@@ -273,12 +292,13 @@ async function streamChatToResponses(stream, res, responseId, itemId) {
|
|
|
273
292
|
let chunkCount = 0;
|
|
274
293
|
let deltaCount = 0;
|
|
275
294
|
let lastParsed = null;
|
|
295
|
+
let didComplete = false;
|
|
276
296
|
|
|
277
297
|
log('debug', 'Starting to process stream');
|
|
278
298
|
|
|
279
|
-
// Send initial event to create the output item
|
|
299
|
+
// Send initial event to create the output item - using "added" not "add"
|
|
280
300
|
const addEvent = {
|
|
281
|
-
type: 'response.output_item.
|
|
301
|
+
type: 'response.output_item.added',
|
|
282
302
|
item: {
|
|
283
303
|
type: 'message',
|
|
284
304
|
role: 'assistant',
|
|
@@ -289,104 +309,113 @@ async function streamChatToResponses(stream, res, responseId, itemId) {
|
|
|
289
309
|
response_id: responseId
|
|
290
310
|
};
|
|
291
311
|
res.write(`data: ${JSON.stringify(addEvent)}\n\n`);
|
|
292
|
-
log('debug', 'Sent output_item.
|
|
293
|
-
|
|
294
|
-
for await (const chunk of stream) {
|
|
295
|
-
buffer += decoder.decode(chunk, { stream: true });
|
|
296
|
-
const lines = buffer.split('\n');
|
|
297
|
-
buffer = lines.pop() || '';
|
|
312
|
+
log('debug', 'Sent output_item.added event');
|
|
298
313
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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;
|
|
305
328
|
}
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
329
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
// Check for stream end
|
|
313
|
-
if (data === '[DONE]') {
|
|
314
|
-
log('info', `Stream end received - wrote ${deltaCount} deltas total`);
|
|
315
|
-
|
|
316
|
-
// Send response.completed event in OpenAI Responses API format
|
|
317
|
-
const zaiUsage = lastParsed?.usage;
|
|
318
|
-
const completedEvent = {
|
|
319
|
-
type: 'response.completed',
|
|
320
|
-
response: {
|
|
321
|
-
id: responseId,
|
|
322
|
-
status: 'completed',
|
|
323
|
-
output: [{
|
|
324
|
-
type: 'message',
|
|
325
|
-
role: 'assistant',
|
|
326
|
-
content: [{ type: 'output_text', text: '' }]
|
|
327
|
-
}],
|
|
328
|
-
usage: zaiUsage ? {
|
|
329
|
-
input_tokens: zaiUsage.prompt_tokens || 0,
|
|
330
|
-
output_tokens: zaiUsage.completion_tokens || 0,
|
|
331
|
-
total_tokens: zaiUsage.total_tokens || 0
|
|
332
|
-
} : {
|
|
333
|
-
input_tokens: 0,
|
|
334
|
-
output_tokens: 0,
|
|
335
|
-
total_tokens: 0
|
|
336
|
-
}
|
|
337
|
-
},
|
|
338
|
-
sequence_number: deltaCount
|
|
339
|
-
};
|
|
330
|
+
const data = line.slice(6).trim();
|
|
331
|
+
log('debug', 'SSE data:', data.substring(0, 100));
|
|
340
332
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
+
}
|
|
346
339
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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));
|
|
367
363
|
}
|
|
368
|
-
} catch (e) {
|
|
369
|
-
log('warn', 'Failed to parse SSE chunk:', e.message, 'data:', data.substring(0, 100));
|
|
370
364
|
}
|
|
371
|
-
}
|
|
372
365
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
366
|
+
if (didComplete) break;
|
|
367
|
+
|
|
368
|
+
if (chunkCount > 1000) {
|
|
369
|
+
log('warn', 'Too many chunks, possible loop');
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
376
372
|
}
|
|
373
|
+
} catch (e) {
|
|
374
|
+
log('error', 'Stream processing error:', e);
|
|
377
375
|
}
|
|
378
376
|
|
|
379
|
-
|
|
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`);
|
|
380
405
|
}
|
|
381
406
|
|
|
382
407
|
/**
|
|
383
408
|
* Handle POST requests
|
|
384
409
|
*/
|
|
385
410
|
async function handlePostRequest(req, res) {
|
|
386
|
-
|
|
411
|
+
// Use normalized pathname instead of raw req.url
|
|
412
|
+
const { pathname: path } = new URL(req.url, 'http://127.0.0.1');
|
|
387
413
|
|
|
388
|
-
//
|
|
389
|
-
|
|
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) {
|
|
390
419
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
391
420
|
res.end(JSON.stringify({ error: 'Not Found', path }));
|
|
392
421
|
return;
|
|
@@ -488,16 +517,32 @@ async function handlePostRequest(req, res) {
|
|
|
488
517
|
* Create HTTP server
|
|
489
518
|
*/
|
|
490
519
|
const server = http.createServer(async (req, res) => {
|
|
491
|
-
|
|
520
|
+
// Use normalized pathname
|
|
521
|
+
const { pathname } = new URL(req.url, 'http://127.0.0.1');
|
|
522
|
+
|
|
523
|
+
log('debug', 'Request:', req.method, pathname);
|
|
492
524
|
|
|
493
525
|
// Health check
|
|
494
|
-
if (
|
|
526
|
+
if (pathname === '/health' && req.method === 'GET') {
|
|
495
527
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
496
528
|
res.end(JSON.stringify({ ok: true }));
|
|
497
529
|
return;
|
|
498
530
|
}
|
|
499
531
|
|
|
500
|
-
//
|
|
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
|
|
501
546
|
if (req.method === 'POST') {
|
|
502
547
|
await handlePostRequest(req, res);
|
|
503
548
|
return;
|
|
@@ -515,4 +560,5 @@ server.listen(PORT, HOST, () => {
|
|
|
515
560
|
log('info', `zai-codex-bridge listening on http://${HOST}:${PORT}`);
|
|
516
561
|
log('info', `Proxying to Z.AI at: ${ZAI_BASE_URL}`);
|
|
517
562
|
log('info', `Health check: http://${HOST}:${PORT}/health`);
|
|
563
|
+
log('info', `Models endpoint: http://${HOST}:${PORT}/v1/models`);
|
|
518
564
|
});
|