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.
- package/cli/builtin-proxy.js +107 -2
- package/cli/config-bootstrap.js +30 -12
- package/cli/config-health.js +117 -1
- package/cli/local-bridge.js +324 -0
- package/cli/openai-bridge.js +195 -31
- package/cli.js +245 -28
- package/lib/cli-webhook.js +126 -0
- package/package.json +1 -1
- package/web-ui/app.js +28 -8
- package/web-ui/index.html +1 -0
- package/web-ui/logic.codex.mjs +13 -0
- package/web-ui/modules/app.computed.dashboard.mjs +25 -2
- package/web-ui/modules/app.computed.session.mjs +22 -17
- package/web-ui/modules/app.methods.claude-config.mjs +12 -2
- package/web-ui/modules/app.methods.codex-config.mjs +25 -0
- package/web-ui/modules/app.methods.index.mjs +2 -0
- package/web-ui/modules/app.methods.navigation.mjs +39 -8
- package/web-ui/modules/app.methods.providers.mjs +125 -8
- package/web-ui/modules/app.methods.session-actions.mjs +1 -1
- package/web-ui/modules/app.methods.session-browser.mjs +1 -1
- package/web-ui/modules/app.methods.session-trash.mjs +3 -4
- package/web-ui/modules/app.methods.startup-claude.mjs +1 -0
- package/web-ui/modules/app.methods.webhook.mjs +79 -0
- package/web-ui/modules/i18n.dict.mjs +1109 -72
- package/web-ui/modules/i18n.mjs +9 -3
- package/web-ui/modules/skills.methods.mjs +1 -0
- package/web-ui/partials/index/layout-header.html +25 -0
- package/web-ui/partials/index/modals-basic.html +0 -3
- package/web-ui/partials/index/panel-config-claude.html +8 -2
- package/web-ui/partials/index/panel-config-codex.html +28 -3
- package/web-ui/partials/index/panel-dashboard.html +33 -0
- package/web-ui/partials/index/panel-market.html +3 -3
- package/web-ui/partials/index/panel-plugins.html +2 -2
- package/web-ui/partials/index/panel-sessions.html +1 -9
- package/web-ui/partials/index/panel-settings.html +71 -134
- package/web-ui/partials/index/panel-trash.html +88 -0
- package/web-ui/session-helpers.mjs +20 -2
- package/web-ui/styles/dashboard.css +132 -0
- package/web-ui/styles/docs-panel.css +63 -39
- package/web-ui/styles/layout-shell.css +54 -34
- package/web-ui/styles/plugins-panel.css +121 -80
- package/web-ui/styles/sessions-list.css +41 -43
- package/web-ui/styles/sessions-preview.css +34 -38
- package/web-ui/styles/sessions-toolbar-trash.css +31 -27
- package/web-ui/styles/settings-panel.css +197 -33
- package/web-ui/styles/skills-list.css +12 -10
- package/web-ui/styles/skills-market.css +67 -44
- package/web-ui/styles/trash-panel.css +90 -0
- package/web-ui/styles/webhook.css +81 -0
- package/web-ui/styles.css +2 -0
package/cli/openai-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
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 +=
|
|
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:
|
|
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
|
-
:
|
|
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 +=
|
|
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
|
-
:
|
|
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 =
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
};
|