codexmate 0.0.28 → 0.0.30

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 (50) hide show
  1. package/cli/builtin-proxy.js +107 -2
  2. package/cli/config-bootstrap.js +30 -12
  3. package/cli/config-health.js +117 -1
  4. package/cli/local-bridge.js +324 -0
  5. package/cli/openai-bridge.js +195 -31
  6. package/cli.js +245 -28
  7. package/lib/cli-webhook.js +126 -0
  8. package/package.json +1 -1
  9. package/web-ui/app.js +28 -8
  10. package/web-ui/index.html +1 -0
  11. package/web-ui/logic.codex.mjs +13 -0
  12. package/web-ui/modules/app.computed.dashboard.mjs +25 -2
  13. package/web-ui/modules/app.computed.session.mjs +22 -17
  14. package/web-ui/modules/app.methods.claude-config.mjs +12 -2
  15. package/web-ui/modules/app.methods.codex-config.mjs +25 -0
  16. package/web-ui/modules/app.methods.index.mjs +2 -0
  17. package/web-ui/modules/app.methods.navigation.mjs +39 -8
  18. package/web-ui/modules/app.methods.providers.mjs +125 -8
  19. package/web-ui/modules/app.methods.session-actions.mjs +1 -1
  20. package/web-ui/modules/app.methods.session-browser.mjs +1 -1
  21. package/web-ui/modules/app.methods.session-trash.mjs +3 -4
  22. package/web-ui/modules/app.methods.startup-claude.mjs +1 -0
  23. package/web-ui/modules/app.methods.webhook.mjs +79 -0
  24. package/web-ui/modules/i18n.dict.mjs +1109 -72
  25. package/web-ui/modules/i18n.mjs +9 -3
  26. package/web-ui/modules/skills.methods.mjs +1 -0
  27. package/web-ui/partials/index/layout-header.html +25 -0
  28. package/web-ui/partials/index/modals-basic.html +0 -3
  29. package/web-ui/partials/index/panel-config-claude.html +8 -2
  30. package/web-ui/partials/index/panel-config-codex.html +28 -3
  31. package/web-ui/partials/index/panel-dashboard.html +33 -0
  32. package/web-ui/partials/index/panel-market.html +3 -3
  33. package/web-ui/partials/index/panel-plugins.html +2 -2
  34. package/web-ui/partials/index/panel-sessions.html +1 -9
  35. package/web-ui/partials/index/panel-settings.html +71 -134
  36. package/web-ui/partials/index/panel-trash.html +88 -0
  37. package/web-ui/session-helpers.mjs +20 -2
  38. package/web-ui/styles/dashboard.css +132 -0
  39. package/web-ui/styles/docs-panel.css +63 -39
  40. package/web-ui/styles/layout-shell.css +54 -34
  41. package/web-ui/styles/plugins-panel.css +121 -80
  42. package/web-ui/styles/sessions-list.css +41 -43
  43. package/web-ui/styles/sessions-preview.css +34 -38
  44. package/web-ui/styles/sessions-toolbar-trash.css +31 -27
  45. package/web-ui/styles/settings-panel.css +197 -33
  46. package/web-ui/styles/skills-list.css +12 -10
  47. package/web-ui/styles/skills-market.css +67 -44
  48. package/web-ui/styles/trash-panel.css +90 -0
  49. package/web-ui/styles/webhook.css +81 -0
  50. package/web-ui/styles.css +2 -0
@@ -1,11 +1,16 @@
1
1
  const http = require('http');
2
2
  const https = require('https');
3
3
  const crypto = require('crypto');
4
+ const { StringDecoder } = require('string_decoder');
4
5
  const { readJsonFile, writeJsonAtomic } = require('../lib/cli-file-utils');
5
6
  const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils');
6
7
 
7
8
  const DEFAULT_BRIDGE_TOKEN = 'codexmate';
8
9
  const SETTINGS_VERSION = 1;
10
+ // 推理模型 reasoning 阶段可能长时间无字节输出,需匹配 codex 的 stream_idle_timeout_ms=300000。
11
+ const STREAM_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
12
+ const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
13
+ const RESPONSES_UNSUPPORTED_TTL_MS = 30 * 60 * 1000;
9
14
 
10
15
  function normalizeText(value) {
11
16
  return typeof value === 'string' ? value.trim() : '';
@@ -281,7 +286,10 @@ function normalizeResponsesInputToChatMessages(input) {
281
286
 
282
287
  const toRole = (value) => {
283
288
  const roleRaw = typeof value === 'string' ? value.trim().toLowerCase() : '';
284
- return roleRaw === 'assistant' ? 'assistant' : (roleRaw === 'system' ? 'system' : 'user');
289
+ if (roleRaw === 'assistant') return 'assistant';
290
+ // codex 把 AGENTS.md 注入 developer 角色;Responses 的 developer 在 chat 侧等价于 system。
291
+ if (roleRaw === 'system' || roleRaw === 'developer') return 'system';
292
+ return 'user';
285
293
  };
286
294
 
287
295
  if (input && typeof input === 'object' && !Array.isArray(input)) {
@@ -439,6 +447,43 @@ function normalizeResponsesToolsForResponsesApi(tools) {
439
447
  .filter(Boolean);
440
448
  }
441
449
 
450
+ function mergeLeadingSystemMessages(messages, leadingInstructions) {
451
+ const segments = [];
452
+ const seen = new Set();
453
+ const pushSegment = (text) => {
454
+ const trimmed = typeof text === 'string' ? text.trim() : '';
455
+ if (!trimmed || seen.has(trimmed)) return;
456
+ seen.add(trimmed);
457
+ segments.push(trimmed);
458
+ };
459
+ if (typeof leadingInstructions === 'string') {
460
+ pushSegment(leadingInstructions);
461
+ }
462
+ const rest = [];
463
+ for (const msg of messages) {
464
+ if (msg && msg.role === 'system') {
465
+ const content = msg.content;
466
+ if (typeof content === 'string') {
467
+ pushSegment(content);
468
+ } else if (Array.isArray(content)) {
469
+ for (const part of content) {
470
+ if (part && typeof part === 'object' && typeof part.text === 'string') {
471
+ pushSegment(part.text);
472
+ }
473
+ }
474
+ }
475
+ continue;
476
+ }
477
+ rest.push(msg);
478
+ }
479
+ const out = [];
480
+ if (segments.length) {
481
+ out.push({ role: 'system', content: segments.join('\n\n---\n\n') });
482
+ }
483
+ for (const msg of rest) out.push(msg);
484
+ return out;
485
+ }
486
+
442
487
  function convertResponsesRequestToChatCompletions(payload) {
443
488
  const body = payload && typeof payload === 'object' ? payload : {};
444
489
  const model = typeof body.model === 'string' ? body.model.trim() : '';
@@ -446,12 +491,10 @@ function convertResponsesRequestToChatCompletions(payload) {
446
491
  return { error: 'responses 请求缺少 model' };
447
492
  }
448
493
 
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));
494
+ const rawMessages = normalizeResponsesInputToChatMessages(body.input);
495
+ // codex 同时下发 body.instructions(内置 prompt)与 input developer/system 消息(AGENTS.md)。
496
+ // 合流为一条领头 system,避免某些上游"只认第一条 system"导致 AGENTS.md 失效。
497
+ const messages = mergeLeadingSystemMessages(rawMessages, body.instructions);
455
498
  if (!messages.length) {
456
499
  // codex sometimes sends empty input for probes; tolerate.
457
500
  messages.push({ role: 'user', content: '' });
@@ -710,12 +753,72 @@ function shouldFallbackFromUpstreamResponses(status, bodyText) {
710
753
  return false;
711
754
  }
712
755
 
756
+ // 仅识别"端点级别不支持"——可缓存,与 per-request 的 tool 格式错误区分。
757
+ function isResponsesEndpointUnsupported(status, bodyText) {
758
+ if (!Number.isFinite(status)) return false;
759
+ if (status === 404 || status === 405 || status === 501) return true;
760
+ const text = String(bodyText || '');
761
+ if (!text) return false;
762
+ if (/not implemented/i.test(text)) return true;
763
+ if (/convert_request_failed/i.test(text)) return true;
764
+ if (/unknown (endpoint|route)/i.test(text)) return true;
765
+ if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
766
+ if (/does not support.*responses/i.test(text)) return true;
767
+ try {
768
+ const parsed = JSON.parse(text);
769
+ const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
770
+ const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
771
+ if (code === 'convert_request_failed') return true;
772
+ if (/not implemented/i.test(msg)) return true;
773
+ if (/unknown (endpoint|route)/i.test(msg)) return true;
774
+ if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
775
+ if (/does not support.*responses/i.test(msg)) return true;
776
+ } catch (_) {}
777
+ return false;
778
+ }
779
+
713
780
  function isLoopbackAddress(address) {
714
781
  if (!address) return false;
715
782
  const value = String(address);
716
783
  return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
717
784
  }
718
785
 
786
+ function isTransientNetworkError(error) {
787
+ const text = String(error || '').trim();
788
+ if (!text) return false;
789
+ if (/socket hang up/i.test(text)) return true;
790
+ if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true;
791
+ if (/EAI_AGAIN/i.test(text)) return true;
792
+ if (/UND_ERR_SOCKET/i.test(text)) return true;
793
+ if (/disconnected before|secure tls|tls handshake/i.test(text)) return true;
794
+ return false;
795
+ }
796
+
797
+ const TRANSIENT_RETRY_DELAYS_MS = [200, 600];
798
+
799
+ async function retryTransientRequest(executor) {
800
+ let lastResult = null;
801
+ for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) {
802
+ if (attempt > 0) {
803
+ const delay = TRANSIENT_RETRY_DELAYS_MS[attempt - 1];
804
+ // eslint-disable-next-line no-await-in-loop
805
+ await new Promise((r) => {
806
+ const t = setTimeout(r, delay);
807
+ if (typeof t.unref === 'function') t.unref();
808
+ });
809
+ }
810
+ // eslint-disable-next-line no-await-in-loop
811
+ const result = await executor(attempt);
812
+ lastResult = result;
813
+ if (!result) return result;
814
+ if (result.ok) return result;
815
+ if (result.retry) return result;
816
+ if (result.status && result.status > 0) return result;
817
+ if (!isTransientNetworkError(result.error)) return result;
818
+ }
819
+ return lastResult;
820
+ }
821
+
719
822
  function writeSse(res, eventName, dataObj) {
720
823
  if (!res || res.writableEnded || res.destroyed) return;
721
824
  if (eventName) {
@@ -758,7 +861,15 @@ function writeChatCompletionChunkAsResponsesSse(state, chunk) {
758
861
  const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null;
759
862
  if (!delta) continue;
760
863
 
864
+ const segments = [];
865
+ // DeepSeek-style OpenAI-compatible streams may emit private reasoning in
866
+ // `reasoning_content` before the final answer. Responses `output_text`
867
+ // must stay user-visible answer text only; forwarding reasoning here
868
+ // pollutes Codex output and breaks exact-answer prompts.
761
869
  if (typeof delta.content === 'string' && delta.content) {
870
+ segments.push(delta.content);
871
+ }
872
+ for (const seg of segments) {
762
873
  if (!state.messageItem) {
763
874
  state.messageItem = {
764
875
  id: `msg_${crypto.randomBytes(8).toString('hex')}`,
@@ -773,14 +884,14 @@ function writeChatCompletionChunkAsResponsesSse(state, chunk) {
773
884
  item: state.messageItem
774
885
  });
775
886
  }
776
- state.messageText += delta.content;
887
+ state.messageText += seg;
777
888
  state.messageItem.content[0].text = state.messageText;
778
889
  writeSse(state.res, 'response.output_text.delta', {
779
890
  type: 'response.output_text.delta',
780
891
  item_id: state.messageItem.id,
781
892
  output_index: state.output.length - 1,
782
893
  content_index: 0,
783
- delta: delta.content,
894
+ delta: seg,
784
895
  sequence_number: state.nextSeq()
785
896
  });
786
897
  }
@@ -790,6 +901,10 @@ function writeChatCompletionChunkAsResponsesSse(state, chunk) {
790
901
  appendChatStreamToolCall(state.toolCalls, toolCall);
791
902
  }
792
903
  }
904
+
905
+ if (typeof choice.finish_reason === 'string' && choice.finish_reason) {
906
+ state.sawFinishReason = true;
907
+ }
793
908
  }
794
909
  }
795
910
 
@@ -903,7 +1018,7 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
903
1018
  }
904
1019
  const timeoutMs = Number.isFinite(options.timeoutMs)
905
1020
  ? Math.max(1000, Number(options.timeoutMs))
906
- : 30000;
1021
+ : STREAM_IDLE_TIMEOUT_MS;
907
1022
  const res = options.res;
908
1023
  const fallbackModel = typeof options.model === 'string' ? options.model : '';
909
1024
 
@@ -1006,6 +1121,7 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
1006
1121
  toolCalls: [],
1007
1122
  finished: false,
1008
1123
  sawDone: false,
1124
+ sawFinishReason: false,
1009
1125
  nextSeq: () => {
1010
1126
  sequence += 1;
1011
1127
  return sequence;
@@ -1021,6 +1137,7 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
1021
1137
  });
1022
1138
 
1023
1139
  let buffer = '';
1140
+ const utf8Decoder = new StringDecoder('utf8');
1024
1141
  const handleEventBlock = (block) => {
1025
1142
  const dataLines = String(block || '')
1026
1143
  .split(/\r?\n/)
@@ -1048,7 +1165,7 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
1048
1165
 
1049
1166
  upstreamRes.on('data', (chunk) => {
1050
1167
  if (!chunk) return;
1051
- buffer += chunk.toString('utf-8');
1168
+ buffer += utf8Decoder.write(chunk);
1052
1169
  let boundary = buffer.search(/\r?\n\r?\n/);
1053
1170
  while (boundary >= 0) {
1054
1171
  const block = buffer.slice(0, boundary);
@@ -1059,8 +1176,9 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
1059
1176
  }
1060
1177
  });
1061
1178
  upstreamRes.on('end', () => {
1179
+ buffer += utf8Decoder.end();
1062
1180
  if (buffer.trim()) handleEventBlock(buffer);
1063
- if (!state.finished && !state.sawDone) {
1181
+ if (!state.finished && !state.sawDone && !state.sawFinishReason) {
1064
1182
  failChatStreamResponsesSse(state, 'upstream stream ended before [DONE]');
1065
1183
  finish({ ok: true });
1066
1184
  return;
@@ -1105,7 +1223,7 @@ async function proxyRequestJson(targetUrl, options = {}) {
1105
1223
 
1106
1224
  const timeoutMs = Number.isFinite(options.timeoutMs)
1107
1225
  ? Math.max(1000, Number(options.timeoutMs))
1108
- : 30000;
1226
+ : REQUEST_TIMEOUT_MS;
1109
1227
  return new Promise((resolve) => {
1110
1228
  let settled = false;
1111
1229
  const finish = (value) => {
@@ -1177,6 +1295,27 @@ function createOpenaiBridgeHttpHandler(options = {}) {
1177
1295
  throw new Error('createOpenaiBridgeHttpHandler 缺少 settingsFile');
1178
1296
  }
1179
1297
 
1298
+ // 端点不支持的缓存(per-baseUrl, TTL 30 分钟):避免每次非流式请求重复探测 /v1/responses。
1299
+ const unsupportedResponses = new Map();
1300
+ const isResponsesKnownUnsupported = (baseUrl) => {
1301
+ if (!baseUrl) return false;
1302
+ const entry = unsupportedResponses.get(baseUrl);
1303
+ if (!entry) return false;
1304
+ if (entry.expiresAt <= Date.now()) {
1305
+ unsupportedResponses.delete(baseUrl);
1306
+ return false;
1307
+ }
1308
+ return true;
1309
+ };
1310
+ const markResponsesUnsupported = (baseUrl) => {
1311
+ if (!baseUrl) return;
1312
+ unsupportedResponses.set(baseUrl, { expiresAt: Date.now() + RESPONSES_UNSUPPORTED_TTL_MS });
1313
+ };
1314
+ const clearResponsesUnsupported = (baseUrl) => {
1315
+ if (!baseUrl) return;
1316
+ unsupportedResponses.delete(baseUrl);
1317
+ };
1318
+
1180
1319
  const matchPath = (requestPath) => {
1181
1320
  const normalized = String(requestPath || '');
1182
1321
  const prefix = '/bridge/openai/';
@@ -1266,7 +1405,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
1266
1405
  }
1267
1406
 
1268
1407
  const url = joinApiUrl(upstream.baseUrl, 'models');
1269
- const result = await proxyRequestJson(url, {
1408
+ const result = await retryTransientRequest(() => proxyRequestJson(url, {
1270
1409
  method: 'GET',
1271
1410
  headers: {
1272
1411
  ...(authHeader ? { Authorization: authHeader } : {}),
@@ -1275,7 +1414,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
1275
1414
  maxBytes: maxUpstreamBytes,
1276
1415
  httpAgent,
1277
1416
  httpsAgent
1278
- });
1417
+ }));
1279
1418
  if (!result.ok) {
1280
1419
  res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1281
1420
  res.end(JSON.stringify({ error: `Upstream request failed: ${result.error}` }));
@@ -1325,7 +1464,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
1325
1464
  }
1326
1465
  const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
1327
1466
  const chatBody = { ...converted.chat, stream: true };
1328
- const streamed = await streamChatCompletionsAsResponsesSse(upstreamUrl, {
1467
+ const streamed = await retryTransientRequest(() => streamChatCompletionsAsResponsesSse(upstreamUrl, {
1329
1468
  method: 'POST',
1330
1469
  body: chatBody,
1331
1470
  headers: {
@@ -1337,7 +1476,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
1337
1476
  httpsAgent,
1338
1477
  res,
1339
1478
  model: typeof chatBody.model === 'string' ? chatBody.model : ''
1340
- });
1479
+ }));
1341
1480
  if (!streamed.ok) {
1342
1481
  if (res.writableEnded || res.destroyed) {
1343
1482
  return;
@@ -1356,20 +1495,25 @@ function createOpenaiBridgeHttpHandler(options = {}) {
1356
1495
 
1357
1496
  // Maxx-style behavior: prefer upstream /responses if supported.
1358
1497
  // Fallback to /chat/completions conversion when upstream does not implement /responses (404/405).
1498
+ // 已知不支持的上游:直接跳过探测,节省一次 round-trip。
1499
+ const skipResponsesProbe = isResponsesKnownUnsupported(upstream.baseUrl);
1359
1500
  const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
1360
- const upstreamResponsesResult = await proxyRequestJson(upstreamResponsesUrl, {
1361
- method: 'POST',
1362
- body: toUpstreamNonStreamingResponsesPayload(responsesRequest),
1363
- headers: {
1364
- ...(authHeader ? { Authorization: authHeader } : {}),
1365
- ...upstreamHeaders
1366
- },
1367
- maxBytes: maxUpstreamBytes,
1368
- httpAgent,
1369
- httpsAgent
1370
- });
1501
+ const upstreamResponsesResult = skipResponsesProbe
1502
+ ? { ok: true, status: 404, bodyText: '' }
1503
+ : await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, {
1504
+ method: 'POST',
1505
+ body: toUpstreamNonStreamingResponsesPayload(responsesRequest),
1506
+ headers: {
1507
+ ...(authHeader ? { Authorization: authHeader } : {}),
1508
+ ...upstreamHeaders
1509
+ },
1510
+ maxBytes: maxUpstreamBytes,
1511
+ httpAgent,
1512
+ httpsAgent
1513
+ }));
1371
1514
 
1372
1515
  if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) {
1516
+ clearResponsesUnsupported(upstream.baseUrl);
1373
1517
  const upstreamJson = parseJsonOrError(upstreamResponsesResult.bodyText);
1374
1518
  if (upstreamJson.error) {
1375
1519
  res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -1401,6 +1545,9 @@ function createOpenaiBridgeHttpHandler(options = {}) {
1401
1545
  res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
1402
1546
  return;
1403
1547
  }
1548
+ if (!skipResponsesProbe && isResponsesEndpointUnsupported(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) {
1549
+ markResponsesUnsupported(upstream.baseUrl);
1550
+ }
1404
1551
  // fallthrough to chat/completions conversion
1405
1552
  }
1406
1553
 
@@ -1418,7 +1565,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
1418
1565
  }
1419
1566
 
1420
1567
  const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
1421
- const upstreamResult = await proxyRequestJson(upstreamUrl, {
1568
+ const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, {
1422
1569
  method: 'POST',
1423
1570
  body: converted.chat,
1424
1571
  headers: {
@@ -1428,7 +1575,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
1428
1575
  maxBytes: maxUpstreamBytes,
1429
1576
  httpAgent,
1430
1577
  httpsAgent
1431
- });
1578
+ }));
1432
1579
  if (!upstreamResult.ok) {
1433
1580
  res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1434
1581
  res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` }));
@@ -1485,5 +1632,22 @@ module.exports = {
1485
1632
  readOpenaiBridgeSettings,
1486
1633
  upsertOpenaiBridgeProvider,
1487
1634
  resolveOpenaiBridgeUpstream,
1488
- createOpenaiBridgeHttpHandler
1635
+ createOpenaiBridgeHttpHandler,
1636
+ // exported for local-bridge reuse
1637
+ convertResponsesRequestToChatCompletions,
1638
+ streamChatCompletionsAsResponsesSse,
1639
+ proxyRequestJson,
1640
+ ensureResponseMetadata,
1641
+ sendResponsesSse,
1642
+ extractAuthorizationToken,
1643
+ readRequestBody,
1644
+ parseJsonOrError,
1645
+ extractChatCompletionResult,
1646
+ buildResponsesPayloadFromChatResult,
1647
+ retryTransientRequest,
1648
+ normalizeOpenaiUpstreamBaseUrl,
1649
+ extractResponsesOutputText,
1650
+ shouldFallbackFromUpstreamResponses,
1651
+ isTransientNetworkError,
1652
+ isLoopbackAddress
1489
1653
  };