@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.
- package/package.json +1 -1
- package/src/server.js +179 -106
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
|
|
|
@@ -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':
|
|
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
|
-
|
|
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.
|
|
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.
|
|
338
|
+
log('debug', 'Sent output_item.added event');
|
|
293
339
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (line.trim()
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
};
|
|
356
|
+
const data = line.slice(6).trim();
|
|
357
|
+
log('debug', 'SSE data:', data.substring(0, 100));
|
|
340
358
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
497
|
+
status: status,
|
|
442
498
|
body: errorBody.substring(0, 200)
|
|
443
499
|
});
|
|
444
500
|
|
|
445
|
-
res.writeHead(
|
|
501
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
446
502
|
res.end(JSON.stringify({
|
|
447
503
|
error: 'Upstream request failed',
|
|
448
|
-
upstream_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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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
|
});
|