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.
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/cli/builtin-proxy.js +430 -4
- package/cli/openai-bridge.js +498 -13
- package/cli.js +130 -41
- package/lib/cli-models-utils.js +71 -10
- package/lib/cli-webhook.js +126 -0
- package/package.json +76 -74
- package/plugins/prompt-templates/computed.mjs +1 -1
- package/plugins/prompt-templates/methods.mjs +0 -66
- package/plugins/prompt-templates/overview.mjs +1 -0
- package/web-ui/app.js +21 -16
- package/web-ui/index.html +1 -0
- package/web-ui/logic.codex.mjs +69 -0
- package/web-ui/modules/app.computed.dashboard.mjs +54 -0
- package/web-ui/modules/app.computed.session.mjs +22 -17
- package/web-ui/modules/app.methods.claude-config.mjs +24 -8
- package/web-ui/modules/app.methods.codex-config.mjs +35 -3
- package/web-ui/modules/app.methods.index.mjs +2 -0
- package/web-ui/modules/app.methods.navigation.mjs +21 -3
- package/web-ui/modules/app.methods.providers.mjs +96 -7
- package/web-ui/modules/app.methods.session-actions.mjs +3 -6
- package/web-ui/modules/app.methods.session-browser.mjs +1 -6
- package/web-ui/modules/app.methods.session-trash.mjs +6 -7
- package/web-ui/modules/app.methods.startup-claude.mjs +8 -1
- package/web-ui/modules/app.methods.webhook.mjs +79 -0
- package/web-ui/modules/i18n.dict.mjs +1104 -104
- package/web-ui/modules/i18n.mjs +9 -3
- package/web-ui/modules/provider-url-display.mjs +17 -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 +10 -3
- package/web-ui/partials/index/panel-config-codex.html +44 -4
- package/web-ui/partials/index/panel-plugins.html +3 -29
- package/web-ui/partials/index/panel-sessions.html +0 -10
- package/web-ui/partials/index/panel-settings.html +93 -177
- package/web-ui/partials/index/panel-trash.html +88 -0
- package/web-ui/session-helpers.mjs +2 -2
- package/web-ui/styles/base-theme.css +47 -34
- package/web-ui/styles/controls-forms.css +27 -28
- package/web-ui/styles/docs-panel.css +63 -39
- package/web-ui/styles/layout-shell.css +69 -46
- package/web-ui/styles/modals-core.css +12 -10
- package/web-ui/styles/navigation-panels.css +36 -35
- package/web-ui/styles/responsive.css +4 -4
- package/web-ui/styles/sessions-list.css +10 -6
- package/web-ui/styles/settings-panel.css +197 -33
- package/web-ui/styles/titles-cards.css +90 -26
- 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
|
@@ -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
|
-
|
|
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
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
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}` }));
|