codexmate 0.0.27 → 0.0.29

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 (51) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/cli/builtin-proxy.js +430 -4
  4. package/cli/openai-bridge.js +498 -13
  5. package/cli.js +130 -41
  6. package/lib/cli-models-utils.js +71 -10
  7. package/lib/cli-webhook.js +126 -0
  8. package/package.json +76 -74
  9. package/plugins/prompt-templates/computed.mjs +1 -1
  10. package/plugins/prompt-templates/methods.mjs +0 -66
  11. package/plugins/prompt-templates/overview.mjs +1 -0
  12. package/web-ui/app.js +21 -16
  13. package/web-ui/index.html +1 -0
  14. package/web-ui/logic.codex.mjs +69 -0
  15. package/web-ui/modules/app.computed.dashboard.mjs +54 -0
  16. package/web-ui/modules/app.computed.session.mjs +22 -17
  17. package/web-ui/modules/app.methods.claude-config.mjs +24 -8
  18. package/web-ui/modules/app.methods.codex-config.mjs +35 -3
  19. package/web-ui/modules/app.methods.index.mjs +2 -0
  20. package/web-ui/modules/app.methods.navigation.mjs +21 -3
  21. package/web-ui/modules/app.methods.providers.mjs +96 -7
  22. package/web-ui/modules/app.methods.session-actions.mjs +3 -6
  23. package/web-ui/modules/app.methods.session-browser.mjs +1 -6
  24. package/web-ui/modules/app.methods.session-trash.mjs +6 -7
  25. package/web-ui/modules/app.methods.startup-claude.mjs +8 -1
  26. package/web-ui/modules/app.methods.webhook.mjs +79 -0
  27. package/web-ui/modules/i18n.dict.mjs +1104 -104
  28. package/web-ui/modules/i18n.mjs +9 -3
  29. package/web-ui/modules/provider-url-display.mjs +17 -0
  30. package/web-ui/partials/index/layout-header.html +25 -0
  31. package/web-ui/partials/index/modals-basic.html +0 -3
  32. package/web-ui/partials/index/panel-config-claude.html +10 -3
  33. package/web-ui/partials/index/panel-config-codex.html +44 -4
  34. package/web-ui/partials/index/panel-plugins.html +3 -29
  35. package/web-ui/partials/index/panel-sessions.html +0 -10
  36. package/web-ui/partials/index/panel-settings.html +93 -177
  37. package/web-ui/partials/index/panel-trash.html +88 -0
  38. package/web-ui/session-helpers.mjs +2 -2
  39. package/web-ui/styles/base-theme.css +47 -34
  40. package/web-ui/styles/controls-forms.css +27 -28
  41. package/web-ui/styles/docs-panel.css +63 -39
  42. package/web-ui/styles/layout-shell.css +69 -46
  43. package/web-ui/styles/modals-core.css +12 -10
  44. package/web-ui/styles/navigation-panels.css +36 -35
  45. package/web-ui/styles/responsive.css +4 -4
  46. package/web-ui/styles/sessions-list.css +10 -6
  47. package/web-ui/styles/settings-panel.css +197 -33
  48. package/web-ui/styles/titles-cards.css +90 -26
  49. package/web-ui/styles/trash-panel.css +90 -0
  50. package/web-ui/styles/webhook.css +81 -0
  51. package/web-ui/styles.css +2 -0
@@ -281,7 +281,10 @@ function normalizeResponsesInputToChatMessages(input) {
281
281
 
282
282
  const toRole = (value) => {
283
283
  const roleRaw = typeof value === 'string' ? value.trim().toLowerCase() : '';
284
- return roleRaw === 'assistant' ? 'assistant' : (roleRaw === 'system' ? 'system' : 'user');
284
+ if (roleRaw === 'assistant') return 'assistant';
285
+ // codex 把 AGENTS.md 注入 developer 角色;Responses 的 developer 在 chat 侧等价于 system。
286
+ if (roleRaw === 'system' || roleRaw === 'developer') return 'system';
287
+ return 'user';
285
288
  };
286
289
 
287
290
  if (input && typeof input === 'object' && !Array.isArray(input)) {
@@ -439,6 +442,43 @@ function normalizeResponsesToolsForResponsesApi(tools) {
439
442
  .filter(Boolean);
440
443
  }
441
444
 
445
+ function mergeLeadingSystemMessages(messages, leadingInstructions) {
446
+ const segments = [];
447
+ const seen = new Set();
448
+ const pushSegment = (text) => {
449
+ const trimmed = typeof text === 'string' ? text.trim() : '';
450
+ if (!trimmed || seen.has(trimmed)) return;
451
+ seen.add(trimmed);
452
+ segments.push(trimmed);
453
+ };
454
+ if (typeof leadingInstructions === 'string') {
455
+ pushSegment(leadingInstructions);
456
+ }
457
+ const rest = [];
458
+ for (const msg of messages) {
459
+ if (msg && msg.role === 'system') {
460
+ const content = msg.content;
461
+ if (typeof content === 'string') {
462
+ pushSegment(content);
463
+ } else if (Array.isArray(content)) {
464
+ for (const part of content) {
465
+ if (part && typeof part === 'object' && typeof part.text === 'string') {
466
+ pushSegment(part.text);
467
+ }
468
+ }
469
+ }
470
+ continue;
471
+ }
472
+ rest.push(msg);
473
+ }
474
+ const out = [];
475
+ if (segments.length) {
476
+ out.push({ role: 'system', content: segments.join('\n\n---\n\n') });
477
+ }
478
+ for (const msg of rest) out.push(msg);
479
+ return out;
480
+ }
481
+
442
482
  function convertResponsesRequestToChatCompletions(payload) {
443
483
  const body = payload && typeof payload === 'object' ? payload : {};
444
484
  const model = typeof body.model === 'string' ? body.model.trim() : '';
@@ -446,12 +486,10 @@ function convertResponsesRequestToChatCompletions(payload) {
446
486
  return { error: 'responses 请求缺少 model' };
447
487
  }
448
488
 
449
- const messages = [];
450
- // Align with Maxx/CLIProxyAPI style: map "instructions" to a leading system message.
451
- if (typeof body.instructions === 'string' && body.instructions.trim()) {
452
- messages.push({ role: 'system', content: body.instructions.trim() });
453
- }
454
- messages.push(...normalizeResponsesInputToChatMessages(body.input));
489
+ const rawMessages = normalizeResponsesInputToChatMessages(body.input);
490
+ // codex 同时下发 body.instructions(内置 prompt)与 input developer/system 消息(AGENTS.md)。
491
+ // 合流为一条领头 system,避免某些上游"只认第一条 system"导致 AGENTS.md 失效。
492
+ const messages = mergeLeadingSystemMessages(rawMessages, body.instructions);
455
493
  if (!messages.length) {
456
494
  // codex sometimes sends empty input for probes; tolerate.
457
495
  messages.push({ role: 'user', content: '' });
@@ -716,7 +754,44 @@ function isLoopbackAddress(address) {
716
754
  return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
717
755
  }
718
756
 
757
+ function isTransientNetworkError(error) {
758
+ const text = String(error || '').trim();
759
+ if (!text) return false;
760
+ if (/socket hang up/i.test(text)) return true;
761
+ if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true;
762
+ if (/EAI_AGAIN/i.test(text)) return true;
763
+ if (/UND_ERR_SOCKET/i.test(text)) return true;
764
+ if (/disconnected before|secure tls|tls handshake/i.test(text)) return true;
765
+ return false;
766
+ }
767
+
768
+ const TRANSIENT_RETRY_DELAYS_MS = [200, 600];
769
+
770
+ async function retryTransientRequest(executor) {
771
+ let lastResult = null;
772
+ for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) {
773
+ if (attempt > 0) {
774
+ const delay = TRANSIENT_RETRY_DELAYS_MS[attempt - 1];
775
+ // eslint-disable-next-line no-await-in-loop
776
+ await new Promise((r) => {
777
+ const t = setTimeout(r, delay);
778
+ if (typeof t.unref === 'function') t.unref();
779
+ });
780
+ }
781
+ // eslint-disable-next-line no-await-in-loop
782
+ const result = await executor(attempt);
783
+ lastResult = result;
784
+ if (!result) return result;
785
+ if (result.ok) return result;
786
+ if (result.retry) return result;
787
+ if (result.status && result.status > 0) return result;
788
+ if (!isTransientNetworkError(result.error)) return result;
789
+ }
790
+ return lastResult;
791
+ }
792
+
719
793
  function writeSse(res, eventName, dataObj) {
794
+ if (!res || res.writableEnded || res.destroyed) return;
720
795
  if (eventName) {
721
796
  res.write(`event: ${eventName}\n`);
722
797
  }
@@ -727,6 +802,378 @@ function writeSse(res, eventName, dataObj) {
727
802
  res.write(`data: ${JSON.stringify(dataObj)}\n\n`);
728
803
  }
729
804
 
805
+ function appendChatStreamToolCall(target, toolCall) {
806
+ if (!toolCall || typeof toolCall !== 'object') return;
807
+ const index = Number.isFinite(toolCall.index) ? toolCall.index : target.length;
808
+ if (!target[index]) {
809
+ target[index] = {
810
+ id: '',
811
+ type: 'function',
812
+ function: { name: '', arguments: '' }
813
+ };
814
+ }
815
+ const current = target[index];
816
+ if (typeof toolCall.id === 'string' && toolCall.id) current.id = toolCall.id;
817
+ if (typeof toolCall.type === 'string' && toolCall.type) current.type = toolCall.type;
818
+ const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : null;
819
+ if (fn) {
820
+ if (typeof fn.name === 'string' && fn.name) current.function.name = fn.name;
821
+ if (typeof fn.arguments === 'string') current.function.arguments += fn.arguments;
822
+ }
823
+ }
824
+
825
+ function writeChatCompletionChunkAsResponsesSse(state, chunk) {
826
+ if (!chunk || typeof chunk !== 'object') return;
827
+ if (typeof chunk.model === 'string' && chunk.model) {
828
+ state.model = chunk.model;
829
+ }
830
+ const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
831
+ for (const choice of choices) {
832
+ const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null;
833
+ if (!delta) continue;
834
+
835
+ const segments = [];
836
+ // DeepSeek-style OpenAI-compatible streams may emit private reasoning in
837
+ // `reasoning_content` before the final answer. Responses `output_text`
838
+ // must stay user-visible answer text only; forwarding reasoning here
839
+ // pollutes Codex output and breaks exact-answer prompts.
840
+ if (typeof delta.content === 'string' && delta.content) {
841
+ segments.push(delta.content);
842
+ }
843
+ for (const seg of segments) {
844
+ if (!state.messageItem) {
845
+ state.messageItem = {
846
+ id: `msg_${crypto.randomBytes(8).toString('hex')}`,
847
+ type: 'message',
848
+ role: 'assistant',
849
+ content: [{ type: 'output_text', text: '' }]
850
+ };
851
+ state.output.push(state.messageItem);
852
+ writeSse(state.res, 'response.output_item.added', {
853
+ type: 'response.output_item.added',
854
+ output_index: state.output.length - 1,
855
+ item: state.messageItem
856
+ });
857
+ }
858
+ state.messageText += seg;
859
+ state.messageItem.content[0].text = state.messageText;
860
+ writeSse(state.res, 'response.output_text.delta', {
861
+ type: 'response.output_text.delta',
862
+ item_id: state.messageItem.id,
863
+ output_index: state.output.length - 1,
864
+ content_index: 0,
865
+ delta: seg,
866
+ sequence_number: state.nextSeq()
867
+ });
868
+ }
869
+
870
+ if (Array.isArray(delta.tool_calls)) {
871
+ for (const toolCall of delta.tool_calls) {
872
+ appendChatStreamToolCall(state.toolCalls, toolCall);
873
+ }
874
+ }
875
+
876
+ if (typeof choice.finish_reason === 'string' && choice.finish_reason) {
877
+ state.sawFinishReason = true;
878
+ }
879
+ }
880
+ }
881
+
882
+ function finishChatStreamResponsesSse(state) {
883
+ if (!state || state.finished) return;
884
+ state.finished = true;
885
+
886
+ if (state.messageItem) {
887
+ const outputIndex = state.output.indexOf(state.messageItem);
888
+ writeSse(state.res, 'response.output_text.done', {
889
+ type: 'response.output_text.done',
890
+ item_id: state.messageItem.id,
891
+ output_index: outputIndex,
892
+ content_index: 0,
893
+ text: state.messageText,
894
+ sequence_number: state.nextSeq()
895
+ });
896
+ writeSse(state.res, 'response.output_item.done', {
897
+ type: 'response.output_item.done',
898
+ output_index: outputIndex,
899
+ item: state.messageItem,
900
+ sequence_number: state.nextSeq()
901
+ });
902
+ }
903
+
904
+ for (const toolCall of state.toolCalls) {
905
+ if (!toolCall) continue;
906
+ const name = toolCall.function && typeof toolCall.function.name === 'string' ? toolCall.function.name : '';
907
+ if (!name) continue;
908
+ const item = {
909
+ type: 'function_call',
910
+ call_id: toolCall.id || `call_${crypto.randomBytes(8).toString('hex')}`,
911
+ name,
912
+ arguments: toolCall.function && typeof toolCall.function.arguments === 'string' ? toolCall.function.arguments : ''
913
+ };
914
+ const outputIndex = state.output.length;
915
+ state.output.push(item);
916
+ writeSse(state.res, 'response.output_item.added', {
917
+ type: 'response.output_item.added',
918
+ output_index: outputIndex,
919
+ item
920
+ });
921
+ writeSse(state.res, 'response.output_item.done', {
922
+ type: 'response.output_item.done',
923
+ output_index: outputIndex,
924
+ item,
925
+ sequence_number: state.nextSeq()
926
+ });
927
+ }
928
+
929
+ const response = ensureResponseMetadata({
930
+ id: state.responseId,
931
+ model: state.model,
932
+ created_at: state.createdAt,
933
+ status: 'completed',
934
+ output: state.output,
935
+ output_text: state.messageText
936
+ });
937
+ writeSse(state.res, 'response.completed', { type: 'response.completed', response });
938
+ writeSse(state.res, 'done', '[DONE]');
939
+ if (!state.res.writableEnded && !state.res.destroyed) {
940
+ state.res.end();
941
+ }
942
+ }
943
+
944
+ function failChatStreamResponsesSse(state, errorMessage) {
945
+ if (!state || state.finished) return;
946
+ state.finished = true;
947
+ writeSse(state.res, 'response.failed', {
948
+ type: 'response.failed',
949
+ response: ensureResponseMetadata({
950
+ id: state.responseId,
951
+ model: state.model,
952
+ created_at: state.createdAt,
953
+ status: 'failed',
954
+ output: state.output,
955
+ output_text: state.messageText
956
+ }),
957
+ error: String(errorMessage || 'upstream stream failed')
958
+ });
959
+ writeSse(state.res, 'done', '[DONE]');
960
+ if (!state.res.writableEnded && !state.res.destroyed) {
961
+ state.res.end();
962
+ }
963
+ }
964
+
965
+ function formatUpstreamStreamError(errorValue) {
966
+ if (!errorValue) return 'upstream stream failed';
967
+ if (typeof errorValue === 'string') return errorValue;
968
+ if (typeof errorValue === 'object') {
969
+ if (typeof errorValue.message === 'string' && errorValue.message) return errorValue.message;
970
+ try { return JSON.stringify(errorValue); } catch (_) {}
971
+ }
972
+ return String(errorValue || 'upstream stream failed');
973
+ }
974
+
975
+ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
976
+ const parsed = new URL(targetUrl);
977
+ const transport = parsed.protocol === 'https:' ? https : http;
978
+ const bodyText = options.body ? JSON.stringify(options.body) : '';
979
+ const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
980
+ ? Math.floor(options.maxBytes)
981
+ : 0;
982
+ const headers = {
983
+ 'Accept': 'text/event-stream',
984
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
985
+ ...(options.headers || {})
986
+ };
987
+ if (options.body) {
988
+ headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
989
+ }
990
+ const timeoutMs = Number.isFinite(options.timeoutMs)
991
+ ? Math.max(1000, Number(options.timeoutMs))
992
+ : 30000;
993
+ const res = options.res;
994
+ const fallbackModel = typeof options.model === 'string' ? options.model : '';
995
+
996
+ return new Promise((resolve) => {
997
+ let settled = false;
998
+ let upstreamReq = null;
999
+ const finish = (value) => {
1000
+ if (settled) return;
1001
+ settled = true;
1002
+ resolve(value);
1003
+ };
1004
+ const abortUpstream = () => {
1005
+ if (upstreamReq) {
1006
+ try { upstreamReq.destroy(new Error('client aborted')); } catch (_) {}
1007
+ }
1008
+ };
1009
+ if (res && typeof res.once === 'function') {
1010
+ res.once('close', abortUpstream);
1011
+ }
1012
+
1013
+ upstreamReq = transport.request({
1014
+ protocol: parsed.protocol,
1015
+ hostname: parsed.hostname,
1016
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
1017
+ method: options.method || 'POST',
1018
+ path: `${parsed.pathname}${parsed.search}`,
1019
+ headers,
1020
+ agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
1021
+ }, (upstreamRes) => {
1022
+ const status = upstreamRes.statusCode || 0;
1023
+ const chunks = [];
1024
+ let size = 0;
1025
+ const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
1026
+
1027
+ const collectChunk = (chunk) => {
1028
+ if (!chunk) return true;
1029
+ if (maxBytes > 0) {
1030
+ size += chunk.length;
1031
+ if (size > maxBytes) {
1032
+ chunks.length = 0;
1033
+ try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
1034
+ try { upstreamReq.destroy(new Error('response too large')); } catch (_) {}
1035
+ finish({ ok: false, status, error: 'response too large' });
1036
+ return false;
1037
+ }
1038
+ }
1039
+ chunks.push(chunk);
1040
+ return true;
1041
+ };
1042
+
1043
+ if (status >= 400) {
1044
+ upstreamRes.on('data', collectChunk);
1045
+ upstreamRes.on('end', () => finish({
1046
+ ok: false,
1047
+ status,
1048
+ bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''
1049
+ }));
1050
+ return;
1051
+ }
1052
+
1053
+ if (!res.headersSent) {
1054
+ res.writeHead(200, {
1055
+ 'Content-Type': 'text/event-stream; charset=utf-8',
1056
+ 'Cache-Control': 'no-cache',
1057
+ 'Connection': 'keep-alive',
1058
+ 'X-Accel-Buffering': 'no'
1059
+ });
1060
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
1061
+ }
1062
+
1063
+ if (!/text\/event-stream/i.test(contentType)) {
1064
+ upstreamRes.on('data', collectChunk);
1065
+ upstreamRes.on('end', () => {
1066
+ const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
1067
+ const parsedJson = parseJsonOrError(text);
1068
+ if (parsedJson.error) {
1069
+ writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
1070
+ writeSse(res, 'done', '[DONE]');
1071
+ if (!res.writableEnded && !res.destroyed) res.end();
1072
+ finish({ ok: true });
1073
+ return;
1074
+ }
1075
+ const extracted = extractChatCompletionResult(parsedJson.value);
1076
+ sendResponsesSse(res, buildResponsesPayloadFromChatResult(fallbackModel, extracted.text, extracted.toolCalls, parsedJson.value));
1077
+ if (!res.writableEnded && !res.destroyed) res.end();
1078
+ finish({ ok: true });
1079
+ });
1080
+ return;
1081
+ }
1082
+
1083
+ let sequence = 0;
1084
+ const state = {
1085
+ res,
1086
+ responseId: `resp_${crypto.randomBytes(10).toString('hex')}`,
1087
+ model: fallbackModel,
1088
+ createdAt: Math.floor(Date.now() / 1000),
1089
+ output: [],
1090
+ messageItem: null,
1091
+ messageText: '',
1092
+ toolCalls: [],
1093
+ finished: false,
1094
+ sawDone: false,
1095
+ sawFinishReason: false,
1096
+ nextSeq: () => {
1097
+ sequence += 1;
1098
+ return sequence;
1099
+ }
1100
+ };
1101
+ writeSse(res, 'response.created', {
1102
+ type: 'response.created',
1103
+ response: {
1104
+ id: state.responseId,
1105
+ model: state.model,
1106
+ created_at: state.createdAt
1107
+ }
1108
+ });
1109
+
1110
+ let buffer = '';
1111
+ const handleEventBlock = (block) => {
1112
+ const dataLines = String(block || '')
1113
+ .split(/\r?\n/)
1114
+ .filter((line) => line.startsWith('data:'))
1115
+ .map((line) => line.slice(5).trimStart());
1116
+ if (dataLines.length === 0) return;
1117
+ const data = dataLines.join('\n').trim();
1118
+ if (!data) return;
1119
+ if (data === '[DONE]') {
1120
+ state.sawDone = true;
1121
+ finishChatStreamResponsesSse(state);
1122
+ finish({ ok: true });
1123
+ return;
1124
+ }
1125
+ const parsedChunk = parseJsonOrError(data);
1126
+ if (!parsedChunk.error) {
1127
+ if (parsedChunk.value && typeof parsedChunk.value === 'object' && parsedChunk.value.error) {
1128
+ failChatStreamResponsesSse(state, formatUpstreamStreamError(parsedChunk.value.error));
1129
+ finish({ ok: true });
1130
+ return;
1131
+ }
1132
+ writeChatCompletionChunkAsResponsesSse(state, parsedChunk.value);
1133
+ }
1134
+ };
1135
+
1136
+ upstreamRes.on('data', (chunk) => {
1137
+ if (!chunk) return;
1138
+ buffer += chunk.toString('utf-8');
1139
+ let boundary = buffer.search(/\r?\n\r?\n/);
1140
+ while (boundary >= 0) {
1141
+ const block = buffer.slice(0, boundary);
1142
+ const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
1143
+ buffer = buffer.slice(boundary + (match ? match[0].length : 2));
1144
+ handleEventBlock(block);
1145
+ boundary = buffer.search(/\r?\n\r?\n/);
1146
+ }
1147
+ });
1148
+ upstreamRes.on('end', () => {
1149
+ if (buffer.trim()) handleEventBlock(buffer);
1150
+ if (!state.finished && !state.sawDone && !state.sawFinishReason) {
1151
+ failChatStreamResponsesSse(state, 'upstream stream ended before [DONE]');
1152
+ finish({ ok: true });
1153
+ return;
1154
+ }
1155
+ finishChatStreamResponsesSse(state);
1156
+ finish({ ok: true });
1157
+ });
1158
+ upstreamRes.on('aborted', () => {
1159
+ failChatStreamResponsesSse(state, 'upstream stream aborted');
1160
+ finish({ ok: true });
1161
+ });
1162
+ upstreamRes.on('error', (err) => {
1163
+ failChatStreamResponsesSse(state, err && err.message ? err.message : 'upstream stream failed');
1164
+ finish({ ok: true });
1165
+ });
1166
+ });
1167
+ upstreamReq.setTimeout(timeoutMs, () => {
1168
+ try { upstreamReq.destroy(new Error('timeout')); } catch (_) {}
1169
+ finish({ ok: false, error: 'timeout' });
1170
+ });
1171
+ upstreamReq.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
1172
+ if (bodyText) upstreamReq.write(bodyText);
1173
+ upstreamReq.end();
1174
+ });
1175
+ }
1176
+
730
1177
  async function proxyRequestJson(targetUrl, options = {}) {
731
1178
  const parsed = new URL(targetUrl);
732
1179
  const transport = parsed.protocol === 'https:' ? https : http;
@@ -906,7 +1353,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
906
1353
  }
907
1354
 
908
1355
  const url = joinApiUrl(upstream.baseUrl, 'models');
909
- const result = await proxyRequestJson(url, {
1356
+ const result = await retryTransientRequest(() => proxyRequestJson(url, {
910
1357
  method: 'GET',
911
1358
  headers: {
912
1359
  ...(authHeader ? { Authorization: authHeader } : {}),
@@ -915,7 +1362,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
915
1362
  maxBytes: maxUpstreamBytes,
916
1363
  httpAgent,
917
1364
  httpsAgent
918
- });
1365
+ }));
919
1366
  if (!result.ok) {
920
1367
  res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
921
1368
  res.end(JSON.stringify({ error: `Upstream request failed: ${result.error}` }));
@@ -956,10 +1403,48 @@ function createOpenaiBridgeHttpHandler(options = {}) {
956
1403
  const acceptHeader = req && req.headers ? (req.headers.accept || req.headers.Accept || '') : '';
957
1404
  const wantsSse = /text\/event-stream/i.test(String(acceptHeader || ''));
958
1405
 
1406
+ if (streamRequested && wantsSse) {
1407
+ const converted = convertResponsesRequestToChatCompletions(responsesRequest);
1408
+ if (converted.error) {
1409
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1410
+ res.end(JSON.stringify({ error: converted.error }));
1411
+ return;
1412
+ }
1413
+ const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
1414
+ const chatBody = { ...converted.chat, stream: true };
1415
+ const streamed = await retryTransientRequest(() => streamChatCompletionsAsResponsesSse(upstreamUrl, {
1416
+ method: 'POST',
1417
+ body: chatBody,
1418
+ headers: {
1419
+ ...(authHeader ? { Authorization: authHeader } : {}),
1420
+ ...upstreamHeaders
1421
+ },
1422
+ maxBytes: maxUpstreamBytes,
1423
+ httpAgent,
1424
+ httpsAgent,
1425
+ res,
1426
+ model: typeof chatBody.model === 'string' ? chatBody.model : ''
1427
+ }));
1428
+ if (!streamed.ok) {
1429
+ if (res.writableEnded || res.destroyed) {
1430
+ return;
1431
+ }
1432
+ if (!res.headersSent) {
1433
+ res.writeHead(streamed.status && streamed.status >= 400 ? streamed.status : 502, { 'Content-Type': 'application/json; charset=utf-8' });
1434
+ res.end(streamed.bodyText || JSON.stringify({ error: streamed.error || 'Upstream request failed' }));
1435
+ } else if (!res.writableEnded && !res.destroyed) {
1436
+ writeSse(res, 'response.failed', { type: 'response.failed', error: streamed.error || streamed.bodyText || 'Upstream request failed' });
1437
+ writeSse(res, 'done', '[DONE]');
1438
+ res.end();
1439
+ }
1440
+ }
1441
+ return;
1442
+ }
1443
+
959
1444
  // Maxx-style behavior: prefer upstream /responses if supported.
960
1445
  // Fallback to /chat/completions conversion when upstream does not implement /responses (404/405).
961
1446
  const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
962
- const upstreamResponsesResult = await proxyRequestJson(upstreamResponsesUrl, {
1447
+ const upstreamResponsesResult = await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, {
963
1448
  method: 'POST',
964
1449
  body: toUpstreamNonStreamingResponsesPayload(responsesRequest),
965
1450
  headers: {
@@ -969,7 +1454,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
969
1454
  maxBytes: maxUpstreamBytes,
970
1455
  httpAgent,
971
1456
  httpsAgent
972
- });
1457
+ }));
973
1458
 
974
1459
  if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) {
975
1460
  const upstreamJson = parseJsonOrError(upstreamResponsesResult.bodyText);
@@ -1020,7 +1505,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
1020
1505
  }
1021
1506
 
1022
1507
  const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
1023
- const upstreamResult = await proxyRequestJson(upstreamUrl, {
1508
+ const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, {
1024
1509
  method: 'POST',
1025
1510
  body: converted.chat,
1026
1511
  headers: {
@@ -1030,7 +1515,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
1030
1515
  maxBytes: maxUpstreamBytes,
1031
1516
  httpAgent,
1032
1517
  httpsAgent
1033
- });
1518
+ }));
1034
1519
  if (!upstreamResult.ok) {
1035
1520
  res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1036
1521
  res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` }));