@mmmbuto/zai-codex-bridge 0.1.13 → 0.2.1

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 +179 -106
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.1",
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
 
@@ -228,6 +247,30 @@ function translateChatToResponses(chatResponse) {
228
247
  return response;
229
248
  }
230
249
 
250
+ /**
251
+ * Extract and normalize Bearer token
252
+ */
253
+ function getBearer(raw) {
254
+ if (!raw) return '';
255
+ let t = String(raw).trim();
256
+ if (!t) return '';
257
+ // If already "Bearer xxx" keep it, otherwise add it
258
+ if (!t.toLowerCase().startsWith('bearer ')) t = `Bearer ${t}`;
259
+ return t;
260
+ }
261
+
262
+ /**
263
+ * Pick auth token from env ZAI_API_KEY (priority) or incoming headers
264
+ */
265
+ function pickAuth(incomingHeaders) {
266
+ // PRIORITY: env ZAI_API_KEY (force correct key) -> incoming header
267
+ const envTok = (process.env.ZAI_API_KEY || '').trim();
268
+ if (envTok) return getBearer(envTok);
269
+
270
+ const h = (incomingHeaders['authorization'] || incomingHeaders['Authorization'] || '').trim();
271
+ return getBearer(h);
272
+ }
273
+
231
274
  /**
232
275
  * Make upstream request to Z.AI
233
276
  */
@@ -238,9 +281,10 @@ async function makeUpstreamRequest(path, body, headers) {
238
281
  const cleanPath = path.startsWith('/') ? path.slice(1) : path;
239
282
  const url = new URL(cleanPath, baseUrl);
240
283
 
284
+ const auth = pickAuth(headers);
241
285
  const upstreamHeaders = {
242
286
  'Content-Type': 'application/json',
243
- 'Authorization': headers['authorization'] || headers['Authorization'] || ''
287
+ 'Authorization': auth
244
288
  };
245
289
 
246
290
  log('info', 'Upstream request:', {
@@ -248,7 +292,8 @@ async function makeUpstreamRequest(path, body, headers) {
248
292
  path: path,
249
293
  cleanPath: cleanPath,
250
294
  base: ZAI_BASE_URL,
251
- hasAuth: !!upstreamHeaders.Authorization,
295
+ auth_len: auth.length,
296
+ auth_prefix: auth.slice(0, 14), // "Bearer xxxxxx"
252
297
  bodyKeys: Object.keys(body),
253
298
  bodyPreview: JSON.stringify(body).substring(0, 800),
254
299
  messagesCount: body.messages?.length || 0,
@@ -273,12 +318,13 @@ async function streamChatToResponses(stream, res, responseId, itemId) {
273
318
  let chunkCount = 0;
274
319
  let deltaCount = 0;
275
320
  let lastParsed = null;
321
+ let didComplete = false;
276
322
 
277
323
  log('debug', 'Starting to process stream');
278
324
 
279
- // Send initial event to create the output item
325
+ // Send initial event to create the output item - using "added" not "add"
280
326
  const addEvent = {
281
- type: 'response.output_item.add',
327
+ type: 'response.output_item.added',
282
328
  item: {
283
329
  type: 'message',
284
330
  role: 'assistant',
@@ -289,104 +335,113 @@ async function streamChatToResponses(stream, res, responseId, itemId) {
289
335
  response_id: responseId
290
336
  };
291
337
  res.write(`data: ${JSON.stringify(addEvent)}\n\n`);
292
- log('debug', 'Sent output_item.add event');
338
+ log('debug', 'Sent output_item.added event');
293
339
 
294
- for await (const chunk of stream) {
295
- buffer += decoder.decode(chunk, { stream: true });
296
- const lines = buffer.split('\n');
297
- buffer = lines.pop() || '';
298
-
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));
340
+ try {
341
+ for await (const chunk of stream) {
342
+ buffer += decoder.decode(chunk, { stream: true });
343
+ const lines = buffer.split('\n');
344
+ buffer = lines.pop() || '';
345
+
346
+ chunkCount++;
347
+
348
+ for (const line of lines) {
349
+ if (!line.trim() || !line.startsWith('data: ')) {
350
+ if (line.trim() && !line.startsWith(':')) {
351
+ log('debug', 'Non-data line:', line.substring(0, 50));
352
+ }
353
+ continue;
305
354
  }
306
- continue;
307
- }
308
355
 
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
- };
356
+ const data = line.slice(6).trim();
357
+ log('debug', 'SSE data:', data.substring(0, 100));
340
358
 
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
- }
359
+ // Check for stream end
360
+ if (data === '[DONE]') {
361
+ log('info', `Stream end received - wrote ${deltaCount} deltas total`);
362
+ didComplete = true;
363
+ break;
364
+ }
346
365
 
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`);
366
+ try {
367
+ const parsed = JSON.parse(data);
368
+ lastParsed = parsed;
369
+ log('debug', 'Parsed SSE:', JSON.stringify(parsed).substring(0, 150));
370
+
371
+ const delta = parsed.choices?.[0]?.delta;
372
+ const content = delta?.content || delta?.reasoning_content || '';
373
+
374
+ if (content) {
375
+ deltaCount++;
376
+ log('debug', 'Writing delta:', content.substring(0, 30));
377
+ // OpenAI Responses API format for text delta
378
+ const deltaEvent = {
379
+ type: 'response.output_text.delta',
380
+ delta: content,
381
+ output_index: 0,
382
+ item_id: itemId,
383
+ sequence_number: deltaCount - 1
384
+ };
385
+ res.write(`data: ${JSON.stringify(deltaEvent)}\n\n`);
386
+ }
387
+ } catch (e) {
388
+ log('warn', 'Failed to parse SSE chunk:', e.message, 'data:', data.substring(0, 100));
367
389
  }
368
- } catch (e) {
369
- log('warn', 'Failed to parse SSE chunk:', e.message, 'data:', data.substring(0, 100));
370
390
  }
371
- }
372
391
 
373
- if (chunkCount > 1000) {
374
- log('warn', 'Too many chunks, possible loop');
375
- return;
392
+ if (didComplete) break;
393
+
394
+ if (chunkCount > 1000) {
395
+ log('warn', 'Too many chunks, possible loop');
396
+ break;
397
+ }
376
398
  }
399
+ } catch (e) {
400
+ log('error', 'Stream processing error:', e);
377
401
  }
378
402
 
379
- log('info', `Stream ended naturally - wrote ${deltaCount} deltas`);
403
+ // ALWAYS send response.completed event (even if stream ended without [DONE])
404
+ const zaiUsage = lastParsed?.usage;
405
+ const completedEvent = {
406
+ type: 'response.completed',
407
+ response: {
408
+ id: responseId,
409
+ status: 'completed',
410
+ output: [{
411
+ type: 'message',
412
+ role: 'assistant',
413
+ content: [{ type: 'output_text', text: '' }]
414
+ }],
415
+ usage: zaiUsage ? {
416
+ input_tokens: zaiUsage.prompt_tokens || 0,
417
+ output_tokens: zaiUsage.completion_tokens || 0,
418
+ total_tokens: zaiUsage.total_tokens || 0
419
+ } : {
420
+ input_tokens: 0,
421
+ output_tokens: 0,
422
+ total_tokens: 0
423
+ }
424
+ },
425
+ sequence_number: deltaCount + 1
426
+ };
427
+
428
+ log('info', 'Sending response.completed event');
429
+ res.write(`data: ${JSON.stringify(completedEvent)}\n\n`);
430
+ log('info', `Stream ended - wrote ${deltaCount} deltas total`);
380
431
  }
381
432
 
382
433
  /**
383
434
  * Handle POST requests
384
435
  */
385
436
  async function handlePostRequest(req, res) {
386
- const path = req.url;
437
+ // Use normalized pathname instead of raw req.url
438
+ const { pathname: path } = new URL(req.url, 'http://127.0.0.1');
439
+
440
+ // Handle both /responses and /v1/responses, /chat/completions and /v1/chat/completions
441
+ const isResponses = (path === '/responses' || path === '/v1/responses');
442
+ const isChat = (path === '/chat/completions' || path === '/v1/chat/completions');
387
443
 
388
- // Only handle /responses and /v1/responses
389
- if (!path.endsWith('/responses') && !path.endsWith('/v1/responses')) {
444
+ if (!isResponses && !isChat) {
390
445
  res.writeHead(404, { 'Content-Type': 'application/json' });
391
446
  res.end(JSON.stringify({ error: 'Not Found', path }));
392
447
  return;
@@ -437,15 +492,16 @@ async function handlePostRequest(req, res) {
437
492
 
438
493
  if (!upstreamResponse.ok) {
439
494
  const errorBody = await upstreamResponse.text();
495
+ const status = upstreamResponse.status;
440
496
  log('error', 'Upstream error:', {
441
- status: upstreamResponse.status,
497
+ status: status,
442
498
  body: errorBody.substring(0, 200)
443
499
  });
444
500
 
445
- res.writeHead(502, { 'Content-Type': 'application/json' });
501
+ res.writeHead(status, { 'Content-Type': 'application/json' });
446
502
  res.end(JSON.stringify({
447
503
  error: 'Upstream request failed',
448
- upstream_status: upstreamResponse.status,
504
+ upstream_status: status,
449
505
  upstream_body: errorBody
450
506
  }));
451
507
  return;
@@ -488,16 +544,32 @@ async function handlePostRequest(req, res) {
488
544
  * Create HTTP server
489
545
  */
490
546
  const server = http.createServer(async (req, res) => {
491
- log('debug', 'Request:', req.method, req.url);
547
+ // Use normalized pathname
548
+ const { pathname } = new URL(req.url, 'http://127.0.0.1');
549
+
550
+ log('debug', 'Request:', req.method, pathname);
492
551
 
493
552
  // Health check
494
- if (req.url === '/health' && req.method === 'GET') {
553
+ if (pathname === '/health' && req.method === 'GET') {
495
554
  res.writeHead(200, { 'Content-Type': 'application/json' });
496
555
  res.end(JSON.stringify({ ok: true }));
497
556
  return;
498
557
  }
499
558
 
500
- // POST /responses
559
+ // Models endpoint (Codex often calls /v1/models)
560
+ if ((pathname === '/v1/models' || pathname === '/models') && req.method === 'GET') {
561
+ res.writeHead(200, { 'Content-Type': 'application/json' });
562
+ res.end(JSON.stringify({
563
+ object: 'list',
564
+ data: [
565
+ { id: 'GLM-4.7', object: 'model' },
566
+ { id: 'glm-4.7', object: 'model' }
567
+ ]
568
+ }));
569
+ return;
570
+ }
571
+
572
+ // POST requests
501
573
  if (req.method === 'POST') {
502
574
  await handlePostRequest(req, res);
503
575
  return;
@@ -515,4 +587,5 @@ server.listen(PORT, HOST, () => {
515
587
  log('info', `zai-codex-bridge listening on http://${HOST}:${PORT}`);
516
588
  log('info', `Proxying to Z.AI at: ${ZAI_BASE_URL}`);
517
589
  log('info', `Health check: http://${HOST}:${PORT}/health`);
590
+ log('info', `Models endpoint: http://${HOST}:${PORT}/v1/models`);
518
591
  });