@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +147 -101
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/zai-codex-bridge",
3
- "version": "0.1.13",
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
 
@@ -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.add',
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.add event');
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
- chunkCount++;
300
-
301
- for (const line of lines) {
302
- if (!line.trim() || !line.startsWith('data: ')) {
303
- if (line.trim() && !line.startsWith(':')) {
304
- 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;
305
328
  }
306
- continue;
307
- }
308
329
 
309
- const data = line.slice(6).trim();
310
- log('debug', 'SSE data:', data.substring(0, 100));
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
- log('info', 'Sending response.completed event');
342
- res.write(`data: ${JSON.stringify(completedEvent)}\n\n`);
343
- log('info', 'Sent response.completed event');
344
- return;
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
- try {
348
- const parsed = JSON.parse(data);
349
- lastParsed = parsed;
350
- log('debug', 'Parsed SSE:', JSON.stringify(parsed).substring(0, 150));
351
-
352
- const delta = parsed.choices?.[0]?.delta;
353
- const content = delta?.content || delta?.reasoning_content || '';
354
-
355
- if (content) {
356
- deltaCount++;
357
- log('debug', 'Writing delta:', content.substring(0, 30));
358
- // OpenAI Responses API format for text delta
359
- const deltaEvent = {
360
- type: 'response.output_text.delta',
361
- delta: content,
362
- output_index: 0,
363
- item_id: itemId,
364
- sequence_number: deltaCount - 1
365
- };
366
- 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));
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
- if (chunkCount > 1000) {
374
- log('warn', 'Too many chunks, possible loop');
375
- return;
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
- 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`);
380
405
  }
381
406
 
382
407
  /**
383
408
  * Handle POST requests
384
409
  */
385
410
  async function handlePostRequest(req, res) {
386
- 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');
387
413
 
388
- // Only handle /responses and /v1/responses
389
- 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) {
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
- 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);
492
524
 
493
525
  // Health check
494
- if (req.url === '/health' && req.method === 'GET') {
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
- // 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
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
  });