codexmate 0.0.26 → 0.0.28
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 +7 -2
- package/README.zh.md +7 -2
- package/cli/builtin-proxy.js +636 -95
- package/cli/openai-bridge.js +497 -5
- package/cli.js +75 -29
- package/lib/cli-models-utils.js +71 -10
- package/package.json +3 -1
- 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 +16 -16
- package/web-ui/logic.codex.mjs +56 -0
- package/web-ui/logic.sessions.mjs +56 -0
- package/web-ui/modules/app.computed.dashboard.mjs +54 -0
- package/web-ui/modules/app.computed.session.mjs +48 -0
- package/web-ui/modules/app.methods.claude-config.mjs +18 -7
- package/web-ui/modules/app.methods.codex-config.mjs +35 -3
- package/web-ui/modules/app.methods.providers.mjs +9 -1
- package/web-ui/modules/app.methods.session-actions.mjs +2 -5
- package/web-ui/modules/app.methods.session-browser.mjs +4 -5
- package/web-ui/modules/app.methods.session-trash.mjs +19 -4
- package/web-ui/modules/app.methods.startup-claude.mjs +12 -1
- package/web-ui/modules/i18n.dict.mjs +28 -32
- package/web-ui/modules/provider-url-display.mjs +17 -0
- package/web-ui/partials/index/panel-config-claude.html +5 -1
- package/web-ui/partials/index/panel-config-codex.html +33 -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 +62 -67
- package/web-ui/partials/index/panel-usage.html +31 -2
- 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/layout-shell.css +37 -34
- 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/sessions-usage.css +95 -0
- package/web-ui/styles/settings-panel.css +19 -0
- package/web-ui/styles/titles-cards.css +90 -26
package/cli/openai-bridge.js
CHANGED
|
@@ -369,6 +369,76 @@ function normalizeResponsesInputToChatMessages(input) {
|
|
|
369
369
|
return [];
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
|
|
373
|
+
function normalizeResponsesToolsToChatTools(tools) {
|
|
374
|
+
if (!Array.isArray(tools)) return tools;
|
|
375
|
+
return tools
|
|
376
|
+
.map((tool) => {
|
|
377
|
+
if (!tool || typeof tool !== 'object') return null;
|
|
378
|
+
if (tool.type !== 'function') return null;
|
|
379
|
+
const sourceFn = tool.function && typeof tool.function === 'object' && !Array.isArray(tool.function)
|
|
380
|
+
? tool.function
|
|
381
|
+
: {};
|
|
382
|
+
const name = typeof sourceFn.name === 'string' && sourceFn.name.trim()
|
|
383
|
+
? sourceFn.name.trim()
|
|
384
|
+
: (typeof tool.name === 'string' ? tool.name.trim() : '');
|
|
385
|
+
if (!name) return null;
|
|
386
|
+
const parameters = sourceFn.parameters && typeof sourceFn.parameters === 'object' && !Array.isArray(sourceFn.parameters)
|
|
387
|
+
? sourceFn.parameters
|
|
388
|
+
: (tool.parameters && typeof tool.parameters === 'object' && !Array.isArray(tool.parameters) ? tool.parameters : {});
|
|
389
|
+
const fn = { name, parameters };
|
|
390
|
+
const description = typeof sourceFn.description === 'string'
|
|
391
|
+
? sourceFn.description
|
|
392
|
+
: (typeof tool.description === 'string' ? tool.description : undefined);
|
|
393
|
+
const strict = typeof sourceFn.strict === 'boolean'
|
|
394
|
+
? sourceFn.strict
|
|
395
|
+
: (typeof tool.strict === 'boolean' ? tool.strict : undefined);
|
|
396
|
+
if (description !== undefined) fn.description = description;
|
|
397
|
+
if (strict !== undefined) fn.strict = strict;
|
|
398
|
+
return { type: 'function', function: fn };
|
|
399
|
+
})
|
|
400
|
+
.filter(Boolean);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function normalizeResponsesToolChoiceToChatToolChoice(toolChoice) {
|
|
404
|
+
if (!toolChoice || typeof toolChoice !== 'object' || Array.isArray(toolChoice)) return toolChoice;
|
|
405
|
+
if (toolChoice.type === 'function' && typeof toolChoice.name === 'string' && toolChoice.name.trim()) {
|
|
406
|
+
return { type: 'function', function: { name: toolChoice.name.trim() } };
|
|
407
|
+
}
|
|
408
|
+
return toolChoice;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function normalizeResponsesToolsForResponsesApi(tools) {
|
|
412
|
+
if (!Array.isArray(tools)) return tools;
|
|
413
|
+
return tools
|
|
414
|
+
.map((tool) => {
|
|
415
|
+
if (!tool || typeof tool !== 'object') return null;
|
|
416
|
+
if (tool.type !== 'function') return null;
|
|
417
|
+
const sourceFn = tool.function && typeof tool.function === 'object' && !Array.isArray(tool.function)
|
|
418
|
+
? tool.function
|
|
419
|
+
: {};
|
|
420
|
+
const name = typeof sourceFn.name === 'string' && sourceFn.name.trim()
|
|
421
|
+
? sourceFn.name.trim()
|
|
422
|
+
: (typeof tool.name === 'string' ? tool.name.trim() : '');
|
|
423
|
+
if (!name) return null;
|
|
424
|
+
const out = { type: 'function', name };
|
|
425
|
+
const description = typeof sourceFn.description === 'string'
|
|
426
|
+
? sourceFn.description
|
|
427
|
+
: (typeof tool.description === 'string' ? tool.description : undefined);
|
|
428
|
+
const parameters = sourceFn.parameters && typeof sourceFn.parameters === 'object' && !Array.isArray(sourceFn.parameters)
|
|
429
|
+
? sourceFn.parameters
|
|
430
|
+
: (tool.parameters && typeof tool.parameters === 'object' && !Array.isArray(tool.parameters) ? tool.parameters : undefined);
|
|
431
|
+
const strict = typeof sourceFn.strict === 'boolean'
|
|
432
|
+
? sourceFn.strict
|
|
433
|
+
: (typeof tool.strict === 'boolean' ? tool.strict : undefined);
|
|
434
|
+
if (description !== undefined) out.description = description;
|
|
435
|
+
if (parameters !== undefined) out.parameters = parameters;
|
|
436
|
+
if (strict !== undefined) out.strict = strict;
|
|
437
|
+
return out;
|
|
438
|
+
})
|
|
439
|
+
.filter(Boolean);
|
|
440
|
+
}
|
|
441
|
+
|
|
372
442
|
function convertResponsesRequestToChatCompletions(payload) {
|
|
373
443
|
const body = payload && typeof payload === 'object' ? payload : {};
|
|
374
444
|
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
|
@@ -401,12 +471,11 @@ function convertResponsesRequestToChatCompletions(payload) {
|
|
|
401
471
|
if (Array.isArray(body.stop) && body.stop.length) {
|
|
402
472
|
chat.stop = body.stop.filter((item) => typeof item === 'string' && item.trim());
|
|
403
473
|
}
|
|
404
|
-
// Best-effort: pass through tool definitions (most OpenAI-compatible providers accept these fields).
|
|
405
474
|
if (Array.isArray(body.tools) && body.tools.length) {
|
|
406
|
-
chat.tools = body.tools;
|
|
475
|
+
chat.tools = normalizeResponsesToolsToChatTools(body.tools);
|
|
407
476
|
}
|
|
408
477
|
if (body.tool_choice !== undefined) {
|
|
409
|
-
chat.tool_choice = body.tool_choice;
|
|
478
|
+
chat.tool_choice = normalizeResponsesToolChoiceToChatToolChoice(body.tool_choice);
|
|
410
479
|
}
|
|
411
480
|
if (body.response_format !== undefined) {
|
|
412
481
|
chat.response_format = body.response_format;
|
|
@@ -489,6 +558,9 @@ function buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamPay
|
|
|
489
558
|
|
|
490
559
|
function ensureResponseMetadata(response) {
|
|
491
560
|
const payload = response && typeof response === 'object' ? response : {};
|
|
561
|
+
if (typeof payload.object !== 'string' || !payload.object.trim()) {
|
|
562
|
+
payload.object = 'response';
|
|
563
|
+
}
|
|
492
564
|
if (typeof payload.created_at !== 'number') {
|
|
493
565
|
payload.created_at = Math.floor(Date.now() / 1000);
|
|
494
566
|
}
|
|
@@ -599,7 +671,11 @@ function extractResponsesOutputText(payload) {
|
|
|
599
671
|
|
|
600
672
|
function toUpstreamNonStreamingResponsesPayload(payload) {
|
|
601
673
|
const body = payload && typeof payload === 'object' ? payload : {};
|
|
602
|
-
|
|
674
|
+
const normalized = { ...body, stream: false };
|
|
675
|
+
if (Array.isArray(body.tools)) {
|
|
676
|
+
normalized.tools = normalizeResponsesToolsForResponsesApi(body.tools);
|
|
677
|
+
}
|
|
678
|
+
return normalized;
|
|
603
679
|
}
|
|
604
680
|
|
|
605
681
|
function shouldFallbackFromUpstreamResponses(status, bodyText) {
|
|
@@ -616,6 +692,7 @@ function shouldFallbackFromUpstreamResponses(status, bodyText) {
|
|
|
616
692
|
if (/unknown (endpoint|route)/i.test(text)) return true;
|
|
617
693
|
if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
|
|
618
694
|
if (/does not support.*responses/i.test(text)) return true;
|
|
695
|
+
if (/name['"`]?\s+is a required property/i.test(text) && /tools/i.test(text) && /function/i.test(text)) return true;
|
|
619
696
|
|
|
620
697
|
// Best-effort parse for structured error codes.
|
|
621
698
|
try {
|
|
@@ -627,6 +704,7 @@ function shouldFallbackFromUpstreamResponses(status, bodyText) {
|
|
|
627
704
|
if (/unknown (endpoint|route)/i.test(msg)) return true;
|
|
628
705
|
if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
|
|
629
706
|
if (/does not support.*responses/i.test(msg)) return true;
|
|
707
|
+
if (/name['"`]?\s+is a required property/i.test(msg) && /tools/i.test(msg) && /function/i.test(msg)) return true;
|
|
630
708
|
} catch (_) {}
|
|
631
709
|
|
|
632
710
|
return false;
|
|
@@ -639,6 +717,7 @@ function isLoopbackAddress(address) {
|
|
|
639
717
|
}
|
|
640
718
|
|
|
641
719
|
function writeSse(res, eventName, dataObj) {
|
|
720
|
+
if (!res || res.writableEnded || res.destroyed) return;
|
|
642
721
|
if (eventName) {
|
|
643
722
|
res.write(`event: ${eventName}\n`);
|
|
644
723
|
}
|
|
@@ -649,6 +728,365 @@ function writeSse(res, eventName, dataObj) {
|
|
|
649
728
|
res.write(`data: ${JSON.stringify(dataObj)}\n\n`);
|
|
650
729
|
}
|
|
651
730
|
|
|
731
|
+
function appendChatStreamToolCall(target, toolCall) {
|
|
732
|
+
if (!toolCall || typeof toolCall !== 'object') return;
|
|
733
|
+
const index = Number.isFinite(toolCall.index) ? toolCall.index : target.length;
|
|
734
|
+
if (!target[index]) {
|
|
735
|
+
target[index] = {
|
|
736
|
+
id: '',
|
|
737
|
+
type: 'function',
|
|
738
|
+
function: { name: '', arguments: '' }
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
const current = target[index];
|
|
742
|
+
if (typeof toolCall.id === 'string' && toolCall.id) current.id = toolCall.id;
|
|
743
|
+
if (typeof toolCall.type === 'string' && toolCall.type) current.type = toolCall.type;
|
|
744
|
+
const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : null;
|
|
745
|
+
if (fn) {
|
|
746
|
+
if (typeof fn.name === 'string' && fn.name) current.function.name = fn.name;
|
|
747
|
+
if (typeof fn.arguments === 'string') current.function.arguments += fn.arguments;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function writeChatCompletionChunkAsResponsesSse(state, chunk) {
|
|
752
|
+
if (!chunk || typeof chunk !== 'object') return;
|
|
753
|
+
if (typeof chunk.model === 'string' && chunk.model) {
|
|
754
|
+
state.model = chunk.model;
|
|
755
|
+
}
|
|
756
|
+
const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
|
|
757
|
+
for (const choice of choices) {
|
|
758
|
+
const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null;
|
|
759
|
+
if (!delta) continue;
|
|
760
|
+
|
|
761
|
+
if (typeof delta.content === 'string' && delta.content) {
|
|
762
|
+
if (!state.messageItem) {
|
|
763
|
+
state.messageItem = {
|
|
764
|
+
id: `msg_${crypto.randomBytes(8).toString('hex')}`,
|
|
765
|
+
type: 'message',
|
|
766
|
+
role: 'assistant',
|
|
767
|
+
content: [{ type: 'output_text', text: '' }]
|
|
768
|
+
};
|
|
769
|
+
state.output.push(state.messageItem);
|
|
770
|
+
writeSse(state.res, 'response.output_item.added', {
|
|
771
|
+
type: 'response.output_item.added',
|
|
772
|
+
output_index: state.output.length - 1,
|
|
773
|
+
item: state.messageItem
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
state.messageText += delta.content;
|
|
777
|
+
state.messageItem.content[0].text = state.messageText;
|
|
778
|
+
writeSse(state.res, 'response.output_text.delta', {
|
|
779
|
+
type: 'response.output_text.delta',
|
|
780
|
+
item_id: state.messageItem.id,
|
|
781
|
+
output_index: state.output.length - 1,
|
|
782
|
+
content_index: 0,
|
|
783
|
+
delta: delta.content,
|
|
784
|
+
sequence_number: state.nextSeq()
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
789
|
+
for (const toolCall of delta.tool_calls) {
|
|
790
|
+
appendChatStreamToolCall(state.toolCalls, toolCall);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function finishChatStreamResponsesSse(state) {
|
|
797
|
+
if (!state || state.finished) return;
|
|
798
|
+
state.finished = true;
|
|
799
|
+
|
|
800
|
+
if (state.messageItem) {
|
|
801
|
+
const outputIndex = state.output.indexOf(state.messageItem);
|
|
802
|
+
writeSse(state.res, 'response.output_text.done', {
|
|
803
|
+
type: 'response.output_text.done',
|
|
804
|
+
item_id: state.messageItem.id,
|
|
805
|
+
output_index: outputIndex,
|
|
806
|
+
content_index: 0,
|
|
807
|
+
text: state.messageText,
|
|
808
|
+
sequence_number: state.nextSeq()
|
|
809
|
+
});
|
|
810
|
+
writeSse(state.res, 'response.output_item.done', {
|
|
811
|
+
type: 'response.output_item.done',
|
|
812
|
+
output_index: outputIndex,
|
|
813
|
+
item: state.messageItem,
|
|
814
|
+
sequence_number: state.nextSeq()
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
for (const toolCall of state.toolCalls) {
|
|
819
|
+
if (!toolCall) continue;
|
|
820
|
+
const name = toolCall.function && typeof toolCall.function.name === 'string' ? toolCall.function.name : '';
|
|
821
|
+
if (!name) continue;
|
|
822
|
+
const item = {
|
|
823
|
+
type: 'function_call',
|
|
824
|
+
call_id: toolCall.id || `call_${crypto.randomBytes(8).toString('hex')}`,
|
|
825
|
+
name,
|
|
826
|
+
arguments: toolCall.function && typeof toolCall.function.arguments === 'string' ? toolCall.function.arguments : ''
|
|
827
|
+
};
|
|
828
|
+
const outputIndex = state.output.length;
|
|
829
|
+
state.output.push(item);
|
|
830
|
+
writeSse(state.res, 'response.output_item.added', {
|
|
831
|
+
type: 'response.output_item.added',
|
|
832
|
+
output_index: outputIndex,
|
|
833
|
+
item
|
|
834
|
+
});
|
|
835
|
+
writeSse(state.res, 'response.output_item.done', {
|
|
836
|
+
type: 'response.output_item.done',
|
|
837
|
+
output_index: outputIndex,
|
|
838
|
+
item,
|
|
839
|
+
sequence_number: state.nextSeq()
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const response = ensureResponseMetadata({
|
|
844
|
+
id: state.responseId,
|
|
845
|
+
model: state.model,
|
|
846
|
+
created_at: state.createdAt,
|
|
847
|
+
status: 'completed',
|
|
848
|
+
output: state.output,
|
|
849
|
+
output_text: state.messageText
|
|
850
|
+
});
|
|
851
|
+
writeSse(state.res, 'response.completed', { type: 'response.completed', response });
|
|
852
|
+
writeSse(state.res, 'done', '[DONE]');
|
|
853
|
+
if (!state.res.writableEnded && !state.res.destroyed) {
|
|
854
|
+
state.res.end();
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function failChatStreamResponsesSse(state, errorMessage) {
|
|
859
|
+
if (!state || state.finished) return;
|
|
860
|
+
state.finished = true;
|
|
861
|
+
writeSse(state.res, 'response.failed', {
|
|
862
|
+
type: 'response.failed',
|
|
863
|
+
response: ensureResponseMetadata({
|
|
864
|
+
id: state.responseId,
|
|
865
|
+
model: state.model,
|
|
866
|
+
created_at: state.createdAt,
|
|
867
|
+
status: 'failed',
|
|
868
|
+
output: state.output,
|
|
869
|
+
output_text: state.messageText
|
|
870
|
+
}),
|
|
871
|
+
error: String(errorMessage || 'upstream stream failed')
|
|
872
|
+
});
|
|
873
|
+
writeSse(state.res, 'done', '[DONE]');
|
|
874
|
+
if (!state.res.writableEnded && !state.res.destroyed) {
|
|
875
|
+
state.res.end();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function formatUpstreamStreamError(errorValue) {
|
|
880
|
+
if (!errorValue) return 'upstream stream failed';
|
|
881
|
+
if (typeof errorValue === 'string') return errorValue;
|
|
882
|
+
if (typeof errorValue === 'object') {
|
|
883
|
+
if (typeof errorValue.message === 'string' && errorValue.message) return errorValue.message;
|
|
884
|
+
try { return JSON.stringify(errorValue); } catch (_) {}
|
|
885
|
+
}
|
|
886
|
+
return String(errorValue || 'upstream stream failed');
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
|
|
890
|
+
const parsed = new URL(targetUrl);
|
|
891
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
892
|
+
const bodyText = options.body ? JSON.stringify(options.body) : '';
|
|
893
|
+
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
|
|
894
|
+
? Math.floor(options.maxBytes)
|
|
895
|
+
: 0;
|
|
896
|
+
const headers = {
|
|
897
|
+
'Accept': 'text/event-stream',
|
|
898
|
+
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
|
899
|
+
...(options.headers || {})
|
|
900
|
+
};
|
|
901
|
+
if (options.body) {
|
|
902
|
+
headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
|
|
903
|
+
}
|
|
904
|
+
const timeoutMs = Number.isFinite(options.timeoutMs)
|
|
905
|
+
? Math.max(1000, Number(options.timeoutMs))
|
|
906
|
+
: 30000;
|
|
907
|
+
const res = options.res;
|
|
908
|
+
const fallbackModel = typeof options.model === 'string' ? options.model : '';
|
|
909
|
+
|
|
910
|
+
return new Promise((resolve) => {
|
|
911
|
+
let settled = false;
|
|
912
|
+
let upstreamReq = null;
|
|
913
|
+
const finish = (value) => {
|
|
914
|
+
if (settled) return;
|
|
915
|
+
settled = true;
|
|
916
|
+
resolve(value);
|
|
917
|
+
};
|
|
918
|
+
const abortUpstream = () => {
|
|
919
|
+
if (upstreamReq) {
|
|
920
|
+
try { upstreamReq.destroy(new Error('client aborted')); } catch (_) {}
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
if (res && typeof res.once === 'function') {
|
|
924
|
+
res.once('close', abortUpstream);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
upstreamReq = transport.request({
|
|
928
|
+
protocol: parsed.protocol,
|
|
929
|
+
hostname: parsed.hostname,
|
|
930
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
931
|
+
method: options.method || 'POST',
|
|
932
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
933
|
+
headers,
|
|
934
|
+
agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
|
|
935
|
+
}, (upstreamRes) => {
|
|
936
|
+
const status = upstreamRes.statusCode || 0;
|
|
937
|
+
const chunks = [];
|
|
938
|
+
let size = 0;
|
|
939
|
+
const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
|
|
940
|
+
|
|
941
|
+
const collectChunk = (chunk) => {
|
|
942
|
+
if (!chunk) return true;
|
|
943
|
+
if (maxBytes > 0) {
|
|
944
|
+
size += chunk.length;
|
|
945
|
+
if (size > maxBytes) {
|
|
946
|
+
chunks.length = 0;
|
|
947
|
+
try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
|
|
948
|
+
try { upstreamReq.destroy(new Error('response too large')); } catch (_) {}
|
|
949
|
+
finish({ ok: false, status, error: 'response too large' });
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
chunks.push(chunk);
|
|
954
|
+
return true;
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
if (status >= 400) {
|
|
958
|
+
upstreamRes.on('data', collectChunk);
|
|
959
|
+
upstreamRes.on('end', () => finish({
|
|
960
|
+
ok: false,
|
|
961
|
+
status,
|
|
962
|
+
bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''
|
|
963
|
+
}));
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (!res.headersSent) {
|
|
968
|
+
res.writeHead(200, {
|
|
969
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
970
|
+
'Cache-Control': 'no-cache',
|
|
971
|
+
'Connection': 'keep-alive',
|
|
972
|
+
'X-Accel-Buffering': 'no'
|
|
973
|
+
});
|
|
974
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (!/text\/event-stream/i.test(contentType)) {
|
|
978
|
+
upstreamRes.on('data', collectChunk);
|
|
979
|
+
upstreamRes.on('end', () => {
|
|
980
|
+
const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
|
|
981
|
+
const parsedJson = parseJsonOrError(text);
|
|
982
|
+
if (parsedJson.error) {
|
|
983
|
+
writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
|
|
984
|
+
writeSse(res, 'done', '[DONE]');
|
|
985
|
+
if (!res.writableEnded && !res.destroyed) res.end();
|
|
986
|
+
finish({ ok: true });
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const extracted = extractChatCompletionResult(parsedJson.value);
|
|
990
|
+
sendResponsesSse(res, buildResponsesPayloadFromChatResult(fallbackModel, extracted.text, extracted.toolCalls, parsedJson.value));
|
|
991
|
+
if (!res.writableEnded && !res.destroyed) res.end();
|
|
992
|
+
finish({ ok: true });
|
|
993
|
+
});
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
let sequence = 0;
|
|
998
|
+
const state = {
|
|
999
|
+
res,
|
|
1000
|
+
responseId: `resp_${crypto.randomBytes(10).toString('hex')}`,
|
|
1001
|
+
model: fallbackModel,
|
|
1002
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
1003
|
+
output: [],
|
|
1004
|
+
messageItem: null,
|
|
1005
|
+
messageText: '',
|
|
1006
|
+
toolCalls: [],
|
|
1007
|
+
finished: false,
|
|
1008
|
+
sawDone: false,
|
|
1009
|
+
nextSeq: () => {
|
|
1010
|
+
sequence += 1;
|
|
1011
|
+
return sequence;
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
writeSse(res, 'response.created', {
|
|
1015
|
+
type: 'response.created',
|
|
1016
|
+
response: {
|
|
1017
|
+
id: state.responseId,
|
|
1018
|
+
model: state.model,
|
|
1019
|
+
created_at: state.createdAt
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
let buffer = '';
|
|
1024
|
+
const handleEventBlock = (block) => {
|
|
1025
|
+
const dataLines = String(block || '')
|
|
1026
|
+
.split(/\r?\n/)
|
|
1027
|
+
.filter((line) => line.startsWith('data:'))
|
|
1028
|
+
.map((line) => line.slice(5).trimStart());
|
|
1029
|
+
if (dataLines.length === 0) return;
|
|
1030
|
+
const data = dataLines.join('\n').trim();
|
|
1031
|
+
if (!data) return;
|
|
1032
|
+
if (data === '[DONE]') {
|
|
1033
|
+
state.sawDone = true;
|
|
1034
|
+
finishChatStreamResponsesSse(state);
|
|
1035
|
+
finish({ ok: true });
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const parsedChunk = parseJsonOrError(data);
|
|
1039
|
+
if (!parsedChunk.error) {
|
|
1040
|
+
if (parsedChunk.value && typeof parsedChunk.value === 'object' && parsedChunk.value.error) {
|
|
1041
|
+
failChatStreamResponsesSse(state, formatUpstreamStreamError(parsedChunk.value.error));
|
|
1042
|
+
finish({ ok: true });
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
writeChatCompletionChunkAsResponsesSse(state, parsedChunk.value);
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
upstreamRes.on('data', (chunk) => {
|
|
1050
|
+
if (!chunk) return;
|
|
1051
|
+
buffer += chunk.toString('utf-8');
|
|
1052
|
+
let boundary = buffer.search(/\r?\n\r?\n/);
|
|
1053
|
+
while (boundary >= 0) {
|
|
1054
|
+
const block = buffer.slice(0, boundary);
|
|
1055
|
+
const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
|
|
1056
|
+
buffer = buffer.slice(boundary + (match ? match[0].length : 2));
|
|
1057
|
+
handleEventBlock(block);
|
|
1058
|
+
boundary = buffer.search(/\r?\n\r?\n/);
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
upstreamRes.on('end', () => {
|
|
1062
|
+
if (buffer.trim()) handleEventBlock(buffer);
|
|
1063
|
+
if (!state.finished && !state.sawDone) {
|
|
1064
|
+
failChatStreamResponsesSse(state, 'upstream stream ended before [DONE]');
|
|
1065
|
+
finish({ ok: true });
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
finishChatStreamResponsesSse(state);
|
|
1069
|
+
finish({ ok: true });
|
|
1070
|
+
});
|
|
1071
|
+
upstreamRes.on('aborted', () => {
|
|
1072
|
+
failChatStreamResponsesSse(state, 'upstream stream aborted');
|
|
1073
|
+
finish({ ok: true });
|
|
1074
|
+
});
|
|
1075
|
+
upstreamRes.on('error', (err) => {
|
|
1076
|
+
failChatStreamResponsesSse(state, err && err.message ? err.message : 'upstream stream failed');
|
|
1077
|
+
finish({ ok: true });
|
|
1078
|
+
});
|
|
1079
|
+
});
|
|
1080
|
+
upstreamReq.setTimeout(timeoutMs, () => {
|
|
1081
|
+
try { upstreamReq.destroy(new Error('timeout')); } catch (_) {}
|
|
1082
|
+
finish({ ok: false, error: 'timeout' });
|
|
1083
|
+
});
|
|
1084
|
+
upstreamReq.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
|
|
1085
|
+
if (bodyText) upstreamReq.write(bodyText);
|
|
1086
|
+
upstreamReq.end();
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
652
1090
|
async function proxyRequestJson(targetUrl, options = {}) {
|
|
653
1091
|
const parsed = new URL(targetUrl);
|
|
654
1092
|
const transport = parsed.protocol === 'https:' ? https : http;
|
|
@@ -804,7 +1242,23 @@ function createOpenaiBridgeHttpHandler(options = {}) {
|
|
|
804
1242
|
? upstream.headers
|
|
805
1243
|
: {};
|
|
806
1244
|
|
|
807
|
-
if (!normalizedSuffix
|
|
1245
|
+
if (!normalizedSuffix) {
|
|
1246
|
+
if ((req.method || 'GET').toUpperCase() !== 'GET') {
|
|
1247
|
+
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1248
|
+
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1252
|
+
res.end(JSON.stringify({
|
|
1253
|
+
object: 'codexmate.openai_bridge',
|
|
1254
|
+
provider: match.provider,
|
|
1255
|
+
status: 'ok',
|
|
1256
|
+
endpoints: ['/v1/responses', '/v1/models']
|
|
1257
|
+
}));
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
if (normalizedSuffix === 'models') {
|
|
808
1262
|
if ((req.method || 'GET').toUpperCase() !== 'GET') {
|
|
809
1263
|
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
810
1264
|
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
@@ -862,6 +1316,44 @@ function createOpenaiBridgeHttpHandler(options = {}) {
|
|
|
862
1316
|
const acceptHeader = req && req.headers ? (req.headers.accept || req.headers.Accept || '') : '';
|
|
863
1317
|
const wantsSse = /text\/event-stream/i.test(String(acceptHeader || ''));
|
|
864
1318
|
|
|
1319
|
+
if (streamRequested && wantsSse) {
|
|
1320
|
+
const converted = convertResponsesRequestToChatCompletions(responsesRequest);
|
|
1321
|
+
if (converted.error) {
|
|
1322
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1323
|
+
res.end(JSON.stringify({ error: converted.error }));
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
|
|
1327
|
+
const chatBody = { ...converted.chat, stream: true };
|
|
1328
|
+
const streamed = await streamChatCompletionsAsResponsesSse(upstreamUrl, {
|
|
1329
|
+
method: 'POST',
|
|
1330
|
+
body: chatBody,
|
|
1331
|
+
headers: {
|
|
1332
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
1333
|
+
...upstreamHeaders
|
|
1334
|
+
},
|
|
1335
|
+
maxBytes: maxUpstreamBytes,
|
|
1336
|
+
httpAgent,
|
|
1337
|
+
httpsAgent,
|
|
1338
|
+
res,
|
|
1339
|
+
model: typeof chatBody.model === 'string' ? chatBody.model : ''
|
|
1340
|
+
});
|
|
1341
|
+
if (!streamed.ok) {
|
|
1342
|
+
if (res.writableEnded || res.destroyed) {
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
if (!res.headersSent) {
|
|
1346
|
+
res.writeHead(streamed.status && streamed.status >= 400 ? streamed.status : 502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1347
|
+
res.end(streamed.bodyText || JSON.stringify({ error: streamed.error || 'Upstream request failed' }));
|
|
1348
|
+
} else if (!res.writableEnded && !res.destroyed) {
|
|
1349
|
+
writeSse(res, 'response.failed', { type: 'response.failed', error: streamed.error || streamed.bodyText || 'Upstream request failed' });
|
|
1350
|
+
writeSse(res, 'done', '[DONE]');
|
|
1351
|
+
res.end();
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
865
1357
|
// Maxx-style behavior: prefer upstream /responses if supported.
|
|
866
1358
|
// Fallback to /chat/completions conversion when upstream does not implement /responses (404/405).
|
|
867
1359
|
const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
|