aliyun-codex-bridge 0.1.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/src/server.js ADDED
@@ -0,0 +1,1594 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * aliyun-codex-bridge
5
+ *
6
+ * Local proxy that translates OpenAI Responses API format to Coding Plan Dashscope Chat Completions format.
7
+ * Allows Codex to use Coding Plan Dashscope models through the /responses endpoint.
8
+ *
9
+ * Author: Davide A. Guglielmi
10
+ * License: MIT
11
+ */
12
+
13
+ const http = require('http');
14
+ const { randomUUID } = require('crypto');
15
+
16
+ // Configuration from environment
17
+ const PORT = parseInt(process.env.PORT || '31415', 10);
18
+ const HOST = process.env.HOST || '127.0.0.1';
19
+ const AI_BASE_URL =
20
+ process.env.AI_API_BASE ||
21
+ 'https://coding.dashscope.aliyuncs.com/v1';
22
+ const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
23
+ const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'glm-4.7';
24
+ const LOG_STREAM_RAW = process.env.LOG_STREAM_RAW === '1';
25
+ const LOG_STREAM_MAX = parseInt(process.env.LOG_STREAM_MAX || '800', 10);
26
+ const SUPPRESS_ASSISTANT_TEXT_WHEN_TOOLS = process.env.SUPPRESS_ASSISTANT_TEXT_WHEN_TOOLS === '1';
27
+ const DEFER_OUTPUT_TEXT_UNTIL_DONE = process.env.DEFER_OUTPUT_TEXT_UNTIL_DONE === '1';
28
+ const SUPPRESS_REASONING_TEXT = process.env.SUPPRESS_REASONING_TEXT === '1';
29
+ const ALLOW_MULTI_TOOL_CALLS = process.env.ALLOW_MULTI_TOOL_CALLS === '1';
30
+
31
+ // Env toggles for compatibility
32
+ // Default true: preserve system/developer roles unless explicitly disabled.
33
+ const ALLOW_SYSTEM = process.env.ALLOW_SYSTEM !== '0';
34
+ const ALLOW_TOOLS_ENV = process.env.ALLOW_TOOLS === '1';
35
+ // Default true: do not forward incoming Authorization to upstream.
36
+ const FORCE_ENV_AUTH = process.env.FORCE_ENV_AUTH !== '0';
37
+
38
+ function nowSec() {
39
+ return Math.floor(Date.now() / 1000);
40
+ }
41
+
42
+ /**
43
+ * Generate a random request ID for logging
44
+ */
45
+ function generateRequestId() {
46
+ return `req_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
47
+ }
48
+
49
+ function createSseEmitter(writeFn) {
50
+ let seq = 1;
51
+ return (obj) => {
52
+ if (obj.sequence_number == null) obj.sequence_number = seq++;
53
+ writeFn(obj);
54
+ };
55
+ }
56
+
57
+ function extractToolCallsFromChoice(choice, delta) {
58
+ if (Array.isArray(delta?.tool_calls) && delta.tool_calls.length > 0) {
59
+ return delta.tool_calls;
60
+ }
61
+ if (Array.isArray(choice?.message?.tool_calls) && choice.message.tool_calls.length > 0) {
62
+ return choice.message.tool_calls;
63
+ }
64
+ if (Array.isArray(choice?.tool_calls) && choice.tool_calls.length > 0) {
65
+ return choice.tool_calls;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Lightweight validation for Requests API format
72
+ * Returns { valid: boolean, errors: string[] }
73
+ */
74
+ function validateRequest(request, format) {
75
+ const errors = [];
76
+
77
+ if (format === 'responses') {
78
+ if (request.instructions !== undefined && typeof request.instructions !== 'string') {
79
+ errors.push('instructions must be a string');
80
+ }
81
+
82
+ if (request.input !== undefined) {
83
+ if (typeof request.input !== 'string' && !Array.isArray(request.input)) {
84
+ errors.push('input must be a string or array');
85
+ }
86
+ }
87
+
88
+ if (request.model !== undefined && typeof request.model !== 'string') {
89
+ errors.push('model must be a string');
90
+ }
91
+
92
+ if (request.tools !== undefined) {
93
+ if (!Array.isArray(request.tools)) {
94
+ errors.push('tools must be an array');
95
+ }
96
+ }
97
+ }
98
+
99
+ return {
100
+ valid: errors.length === 0,
101
+ errors
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Build a response.failed SSE event
107
+ */
108
+ function buildResponseFailed({
109
+ responseId,
110
+ model,
111
+ createdAt,
112
+ errorCode,
113
+ errorMessage,
114
+ responsesRequest = null,
115
+ input = [],
116
+ tools = [],
117
+ usage = null,
118
+ }) {
119
+ const response = buildResponseObject({
120
+ id: responseId,
121
+ model,
122
+ status: 'failed',
123
+ created_at: createdAt,
124
+ completed_at: null,
125
+ input: input || responsesRequest?.input || [],
126
+ output: [],
127
+ tools: tools || responsesRequest?.tools || [],
128
+ request: responsesRequest || null,
129
+ usage,
130
+ error: {
131
+ code: errorCode,
132
+ message: errorMessage,
133
+ },
134
+ });
135
+
136
+ return {
137
+ type: 'response.failed',
138
+ response,
139
+ };
140
+ }
141
+
142
+ function buildResponseObject({
143
+ id,
144
+ model,
145
+ status,
146
+ created_at,
147
+ completed_at = null,
148
+ input = [],
149
+ output = [],
150
+ tools = [],
151
+ request = null,
152
+ usage = null,
153
+ error = null,
154
+ }) {
155
+ const instructions = request?.instructions ?? null;
156
+ const max_output_tokens = request?.max_output_tokens ?? null;
157
+ const metadata = request?.metadata ?? {};
158
+ const text = request?.text ?? { format: { type: 'text' } };
159
+ const tool_choice = request?.tool_choice ?? 'auto';
160
+ const temperature = request?.temperature ?? 1;
161
+ const top_p = request?.top_p ?? 1;
162
+ const user = request?.user ?? null;
163
+ const reasoning_effort = request?.reasoning?.effort ?? null;
164
+
165
+ // Struttura compatibile con Responses API per Codex CLI
166
+ return {
167
+ id,
168
+ object: 'response',
169
+ created_at,
170
+ status,
171
+ completed_at,
172
+ error,
173
+ incomplete_details: null,
174
+ input,
175
+ instructions,
176
+ max_output_tokens,
177
+ model,
178
+ output,
179
+ previous_response_id: null,
180
+ reasoning_effort,
181
+ store: false,
182
+ temperature,
183
+ text,
184
+ tool_choice,
185
+ tools,
186
+ top_p,
187
+ truncation: 'disabled',
188
+ usage,
189
+ user,
190
+ metadata,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Logger
196
+ */
197
+ function log(level, ...args) {
198
+ const levels = { debug: 0, info: 1, warn: 2, error: 3 };
199
+ if (levels[level] >= levels[LOG_LEVEL]) {
200
+ console.error(`[${level.toUpperCase()}]`, new Date().toISOString(), ...args);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Detect if request body is Responses format or Chat format
206
+ */
207
+ function detectFormat(body) {
208
+ if (body.instructions !== undefined || body.input !== undefined) {
209
+ return 'responses';
210
+ }
211
+ if (body.messages !== undefined) {
212
+ return 'chat';
213
+ }
214
+ return 'unknown';
215
+ }
216
+
217
+ /**
218
+ * Detect if request carries tool-related data
219
+ */
220
+ function requestHasTools(request) {
221
+ if (!request || typeof request !== 'object') return false;
222
+
223
+ if (Array.isArray(request.tools) && request.tools.length > 0) return true;
224
+ if (request.tool_choice) return true;
225
+
226
+ if (Array.isArray(request.input)) {
227
+ for (const item of request.input) {
228
+ if (!item) continue;
229
+ if (item.type === 'function_call_output') return true;
230
+ if (Array.isArray(item.tool_calls) && item.tool_calls.length > 0) return true;
231
+ if (item.tool_call_id) return true;
232
+ }
233
+ }
234
+
235
+ if (Array.isArray(request.messages)) {
236
+ for (const msg of request.messages) {
237
+ if (!msg) continue;
238
+ if (msg.role === 'tool') return true;
239
+ if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) return true;
240
+ if (msg.tool_call_id) return true;
241
+ }
242
+ }
243
+
244
+ return false;
245
+ }
246
+
247
+ function summarizeTools(tools, limit = 8) {
248
+ if (!Array.isArray(tools)) return null;
249
+ const types = {};
250
+ const names = [];
251
+
252
+ for (const tool of tools) {
253
+ const type = tool?.type || 'unknown';
254
+ types[type] = (types[type] || 0) + 1;
255
+ if (names.length < limit) {
256
+ if (type === 'function') {
257
+ names.push(tool?.function?.name || tool?.name || '(missing_name)');
258
+ } else {
259
+ names.push(type);
260
+ }
261
+ }
262
+ }
263
+
264
+ return { count: tools.length, types, sample_names: names };
265
+ }
266
+
267
+ function summarizeToolShape(tool) {
268
+ if (!tool || typeof tool !== 'object') return null;
269
+ return {
270
+ keys: Object.keys(tool),
271
+ type: tool.type,
272
+ name: tool.name,
273
+ functionKeys: tool.function && typeof tool.function === 'object' ? Object.keys(tool.function) : null,
274
+ functionName: tool.function?.name
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Flatten content parts to string - supports text, input_text, output_text
280
+ */
281
+ function flattenContent(content) {
282
+ if (typeof content === 'string') {
283
+ return content;
284
+ }
285
+ if (Array.isArray(content)) {
286
+ const texts = content
287
+ .filter(p =>
288
+ (p && (p.type === 'text' || p.type === 'input_text' || p.type === 'output_text')) && p.text
289
+ )
290
+ .map(p => p.text);
291
+ if (texts.length) return texts.join('\n');
292
+ try { return JSON.stringify(content); } catch { return String(content); }
293
+ }
294
+ if (content == null) return '';
295
+ return String(content);
296
+ }
297
+
298
+ /**
299
+ * Extract reasoning text from upstream payloads (message or delta).
300
+ */
301
+ function extractReasoningText(obj) {
302
+ if (!obj || typeof obj !== 'object') return '';
303
+ const candidates = ['reasoning_content', 'reasoning', 'thinking', 'thought'];
304
+ for (const key of candidates) {
305
+ const val = obj[key];
306
+ if (typeof val === 'string' && val.length) return val;
307
+ }
308
+ return '';
309
+ }
310
+
311
+ /**
312
+ * Compute a safe incremental delta for providers that sometimes stream
313
+ * the full content-so-far instead of true deltas.
314
+ */
315
+ function computeDelta(prev, incoming) {
316
+ if (!incoming) return { delta: '', next: prev };
317
+ if (!prev) return { delta: incoming, next: incoming };
318
+
319
+ // Full-content streaming: incoming is the full buffer so far.
320
+ if (incoming.startsWith(prev)) {
321
+ return { delta: incoming.slice(prev.length), next: incoming };
322
+ }
323
+
324
+ // Duplicate chunk (provider repeated last fragment).
325
+ if (prev.endsWith(incoming)) {
326
+ return { delta: '', next: prev };
327
+ }
328
+
329
+ // Overlap fix: avoid duplicated boundary text.
330
+ const max = Math.min(prev.length, incoming.length);
331
+ for (let i = max; i > 0; i--) {
332
+ if (prev.endsWith(incoming.slice(0, i))) {
333
+ const delta = incoming.slice(i);
334
+ return { delta, next: prev + delta };
335
+ }
336
+ }
337
+
338
+ // Fallback: treat as incremental.
339
+ return { delta: incoming, next: prev + incoming };
340
+ }
341
+
342
+ function normalizeToolName(name, fallback = 'tool') {
343
+ const base = String(name || fallback).trim().toLowerCase();
344
+ const safe = base.replace(/[^a-z0-9_]/g, '_').replace(/_+/g, '_');
345
+ if (!safe) return 'tool';
346
+ if (/^[a-z_]/.test(safe)) return safe;
347
+ return `tool_${safe}`;
348
+ }
349
+
350
+ function stringifyToolArguments(args) {
351
+ if (typeof args === 'string') return args;
352
+ if (args === undefined || args === null) return '';
353
+ try {
354
+ return JSON.stringify(args);
355
+ } catch {
356
+ return '';
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Translate Responses format to Chat Completions format
362
+ */
363
+ function translateResponsesToChat(request, allowTools, options = {}) {
364
+ const messages = [];
365
+ const knownToolCalls = new Map();
366
+
367
+ // Add system message from instructions (with ALLOW_SYSTEM toggle)
368
+ if (request.instructions) {
369
+ if (ALLOW_SYSTEM) {
370
+ messages.push({
371
+ role: 'system',
372
+ content: request.instructions
373
+ });
374
+ } else {
375
+ // Prepend to first user message for Z.ai compatibility
376
+ const instr = String(request.instructions).trim();
377
+ if (messages.length && messages[0].role === 'user') {
378
+ messages[0].content = `[INSTRUCTIONS]\n${instr}\n[/INSTRUCTIONS]\n\n${messages[0].content || ''}`;
379
+ } else {
380
+ messages.unshift({ role: 'user', content: `[INSTRUCTIONS]\n${instr}\n[/INSTRUCTIONS]` });
381
+ }
382
+ }
383
+ }
384
+
385
+ // Handle input: can be string (simple user message) or array (message history)
386
+ if (request.input) {
387
+ if (typeof request.input === 'string') {
388
+ // Simple string input -> user message
389
+ messages.push({
390
+ role: 'user',
391
+ content: request.input
392
+ });
393
+ } else if (Array.isArray(request.input)) {
394
+ // Array of ResponseItem objects
395
+ for (const item of request.input) {
396
+ // Preserve function_call items as assistant tool_calls messages for upstream validation.
397
+ if (allowTools && item.type === 'function_call') {
398
+ const callId = item.call_id || item.id || `call_${randomUUID().replace(/-/g, '')}`;
399
+ const name = normalizeToolName(item.name || 'tool');
400
+ const argumentsText = stringifyToolArguments(item.arguments);
401
+ const toolCall = {
402
+ id: callId,
403
+ type: 'function',
404
+ function: {
405
+ name,
406
+ arguments: argumentsText
407
+ }
408
+ };
409
+ messages.push({
410
+ role: 'assistant',
411
+ content: '',
412
+ tool_calls: [toolCall]
413
+ });
414
+ knownToolCalls.set(callId, {
415
+ name,
416
+ arguments: argumentsText
417
+ });
418
+ continue;
419
+ }
420
+
421
+ // Handle function_call_output items (tool responses) - only if allowTools
422
+ if (allowTools && item.type === 'function_call_output') {
423
+ const callId = item.call_id || item.tool_call_id || '';
424
+ const lastMsg = messages.length ? messages[messages.length - 1] : null;
425
+ const hasPrecedingToolCall =
426
+ !!lastMsg &&
427
+ lastMsg.role === 'assistant' &&
428
+ Array.isArray(lastMsg.tool_calls) &&
429
+ lastMsg.tool_calls.some(tc => tc && tc.id === callId);
430
+
431
+ // Some providers reject tool messages unless they immediately follow assistant.tool_calls.
432
+ if (!hasPrecedingToolCall && callId) {
433
+ const known = knownToolCalls.get(callId);
434
+ const syntheticName = normalizeToolName(known?.name || 'tool');
435
+ const syntheticArgs = stringifyToolArguments(known?.arguments || '');
436
+ messages.push({
437
+ role: 'assistant',
438
+ content: '',
439
+ tool_calls: [
440
+ {
441
+ id: callId,
442
+ type: 'function',
443
+ function: {
444
+ name: syntheticName,
445
+ arguments: syntheticArgs
446
+ }
447
+ }
448
+ ]
449
+ });
450
+ }
451
+
452
+ const toolMsg = {
453
+ role: 'tool',
454
+ tool_call_id: callId,
455
+ content: ''
456
+ };
457
+
458
+ // Extract content from output or content field
459
+ if (item.output !== undefined) {
460
+ toolMsg.content = typeof item.output === 'string'
461
+ ? item.output
462
+ : JSON.stringify(item.output);
463
+ } else if (item.content !== undefined) {
464
+ toolMsg.content = typeof item.content === 'string'
465
+ ? item.content
466
+ : JSON.stringify(item.content);
467
+ }
468
+
469
+ messages.push(toolMsg);
470
+ continue;
471
+ }
472
+
473
+ // Only process items with a 'role' field (Message items)
474
+ // Skip Reasoning, FunctionCall, LocalShellCall, etc.
475
+ if (!item.role) continue;
476
+
477
+ // Map non-standard roles to upstream-compatible roles
478
+ // Upstream accepts: system, user, assistant, tool
479
+ let role = item.role;
480
+ if (role === 'developer') {
481
+ role = ALLOW_SYSTEM ? 'system' : 'user';
482
+ } else if (role !== 'system' && role !== 'user' && role !== 'assistant' && role !== 'tool') {
483
+ // Skip any other non-standard roles
484
+ continue;
485
+ }
486
+
487
+ const msg = {
488
+ role: role,
489
+ content: flattenContent(item.content)
490
+ };
491
+
492
+ // Handle tool calls if present (only if allowTools)
493
+ if (allowTools && item.tool_calls && Array.isArray(item.tool_calls)) {
494
+ msg.tool_calls = item.tool_calls;
495
+ }
496
+
497
+ // Handle tool call ID for tool responses (only if allowTools)
498
+ if (allowTools && item.tool_call_id) {
499
+ msg.tool_call_id = item.tool_call_id;
500
+ }
501
+
502
+ messages.push(msg);
503
+ }
504
+ }
505
+ }
506
+
507
+ // Build chat request
508
+ // Preserve model casing from client request for providers with case-sensitive model IDs.
509
+ const model = request.model || DEFAULT_MODEL;
510
+ const chatRequest = {
511
+ model: model,
512
+ messages: messages,
513
+ stream: options.forceStream ? true : (request.stream !== false) // default true
514
+ };
515
+
516
+ // Pass through reasoning controls when present (provider may ignore unknown fields)
517
+ if (request.reasoning !== undefined) {
518
+ chatRequest.reasoning = request.reasoning;
519
+ if (request.reasoning && typeof request.reasoning === 'object' && request.reasoning.effort !== undefined) {
520
+ chatRequest.reasoning_effort = request.reasoning.effort;
521
+ }
522
+ }
523
+
524
+ // Map optional fields
525
+ if (request.max_output_tokens) {
526
+ chatRequest.max_tokens = request.max_output_tokens;
527
+ } else if (request.max_tokens) {
528
+ chatRequest.max_tokens = request.max_tokens;
529
+ }
530
+
531
+ if (request.temperature !== undefined) {
532
+ chatRequest.temperature = request.temperature;
533
+ }
534
+
535
+ if (request.top_p !== undefined) {
536
+ chatRequest.top_p = request.top_p;
537
+ }
538
+
539
+ // Tools handling (only if allowTools)
540
+ if (allowTools && request.tools && Array.isArray(request.tools)) {
541
+ const normalized = [];
542
+
543
+ for (let i = 0; i < request.tools.length; i++) {
544
+ const tool = request.tools[i];
545
+ if (!tool || typeof tool !== 'object') continue;
546
+
547
+ const fn = tool.function && typeof tool.function === 'object' ? tool.function : null;
548
+ const rawName = fn?.name || tool.name || tool.type || `tool_${i + 1}`;
549
+ const name = normalizeToolName(rawName, `tool_${i + 1}`);
550
+ if (!name) continue;
551
+
552
+ // Convert non-function tool definitions to function schema for Chat Completions compatibility.
553
+ const typeHint = tool.type && tool.type !== 'function' ? ` [original_type=${tool.type}]` : '';
554
+ const description = (fn?.description ?? tool.description ?? `Bridged tool ${name}`) + typeHint;
555
+ const parameters =
556
+ fn?.parameters ??
557
+ tool.parameters ??
558
+ tool.input_schema ??
559
+ { type: 'object', properties: {} };
560
+
561
+ const functionObj = { name, parameters };
562
+ if (description) functionObj.description = description;
563
+
564
+ // Send minimal tool schema for upstream compatibility
565
+ normalized.push({
566
+ type: 'function',
567
+ function: functionObj
568
+ });
569
+ }
570
+
571
+ chatRequest.tools = normalized;
572
+
573
+ // Only add tools array if there are valid tools
574
+ if (chatRequest.tools.length === 0) {
575
+ delete chatRequest.tools;
576
+ }
577
+ }
578
+
579
+ if (allowTools && request.tool_choice) {
580
+ chatRequest.tool_choice = request.tool_choice;
581
+ if (!chatRequest.tools || chatRequest.tools.length === 0) {
582
+ delete chatRequest.tool_choice;
583
+ }
584
+ }
585
+
586
+ log('debug', 'Translated Responses->Chat:', {
587
+ messagesCount: messages.length,
588
+ model: chatRequest.model,
589
+ stream: chatRequest.stream
590
+ });
591
+
592
+ return chatRequest;
593
+ }
594
+
595
+ /**
596
+ * Translate Chat Completions response to Responses format
597
+ * Handles both output_text and reasoning_text content
598
+ * Handles tool_calls if present (only if allowTools)
599
+ */
600
+ function translateChatToResponses(chatResponse, responsesRequest, ids, allowTools) {
601
+ const msg = chatResponse.choices?.[0]?.message ?? {};
602
+ const outputText = msg.content ?? '';
603
+ const reasoningText = SUPPRESS_REASONING_TEXT ? '' : extractReasoningText(msg);
604
+
605
+ const createdAt = ids?.createdAt ?? nowSec();
606
+ const responseId = ids?.responseId ?? `resp_${randomUUID().replace(/-/g, '')}`;
607
+ const msgId = ids?.msgId ?? `msg_${randomUUID().replace(/-/g, '')}`;
608
+
609
+ const content = [];
610
+ if (outputText) {
611
+ content.push({ type: 'output_text', text: outputText, annotations: [] });
612
+ }
613
+
614
+ const msgItem = {
615
+ id: msgId,
616
+ type: 'message',
617
+ status: 'completed',
618
+ role: 'assistant',
619
+ content,
620
+ };
621
+
622
+ // Build output array: reasoning item (if any) + message item (if any) + tool calls
623
+ const finalOutput = [];
624
+
625
+ if (reasoningText) {
626
+ finalOutput.push({
627
+ id: `rs_${randomUUID().replace(/-/g, '')}`,
628
+ type: 'reasoning',
629
+ status: 'completed',
630
+ content: [{ type: 'reasoning_text', text: reasoningText }],
631
+ summary: [],
632
+ });
633
+ }
634
+
635
+ const hasToolCalls = allowTools && msg.tool_calls && Array.isArray(msg.tool_calls);
636
+ if (content.length > 0 || !hasToolCalls) {
637
+ finalOutput.push(msgItem);
638
+ }
639
+
640
+ // Handle tool_calls (only if allowTools)
641
+ if (hasToolCalls) {
642
+ for (const tc of msg.tool_calls) {
643
+ const callId = tc.id || `call_${randomUUID().replace(/-/g, '')}`;
644
+ const name = tc.function?.name || '';
645
+ const args = tc.function?.arguments || '';
646
+
647
+ // Enhanced logging for FunctionCall debugging
648
+ log('info', `FunctionCall: ${name}(${callId}) args_length=${args.length}`);
649
+
650
+ finalOutput.push({
651
+ id: callId,
652
+ type: 'function_call',
653
+ status: 'completed',
654
+ call_id: callId,
655
+ name,
656
+ arguments: args,
657
+ });
658
+ }
659
+ }
660
+
661
+ return buildResponseObject({
662
+ id: responseId,
663
+ model: responsesRequest?.model || chatResponse.model || DEFAULT_MODEL,
664
+ status: 'completed',
665
+ created_at: createdAt,
666
+ completed_at: nowSec(),
667
+ input: responsesRequest?.input || [],
668
+ output: finalOutput,
669
+ tools: responsesRequest?.tools || [],
670
+ request: responsesRequest || null,
671
+ });
672
+ }
673
+
674
+ /**
675
+ * Extract and normalize Bearer token
676
+ */
677
+ function getBearer(raw) {
678
+ if (!raw) return '';
679
+ let t = String(raw).trim();
680
+ if (!t) return '';
681
+ // If already "Bearer xxx" keep it, otherwise add it
682
+ if (!t.toLowerCase().startsWith('bearer ')) t = `Bearer ${t}`;
683
+ return t;
684
+ }
685
+
686
+ /**
687
+ * Pick auth token from env AI_API_KEY (priority) or incoming headers
688
+ */
689
+ function pickAuth(incomingHeaders) {
690
+ // PRIORITY: env AI_API_KEY (force correct key) -> incoming header
691
+ const envTok = (process.env.AI_API_KEY || '').trim();
692
+ if (envTok) return getBearer(envTok);
693
+
694
+ if (FORCE_ENV_AUTH) return '';
695
+
696
+ const h = (incomingHeaders['authorization'] || incomingHeaders['Authorization'] || '').trim();
697
+ return getBearer(h);
698
+ }
699
+
700
+ /**
701
+ * Make upstream request to Coding Plan Dashscope
702
+ */
703
+ async function makeUpstreamRequest(path, body, headers) {
704
+ // Ensure base URL ends with / for proper path concatenation
705
+ const baseUrl = AI_BASE_URL.endsWith('/') ? AI_BASE_URL : AI_BASE_URL + '/';
706
+ // Remove leading slash from path to avoid replacing base URL path
707
+ const cleanPath = path.startsWith('/') ? path.slice(1) : path;
708
+ const url = new URL(cleanPath, baseUrl);
709
+
710
+ const auth = pickAuth(headers);
711
+ if (!auth) {
712
+ throw new Error('Missing upstream API key: set AI_API_KEY');
713
+ }
714
+ const upstreamHeaders = {
715
+ 'Content-Type': 'application/json',
716
+ 'Authorization': auth,
717
+ 'Accept-Encoding': 'identity' // Disable compression to avoid gzip issues
718
+ };
719
+
720
+ log('info', 'Upstream request:', {
721
+ url: url.href,
722
+ path: path,
723
+ cleanPath: cleanPath,
724
+ base: AI_BASE_URL,
725
+ auth_len: auth.length,
726
+ auth_prefix: auth.slice(0, 14) + '...', // Mask full token, keep prefix "Bearer xxxxxx..."
727
+ bodyKeys: Object.keys(body),
728
+ bodyPreview: JSON.stringify(body).substring(0, 800),
729
+ messagesCount: body.messages?.length || 0,
730
+ allRoles: body.messages?.map(m => m.role) || []
731
+ });
732
+
733
+ const response = await fetch(url, {
734
+ method: 'POST',
735
+ headers: upstreamHeaders,
736
+ body: JSON.stringify(body)
737
+ });
738
+
739
+ return response;
740
+ }
741
+
742
+ /**
743
+ * Handle streaming response from Coding Plan Dashscope with proper Responses API event format
744
+ * Separates reasoning_content, content, and tool_calls into distinct events
745
+ */
746
+ async function streamChatToResponses(upstreamBody, responsesRequest, ids, allowTools, writer = {}) {
747
+ const emit = writer.emit || createSseEmitter(() => {});
748
+ const end = writer.end || (() => {});
749
+ const decoder = new TextDecoder();
750
+ const reader = upstreamBody.getReader();
751
+ let buffer = '';
752
+
753
+ const createdAt = ids.createdAt;
754
+ const responseId = ids.responseId;
755
+ const msgId = ids.msgId;
756
+ const model = responsesRequest?.model || DEFAULT_MODEL;
757
+
758
+ const CONTENT_INDEX = 0;
759
+ const outputItems = [];
760
+ let currentTextItem = null;
761
+ let messageCount = 0;
762
+
763
+ // Track if stream has been terminated to avoid double-end
764
+ let streamTerminated = false;
765
+ let responseUsage = null;
766
+
767
+ function sse(obj) {
768
+ emit(obj);
769
+ }
770
+
771
+ /**
772
+ * Send response.failed SSE event and safely close the stream
773
+ */
774
+ let failedResponse = null;
775
+ function sendResponseFailed(errorCode, errorMessage) {
776
+ if (streamTerminated) return;
777
+ streamTerminated = true;
778
+
779
+ const failedEvent = buildResponseFailed({
780
+ responseId,
781
+ model,
782
+ createdAt,
783
+ errorCode,
784
+ errorMessage,
785
+ responsesRequest,
786
+ input: responsesRequest?.input || [],
787
+ tools: responsesRequest?.tools || [],
788
+ usage: responseUsage,
789
+ });
790
+ failedResponse = failedEvent.response;
791
+ sse(failedEvent);
792
+ try {
793
+ const cancel = reader.cancel();
794
+ if (cancel && typeof cancel.catch === 'function') {
795
+ cancel.catch(() => {});
796
+ }
797
+ } catch {
798
+ // Ignore errors during reader cancel
799
+ }
800
+ try {
801
+ end();
802
+ } catch {
803
+ // Ignore errors during end
804
+ }
805
+ }
806
+
807
+ // response.created / response.in_progress
808
+ const baseResp = buildResponseObject({
809
+ id: responseId,
810
+ model: responsesRequest?.model || DEFAULT_MODEL,
811
+ status: 'in_progress',
812
+ created_at: createdAt,
813
+ completed_at: null,
814
+ input: responsesRequest?.input || [],
815
+ output: [],
816
+ tools: responsesRequest?.tools || [],
817
+ request: responsesRequest || null,
818
+ });
819
+
820
+ sse({ type: 'response.created', response: baseResp });
821
+ sse({ type: 'response.in_progress', response: baseResp });
822
+
823
+ let allOutputText = '';
824
+ let allReasoningText = '';
825
+ let sawToolCalls = false;
826
+ let lastFinishReason = null;
827
+
828
+ function addOutputItem(item) {
829
+ outputItems.push(item);
830
+ return outputItems.length - 1;
831
+ }
832
+
833
+ function startTextItem(kind, forceReasoning = false) {
834
+ if (kind === 'reasoning' && SUPPRESS_REASONING_TEXT && !forceReasoning) {
835
+ return null;
836
+ }
837
+ if (currentTextItem && currentTextItem.type === kind && !currentTextItem.closed) {
838
+ return currentTextItem;
839
+ }
840
+ if (currentTextItem && !currentTextItem.closed) {
841
+ closeTextItem(currentTextItem);
842
+ }
843
+
844
+ let id;
845
+ let item;
846
+ if (kind === 'message') {
847
+ id = messageCount === 0 ? msgId : `msg_${randomUUID().replace(/-/g, '')}`;
848
+ messageCount += 1;
849
+ item = {
850
+ id,
851
+ type: 'message',
852
+ status: 'in_progress',
853
+ role: 'assistant',
854
+ content: [],
855
+ };
856
+ } else {
857
+ id = `rs_${randomUUID().replace(/-/g, '')}`;
858
+ item = {
859
+ id,
860
+ type: 'reasoning',
861
+ status: 'in_progress',
862
+ content: [],
863
+ summary: [],
864
+ };
865
+ }
866
+
867
+ const outputIndex = addOutputItem(item);
868
+ sse({
869
+ type: 'response.output_item.added',
870
+ output_index: outputIndex,
871
+ item,
872
+ });
873
+
874
+ currentTextItem = {
875
+ type: kind,
876
+ id,
877
+ outputIndex,
878
+ text: '',
879
+ contentAdded: false,
880
+ closed: false,
881
+ forced: forceReasoning,
882
+ };
883
+ return currentTextItem;
884
+ }
885
+
886
+ function ensureContentPart(itemState) {
887
+ if (!itemState || itemState.contentAdded) return;
888
+ const part =
889
+ itemState.type === 'message'
890
+ ? { type: 'output_text', text: '', annotations: [] }
891
+ : { type: 'reasoning_text', text: '' };
892
+
893
+ sse({
894
+ type: 'response.content_part.added',
895
+ item_id: itemState.id,
896
+ output_index: itemState.outputIndex,
897
+ content_index: CONTENT_INDEX,
898
+ part,
899
+ });
900
+ itemState.contentAdded = true;
901
+ }
902
+
903
+ function closeTextItem(itemState, options = {}) {
904
+ if (!itemState || itemState.closed) return;
905
+
906
+ if (itemState.type === 'message') {
907
+ const allowOutputText = options.allowOutputText !== false;
908
+ if (allowOutputText && itemState.text.length) {
909
+ if (!itemState.contentAdded) {
910
+ ensureContentPart(itemState);
911
+ }
912
+ sse({
913
+ type: 'response.output_text.done',
914
+ item_id: itemState.id,
915
+ output_index: itemState.outputIndex,
916
+ content_index: CONTENT_INDEX,
917
+ text: itemState.text,
918
+ });
919
+ if (itemState.contentAdded) {
920
+ sse({
921
+ type: 'response.content_part.done',
922
+ item_id: itemState.id,
923
+ output_index: itemState.outputIndex,
924
+ content_index: CONTENT_INDEX,
925
+ part: { type: 'output_text', text: itemState.text, annotations: [] },
926
+ });
927
+ }
928
+ }
929
+
930
+ const msgItemDone = {
931
+ id: itemState.id,
932
+ type: 'message',
933
+ status: 'completed',
934
+ role: 'assistant',
935
+ content:
936
+ allowOutputText && itemState.text.length
937
+ ? [{ type: 'output_text', text: itemState.text, annotations: [] }]
938
+ : [],
939
+ };
940
+
941
+ sse({
942
+ type: 'response.output_item.done',
943
+ output_index: itemState.outputIndex,
944
+ item: msgItemDone,
945
+ });
946
+ outputItems[itemState.outputIndex] = msgItemDone;
947
+ } else {
948
+ const allowReasoningText = !SUPPRESS_REASONING_TEXT || itemState.forced;
949
+ if (allowReasoningText && itemState.text.length) {
950
+ if (!itemState.contentAdded) {
951
+ ensureContentPart(itemState);
952
+ }
953
+ sse({
954
+ type: 'response.reasoning_text.done',
955
+ item_id: itemState.id,
956
+ output_index: itemState.outputIndex,
957
+ content_index: CONTENT_INDEX,
958
+ text: itemState.text,
959
+ });
960
+ if (itemState.contentAdded) {
961
+ sse({
962
+ type: 'response.content_part.done',
963
+ item_id: itemState.id,
964
+ output_index: itemState.outputIndex,
965
+ content_index: CONTENT_INDEX,
966
+ part: { type: 'reasoning_text', text: itemState.text },
967
+ });
968
+ }
969
+ }
970
+
971
+ const reasoningItemDone = {
972
+ id: itemState.id,
973
+ type: 'reasoning',
974
+ status: 'completed',
975
+ content: allowReasoningText && itemState.text.length ? [{ type: 'reasoning_text', text: itemState.text }] : [],
976
+ summary: [],
977
+ };
978
+
979
+ sse({
980
+ type: 'response.output_item.done',
981
+ output_index: itemState.outputIndex,
982
+ item: reasoningItemDone,
983
+ });
984
+ outputItems[itemState.outputIndex] = reasoningItemDone;
985
+ }
986
+
987
+ itemState.closed = true;
988
+ if (currentTextItem === itemState) {
989
+ currentTextItem = null;
990
+ }
991
+ }
992
+
993
+ // Tool call tracking (only if allowTools)
994
+ const toolCallsMap = new Map(); // index -> { callId, name, outputIndex, arguments, partialArgs, done }
995
+ const toolCallsById = new Map(); // callId -> index
996
+ let nextToolIndex = 0;
997
+
998
+ function finalizeToolCall(tcData) {
999
+ if (!tcData || tcData.done) return;
1000
+ tcData.arguments = tcData.partialArgs;
1001
+ sse({
1002
+ type: 'response.function_call_arguments.done',
1003
+ item_id: tcData.callId,
1004
+ output_index: tcData.outputIndex,
1005
+ arguments: tcData.arguments,
1006
+ });
1007
+
1008
+ const fnItemDone = {
1009
+ id: tcData.callId,
1010
+ type: 'function_call',
1011
+ status: 'completed',
1012
+ call_id: tcData.callId,
1013
+ name: tcData.name,
1014
+ arguments: tcData.arguments,
1015
+ };
1016
+
1017
+ sse({
1018
+ type: 'response.output_item.done',
1019
+ output_index: tcData.outputIndex,
1020
+ item: fnItemDone,
1021
+ });
1022
+ tcData.completedItem = fnItemDone;
1023
+ outputItems[tcData.outputIndex] = fnItemDone;
1024
+ tcData.done = true;
1025
+ }
1026
+
1027
+ try {
1028
+ while (true) {
1029
+ const { done, value } = await reader.read();
1030
+ if (done) break;
1031
+
1032
+ buffer += decoder.decode(value, { stream: true });
1033
+ const events = buffer.split('\n\n');
1034
+ buffer = events.pop() || '';
1035
+
1036
+ for (const evt of events) {
1037
+ const lines = evt.split('\n');
1038
+ for (const line of lines) {
1039
+ if (!line.startsWith('data:')) continue;
1040
+ const payload = line.slice(5).trim();
1041
+ if (!payload) continue;
1042
+ if (payload === '[DONE]') {
1043
+ // termina upstream
1044
+ continue;
1045
+ }
1046
+
1047
+ let chunk;
1048
+ try {
1049
+ chunk = JSON.parse(payload);
1050
+ } catch {
1051
+ continue;
1052
+ }
1053
+
1054
+ if (chunk.error) {
1055
+ const err = chunk.error || {};
1056
+ const code = err.code || 'upstream_stream_error';
1057
+ const message = err.message || 'Upstream provider returned stream error';
1058
+ sendResponseFailed(code, message);
1059
+ return failedResponse;
1060
+ }
1061
+
1062
+ if (LOG_STREAM_RAW) {
1063
+ const preview = JSON.stringify(chunk);
1064
+ log('debug', 'Upstream chunk:', preview.length > LOG_STREAM_MAX ? preview.slice(0, LOG_STREAM_MAX) + '…' : preview);
1065
+ }
1066
+
1067
+ const choice = chunk.choices?.[0] || {};
1068
+ const delta = choice.delta || {};
1069
+ const finishReason = choice.finish_reason;
1070
+
1071
+ if (chunk.usage) {
1072
+ const usage = chunk.usage || {};
1073
+ const promptTokens = usage.prompt_tokens ?? usage.input_tokens ?? 0;
1074
+ const completionTokens = usage.completion_tokens ?? usage.output_tokens ?? 0;
1075
+ const totalTokens = usage.total_tokens ?? (promptTokens + completionTokens);
1076
+ const reasoningTokens =
1077
+ usage.reasoning_tokens ??
1078
+ usage.output_tokens_details?.reasoning_tokens ??
1079
+ 0;
1080
+ responseUsage = {
1081
+ input_tokens: promptTokens,
1082
+ input_tokens_details: { cached_tokens: 0 },
1083
+ output_tokens: completionTokens,
1084
+ output_tokens_details: { reasoning_tokens: reasoningTokens },
1085
+ total_tokens: totalTokens,
1086
+ };
1087
+ }
1088
+
1089
+ if (finishReason) {
1090
+ lastFinishReason = finishReason;
1091
+
1092
+ // Handle content_filter - send response.failed and stop streaming
1093
+ if (finishReason === 'content_filter') {
1094
+ sendResponseFailed(
1095
+ 'content_filter',
1096
+ 'Content was filtered by upstream provider'
1097
+ );
1098
+ return failedResponse; // Exit the loop entirely
1099
+ }
1100
+ }
1101
+
1102
+ // Handle tool_calls (only if allowTools)
1103
+ const toolCalls = extractToolCallsFromChoice(choice, delta);
1104
+ if (allowTools && Array.isArray(toolCalls)) {
1105
+ if (SUPPRESS_ASSISTANT_TEXT_WHEN_TOOLS) {
1106
+ sawToolCalls = true;
1107
+ }
1108
+ if (!ALLOW_MULTI_TOOL_CALLS && toolCalls.length > 1) {
1109
+ log('warn', `Multiple tool_calls received (${toolCalls.length}); only the first will be processed`);
1110
+ }
1111
+ const toolCallsToProcess = ALLOW_MULTI_TOOL_CALLS ? toolCalls : toolCalls.slice(0, 1);
1112
+ for (const tc of toolCallsToProcess) {
1113
+ let index = tc.index;
1114
+ const tcId = tc.id;
1115
+
1116
+ if (index == null) {
1117
+ if (tcId && toolCallsById.has(tcId)) {
1118
+ index = toolCallsById.get(tcId);
1119
+ } else {
1120
+ index = nextToolIndex++;
1121
+ }
1122
+ } else if (index >= nextToolIndex) {
1123
+ nextToolIndex = index + 1;
1124
+ }
1125
+
1126
+ if (!toolCallsMap.has(index)) {
1127
+ // New tool call - send output_item.added
1128
+ const callId = tcId || `call_${randomUUID().replace(/-/g, '')}`;
1129
+ const name = tc.function?.name || '';
1130
+ const fnItemInProgress = {
1131
+ id: callId,
1132
+ type: 'function_call',
1133
+ status: 'in_progress',
1134
+ call_id: callId,
1135
+ name: name,
1136
+ arguments: '',
1137
+ };
1138
+
1139
+ const outputIndex = addOutputItem(fnItemInProgress);
1140
+
1141
+ toolCallsMap.set(index, {
1142
+ callId,
1143
+ name,
1144
+ outputIndex,
1145
+ arguments: '',
1146
+ partialArgs: '',
1147
+ done: false
1148
+ });
1149
+ if (callId) toolCallsById.set(callId, index);
1150
+
1151
+ sse({
1152
+ type: 'response.output_item.added',
1153
+ output_index: outputIndex,
1154
+ item: fnItemInProgress,
1155
+ });
1156
+
1157
+ if (name) {
1158
+ sse({
1159
+ type: 'response.function_call_name.done',
1160
+ item_id: callId,
1161
+ output_index: outputIndex,
1162
+ name: name,
1163
+ });
1164
+ }
1165
+ }
1166
+
1167
+ const tcData = toolCallsMap.get(index);
1168
+
1169
+ // Handle name update if it comes later
1170
+ if (tc.function?.name && !tcData.name) {
1171
+ tcData.name = tc.function.name;
1172
+ sse({
1173
+ type: 'response.function_call_name.done',
1174
+ item_id: tcData.callId,
1175
+ output_index: tcData.outputIndex,
1176
+ name: tcData.name,
1177
+ });
1178
+ }
1179
+
1180
+ // Handle arguments delta
1181
+ if (tc.function?.arguments && typeof tc.function.arguments === 'string') {
1182
+ tcData.partialArgs += tc.function.arguments;
1183
+
1184
+ sse({
1185
+ type: 'response.function_call_arguments.delta',
1186
+ item_id: tcData.callId,
1187
+ output_index: tcData.outputIndex,
1188
+ delta: tc.function.arguments,
1189
+ });
1190
+ }
1191
+
1192
+ // Check if this tool call is done (finish_reason comes later in the choice)
1193
+ if (finishReason === 'tool_calls') {
1194
+ finalizeToolCall(tcData);
1195
+ }
1196
+ }
1197
+ // Skip to next iteration after handling tool_calls
1198
+ continue;
1199
+ }
1200
+
1201
+ // NON mescolare reasoning in output_text
1202
+ const reasoningDelta = extractReasoningText(delta);
1203
+ if (reasoningDelta) {
1204
+ const computed = computeDelta(allReasoningText, reasoningDelta);
1205
+ allReasoningText = computed.next;
1206
+ if (computed.delta.length) {
1207
+ const reasoningItem = startTextItem('reasoning');
1208
+ if (reasoningItem) {
1209
+ reasoningItem.text += computed.delta;
1210
+ if (!reasoningItem.contentAdded) {
1211
+ ensureContentPart(reasoningItem);
1212
+ }
1213
+ sse({
1214
+ type: 'response.reasoning_text.delta',
1215
+ item_id: reasoningItem.id,
1216
+ output_index: reasoningItem.outputIndex,
1217
+ content_index: CONTENT_INDEX,
1218
+ delta: computed.delta,
1219
+ });
1220
+ }
1221
+ }
1222
+ }
1223
+
1224
+ if (typeof delta.content === 'string' && delta.content.length) {
1225
+ const computed = computeDelta(allOutputText, delta.content);
1226
+ allOutputText = computed.next;
1227
+ if (computed.delta.length) {
1228
+ const msgItem = startTextItem('message');
1229
+ if (msgItem) {
1230
+ msgItem.text += computed.delta;
1231
+ const emitOutputText = !DEFER_OUTPUT_TEXT_UNTIL_DONE
1232
+ && !(SUPPRESS_ASSISTANT_TEXT_WHEN_TOOLS && sawToolCalls);
1233
+ if (emitOutputText) {
1234
+ if (!msgItem.contentAdded) {
1235
+ ensureContentPart(msgItem);
1236
+ }
1237
+ sse({
1238
+ type: 'response.output_text.delta',
1239
+ item_id: msgItem.id,
1240
+ output_index: msgItem.outputIndex,
1241
+ content_index: CONTENT_INDEX,
1242
+ delta: computed.delta,
1243
+ });
1244
+ }
1245
+ }
1246
+ }
1247
+ }
1248
+ }
1249
+ }
1250
+ }
1251
+ } catch (streamError) {
1252
+ // Exception occurred during streaming - send response.failed
1253
+ log('error', 'Stream exception:', streamError);
1254
+ sendResponseFailed('stream_error', `Stream processing error: ${streamError.message}`);
1255
+ return failedResponse;
1256
+ }
1257
+
1258
+ // If stream was terminated due to content_filter or error, don't send completion events
1259
+ if (streamTerminated) {
1260
+ return failedResponse;
1261
+ }
1262
+
1263
+ // Ensure any pending tool calls are finalized once at end of stream
1264
+ if (toolCallsMap.size > 0) {
1265
+ for (const tcData of toolCallsMap.values()) {
1266
+ finalizeToolCall(tcData);
1267
+ }
1268
+ }
1269
+
1270
+ const suppressForTools = SUPPRESS_ASSISTANT_TEXT_WHEN_TOOLS
1271
+ && sawToolCalls
1272
+ && lastFinishReason === 'tool_calls';
1273
+ const includeOutputText = allOutputText.length > 0 && !suppressForTools;
1274
+
1275
+ if (suppressForTools && allOutputText.length > 0) {
1276
+ log('info', 'Suppressing assistant output_text due to tool_calls', { finish_reason: lastFinishReason });
1277
+ // Route suppressed assistant text into reasoning stream so it is visible outside chat.
1278
+ const separator = allReasoningText.length ? '\n\n' : '';
1279
+ const routed = separator + allOutputText;
1280
+ allReasoningText += routed;
1281
+ if (currentTextItem && currentTextItem.type === 'message' && !currentTextItem.closed) {
1282
+ closeTextItem(currentTextItem, { allowOutputText: false });
1283
+ }
1284
+ const reasoningItem = startTextItem('reasoning', true);
1285
+ if (reasoningItem) {
1286
+ reasoningItem.text += routed;
1287
+ if (!reasoningItem.contentAdded) {
1288
+ ensureContentPart(reasoningItem);
1289
+ }
1290
+ sse({
1291
+ type: 'response.reasoning_text.delta',
1292
+ item_id: reasoningItem.id,
1293
+ output_index: reasoningItem.outputIndex,
1294
+ content_index: CONTENT_INDEX,
1295
+ delta: routed,
1296
+ });
1297
+ }
1298
+ }
1299
+
1300
+ if (currentTextItem && !currentTextItem.closed) {
1301
+ if (currentTextItem.type === 'message') {
1302
+ closeTextItem(currentTextItem, { allowOutputText: includeOutputText });
1303
+ } else {
1304
+ closeTextItem(currentTextItem);
1305
+ }
1306
+ }
1307
+
1308
+ let finalOutput = outputItems.filter(Boolean);
1309
+ if (suppressForTools) {
1310
+ finalOutput = finalOutput.filter(
1311
+ item => !(item.type === 'message' && Array.isArray(item.content) && item.content.length === 0)
1312
+ );
1313
+ }
1314
+
1315
+ const completed = buildResponseObject({
1316
+ id: responseId,
1317
+ model: responsesRequest?.model || DEFAULT_MODEL,
1318
+ status: 'completed',
1319
+ created_at: createdAt,
1320
+ completed_at: nowSec(),
1321
+ input: responsesRequest?.input || [],
1322
+ output: finalOutput,
1323
+ tools: responsesRequest?.tools || [],
1324
+ request: responsesRequest || null,
1325
+ usage: responseUsage,
1326
+ });
1327
+
1328
+ sse({ type: 'response.completed', response: completed });
1329
+ try {
1330
+ end();
1331
+ } catch {
1332
+ // Ignore errors during end
1333
+ }
1334
+
1335
+ log('info', `Stream completed - ${allOutputText.length} output, ${allReasoningText.length} reasoning, ${toolCallsMap.size} tool_calls`);
1336
+ return completed;
1337
+ }
1338
+
1339
+ /**
1340
+ * Handle POST requests
1341
+ */
1342
+ async function handlePostRequest(req, res) {
1343
+ // Use normalized pathname instead of raw req.url
1344
+ const { pathname: path } = new URL(req.url, 'http://127.0.0.1');
1345
+ const requestId = generateRequestId();
1346
+
1347
+ // Log with request_id
1348
+ log('info', `[${requestId}] Incoming ${req.method} ${path}`);
1349
+
1350
+ // Handle both /responses and /v1/responses, /chat/completions and /v1/chat/completions
1351
+ const isResponses = (path === '/responses' || path === '/v1/responses');
1352
+ const isChat = (path === '/chat/completions' || path === '/v1/chat/completions');
1353
+
1354
+ if (!isResponses && !isChat) {
1355
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1356
+ res.end(JSON.stringify({ error: 'Not Found', path }));
1357
+ return;
1358
+ }
1359
+
1360
+ let body = '';
1361
+ for await (const chunk of req) {
1362
+ body += chunk.toString();
1363
+ }
1364
+
1365
+ let request;
1366
+ try {
1367
+ request = JSON.parse(body);
1368
+ } catch (e) {
1369
+ log('warn', `[${requestId}] Invalid JSON: ${e.message}`);
1370
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1371
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
1372
+ return;
1373
+ }
1374
+
1375
+ // Lightweight validation for Responses format
1376
+ const format = detectFormat(request);
1377
+ if (format === 'responses') {
1378
+ const validation = validateRequest(request, format);
1379
+ if (!validation.valid) {
1380
+ log('warn', `[${requestId}] Validation failed:`, validation.errors);
1381
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1382
+ res.end(JSON.stringify({
1383
+ error: 'Validation Failed',
1384
+ details: validation.errors
1385
+ }));
1386
+ return;
1387
+ }
1388
+ }
1389
+
1390
+ const hasTools = requestHasTools(request);
1391
+ const allowTools = ALLOW_TOOLS_ENV || hasTools;
1392
+
1393
+ log('info', `[${requestId}] Request details:`, {
1394
+ path,
1395
+ format,
1396
+ model: request.model,
1397
+ allowTools,
1398
+ toolsPresent: hasTools
1399
+ });
1400
+ if (hasTools) {
1401
+ log('debug', 'Tools summary:', summarizeTools(request.tools));
1402
+ if (request.tools && request.tools[0]) {
1403
+ log('debug', 'Tool[0] shape:', summarizeToolShape(request.tools[0]));
1404
+ }
1405
+ }
1406
+
1407
+ let upstreamBody;
1408
+ const clientWantsStream = (format === 'responses')
1409
+ ? (request.stream !== false)
1410
+ : (request.stream === true);
1411
+
1412
+ // format is already defined above during validation
1413
+
1414
+ if (format === 'responses') {
1415
+ // Translate Responses to Chat (force upstream streaming for unified handling)
1416
+ upstreamBody = translateResponsesToChat(request, allowTools, { forceStream: true });
1417
+ } else if (format === 'chat') {
1418
+ // Pass through Chat format (force upstream streaming for unified handling)
1419
+ upstreamBody = { ...request, stream: true };
1420
+ } else {
1421
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1422
+ res.end(JSON.stringify({ error: 'Unknown request format' }));
1423
+ return;
1424
+ }
1425
+
1426
+ try {
1427
+ const upstreamResponse = await makeUpstreamRequest(
1428
+ '/chat/completions',
1429
+ upstreamBody,
1430
+ req.headers
1431
+ );
1432
+
1433
+ if (!upstreamResponse.ok) {
1434
+ const errorBody = await upstreamResponse.text();
1435
+ const status = upstreamResponse.status;
1436
+ log('error', `[${requestId}] Upstream error:`, {
1437
+ status: status,
1438
+ body: errorBody.substring(0, 200)
1439
+ });
1440
+
1441
+ // For streaming requests, send SSE response.failed
1442
+ if (clientWantsStream) {
1443
+ const ids = {
1444
+ createdAt: nowSec(),
1445
+ responseId: `resp_${randomUUID().replace(/-/g, '')}`,
1446
+ msgId: `msg_${randomUUID().replace(/-/g, '')}`,
1447
+ };
1448
+
1449
+ res.writeHead(200, {
1450
+ 'Content-Type': 'text/event-stream; charset=utf-8',
1451
+ 'Cache-Control': 'no-cache',
1452
+ 'Connection': 'keep-alive',
1453
+ 'X-Accel-Buffering': 'no',
1454
+ });
1455
+
1456
+ const failedEvent = buildResponseFailed({
1457
+ responseId: ids.responseId,
1458
+ model: request.model || DEFAULT_MODEL,
1459
+ createdAt: ids.createdAt,
1460
+ errorCode: 'upstream_error',
1461
+ errorMessage: `Upstream request failed with status ${status}: ${errorBody.substring(0, 100)}`,
1462
+ responsesRequest: request,
1463
+ input: request?.input || [],
1464
+ tools: request?.tools || [],
1465
+ });
1466
+ const emit = createSseEmitter((obj) => {
1467
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
1468
+ });
1469
+ emit(failedEvent);
1470
+ res.end();
1471
+ return;
1472
+ }
1473
+
1474
+ // Non-streaming: return JSON error
1475
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1476
+ res.end(JSON.stringify({
1477
+ error: 'Upstream request failed',
1478
+ upstream_status: status,
1479
+ upstream_body: errorBody
1480
+ }));
1481
+ return;
1482
+ }
1483
+
1484
+ // Handle streaming response
1485
+ if (clientWantsStream) {
1486
+ const ids = {
1487
+ createdAt: nowSec(),
1488
+ responseId: `resp_${randomUUID().replace(/-/g, '')}`,
1489
+ msgId: `msg_${randomUUID().replace(/-/g, '')}`,
1490
+ };
1491
+ log('info', 'Starting streaming response');
1492
+ res.writeHead(200, {
1493
+ 'Content-Type': 'text/event-stream; charset=utf-8',
1494
+ 'Cache-Control': 'no-cache',
1495
+ 'Connection': 'keep-alive',
1496
+ 'X-Accel-Buffering': 'no',
1497
+ });
1498
+
1499
+ try {
1500
+ const emit = createSseEmitter((obj) => {
1501
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
1502
+ });
1503
+ await streamChatToResponses(
1504
+ upstreamResponse.body,
1505
+ request,
1506
+ ids,
1507
+ allowTools,
1508
+ { emit, end: () => res.end() }
1509
+ );
1510
+ log('info', 'Streaming completed');
1511
+ } catch (e) {
1512
+ log('error', 'Streaming error:', e);
1513
+ }
1514
+ } else {
1515
+ // Non-streaming response (stream-first upstream)
1516
+ const ids = {
1517
+ createdAt: nowSec(),
1518
+ responseId: `resp_${randomUUID().replace(/-/g, '')}`,
1519
+ msgId: `msg_${randomUUID().replace(/-/g, '')}`,
1520
+ };
1521
+
1522
+ const emit = createSseEmitter(() => {});
1523
+ const response = await streamChatToResponses(
1524
+ upstreamResponse.body,
1525
+ request,
1526
+ ids,
1527
+ allowTools,
1528
+ { emit, end: () => {} }
1529
+ );
1530
+ if (!response) {
1531
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1532
+ res.end(JSON.stringify({ error: 'Stream processing failed' }));
1533
+ return;
1534
+ }
1535
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1536
+ res.end(JSON.stringify(response));
1537
+ }
1538
+ } catch (error) {
1539
+ log('error', 'Request failed:', error);
1540
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1541
+ res.end(JSON.stringify({ error: error.message }));
1542
+ }
1543
+ }
1544
+
1545
+ /**
1546
+ * Create HTTP server
1547
+ */
1548
+ const server = http.createServer(async (req, res) => {
1549
+ // Use normalized pathname
1550
+ const { pathname } = new URL(req.url, 'http://127.0.0.1');
1551
+
1552
+ log('debug', 'Request:', req.method, pathname);
1553
+
1554
+ // Health check
1555
+ if (pathname === '/health' && req.method === 'GET') {
1556
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1557
+ res.end(JSON.stringify({ ok: true }));
1558
+ return;
1559
+ }
1560
+
1561
+ // Models endpoint (Codex often calls /v1/models)
1562
+ if ((pathname === '/v1/models' || pathname === '/models') && req.method === 'GET') {
1563
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1564
+ res.end(JSON.stringify({
1565
+ object: 'list',
1566
+ data: [
1567
+ { id: 'GLM-4.7', object: 'model' },
1568
+ { id: 'glm-4.7', object: 'model' }
1569
+ ]
1570
+ }));
1571
+ return;
1572
+ }
1573
+
1574
+ // POST requests
1575
+ if (req.method === 'POST') {
1576
+ await handlePostRequest(req, res);
1577
+ return;
1578
+ }
1579
+
1580
+ // 404
1581
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1582
+ res.end(JSON.stringify({ error: 'Not Found' }));
1583
+ });
1584
+
1585
+ /**
1586
+ * Start server
1587
+ */
1588
+ server.listen(PORT, HOST, () => {
1589
+ log('info', `aliyun-codex-bridge listening on http://${HOST}:${PORT}`);
1590
+ log('info', `Proxying to Coding Plan Dashscope at: ${AI_BASE_URL}`);
1591
+ log('info', `Health check: http://${HOST}:${PORT}/health`);
1592
+ log('info', `Models endpoint: http://${HOST}:${PORT}/v1/models`);
1593
+ });
1594
+