codexmate 0.0.27 → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/cli/builtin-proxy.js +430 -4
  4. package/cli/openai-bridge.js +498 -13
  5. package/cli.js +130 -41
  6. package/lib/cli-models-utils.js +71 -10
  7. package/lib/cli-webhook.js +126 -0
  8. package/package.json +76 -74
  9. package/plugins/prompt-templates/computed.mjs +1 -1
  10. package/plugins/prompt-templates/methods.mjs +0 -66
  11. package/plugins/prompt-templates/overview.mjs +1 -0
  12. package/web-ui/app.js +21 -16
  13. package/web-ui/index.html +1 -0
  14. package/web-ui/logic.codex.mjs +69 -0
  15. package/web-ui/modules/app.computed.dashboard.mjs +54 -0
  16. package/web-ui/modules/app.computed.session.mjs +22 -17
  17. package/web-ui/modules/app.methods.claude-config.mjs +24 -8
  18. package/web-ui/modules/app.methods.codex-config.mjs +35 -3
  19. package/web-ui/modules/app.methods.index.mjs +2 -0
  20. package/web-ui/modules/app.methods.navigation.mjs +21 -3
  21. package/web-ui/modules/app.methods.providers.mjs +96 -7
  22. package/web-ui/modules/app.methods.session-actions.mjs +3 -6
  23. package/web-ui/modules/app.methods.session-browser.mjs +1 -6
  24. package/web-ui/modules/app.methods.session-trash.mjs +6 -7
  25. package/web-ui/modules/app.methods.startup-claude.mjs +8 -1
  26. package/web-ui/modules/app.methods.webhook.mjs +79 -0
  27. package/web-ui/modules/i18n.dict.mjs +1104 -104
  28. package/web-ui/modules/i18n.mjs +9 -3
  29. package/web-ui/modules/provider-url-display.mjs +17 -0
  30. package/web-ui/partials/index/layout-header.html +25 -0
  31. package/web-ui/partials/index/modals-basic.html +0 -3
  32. package/web-ui/partials/index/panel-config-claude.html +10 -3
  33. package/web-ui/partials/index/panel-config-codex.html +44 -4
  34. package/web-ui/partials/index/panel-plugins.html +3 -29
  35. package/web-ui/partials/index/panel-sessions.html +0 -10
  36. package/web-ui/partials/index/panel-settings.html +93 -177
  37. package/web-ui/partials/index/panel-trash.html +88 -0
  38. package/web-ui/session-helpers.mjs +2 -2
  39. package/web-ui/styles/base-theme.css +47 -34
  40. package/web-ui/styles/controls-forms.css +27 -28
  41. package/web-ui/styles/docs-panel.css +63 -39
  42. package/web-ui/styles/layout-shell.css +69 -46
  43. package/web-ui/styles/modals-core.css +12 -10
  44. package/web-ui/styles/navigation-panels.css +36 -35
  45. package/web-ui/styles/responsive.css +4 -4
  46. package/web-ui/styles/sessions-list.css +10 -6
  47. package/web-ui/styles/settings-panel.css +197 -33
  48. package/web-ui/styles/titles-cards.css +90 -26
  49. package/web-ui/styles/trash-panel.css +90 -0
  50. package/web-ui/styles/webhook.css +81 -0
  51. package/web-ui/styles.css +2 -0
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # Codex Mate
6
6
 
7
- **Local-first CLI + Web UI that edits your AI tool configs and sessions directly on disk, with built-in usage analytics and safe rollback.**
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
  [![Build](https://img.shields.io/github/actions/workflow/status/SakuraByteCore/codexmate/release.yml?label=build&style=flat)](https://github.com/SakuraByteCore/codexmate/actions/workflows/release.yml)
10
10
  [![Version](https://img.shields.io/npm/v/codexmate?label=version&style=flat)](https://www.npmjs.com/package/codexmate)
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # Codex Mate
6
6
 
7
- **本地优先的 CLI + Web UI:直接写入你的本地配置与会话文件,内置 Usage 统计,并提供可审计、可回滚的变更保护。**
7
+ **一个面板管好所有本地 AI 编码工具 Codex / Claude Code / OpenClaw 切 provider、管会话、改配置、编排任务。内置 OpenAI 兼容桥接、Usage 统计与提示词模板。纯本地,零上云。**
8
8
 
9
9
  [![Build](https://img.shields.io/github/actions/workflow/status/SakuraByteCore/codexmate/release.yml?label=build&style=flat)](https://github.com/SakuraByteCore/codexmate/actions/workflows/release.yml)
10
10
  [![Version](https://img.shields.io/npm/v/codexmate?label=version&style=flat)](https://www.npmjs.com/package/codexmate)
@@ -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
- res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1048
- res.end(JSON.stringify({ error: upstreamResponses.error || 'Upstream request failed' }));
1049
- return;
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,