@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +161 -100
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/zai-codex-bridge",
3
- "version": "0.1.12",
3
+ "version": "0.2.0",
4
4
  "description": "Local proxy that translates OpenAI Responses API format to Z.AI Chat Completions format for Codex",
5
5
  "main": "src/server.js",
6
6
  "bin": {
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 textParts = content
53
- .filter(part => part.type === 'text' && part.text)
54
- .map(part => part.text);
55
- return textParts.length > 0 ? textParts.join('\n') : JSON.stringify(content);
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
- messages.push({
69
- role: 'system',
70
- content: request.instructions
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
- if (request.tools && Array.isArray(request.tools)) {
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
- const itemId = 'item_' + Date.now();
295
+ let didComplete = false;
277
296
 
278
297
  log('debug', 'Starting to process stream');
279
298
 
280
- for await (const chunk of stream) {
281
- buffer += decoder.decode(chunk, { stream: true });
282
- const lines = buffer.split('\n');
283
- buffer = lines.pop() || '';
284
-
285
- chunkCount++;
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
- for (const line of lines) {
288
- if (!line.trim() || !line.startsWith('data: ')) {
289
- if (line.trim() && !line.startsWith(':')) {
290
- log('debug', 'Non-data line:', line.substring(0, 50));
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
- const data = line.slice(6).trim();
296
- log('debug', 'SSE data:', data.substring(0, 100));
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
- log('info', 'Sending response.completed event');
328
- res.write(`data: ${JSON.stringify(completedEvent)}\n\n`);
329
- log('info', 'Sent response.completed event');
330
- return;
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
- try {
334
- const parsed = JSON.parse(data);
335
- lastParsed = parsed;
336
- log('debug', 'Parsed SSE:', JSON.stringify(parsed).substring(0, 150));
337
-
338
- const delta = parsed.choices?.[0]?.delta;
339
- const content = delta?.content || delta?.reasoning_content || '';
340
-
341
- if (content) {
342
- deltaCount++;
343
- log('debug', 'Writing delta:', content.substring(0, 30));
344
- // OpenAI Responses API format for text delta
345
- const deltaEvent = {
346
- type: 'response.output_text.delta',
347
- delta: content,
348
- output_index: 0,
349
- item_id: itemId,
350
- sequence_number: deltaCount - 1
351
- };
352
- res.write(`data: ${JSON.stringify(deltaEvent)}\n\n`);
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
- if (chunkCount > 1000) {
360
- log('warn', 'Too many chunks, possible loop');
361
- return;
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
- log('info', `Stream ended naturally - wrote ${deltaCount} deltas`);
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
- const path = req.url;
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
- // Only handle /responses and /v1/responses
375
- if (!path.endsWith('/responses') && !path.endsWith('/v1/responses')) {
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
- log('debug', 'Request:', req.method, req.url);
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 (req.url === '/health' && req.method === 'GET') {
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
- // POST /responses
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
  });