codexmate 0.0.26 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +7 -2
  2. package/README.zh.md +7 -2
  3. package/cli/builtin-proxy.js +636 -95
  4. package/cli/openai-bridge.js +497 -5
  5. package/cli.js +75 -29
  6. package/lib/cli-models-utils.js +71 -10
  7. package/package.json +3 -1
  8. package/plugins/prompt-templates/computed.mjs +1 -1
  9. package/plugins/prompt-templates/methods.mjs +0 -66
  10. package/plugins/prompt-templates/overview.mjs +1 -0
  11. package/web-ui/app.js +16 -16
  12. package/web-ui/logic.codex.mjs +56 -0
  13. package/web-ui/logic.sessions.mjs +56 -0
  14. package/web-ui/modules/app.computed.dashboard.mjs +54 -0
  15. package/web-ui/modules/app.computed.session.mjs +48 -0
  16. package/web-ui/modules/app.methods.claude-config.mjs +18 -7
  17. package/web-ui/modules/app.methods.codex-config.mjs +35 -3
  18. package/web-ui/modules/app.methods.providers.mjs +9 -1
  19. package/web-ui/modules/app.methods.session-actions.mjs +2 -5
  20. package/web-ui/modules/app.methods.session-browser.mjs +4 -5
  21. package/web-ui/modules/app.methods.session-trash.mjs +19 -4
  22. package/web-ui/modules/app.methods.startup-claude.mjs +12 -1
  23. package/web-ui/modules/i18n.dict.mjs +28 -32
  24. package/web-ui/modules/provider-url-display.mjs +17 -0
  25. package/web-ui/partials/index/panel-config-claude.html +5 -1
  26. package/web-ui/partials/index/panel-config-codex.html +33 -4
  27. package/web-ui/partials/index/panel-plugins.html +3 -29
  28. package/web-ui/partials/index/panel-sessions.html +0 -10
  29. package/web-ui/partials/index/panel-settings.html +62 -67
  30. package/web-ui/partials/index/panel-usage.html +31 -2
  31. package/web-ui/session-helpers.mjs +2 -2
  32. package/web-ui/styles/base-theme.css +47 -34
  33. package/web-ui/styles/controls-forms.css +27 -28
  34. package/web-ui/styles/layout-shell.css +37 -34
  35. package/web-ui/styles/modals-core.css +12 -10
  36. package/web-ui/styles/navigation-panels.css +36 -35
  37. package/web-ui/styles/responsive.css +4 -4
  38. package/web-ui/styles/sessions-list.css +10 -6
  39. package/web-ui/styles/sessions-usage.css +95 -0
  40. package/web-ui/styles/settings-panel.css +19 -0
  41. package/web-ui/styles/titles-cards.css +90 -26
@@ -118,6 +118,15 @@ function createBuiltinProxyRuntimeController(deps = {}) {
118
118
  return false;
119
119
  }
120
120
 
121
+ function shouldFallbackFromUpstreamResponsesFailure(error) {
122
+ const text = String(error || '').trim();
123
+ if (!text) return false;
124
+ if (/timeout/i.test(text)) return true;
125
+ if (/socket hang up/i.test(text)) return true;
126
+ if (/ECONNRESET/i.test(text)) return true;
127
+ return false;
128
+ }
129
+
121
130
  function proxyRequestJson(targetUrl, options = {}) {
122
131
  const parsed = new URL(targetUrl);
123
132
  const transport = parsed.protocol === 'https:' ? https : http;
@@ -174,70 +183,182 @@ function createBuiltinProxyRuntimeController(deps = {}) {
174
183
  });
175
184
  }
176
185
 
177
- function extractChatCompletionResult(payload) {
178
- if (!payload || typeof payload !== 'object') return { text: '' };
179
- const choice = Array.isArray(payload.choices) ? payload.choices[0] : null;
180
- const message = choice && typeof choice === 'object' ? choice.message : null;
181
- const content = message && typeof message === 'object' ? message.content : '';
182
- let text = '';
183
- if (typeof content === 'string') {
184
- text = content;
185
- } else if (Array.isArray(content)) {
186
- text = content
187
- .map((item) => {
188
- if (!item) return '';
189
- if (typeof item === 'string') return item;
190
- if (typeof item === 'object') {
191
- if (typeof item.text === 'string') return item.text;
192
- if (typeof item.content === 'string') return item.content;
193
- }
194
- return '';
195
- })
196
- .filter(Boolean)
197
- .join('');
186
+ function buildUpstreamUrlCandidates(baseUrl, pathSuffix) {
187
+ const safeSuffix = String(pathSuffix || '').replace(/^\/+/, '');
188
+ const candidates = [];
189
+ const push = (url) => {
190
+ if (url && !candidates.includes(url)) {
191
+ candidates.push(url);
192
+ }
193
+ };
194
+ push(joinApiUrl(baseUrl, safeSuffix));
195
+ const trimmed = normalizeBaseUrl(baseUrl);
196
+ if (trimmed && safeSuffix) {
197
+ push(`${trimmed}/${safeSuffix}`);
198
198
  }
199
- return { text };
199
+ return candidates;
200
200
  }
201
201
 
202
- function normalizeResponsesInputToChatMessages(input) {
203
- // 支持:
204
- // - string
205
- // - { role, content }(单条 message)
206
- // - { type:"input_text"|"input_image", ... }(单个 block)
207
- // - [{ role, content: [{type:"input_text"|"input_image", ...}] }]
208
- // - [{ type:"input_text"|"input_image", ... }](视为单条 user 消息)
209
- if (typeof input === 'string') {
210
- return [{ role: 'user', content: input }];
202
+ async function proxyRequestJsonWithFallbackUrls(baseUrl, pathSuffix, options = {}) {
203
+ const urls = buildUpstreamUrlCandidates(baseUrl, pathSuffix);
204
+ if (urls.length === 0) {
205
+ return { ok: false, error: 'failed to build upstream URL' };
211
206
  }
212
- if (input && typeof input === 'object' && !Array.isArray(input)) {
213
- if (typeof input.role === 'string' && input.content != null) {
214
- const role = input.role.trim() || 'user';
215
- const content = Array.isArray(input.content)
216
- ? toChatContent(input.content)
217
- : input.content;
218
- return content ? [{ role, content }] : [];
207
+ let lastResult = null;
208
+ for (let index = 0; index < urls.length; index += 1) {
209
+ const result = await proxyRequestJson(urls[index], options);
210
+ lastResult = result;
211
+ if (!result.ok) {
212
+ return result;
219
213
  }
220
- // 单个 block:{type:"input_text"|"input_image", ...}
221
- if (typeof input.type === 'string') {
222
- const content = toChatContent([input]);
223
- return content ? [{ role: 'user', content }] : [];
214
+ if (!(result.status === 404 || result.status === 405)) {
215
+ return result;
224
216
  }
225
- return [];
226
217
  }
227
- if (!Array.isArray(input)) {
228
- return [];
218
+ return lastResult || { ok: false, error: 'failed to build upstream URL' };
219
+ }
220
+
221
+ function stringifyJsonValue(value, fallback = '') {
222
+ if (typeof value === 'string') return value;
223
+ if (value == null) return fallback;
224
+ try {
225
+ return JSON.stringify(value);
226
+ } catch (_) {
227
+ return fallback;
228
+ }
229
+ }
230
+
231
+ function normalizeChatUsageToResponsesUsage(usage) {
232
+ if (!usage || typeof usage !== 'object' || Array.isArray(usage)) return undefined;
233
+ const pickNumber = (...keys) => {
234
+ for (const key of keys) {
235
+ if (Number.isFinite(usage[key])) return usage[key];
236
+ }
237
+ return undefined;
238
+ };
239
+ const inputTokens = pickNumber('input_tokens', 'prompt_tokens');
240
+ const outputTokens = pickNumber('output_tokens', 'completion_tokens');
241
+ const totalTokens = pickNumber('total_tokens');
242
+ const result = {};
243
+ if (inputTokens != null) result.input_tokens = inputTokens;
244
+ if (outputTokens != null) result.output_tokens = outputTokens;
245
+ if (totalTokens != null) result.total_tokens = totalTokens;
246
+ if (usage.input_tokens_details && typeof usage.input_tokens_details === 'object') {
247
+ result.input_tokens_details = usage.input_tokens_details;
248
+ } else if (usage.prompt_tokens_details && typeof usage.prompt_tokens_details === 'object') {
249
+ result.input_tokens_details = usage.prompt_tokens_details;
250
+ }
251
+ if (usage.output_tokens_details && typeof usage.output_tokens_details === 'object') {
252
+ result.output_tokens_details = usage.output_tokens_details;
253
+ } else if (usage.completion_tokens_details && typeof usage.completion_tokens_details === 'object') {
254
+ result.output_tokens_details = usage.completion_tokens_details;
255
+ }
256
+ return Object.keys(result).length > 0 ? result : usage;
257
+ }
258
+
259
+ function mapChatFinishReasonToResponses(choice) {
260
+ const finishReason = choice && typeof choice === 'object' && typeof choice.finish_reason === 'string'
261
+ ? choice.finish_reason
262
+ : '';
263
+ if (finishReason === 'length') {
264
+ return { status: 'incomplete', incomplete_details: { reason: 'max_output_tokens' } };
265
+ }
266
+ if (finishReason === 'content_filter') {
267
+ return { status: 'incomplete', incomplete_details: { reason: 'content_filter' } };
229
268
  }
269
+ return { status: 'completed' };
270
+ }
271
+
272
+ function normalizeChatMessageContentToResponsesContent(content, refusal = '') {
273
+ const blocks = [];
274
+ const pushText = (text) => {
275
+ if (typeof text === 'string' && text) {
276
+ blocks.push({ type: 'output_text', text });
277
+ }
278
+ };
279
+ if (typeof content === 'string') {
280
+ pushText(content);
281
+ } else if (Array.isArray(content)) {
282
+ for (const item of content) {
283
+ if (!item) continue;
284
+ if (typeof item === 'string') {
285
+ pushText(item);
286
+ continue;
287
+ }
288
+ if (typeof item !== 'object') continue;
289
+ const type = typeof item.type === 'string' ? item.type : '';
290
+ if ((type === 'text' || type === 'output_text') && typeof item.text === 'string') {
291
+ pushText(item.text);
292
+ continue;
293
+ }
294
+ if (typeof item.content === 'string') {
295
+ pushText(item.content);
296
+ }
297
+ }
298
+ }
299
+ if (typeof refusal === 'string' && refusal) {
300
+ blocks.push({ type: 'refusal', refusal });
301
+ }
302
+ return blocks;
303
+ }
230
304
 
305
+ function buildResponsesPayloadFromChatCompletion(payload, fallbackModel = '') {
306
+ const base = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
307
+ const choice = Array.isArray(base.choices) ? base.choices[0] : null;
308
+ const message = choice && typeof choice === 'object' && choice.message && typeof choice.message === 'object'
309
+ ? choice.message
310
+ : {};
311
+ const output = [];
312
+ const messageContent = normalizeChatMessageContentToResponsesContent(message.content, message.refusal);
313
+ if (messageContent.length > 0 || !Array.isArray(message.tool_calls) || message.tool_calls.length === 0) {
314
+ output.push({
315
+ type: 'message',
316
+ role: 'assistant',
317
+ content: messageContent.length > 0 ? messageContent : [{ type: 'output_text', text: '' }]
318
+ });
319
+ }
320
+ if (Array.isArray(message.tool_calls)) {
321
+ for (const toolCall of message.tool_calls) {
322
+ if (!toolCall || typeof toolCall !== 'object') continue;
323
+ const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : {};
324
+ const name = typeof fn.name === 'string' ? fn.name : '';
325
+ if (!name) continue;
326
+ output.push({
327
+ type: 'function_call',
328
+ call_id: typeof toolCall.id === 'string' && toolCall.id ? toolCall.id : `call_${crypto.randomBytes(8).toString('hex')}`,
329
+ name,
330
+ arguments: stringifyJsonValue(fn.arguments, '{}')
331
+ });
332
+ }
333
+ }
334
+ const finish = mapChatFinishReasonToResponses(choice);
335
+ return ensureResponseMetadata({
336
+ id: typeof base.id === 'string' ? base.id : undefined,
337
+ model: typeof base.model === 'string' ? base.model : fallbackModel,
338
+ status: finish.status,
339
+ ...(finish.incomplete_details ? { incomplete_details: finish.incomplete_details } : {}),
340
+ output,
341
+ usage: normalizeChatUsageToResponsesUsage(base.usage)
342
+ });
343
+ }
344
+
345
+ function normalizeResponsesInputToChatMessages(input) {
346
+ // 参考 cc-switch 的 Responses 转换形态:message content 保持为消息,function_call /
347
+ // function_call_output 提升为 OpenAI Chat 的 assistant tool_calls / tool 消息。
231
348
  const toChatContent = (blocks) => {
232
349
  if (!Array.isArray(blocks)) return '';
233
350
  const out = [];
234
351
  for (const block of blocks) {
235
352
  if (!block || typeof block !== 'object') continue;
236
353
  const type = typeof block.type === 'string' ? block.type : '';
237
- if (type === 'input_text' && typeof block.text === 'string') {
354
+ if ((type === 'input_text' || type === 'output_text' || type === 'text') && typeof block.text === 'string') {
238
355
  out.push({ type: 'text', text: block.text });
239
356
  continue;
240
357
  }
358
+ if (type === 'refusal' && typeof block.refusal === 'string') {
359
+ out.push({ type: 'text', text: block.refusal });
360
+ continue;
361
+ }
241
362
  if (type === 'input_image') {
242
363
  const raw = block.image_url != null ? block.image_url : block.imageUrl;
243
364
  const url = typeof raw === 'string'
@@ -248,11 +369,6 @@ function createBuiltinProxyRuntimeController(deps = {}) {
248
369
  }
249
370
  continue;
250
371
  }
251
- // 容错:兼容已是 chat content 的 {type:"text"} / {type:"image_url"}
252
- if (type === 'text' && typeof block.text === 'string') {
253
- out.push({ type: 'text', text: block.text });
254
- continue;
255
- }
256
372
  if (type === 'image_url' && block.image_url) {
257
373
  out.push({ type: 'image_url', image_url: block.image_url });
258
374
  }
@@ -261,26 +377,67 @@ function createBuiltinProxyRuntimeController(deps = {}) {
261
377
  return out;
262
378
  };
263
379
 
264
- const messages = [];
265
- for (const item of input) {
266
- if (!item || typeof item !== 'object') continue;
380
+ const messageFromResponsesItem = (item) => {
381
+ if (!item || typeof item !== 'object') return null;
382
+ const type = typeof item.type === 'string' ? item.type : '';
383
+ if (type === 'function_call') {
384
+ const name = typeof item.name === 'string' ? item.name : '';
385
+ if (!name) return null;
386
+ return {
387
+ role: 'assistant',
388
+ content: null,
389
+ tool_calls: [{
390
+ id: typeof item.call_id === 'string' && item.call_id ? item.call_id : (typeof item.id === 'string' ? item.id : `call_${crypto.randomBytes(8).toString('hex')}`),
391
+ type: 'function',
392
+ function: {
393
+ name,
394
+ arguments: stringifyJsonValue(item.arguments, '{}')
395
+ }
396
+ }]
397
+ };
398
+ }
399
+ if (type === 'function_call_output') {
400
+ const callId = typeof item.call_id === 'string' ? item.call_id : '';
401
+ return {
402
+ role: 'tool',
403
+ tool_call_id: callId,
404
+ content: stringifyJsonValue(item.output, '')
405
+ };
406
+ }
267
407
  if (typeof item.role === 'string' && item.content != null) {
268
408
  const role = item.role.trim() || 'user';
269
409
  const content = Array.isArray(item.content)
270
410
  ? toChatContent(item.content)
271
411
  : item.content;
272
- if (content) {
273
- messages.push({ role, content });
274
- }
275
- continue;
412
+ return content || content === null ? { role, content } : null;
276
413
  }
414
+ if (type) {
415
+ const content = toChatContent([item]);
416
+ return content ? { role: 'user', content } : null;
417
+ }
418
+ return null;
419
+ };
420
+
421
+ if (typeof input === 'string') {
422
+ return [{ role: 'user', content: input }];
423
+ }
424
+ if (input && typeof input === 'object' && !Array.isArray(input)) {
425
+ const message = messageFromResponsesItem(input);
426
+ return message ? [message] : [];
427
+ }
428
+ if (!Array.isArray(input)) {
429
+ return [];
277
430
  }
278
431
 
432
+ const messages = [];
433
+ for (const item of input) {
434
+ const message = messageFromResponsesItem(item);
435
+ if (message) messages.push(message);
436
+ }
279
437
  if (messages.length > 0) {
280
438
  return messages;
281
439
  }
282
440
 
283
- // 退化:把 input array 当作单条 user content blocks
284
441
  const fallbackContent = toChatContent(input);
285
442
  if (fallbackContent) {
286
443
  return [{ role: 'user', content: fallbackContent }];
@@ -288,6 +445,99 @@ function createBuiltinProxyRuntimeController(deps = {}) {
288
445
  return [];
289
446
  }
290
447
 
448
+ function normalizeResponsesToolsToChatTools(tools) {
449
+ if (!Array.isArray(tools)) return tools;
450
+ return tools
451
+ .map((tool) => {
452
+ if (!tool || typeof tool !== 'object') return null;
453
+ if (tool.type !== 'function') return tool;
454
+ const sourceFn = tool.function && typeof tool.function === 'object' && !Array.isArray(tool.function)
455
+ ? tool.function
456
+ : {};
457
+ const name = typeof sourceFn.name === 'string' && sourceFn.name.trim()
458
+ ? sourceFn.name.trim()
459
+ : (typeof tool.name === 'string' ? tool.name.trim() : '');
460
+ if (!name) return null;
461
+ const description = typeof sourceFn.description === 'string'
462
+ ? sourceFn.description
463
+ : (typeof tool.description === 'string' ? tool.description : undefined);
464
+ const parameters = sourceFn.parameters && typeof sourceFn.parameters === 'object' && !Array.isArray(sourceFn.parameters)
465
+ ? sourceFn.parameters
466
+ : (tool.parameters && typeof tool.parameters === 'object' && !Array.isArray(tool.parameters) ? tool.parameters : {});
467
+ const strict = typeof sourceFn.strict === 'boolean'
468
+ ? sourceFn.strict
469
+ : (typeof tool.strict === 'boolean' ? tool.strict : undefined);
470
+ const fn = { name, parameters };
471
+ if (description !== undefined) fn.description = description;
472
+ if (strict !== undefined) fn.strict = strict;
473
+ return { type: 'function', function: fn };
474
+ })
475
+ .filter(Boolean);
476
+ }
477
+
478
+ function normalizeResponsesToolChoiceToChatToolChoice(toolChoice) {
479
+ if (!toolChoice || typeof toolChoice !== 'object' || Array.isArray(toolChoice)) return toolChoice;
480
+ if (toolChoice.type === 'function' && typeof toolChoice.name === 'string') {
481
+ return { type: 'function', function: { name: toolChoice.name } };
482
+ }
483
+ return toolChoice;
484
+ }
485
+
486
+ function buildChatCompletionsBodyFromResponsesPayload(payload) {
487
+ const source = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
488
+ const messages = normalizeResponsesInputToChatMessages(source.input);
489
+ const instructions = typeof source.instructions === 'string' ? source.instructions.trim() : '';
490
+ if (instructions) {
491
+ messages.unshift({ role: 'system', content: instructions });
492
+ }
493
+
494
+ const chatBody = {
495
+ model: typeof source.model === 'string' ? source.model : '',
496
+ messages,
497
+ stream: false
498
+ };
499
+
500
+ const passthroughKeys = [
501
+ 'frequency_penalty',
502
+ 'presence_penalty',
503
+ 'response_format',
504
+ 'stop',
505
+ 'temperature',
506
+ 'top_p',
507
+ 'tools',
508
+ 'tool_choice',
509
+ 'logprobs',
510
+ 'top_logprobs',
511
+ 'kbs',
512
+ 'is_online',
513
+ 'user',
514
+ 'seed',
515
+ 'n',
516
+ 'modalities',
517
+ 'audio',
518
+ 'reasoning_effort'
519
+ ];
520
+ for (const key of passthroughKeys) {
521
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
522
+ if (key === 'tools') {
523
+ chatBody[key] = normalizeResponsesToolsToChatTools(source[key]);
524
+ } else if (key === 'tool_choice') {
525
+ chatBody[key] = normalizeResponsesToolChoiceToChatToolChoice(source[key]);
526
+ } else {
527
+ chatBody[key] = source[key];
528
+ }
529
+ }
530
+ }
531
+
532
+ if (Object.prototype.hasOwnProperty.call(source, 'max_tokens')) {
533
+ chatBody.max_tokens = source.max_tokens;
534
+ } else if (source.max_output_tokens != null) {
535
+ chatBody.max_tokens = source.max_output_tokens;
536
+ }
537
+
538
+ return chatBody;
539
+ }
540
+
291
541
  function ensureResponseMetadata(payload) {
292
542
  const base = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
293
543
  const id = typeof base.id === 'string' && base.id.trim()
@@ -387,6 +637,291 @@ function createBuiltinProxyRuntimeController(deps = {}) {
387
637
  writeSse(res, 'done', '[DONE]');
388
638
  }
389
639
 
640
+ function appendChatStreamToolCall(target, toolCall) {
641
+ if (!toolCall || typeof toolCall !== 'object') return;
642
+ const index = Number.isFinite(toolCall.index) ? toolCall.index : target.length;
643
+ if (!target[index]) {
644
+ target[index] = {
645
+ id: '',
646
+ type: 'function',
647
+ function: { name: '', arguments: '' }
648
+ };
649
+ }
650
+ const current = target[index];
651
+ if (typeof toolCall.id === 'string' && toolCall.id) current.id = toolCall.id;
652
+ if (typeof toolCall.type === 'string' && toolCall.type) current.type = toolCall.type;
653
+ const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : null;
654
+ if (fn) {
655
+ if (typeof fn.name === 'string' && fn.name) current.function.name = fn.name;
656
+ if (typeof fn.arguments === 'string') current.function.arguments += fn.arguments;
657
+ }
658
+ }
659
+
660
+ function writeChatCompletionChunkAsResponsesSse(state, chunk) {
661
+ if (!chunk || typeof chunk !== 'object') return;
662
+ if (typeof chunk.model === 'string' && chunk.model) {
663
+ state.model = chunk.model;
664
+ }
665
+ const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
666
+ for (const choice of choices) {
667
+ const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null;
668
+ if (!delta) continue;
669
+
670
+ if (typeof delta.content === 'string' && delta.content) {
671
+ if (!state.messageItem) {
672
+ state.messageItem = {
673
+ id: `msg_${crypto.randomBytes(8).toString('hex')}`,
674
+ type: 'message',
675
+ role: 'assistant',
676
+ content: [{ type: 'output_text', text: '' }]
677
+ };
678
+ state.output.push(state.messageItem);
679
+ writeSse(state.res, 'response.output_item.added', {
680
+ type: 'response.output_item.added',
681
+ output_index: state.output.length - 1,
682
+ item: state.messageItem
683
+ });
684
+ }
685
+ state.messageText += delta.content;
686
+ state.messageItem.content[0].text = state.messageText;
687
+ writeSse(state.res, 'response.output_text.delta', {
688
+ type: 'response.output_text.delta',
689
+ item_id: state.messageItem.id,
690
+ output_index: state.output.length - 1,
691
+ content_index: 0,
692
+ delta: delta.content,
693
+ sequence_number: state.nextSeq()
694
+ });
695
+ }
696
+
697
+ if (Array.isArray(delta.tool_calls)) {
698
+ for (const toolCall of delta.tool_calls) {
699
+ appendChatStreamToolCall(state.toolCalls, toolCall);
700
+ }
701
+ }
702
+ }
703
+ }
704
+
705
+ function finishChatStreamResponsesSse(state) {
706
+ if (state.finished) return;
707
+ state.finished = true;
708
+
709
+ if (state.messageItem) {
710
+ const outputIndex = state.output.indexOf(state.messageItem);
711
+ writeSse(state.res, 'response.output_text.done', {
712
+ type: 'response.output_text.done',
713
+ item_id: state.messageItem.id,
714
+ output_index: outputIndex,
715
+ content_index: 0,
716
+ text: state.messageText,
717
+ sequence_number: state.nextSeq()
718
+ });
719
+ writeSse(state.res, 'response.output_item.done', {
720
+ type: 'response.output_item.done',
721
+ output_index: outputIndex,
722
+ item: state.messageItem,
723
+ sequence_number: state.nextSeq()
724
+ });
725
+ }
726
+
727
+ for (const toolCall of state.toolCalls) {
728
+ if (!toolCall) continue;
729
+ const item = {
730
+ type: 'function_call',
731
+ call_id: toolCall.id || `call_${crypto.randomBytes(8).toString('hex')}`,
732
+ name: toolCall.function && typeof toolCall.function.name === 'string' ? toolCall.function.name : '',
733
+ arguments: toolCall.function && typeof toolCall.function.arguments === 'string' ? toolCall.function.arguments : ''
734
+ };
735
+ const outputIndex = state.output.length;
736
+ state.output.push(item);
737
+ writeSse(state.res, 'response.output_item.added', {
738
+ type: 'response.output_item.added',
739
+ output_index: outputIndex,
740
+ item
741
+ });
742
+ writeSse(state.res, 'response.output_item.done', {
743
+ type: 'response.output_item.done',
744
+ output_index: outputIndex,
745
+ item,
746
+ sequence_number: state.nextSeq()
747
+ });
748
+ }
749
+
750
+ const response = ensureResponseMetadata({
751
+ id: state.responseId,
752
+ model: state.model,
753
+ created_at: state.createdAt,
754
+ status: 'completed',
755
+ output: state.output
756
+ });
757
+ writeSse(state.res, 'response.completed', { type: 'response.completed', response });
758
+ writeSse(state.res, 'done', '[DONE]');
759
+ state.res.end();
760
+ }
761
+
762
+ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
763
+ const parsed = new URL(targetUrl);
764
+ const transport = parsed.protocol === 'https:' ? https : http;
765
+ const bodyText = options.body ? JSON.stringify(options.body) : '';
766
+ const headers = {
767
+ 'Accept': 'text/event-stream',
768
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
769
+ ...(options.headers || {})
770
+ };
771
+ if (options.body) {
772
+ headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
773
+ }
774
+ const timeoutMs = Number.isFinite(options.timeoutMs)
775
+ ? Math.max(1000, Number(options.timeoutMs))
776
+ : 30000;
777
+ const res = options.res;
778
+ const model = typeof options.model === 'string' ? options.model : '';
779
+
780
+ return new Promise((resolve) => {
781
+ let settled = false;
782
+ const finish = (value) => {
783
+ if (settled) return;
784
+ settled = true;
785
+ resolve(value);
786
+ };
787
+ const req = transport.request({
788
+ protocol: parsed.protocol,
789
+ hostname: parsed.hostname,
790
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
791
+ method: options.method || 'POST',
792
+ path: `${parsed.pathname}${parsed.search}`,
793
+ headers,
794
+ agent: parsed.protocol === 'https:' ? HTTPS_KEEP_ALIVE_AGENT : HTTP_KEEP_ALIVE_AGENT
795
+ }, (upstreamRes) => {
796
+ const status = upstreamRes.statusCode || 0;
797
+ const chunks = [];
798
+ const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
799
+
800
+ if (status === 404 || status === 405) {
801
+ upstreamRes.on('data', (chunk) => chunk && chunks.push(chunk));
802
+ upstreamRes.on('end', () => finish({ retry: true, status, bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : '' }));
803
+ return;
804
+ }
805
+
806
+ if (status >= 400) {
807
+ upstreamRes.on('data', (chunk) => chunk && chunks.push(chunk));
808
+ upstreamRes.on('end', () => finish({ ok: false, status, bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : '' }));
809
+ return;
810
+ }
811
+
812
+ res.writeHead(200, {
813
+ 'Content-Type': 'text/event-stream; charset=utf-8',
814
+ 'Cache-Control': 'no-cache',
815
+ 'Connection': 'keep-alive',
816
+ 'X-Accel-Buffering': 'no'
817
+ });
818
+
819
+ if (!/text\/event-stream/i.test(contentType)) {
820
+ upstreamRes.on('data', (chunk) => chunk && chunks.push(chunk));
821
+ upstreamRes.on('end', () => {
822
+ const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
823
+ const parsedJson = parseJsonOrError(text);
824
+ if (parsedJson.error) {
825
+ writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
826
+ writeSse(res, 'done', '[DONE]');
827
+ res.end();
828
+ finish({ ok: true });
829
+ return;
830
+ }
831
+ sendResponsesSse(res, buildResponsesPayloadFromChatCompletion(parsedJson.value, model));
832
+ res.end();
833
+ finish({ ok: true });
834
+ });
835
+ return;
836
+ }
837
+
838
+ let sequence = 0;
839
+ const state = {
840
+ res,
841
+ responseId: `resp_${crypto.randomBytes(10).toString('hex')}`,
842
+ model,
843
+ createdAt: Math.floor(Date.now() / 1000),
844
+ output: [],
845
+ messageItem: null,
846
+ messageText: '',
847
+ toolCalls: [],
848
+ finished: false,
849
+ nextSeq: () => {
850
+ sequence += 1;
851
+ return sequence;
852
+ }
853
+ };
854
+ writeSse(res, 'response.created', {
855
+ type: 'response.created',
856
+ response: {
857
+ id: state.responseId,
858
+ model: state.model,
859
+ created_at: state.createdAt
860
+ }
861
+ });
862
+
863
+ let buffer = '';
864
+ const handleEventBlock = (block) => {
865
+ const dataLines = String(block || '')
866
+ .split(/\r?\n/)
867
+ .filter((line) => line.startsWith('data:'))
868
+ .map((line) => line.slice(5).trimStart());
869
+ if (dataLines.length === 0) return;
870
+ const data = dataLines.join('\n').trim();
871
+ if (!data) return;
872
+ if (data === '[DONE]') {
873
+ finishChatStreamResponsesSse(state);
874
+ finish({ ok: true });
875
+ return;
876
+ }
877
+ const parsedChunk = parseJsonOrError(data);
878
+ if (!parsedChunk.error) {
879
+ writeChatCompletionChunkAsResponsesSse(state, parsedChunk.value);
880
+ }
881
+ };
882
+
883
+ upstreamRes.on('data', (chunk) => {
884
+ buffer += chunk.toString('utf-8');
885
+ let boundary = buffer.search(/\r?\n\r?\n/);
886
+ while (boundary >= 0) {
887
+ const block = buffer.slice(0, boundary);
888
+ const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
889
+ buffer = buffer.slice(boundary + (match ? match[0].length : 2));
890
+ handleEventBlock(block);
891
+ boundary = buffer.search(/\r?\n\r?\n/);
892
+ }
893
+ });
894
+ upstreamRes.on('end', () => {
895
+ if (buffer.trim()) handleEventBlock(buffer);
896
+ finishChatStreamResponsesSse(state);
897
+ finish({ ok: true });
898
+ });
899
+ });
900
+ req.setTimeout(timeoutMs, () => {
901
+ try { req.destroy(new Error('timeout')); } catch (_) {}
902
+ finish({ ok: false, error: 'timeout' });
903
+ });
904
+ req.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
905
+ if (bodyText) req.write(bodyText);
906
+ req.end();
907
+ });
908
+ }
909
+
910
+ async function streamChatCompletionsAsResponsesSseWithFallbackUrls(baseUrl, pathSuffix, options = {}) {
911
+ const urls = buildUpstreamUrlCandidates(baseUrl, pathSuffix);
912
+ if (urls.length === 0) {
913
+ return { ok: false, error: 'failed to build upstream URL' };
914
+ }
915
+ let lastResult = null;
916
+ for (const url of urls) {
917
+ const result = await streamChatCompletionsAsResponsesSse(url, options);
918
+ lastResult = result;
919
+ if (result && result.retry) continue;
920
+ return result;
921
+ }
922
+ return lastResult || { ok: false, error: 'failed to build upstream URL' };
923
+ }
924
+
390
925
  function canListenPort(host, port) {
391
926
  return new Promise((resolve) => {
392
927
  const tester = net.createServer();
@@ -761,15 +1296,12 @@ function createBuiltinProxyRuntimeController(deps = {}) {
761
1296
  'X-Codexmate-Proxy': '1'
762
1297
  };
763
1298
 
764
- const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
765
- const upstreamResponses = upstreamResponsesUrl
766
- ? await proxyRequestJson(upstreamResponsesUrl, {
767
- method: 'POST',
768
- headers: commonHeaders,
769
- timeoutMs,
770
- body: { ...payload, stream: false }
771
- })
772
- : { ok: false, error: 'failed to build upstream URL' };
1299
+ const upstreamResponses = await proxyRequestJsonWithFallbackUrls(upstream.baseUrl, 'responses', {
1300
+ method: 'POST',
1301
+ headers: commonHeaders,
1302
+ timeoutMs,
1303
+ body: { ...payload, stream: false }
1304
+ });
773
1305
 
774
1306
  // 优先走上游 /responses(如果支持)。若上游报错且不是“端点不支持”,则直接透传错误。
775
1307
  if (upstreamResponses.ok && upstreamResponses.status >= 200 && upstreamResponses.status < 300) {
@@ -806,30 +1338,42 @@ function createBuiltinProxyRuntimeController(deps = {}) {
806
1338
  }
807
1339
 
808
1340
  if (!upstreamResponses.ok) {
809
- res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
810
- res.end(JSON.stringify({ error: upstreamResponses.error || 'Upstream request failed' }));
811
- return;
1341
+ if (!shouldFallbackFromUpstreamResponsesFailure(upstreamResponses.error)) {
1342
+ res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1343
+ res.end(JSON.stringify({ error: upstreamResponses.error || 'Upstream request failed' }));
1344
+ return;
1345
+ }
1346
+ // Some OpenAI-compatible gateways accept /responses but never complete it.
1347
+ // Treat that as an unsupported Responses endpoint and try the chat fallback.
812
1348
  }
813
1349
 
814
1350
  const model = typeof payload.model === 'string' ? payload.model : '';
815
- const messages = normalizeResponsesInputToChatMessages(payload.input);
816
- const chatBody = {
817
- model,
818
- messages,
819
- stream: false
820
- };
821
- if (payload.max_output_tokens != null && chatBody.max_tokens == null) {
822
- chatBody.max_tokens = payload.max_output_tokens;
823
- }
1351
+ const chatBody = buildChatCompletionsBodyFromResponsesPayload(payload);
824
1352
 
825
- const upstreamChatUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
826
- if (!upstreamChatUrl) {
827
- res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
828
- res.end(JSON.stringify({ error: 'failed to build upstream URL' }));
1353
+ if (wantsStream) {
1354
+ const streamingChatBody = { ...chatBody, stream: true };
1355
+ const streamed = await streamChatCompletionsAsResponsesSseWithFallbackUrls(upstream.baseUrl, 'chat/completions', {
1356
+ method: 'POST',
1357
+ headers: commonHeaders,
1358
+ timeoutMs,
1359
+ body: streamingChatBody,
1360
+ res,
1361
+ model
1362
+ });
1363
+ if (!streamed.ok) {
1364
+ if (!res.headersSent) {
1365
+ res.writeHead(streamed.status && streamed.status >= 400 ? streamed.status : 502, { 'Content-Type': 'application/json; charset=utf-8' });
1366
+ res.end(streamed.bodyText || JSON.stringify({ error: streamed.error || 'proxy request failed' }));
1367
+ } else if (!res.writableEnded) {
1368
+ writeSse(res, 'response.failed', { type: 'response.failed', error: streamed.error || streamed.bodyText || 'proxy request failed' });
1369
+ writeSse(res, 'done', '[DONE]');
1370
+ res.end();
1371
+ }
1372
+ }
829
1373
  return;
830
1374
  }
831
1375
 
832
- const upstreamChat = await proxyRequestJson(upstreamChatUrl, {
1376
+ const upstreamChat = await proxyRequestJsonWithFallbackUrls(upstream.baseUrl, 'chat/completions', {
833
1377
  method: 'POST',
834
1378
  headers: commonHeaders,
835
1379
  timeoutMs,
@@ -841,6 +1385,12 @@ function createBuiltinProxyRuntimeController(deps = {}) {
841
1385
  return;
842
1386
  }
843
1387
 
1388
+ if (upstreamChat.status >= 400) {
1389
+ res.writeHead(upstreamChat.status, { 'Content-Type': 'application/json; charset=utf-8' });
1390
+ res.end(upstreamChat.bodyText || JSON.stringify({ error: 'Upstream error' }));
1391
+ return;
1392
+ }
1393
+
844
1394
  const chatJson = parseJsonOrError(upstreamChat.bodyText);
845
1395
  if (chatJson.error) {
846
1396
  res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -848,16 +1398,7 @@ function createBuiltinProxyRuntimeController(deps = {}) {
848
1398
  return;
849
1399
  }
850
1400
 
851
- const { text } = extractChatCompletionResult(chatJson.value);
852
- const responsesPayload = ensureResponseMetadata({
853
- model,
854
- output: [{
855
- type: 'message',
856
- role: 'assistant',
857
- content: [{ type: 'output_text', text }]
858
- }],
859
- usage: chatJson.value && chatJson.value.usage ? chatJson.value.usage : undefined
860
- });
1401
+ const responsesPayload = buildResponsesPayloadFromChatCompletion(chatJson.value, model);
861
1402
 
862
1403
  if (wantsStream) {
863
1404
  res.writeHead(200, {