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/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# Codex Mate
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**One dashboard for all your local AI coding tools — switch providers, manage sessions, edit configs, and orchestrate tasks across Codex, Claude Code, and OpenClaw. Built-in OpenAI-compatible bridge, usage analytics, and prompt templates. Zero cloud, zero setup.**
|
|
8
8
|
|
|
9
9
|
[](https://github.com/SakuraByteCore/codexmate/actions/workflows/release.yml)
|
|
10
10
|
[](https://www.npmjs.com/package/codexmate)
|
package/README.zh.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# Codex Mate
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**一个面板管好所有本地 AI 编码工具 — 跨 Codex / Claude Code / OpenClaw 切 provider、管会话、改配置、编排任务。内置 OpenAI 兼容桥接、Usage 统计与提示词模板。纯本地,零上云。**
|
|
8
8
|
|
|
9
9
|
[](https://github.com/SakuraByteCore/codexmate/actions/workflows/release.yml)
|
|
10
10
|
[](https://www.npmjs.com/package/codexmate)
|
package/cli/builtin-proxy.js
CHANGED
|
@@ -118,6 +118,51 @@ function createBuiltinProxyRuntimeController(deps = {}) {
|
|
|
118
118
|
return false;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
function shouldFallbackFromUpstreamResponsesFailure(error) {
|
|
122
|
+
const text = String(error || '').trim();
|
|
123
|
+
if (!text) return false;
|
|
124
|
+
if (/timeout/i.test(text)) return true;
|
|
125
|
+
if (/socket hang up/i.test(text)) return true;
|
|
126
|
+
if (/ECONNRESET/i.test(text)) return true;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isTransientNetworkError(error) {
|
|
131
|
+
const text = String(error || '').trim();
|
|
132
|
+
if (!text) return false;
|
|
133
|
+
if (/socket hang up/i.test(text)) return true;
|
|
134
|
+
if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true;
|
|
135
|
+
if (/EAI_AGAIN/i.test(text)) return true;
|
|
136
|
+
if (/UND_ERR_SOCKET/i.test(text)) return true;
|
|
137
|
+
if (/disconnected before|secure tls|tls handshake/i.test(text)) return true;
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const TRANSIENT_RETRY_DELAYS_MS = [200, 600];
|
|
142
|
+
|
|
143
|
+
async function retryTransientRequest(executor) {
|
|
144
|
+
let lastResult = null;
|
|
145
|
+
for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
146
|
+
if (attempt > 0) {
|
|
147
|
+
const delay = TRANSIENT_RETRY_DELAYS_MS[attempt - 1];
|
|
148
|
+
// eslint-disable-next-line no-await-in-loop
|
|
149
|
+
await new Promise((r) => {
|
|
150
|
+
const t = setTimeout(r, delay);
|
|
151
|
+
if (typeof t.unref === 'function') t.unref();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// eslint-disable-next-line no-await-in-loop
|
|
155
|
+
const result = await executor(attempt);
|
|
156
|
+
lastResult = result;
|
|
157
|
+
if (!result) return result;
|
|
158
|
+
if (result.ok) return result;
|
|
159
|
+
if (result.retry) return result;
|
|
160
|
+
if (result.status && result.status > 0) return result;
|
|
161
|
+
if (!isTransientNetworkError(result.error)) return result;
|
|
162
|
+
}
|
|
163
|
+
return lastResult;
|
|
164
|
+
}
|
|
165
|
+
|
|
121
166
|
function proxyRequestJson(targetUrl, options = {}) {
|
|
122
167
|
const parsed = new URL(targetUrl);
|
|
123
168
|
const transport = parsed.protocol === 'https:' ? https : http;
|
|
@@ -197,7 +242,7 @@ function createBuiltinProxyRuntimeController(deps = {}) {
|
|
|
197
242
|
}
|
|
198
243
|
let lastResult = null;
|
|
199
244
|
for (let index = 0; index < urls.length; index += 1) {
|
|
200
|
-
const result = await proxyRequestJson(urls[index], options);
|
|
245
|
+
const result = await retryTransientRequest(() => proxyRequestJson(urls[index], options));
|
|
201
246
|
lastResult = result;
|
|
202
247
|
if (!result.ok) {
|
|
203
248
|
return result;
|
|
@@ -628,6 +673,360 @@ function createBuiltinProxyRuntimeController(deps = {}) {
|
|
|
628
673
|
writeSse(res, 'done', '[DONE]');
|
|
629
674
|
}
|
|
630
675
|
|
|
676
|
+
function appendChatStreamToolCall(target, toolCall) {
|
|
677
|
+
if (!toolCall || typeof toolCall !== 'object') return;
|
|
678
|
+
const index = Number.isFinite(toolCall.index) ? toolCall.index : target.length;
|
|
679
|
+
if (!target[index]) {
|
|
680
|
+
target[index] = {
|
|
681
|
+
id: '',
|
|
682
|
+
type: 'function',
|
|
683
|
+
function: { name: '', arguments: '' }
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
const current = target[index];
|
|
687
|
+
if (typeof toolCall.id === 'string' && toolCall.id) current.id = toolCall.id;
|
|
688
|
+
if (typeof toolCall.type === 'string' && toolCall.type) current.type = toolCall.type;
|
|
689
|
+
const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : null;
|
|
690
|
+
if (fn) {
|
|
691
|
+
if (typeof fn.name === 'string' && fn.name) current.function.name = fn.name;
|
|
692
|
+
if (typeof fn.arguments === 'string') current.function.arguments += fn.arguments;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function writeChatCompletionChunkAsResponsesSse(state, chunk) {
|
|
697
|
+
if (!chunk || typeof chunk !== 'object') return;
|
|
698
|
+
if (typeof chunk.model === 'string' && chunk.model) {
|
|
699
|
+
state.model = chunk.model;
|
|
700
|
+
}
|
|
701
|
+
const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
|
|
702
|
+
for (const choice of choices) {
|
|
703
|
+
const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null;
|
|
704
|
+
if (!delta) continue;
|
|
705
|
+
|
|
706
|
+
if (typeof delta.content === 'string' && delta.content) {
|
|
707
|
+
if (!state.messageItem) {
|
|
708
|
+
state.messageItem = {
|
|
709
|
+
id: `msg_${crypto.randomBytes(8).toString('hex')}`,
|
|
710
|
+
type: 'message',
|
|
711
|
+
role: 'assistant',
|
|
712
|
+
content: [{ type: 'output_text', text: '' }]
|
|
713
|
+
};
|
|
714
|
+
state.output.push(state.messageItem);
|
|
715
|
+
writeSse(state.res, 'response.output_item.added', {
|
|
716
|
+
type: 'response.output_item.added',
|
|
717
|
+
output_index: state.output.length - 1,
|
|
718
|
+
item: state.messageItem
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
state.messageText += delta.content;
|
|
722
|
+
state.messageItem.content[0].text = state.messageText;
|
|
723
|
+
writeSse(state.res, 'response.output_text.delta', {
|
|
724
|
+
type: 'response.output_text.delta',
|
|
725
|
+
item_id: state.messageItem.id,
|
|
726
|
+
output_index: state.output.length - 1,
|
|
727
|
+
content_index: 0,
|
|
728
|
+
delta: delta.content,
|
|
729
|
+
sequence_number: state.nextSeq()
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
734
|
+
for (const toolCall of delta.tool_calls) {
|
|
735
|
+
appendChatStreamToolCall(state.toolCalls, toolCall);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function stopChatStreamHeartbeat(state) {
|
|
742
|
+
if (!state || !state.heartbeatTimer) return;
|
|
743
|
+
clearInterval(state.heartbeatTimer);
|
|
744
|
+
state.heartbeatTimer = null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function startChatStreamHeartbeat(state) {
|
|
748
|
+
if (!state || state.heartbeatTimer) return;
|
|
749
|
+
const timer = setInterval(() => {
|
|
750
|
+
if (state.finished) {
|
|
751
|
+
stopChatStreamHeartbeat(state);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const target = state.res;
|
|
755
|
+
if (!target || target.writableEnded || target.destroyed) {
|
|
756
|
+
stopChatStreamHeartbeat(state);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
try { target.write(': keepalive\n\n'); } catch (_) {}
|
|
760
|
+
}, 15000);
|
|
761
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
762
|
+
state.heartbeatTimer = timer;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function finishChatStreamResponsesSse(state) {
|
|
766
|
+
if (state.finished) return;
|
|
767
|
+
state.finished = true;
|
|
768
|
+
stopChatStreamHeartbeat(state);
|
|
769
|
+
|
|
770
|
+
if (state.messageItem) {
|
|
771
|
+
const outputIndex = state.output.indexOf(state.messageItem);
|
|
772
|
+
writeSse(state.res, 'response.output_text.done', {
|
|
773
|
+
type: 'response.output_text.done',
|
|
774
|
+
item_id: state.messageItem.id,
|
|
775
|
+
output_index: outputIndex,
|
|
776
|
+
content_index: 0,
|
|
777
|
+
text: state.messageText,
|
|
778
|
+
sequence_number: state.nextSeq()
|
|
779
|
+
});
|
|
780
|
+
writeSse(state.res, 'response.output_item.done', {
|
|
781
|
+
type: 'response.output_item.done',
|
|
782
|
+
output_index: outputIndex,
|
|
783
|
+
item: state.messageItem,
|
|
784
|
+
sequence_number: state.nextSeq()
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
for (const toolCall of state.toolCalls) {
|
|
789
|
+
if (!toolCall) continue;
|
|
790
|
+
const item = {
|
|
791
|
+
type: 'function_call',
|
|
792
|
+
call_id: toolCall.id || `call_${crypto.randomBytes(8).toString('hex')}`,
|
|
793
|
+
name: toolCall.function && typeof toolCall.function.name === 'string' ? toolCall.function.name : '',
|
|
794
|
+
arguments: toolCall.function && typeof toolCall.function.arguments === 'string' ? toolCall.function.arguments : ''
|
|
795
|
+
};
|
|
796
|
+
const outputIndex = state.output.length;
|
|
797
|
+
state.output.push(item);
|
|
798
|
+
writeSse(state.res, 'response.output_item.added', {
|
|
799
|
+
type: 'response.output_item.added',
|
|
800
|
+
output_index: outputIndex,
|
|
801
|
+
item
|
|
802
|
+
});
|
|
803
|
+
writeSse(state.res, 'response.output_item.done', {
|
|
804
|
+
type: 'response.output_item.done',
|
|
805
|
+
output_index: outputIndex,
|
|
806
|
+
item,
|
|
807
|
+
sequence_number: state.nextSeq()
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const response = ensureResponseMetadata({
|
|
812
|
+
id: state.responseId,
|
|
813
|
+
model: state.model,
|
|
814
|
+
created_at: state.createdAt,
|
|
815
|
+
status: 'completed',
|
|
816
|
+
output: state.output
|
|
817
|
+
});
|
|
818
|
+
writeSse(state.res, 'response.completed', { type: 'response.completed', response });
|
|
819
|
+
writeSse(state.res, 'done', '[DONE]');
|
|
820
|
+
state.res.end();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function failResponsesSseRaw(res, message) {
|
|
824
|
+
if (!res || res.writableEnded || res.destroyed) return;
|
|
825
|
+
try {
|
|
826
|
+
writeSse(res, 'response.failed', { type: 'response.failed', error: message || 'upstream stream failed' });
|
|
827
|
+
writeSse(res, 'done', '[DONE]');
|
|
828
|
+
res.end();
|
|
829
|
+
} catch (_) {}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function failChatStreamResponsesSse(state, message) {
|
|
833
|
+
if (!state || state.finished) return;
|
|
834
|
+
state.finished = true;
|
|
835
|
+
stopChatStreamHeartbeat(state);
|
|
836
|
+
failResponsesSseRaw(state.res, message);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
|
|
840
|
+
const parsed = new URL(targetUrl);
|
|
841
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
842
|
+
const bodyText = options.body ? JSON.stringify(options.body) : '';
|
|
843
|
+
const headers = {
|
|
844
|
+
'Accept': 'text/event-stream',
|
|
845
|
+
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
|
846
|
+
...(options.headers || {})
|
|
847
|
+
};
|
|
848
|
+
if (options.body) {
|
|
849
|
+
headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
|
|
850
|
+
}
|
|
851
|
+
const timeoutMs = Number.isFinite(options.timeoutMs)
|
|
852
|
+
? Math.max(1000, Number(options.timeoutMs))
|
|
853
|
+
: 30000;
|
|
854
|
+
const res = options.res;
|
|
855
|
+
const model = typeof options.model === 'string' ? options.model : '';
|
|
856
|
+
|
|
857
|
+
return new Promise((resolve) => {
|
|
858
|
+
let settled = false;
|
|
859
|
+
const finish = (value) => {
|
|
860
|
+
if (settled) return;
|
|
861
|
+
settled = true;
|
|
862
|
+
resolve(value);
|
|
863
|
+
};
|
|
864
|
+
const req = transport.request({
|
|
865
|
+
protocol: parsed.protocol,
|
|
866
|
+
hostname: parsed.hostname,
|
|
867
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
868
|
+
method: options.method || 'POST',
|
|
869
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
870
|
+
headers,
|
|
871
|
+
agent: parsed.protocol === 'https:' ? HTTPS_KEEP_ALIVE_AGENT : HTTP_KEEP_ALIVE_AGENT
|
|
872
|
+
}, (upstreamRes) => {
|
|
873
|
+
const status = upstreamRes.statusCode || 0;
|
|
874
|
+
const chunks = [];
|
|
875
|
+
const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
|
|
876
|
+
let streamState = null;
|
|
877
|
+
|
|
878
|
+
const handleAbort = (reason) => {
|
|
879
|
+
if (settled) return;
|
|
880
|
+
if (streamState) {
|
|
881
|
+
failChatStreamResponsesSse(streamState, reason);
|
|
882
|
+
finish({ ok: true });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (res.headersSent) {
|
|
886
|
+
failResponsesSseRaw(res, reason);
|
|
887
|
+
finish({ ok: true });
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
finish({
|
|
891
|
+
ok: false,
|
|
892
|
+
status,
|
|
893
|
+
error: reason,
|
|
894
|
+
bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''
|
|
895
|
+
});
|
|
896
|
+
};
|
|
897
|
+
upstreamRes.on('error', (err) => handleAbort(err && err.message ? err.message : 'upstream stream failed'));
|
|
898
|
+
upstreamRes.on('aborted', () => handleAbort('upstream stream aborted'));
|
|
899
|
+
|
|
900
|
+
if (status === 404 || status === 405) {
|
|
901
|
+
upstreamRes.on('data', (chunk) => chunk && chunks.push(chunk));
|
|
902
|
+
upstreamRes.on('end', () => finish({ retry: true, status, bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : '' }));
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (status >= 400) {
|
|
907
|
+
upstreamRes.on('data', (chunk) => chunk && chunks.push(chunk));
|
|
908
|
+
upstreamRes.on('end', () => finish({ ok: false, status, bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : '' }));
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
res.writeHead(200, {
|
|
913
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
914
|
+
'Cache-Control': 'no-cache',
|
|
915
|
+
'Connection': 'keep-alive',
|
|
916
|
+
'X-Accel-Buffering': 'no'
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
if (!/text\/event-stream/i.test(contentType)) {
|
|
920
|
+
upstreamRes.on('data', (chunk) => chunk && chunks.push(chunk));
|
|
921
|
+
upstreamRes.on('end', () => {
|
|
922
|
+
const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
|
|
923
|
+
const parsedJson = parseJsonOrError(text);
|
|
924
|
+
if (parsedJson.error) {
|
|
925
|
+
writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
|
|
926
|
+
writeSse(res, 'done', '[DONE]');
|
|
927
|
+
res.end();
|
|
928
|
+
finish({ ok: true });
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
sendResponsesSse(res, buildResponsesPayloadFromChatCompletion(parsedJson.value, model));
|
|
932
|
+
res.end();
|
|
933
|
+
finish({ ok: true });
|
|
934
|
+
});
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
let sequence = 0;
|
|
939
|
+
const state = {
|
|
940
|
+
res,
|
|
941
|
+
responseId: `resp_${crypto.randomBytes(10).toString('hex')}`,
|
|
942
|
+
model,
|
|
943
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
944
|
+
output: [],
|
|
945
|
+
messageItem: null,
|
|
946
|
+
messageText: '',
|
|
947
|
+
toolCalls: [],
|
|
948
|
+
finished: false,
|
|
949
|
+
nextSeq: () => {
|
|
950
|
+
sequence += 1;
|
|
951
|
+
return sequence;
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
streamState = state;
|
|
955
|
+
startChatStreamHeartbeat(state);
|
|
956
|
+
if (typeof res.on === 'function') {
|
|
957
|
+
res.on('close', () => stopChatStreamHeartbeat(state));
|
|
958
|
+
}
|
|
959
|
+
writeSse(res, 'response.created', {
|
|
960
|
+
type: 'response.created',
|
|
961
|
+
response: {
|
|
962
|
+
id: state.responseId,
|
|
963
|
+
model: state.model,
|
|
964
|
+
created_at: state.createdAt
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
let buffer = '';
|
|
969
|
+
const handleEventBlock = (block) => {
|
|
970
|
+
const dataLines = String(block || '')
|
|
971
|
+
.split(/\r?\n/)
|
|
972
|
+
.filter((line) => line.startsWith('data:'))
|
|
973
|
+
.map((line) => line.slice(5).trimStart());
|
|
974
|
+
if (dataLines.length === 0) return;
|
|
975
|
+
const data = dataLines.join('\n').trim();
|
|
976
|
+
if (!data) return;
|
|
977
|
+
if (data === '[DONE]') {
|
|
978
|
+
finishChatStreamResponsesSse(state);
|
|
979
|
+
finish({ ok: true });
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
const parsedChunk = parseJsonOrError(data);
|
|
983
|
+
if (!parsedChunk.error) {
|
|
984
|
+
writeChatCompletionChunkAsResponsesSse(state, parsedChunk.value);
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
upstreamRes.on('data', (chunk) => {
|
|
989
|
+
buffer += chunk.toString('utf-8');
|
|
990
|
+
let boundary = buffer.search(/\r?\n\r?\n/);
|
|
991
|
+
while (boundary >= 0) {
|
|
992
|
+
const block = buffer.slice(0, boundary);
|
|
993
|
+
const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
|
|
994
|
+
buffer = buffer.slice(boundary + (match ? match[0].length : 2));
|
|
995
|
+
handleEventBlock(block);
|
|
996
|
+
boundary = buffer.search(/\r?\n\r?\n/);
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
upstreamRes.on('end', () => {
|
|
1000
|
+
if (buffer.trim()) handleEventBlock(buffer);
|
|
1001
|
+
finishChatStreamResponsesSse(state);
|
|
1002
|
+
finish({ ok: true });
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
req.setTimeout(timeoutMs, () => {
|
|
1006
|
+
try { req.destroy(new Error('timeout')); } catch (_) {}
|
|
1007
|
+
finish({ ok: false, error: 'timeout' });
|
|
1008
|
+
});
|
|
1009
|
+
req.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
|
|
1010
|
+
if (bodyText) req.write(bodyText);
|
|
1011
|
+
req.end();
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function streamChatCompletionsAsResponsesSseWithFallbackUrls(baseUrl, pathSuffix, options = {}) {
|
|
1016
|
+
const urls = buildUpstreamUrlCandidates(baseUrl, pathSuffix);
|
|
1017
|
+
if (urls.length === 0) {
|
|
1018
|
+
return { ok: false, error: 'failed to build upstream URL' };
|
|
1019
|
+
}
|
|
1020
|
+
let lastResult = null;
|
|
1021
|
+
for (const url of urls) {
|
|
1022
|
+
const result = await retryTransientRequest(() => streamChatCompletionsAsResponsesSse(url, options));
|
|
1023
|
+
lastResult = result;
|
|
1024
|
+
if (result && result.retry) continue;
|
|
1025
|
+
return result;
|
|
1026
|
+
}
|
|
1027
|
+
return lastResult || { ok: false, error: 'failed to build upstream URL' };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
631
1030
|
function canListenPort(host, port) {
|
|
632
1031
|
return new Promise((resolve) => {
|
|
633
1032
|
const tester = net.createServer();
|
|
@@ -1044,14 +1443,41 @@ function createBuiltinProxyRuntimeController(deps = {}) {
|
|
|
1044
1443
|
}
|
|
1045
1444
|
|
|
1046
1445
|
if (!upstreamResponses.ok) {
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1446
|
+
if (!shouldFallbackFromUpstreamResponsesFailure(upstreamResponses.error)) {
|
|
1447
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1448
|
+
res.end(JSON.stringify({ error: upstreamResponses.error || 'Upstream request failed' }));
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
// Some OpenAI-compatible gateways accept /responses but never complete it.
|
|
1452
|
+
// Treat that as an unsupported Responses endpoint and try the chat fallback.
|
|
1050
1453
|
}
|
|
1051
1454
|
|
|
1052
1455
|
const model = typeof payload.model === 'string' ? payload.model : '';
|
|
1053
1456
|
const chatBody = buildChatCompletionsBodyFromResponsesPayload(payload);
|
|
1054
1457
|
|
|
1458
|
+
if (wantsStream) {
|
|
1459
|
+
const streamingChatBody = { ...chatBody, stream: true };
|
|
1460
|
+
const streamed = await streamChatCompletionsAsResponsesSseWithFallbackUrls(upstream.baseUrl, 'chat/completions', {
|
|
1461
|
+
method: 'POST',
|
|
1462
|
+
headers: commonHeaders,
|
|
1463
|
+
timeoutMs,
|
|
1464
|
+
body: streamingChatBody,
|
|
1465
|
+
res,
|
|
1466
|
+
model
|
|
1467
|
+
});
|
|
1468
|
+
if (!streamed.ok) {
|
|
1469
|
+
if (!res.headersSent) {
|
|
1470
|
+
res.writeHead(streamed.status && streamed.status >= 400 ? streamed.status : 502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1471
|
+
res.end(streamed.bodyText || JSON.stringify({ error: streamed.error || 'proxy request failed' }));
|
|
1472
|
+
} else if (!res.writableEnded) {
|
|
1473
|
+
writeSse(res, 'response.failed', { type: 'response.failed', error: streamed.error || streamed.bodyText || 'proxy request failed' });
|
|
1474
|
+
writeSse(res, 'done', '[DONE]');
|
|
1475
|
+
res.end();
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1055
1481
|
const upstreamChat = await proxyRequestJsonWithFallbackUrls(upstream.baseUrl, 'chat/completions', {
|
|
1056
1482
|
method: 'POST',
|
|
1057
1483
|
headers: commonHeaders,
|