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/CHANGELOG.md +118 -0
- package/CODEX_REPORT_v0.1.0.md +39 -0
- package/CODEX_TEST_SUITE.md +949 -0
- package/LICENSE +21 -0
- package/README.md +319 -0
- package/RELEASING.md +83 -0
- package/bin/aliyun-codex-bridge +227 -0
- package/bin/zai-codex-bridge +3 -0
- package/docs/DIFF_WITH_DIONANOS_MAIN.md +96 -0
- package/docs/guide.md +406 -0
- package/package.json +34 -0
- package/scripts/prepare-clean-release.ps1 +32 -0
- package/scripts/release-patch.js +60 -0
- package/scripts/start-aliyun-codex-bridge.vbs +68 -0
- package/scripts/test-curl.js +327 -0
- package/src/server.js +1594 -0
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
|
+
|