@bitkyc08/opencodex 2.7.36 → 2.7.37
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.ja.md +8 -1
- package/README.ko.md +7 -1
- package/README.md +7 -1
- package/README.ru.md +7 -1
- package/README.zh-CN.md +7 -1
- package/gui/dist/assets/index-BhUTxmCy.js +52 -0
- package/gui/dist/assets/index-oOZcqVmj.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +7 -0
- package/src/adapters/cursor/message-mapper.ts +3 -0
- package/src/adapters/cursor/protobuf-request.ts +223 -27
- package/src/adapters/cursor/request-builder.ts +41 -15
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +3 -1
- package/src/adapters/cursor.ts +44 -9
- package/src/adapters/google.ts +115 -62
- package/src/adapters/kiro.ts +3 -17
- package/src/adapters/openai-chat.ts +16 -5
- package/src/adapters/openai-responses.ts +56 -1
- package/src/adapters/run-turn-queue.ts +11 -1
- package/src/bridge.ts +139 -69
- package/src/chat/outbound.ts +135 -73
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/doctor.ts +197 -2
- package/src/cli/index.ts +17 -3
- package/src/cli/status.ts +80 -0
- package/src/cli/v2.ts +14 -2
- package/src/codex/auth-context.ts +18 -2
- package/src/codex/catalog/bundled.ts +83 -27
- package/src/codex/catalog/effort.ts +95 -3
- package/src/codex/catalog/parsing.ts +17 -0
- package/src/codex/catalog/provider-fetch.ts +31 -8
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/model-cache.ts +44 -0
- package/src/codex/runtime.ts +529 -0
- package/src/codex/shim.ts +608 -10
- package/src/combos/resolve.ts +7 -2
- package/src/config.ts +32 -1
- package/src/lib/bun-stream-caps.ts +88 -0
- package/src/lib/crash-guard.ts +3 -1
- package/src/lib/sse-decoder.ts +25 -6
- package/src/responses/parser.ts +2 -1
- package/src/responses/state.ts +10 -2
- package/src/server/auth-cors.ts +4 -1
- package/src/server/index.ts +191 -1
- package/src/server/live.ts +491 -0
- package/src/server/management/config-routes.ts +79 -3
- package/src/server/management/provider-routes.ts +2 -0
- package/src/server/management/shared.ts +6 -6
- package/src/server/management/system-routes.ts +65 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/memory-watchdog.ts +112 -0
- package/src/server/relay-eager.ts +199 -0
- package/src/server/relay.ts +131 -81
- package/src/server/responses/collaboration.ts +20 -3
- package/src/server/responses/core.ts +236 -21
- package/src/server/responses/encrypted-payload.ts +118 -41
- package/src/server/ws-bridge.ts +7 -0
- package/src/types.ts +25 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +19 -0
- package/src/usage/summary.ts +11 -8
- package/gui/dist/assets/index-BpX-hoSd.css +0 -1
- package/gui/dist/assets/index-ZmFopEYw.js +0 -52
package/src/bridge.ts
CHANGED
|
@@ -158,16 +158,16 @@ export function bridgeToResponsesSSE(
|
|
|
158
158
|
// upstream silence so a stalled routed provider never trips "idle timeout waiting for SSE".
|
|
159
159
|
let activity = false;
|
|
160
160
|
let beat: ReturnType<typeof setInterval> | undefined;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
let controller: ReadableStreamDefaultController<Uint8Array>;
|
|
162
|
+
let emittedFrames = 0;
|
|
163
|
+
let gated = false;
|
|
164
|
+
let stepping = false;
|
|
165
|
+
const emit = (name: string, data: Record<string, unknown>) => {
|
|
166
166
|
if (closed) return;
|
|
167
167
|
activity = true;
|
|
168
168
|
try {
|
|
169
169
|
controller.enqueue(encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })));
|
|
170
|
-
|
|
170
|
+
emittedFrames++;
|
|
171
171
|
} catch {
|
|
172
172
|
closed = true;
|
|
173
173
|
}
|
|
@@ -176,6 +176,7 @@ export function bridgeToResponsesSSE(
|
|
|
176
176
|
if (closed) return;
|
|
177
177
|
try {
|
|
178
178
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
179
|
+
emittedFrames++;
|
|
179
180
|
} catch {
|
|
180
181
|
closed = true;
|
|
181
182
|
}
|
|
@@ -191,40 +192,10 @@ export function bridgeToResponsesSSE(
|
|
|
191
192
|
...(endTurn !== undefined ? { end_turn: endTurn } : {}),
|
|
192
193
|
});
|
|
193
194
|
|
|
194
|
-
emit("response.created", { response: responseSnapshot("in_progress", []) });
|
|
195
|
-
|
|
196
|
-
// Re-arm Codex's idle timer during silence with a parser-ignored heartbeat (RC3). Skips a tick
|
|
197
|
-
// whenever a real event was emitted since the last tick, so it only fires on a genuine stall.
|
|
198
195
|
const heartbeatFrame = encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n');
|
|
199
196
|
let stallTicks = 0;
|
|
200
197
|
const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec);
|
|
201
198
|
const maxStallTicks = Math.ceil((stallSec * 1000) / heartbeatMs);
|
|
202
|
-
beat = setInterval(() => {
|
|
203
|
-
if (closed) return;
|
|
204
|
-
if (activity) { activity = false; stallTicks = 0; return; }
|
|
205
|
-
if (++stallTicks >= maxStallTicks) {
|
|
206
|
-
if (currentMsg) closeCurrentMessage();
|
|
207
|
-
if (currentReasoning) closeCurrentReasoning();
|
|
208
|
-
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
209
|
-
flushHiddenRawReasoning();
|
|
210
|
-
if (currentToolCall) closeCurrentToolCall();
|
|
211
|
-
if (currentWebSearch) closeCurrentWebSearch("failed", []);
|
|
212
|
-
emit("response.incomplete", {
|
|
213
|
-
response: {
|
|
214
|
-
...responseSnapshot("incomplete", finishedItems),
|
|
215
|
-
incomplete_details: { reason: "upstream_stall_timeout" },
|
|
216
|
-
},
|
|
217
|
-
});
|
|
218
|
-
reportTerminal("incomplete");
|
|
219
|
-
terminated = true;
|
|
220
|
-
closed = true;
|
|
221
|
-
clearInterval(beat!);
|
|
222
|
-
beat = undefined;
|
|
223
|
-
onCancel?.();
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
try { controller.enqueue(heartbeatFrame); } catch { closed = true; }
|
|
227
|
-
}, heartbeatMs);
|
|
228
199
|
|
|
229
200
|
let currentMsg: { itemId: string; outputIndex: number; text: string; phase?: OcxMessagePhase } | null = null;
|
|
230
201
|
let currentReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
|
|
@@ -432,19 +403,46 @@ export function bridgeToResponsesSSE(
|
|
|
432
403
|
firstOutputReported = true;
|
|
433
404
|
try { options?.onFirstOutput?.(); } catch { /* metrics must not break the stream */ }
|
|
434
405
|
};
|
|
435
|
-
|
|
436
|
-
let
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
406
|
+
const it = events[Symbol.asyncIterator]();
|
|
407
|
+
let iteratorStarted = false;
|
|
408
|
+
let iteratorReturned = false;
|
|
409
|
+
let upstreamDone = false;
|
|
410
|
+
const returnIterator = () => {
|
|
411
|
+
if (iteratorReturned) return;
|
|
412
|
+
iteratorReturned = true;
|
|
413
|
+
const finishReturn = () => {
|
|
414
|
+
try {
|
|
415
|
+
void it.return?.()?.catch(() => {});
|
|
416
|
+
} catch {
|
|
417
|
+
/* synchronous iterator cleanup failure is also best-effort */
|
|
443
418
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
419
|
+
};
|
|
420
|
+
// Async-generator return() before the first next() does not enter the generator, so its
|
|
421
|
+
// finally blocks cannot cancel prepared upstream bodies. The cancel hook has already
|
|
422
|
+
// aborted the turn; bootstrap one cleanup step, then close the iterator without awaiting it.
|
|
423
|
+
if (!iteratorStarted) {
|
|
424
|
+
iteratorStarted = true;
|
|
425
|
+
try {
|
|
426
|
+
void it.next().then(finishReturn, () => {}).catch(() => {});
|
|
427
|
+
} catch {
|
|
428
|
+
/* synchronous iterator start failure is also best-effort */
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
finishReturn();
|
|
433
|
+
};
|
|
434
|
+
const step = async () => {
|
|
435
|
+
if (stepping || closed) return;
|
|
436
|
+
stepping = true;
|
|
437
|
+
gated = false;
|
|
438
|
+
const emittedAtStart = emittedFrames;
|
|
439
|
+
try {
|
|
440
|
+
while (!terminated && !closed && emittedFrames === emittedAtStart) {
|
|
441
|
+
iteratorStarted = true;
|
|
442
|
+
const next = await it.next();
|
|
443
|
+
if (next.done) { upstreamDone = true; break; }
|
|
444
|
+
const event = next.value;
|
|
445
|
+
let terminalEvent = false;
|
|
448
446
|
activity = true;
|
|
449
447
|
stallTicks = 0;
|
|
450
448
|
reportFirstOutput(event);
|
|
@@ -650,15 +648,18 @@ export function bridgeToResponsesSSE(
|
|
|
650
648
|
finishedItems.push(item as OutputItem);
|
|
651
649
|
outputIndex++;
|
|
652
650
|
}
|
|
653
|
-
if (event.stopReason === "max_tokens") {
|
|
654
|
-
// Upstream
|
|
655
|
-
// distinguish a truncated turn from a
|
|
651
|
+
if (event.stopReason === "max_tokens" || event.stopReason === "content_filter") {
|
|
652
|
+
// Upstream stopped before a normal completion. Surface as incomplete so the
|
|
653
|
+
// client can distinguish a truncated/filtered turn from a finished one.
|
|
656
654
|
const response = {
|
|
657
655
|
...responseSnapshot("incomplete", finishedItems, event.endTurn),
|
|
658
656
|
usage: responsesUsage(event.usage),
|
|
659
|
-
incomplete_details: {
|
|
657
|
+
incomplete_details: {
|
|
658
|
+
reason: event.stopReason === "max_tokens" ? "max_output_tokens" : "content_filter",
|
|
659
|
+
},
|
|
660
660
|
};
|
|
661
|
-
//
|
|
661
|
+
// Cache max-output partials so previous_response_id replay can continue them;
|
|
662
|
+
// rememberResponseState rejects content-filtered incomplete responses.
|
|
662
663
|
options?.onCompletedResponse?.(response, event.providerState);
|
|
663
664
|
emit("response.incomplete", { response });
|
|
664
665
|
reportTerminal("incomplete");
|
|
@@ -670,7 +671,7 @@ export function bridgeToResponsesSSE(
|
|
|
670
671
|
});
|
|
671
672
|
reportTerminal("completed");
|
|
672
673
|
}
|
|
673
|
-
|
|
674
|
+
terminalEvent = true;
|
|
674
675
|
break;
|
|
675
676
|
}
|
|
676
677
|
case "incomplete": {
|
|
@@ -693,7 +694,7 @@ export function bridgeToResponsesSSE(
|
|
|
693
694
|
},
|
|
694
695
|
});
|
|
695
696
|
reportTerminal("incomplete");
|
|
696
|
-
|
|
697
|
+
terminalEvent = true;
|
|
697
698
|
break;
|
|
698
699
|
}
|
|
699
700
|
case "error": {
|
|
@@ -716,27 +717,41 @@ export function bridgeToResponsesSSE(
|
|
|
716
717
|
},
|
|
717
718
|
});
|
|
718
719
|
reportTerminal("failed");
|
|
719
|
-
|
|
720
|
+
terminalEvent = true;
|
|
720
721
|
break;
|
|
721
722
|
}
|
|
722
723
|
}
|
|
724
|
+
if (terminalEvent) {
|
|
725
|
+
onCancel?.();
|
|
726
|
+
terminated = true;
|
|
727
|
+
returnIterator();
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
723
730
|
}
|
|
724
731
|
} catch (err) {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
response
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
732
|
+
if (!terminated) {
|
|
733
|
+
flushHiddenRawReasoning();
|
|
734
|
+
if (currentWebSearch) closeCurrentWebSearch("failed", []);
|
|
735
|
+
emit("response.failed", {
|
|
736
|
+
response: {
|
|
737
|
+
...responseSnapshot("failed", finishedItems),
|
|
738
|
+
error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
|
|
739
|
+
last_error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
reportTerminal("failed");
|
|
743
|
+
onCancel?.();
|
|
744
|
+
terminated = true;
|
|
745
|
+
returnIterator();
|
|
746
|
+
}
|
|
736
747
|
}
|
|
737
748
|
|
|
738
|
-
if (
|
|
739
|
-
|
|
749
|
+
if (!terminated && !upstreamDone) {
|
|
750
|
+
gated = true;
|
|
751
|
+
stepping = false;
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (beat) { clearInterval(beat); beat = undefined; }
|
|
740
755
|
|
|
741
756
|
if (!terminated) {
|
|
742
757
|
// The adapter generator ended without an explicit done/error event. Mark as incomplete
|
|
@@ -755,6 +770,7 @@ export function bridgeToResponsesSSE(
|
|
|
755
770
|
},
|
|
756
771
|
});
|
|
757
772
|
reportTerminal("incomplete");
|
|
773
|
+
terminated = true;
|
|
758
774
|
}
|
|
759
775
|
|
|
760
776
|
emitDone();
|
|
@@ -763,6 +779,59 @@ export function bridgeToResponsesSSE(
|
|
|
763
779
|
} catch {
|
|
764
780
|
/* already closed (e.g. client cancelled) */
|
|
765
781
|
}
|
|
782
|
+
closed = true;
|
|
783
|
+
gated = true;
|
|
784
|
+
stepping = false;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const startStream = () => {
|
|
788
|
+
emit("response.created", { response: responseSnapshot("in_progress", []) });
|
|
789
|
+
// The default ReadableStream strategy has HWM=1. Once one event's frames fill that
|
|
790
|
+
// queue, pull stepping pauses; no custom FIFO or queuing strategy is layered on top.
|
|
791
|
+
gated = true;
|
|
792
|
+
beat = setInterval(() => {
|
|
793
|
+
if (closed || gated) return;
|
|
794
|
+
if (activity) { activity = false; stallTicks = 0; return; }
|
|
795
|
+
if (++stallTicks >= maxStallTicks) {
|
|
796
|
+
if (currentMsg) closeCurrentMessage();
|
|
797
|
+
if (currentReasoning) closeCurrentReasoning();
|
|
798
|
+
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
799
|
+
flushHiddenRawReasoning();
|
|
800
|
+
if (currentToolCall) closeCurrentToolCall();
|
|
801
|
+
if (currentWebSearch) closeCurrentWebSearch("failed", []);
|
|
802
|
+
emit("response.incomplete", {
|
|
803
|
+
response: {
|
|
804
|
+
...responseSnapshot("incomplete", finishedItems),
|
|
805
|
+
incomplete_details: { reason: "upstream_stall_timeout" },
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
reportTerminal("incomplete");
|
|
809
|
+
onCancel?.();
|
|
810
|
+
terminated = true;
|
|
811
|
+
returnIterator();
|
|
812
|
+
emitDone();
|
|
813
|
+
if (beat) clearInterval(beat);
|
|
814
|
+
beat = undefined;
|
|
815
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
816
|
+
closed = true;
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
controller.enqueue(heartbeatFrame);
|
|
821
|
+
emittedFrames++;
|
|
822
|
+
} catch {
|
|
823
|
+
closed = true;
|
|
824
|
+
}
|
|
825
|
+
}, heartbeatMs);
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
return new ReadableStream<Uint8Array>({
|
|
829
|
+
start(streamController) {
|
|
830
|
+
controller = streamController;
|
|
831
|
+
startStream();
|
|
832
|
+
},
|
|
833
|
+
pull() {
|
|
834
|
+
return step();
|
|
766
835
|
},
|
|
767
836
|
cancel() {
|
|
768
837
|
// Client (Codex) disconnected. Stop emitting and let the caller abort the upstream fetch so a
|
|
@@ -771,6 +840,7 @@ export function bridgeToResponsesSSE(
|
|
|
771
840
|
closed = true;
|
|
772
841
|
if (beat) clearInterval(beat);
|
|
773
842
|
onCancel?.();
|
|
843
|
+
returnIterator();
|
|
774
844
|
},
|
|
775
845
|
});
|
|
776
846
|
}
|
package/src/chat/outbound.ts
CHANGED
|
@@ -132,17 +132,22 @@ export function responsesSseToChatCompletionsSse(
|
|
|
132
132
|
// tool call_id -> streaming index (OpenAI requires stable indices per tool call)
|
|
133
133
|
const toolIndexByCallId = new Map<string, number>();
|
|
134
134
|
const toolIndexByItemId = new Map<string, number>();
|
|
135
|
+
const toolCallIdByIndex = new Map<number, string>();
|
|
135
136
|
const toolNameByIndex = new Map<number, string>();
|
|
137
|
+
const toolArgumentsByIndex = new Map<number, string>();
|
|
138
|
+
const emittedToolIndexes = new Set<number>();
|
|
136
139
|
let nextToolIndex = 0;
|
|
137
140
|
let sseIterator: AsyncGenerator<{ event?: string; data: string }> | undefined;
|
|
138
141
|
const upstreamAbort = new AbortController();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
let controller: ReadableStreamDefaultController<Uint8Array>;
|
|
143
|
+
let failed = false;
|
|
144
|
+
let emittedFrames = 0;
|
|
145
|
+
let stepping = false;
|
|
146
|
+
let decoderStarted = false;
|
|
143
147
|
const emit = (payload: Rec | "[DONE]") => {
|
|
144
148
|
if (failed) return;
|
|
145
149
|
controller.enqueue(encoder.encode(dataFrame(payload)));
|
|
150
|
+
emittedFrames++;
|
|
146
151
|
};
|
|
147
152
|
const ensureRole = () => {
|
|
148
153
|
if (started) return;
|
|
@@ -166,8 +171,47 @@ export function responsesSseToChatCompletionsSse(
|
|
|
166
171
|
frame.choices = [{ index: 0, delta: { reasoning_content: text }, finish_reason: null }];
|
|
167
172
|
emit(frame);
|
|
168
173
|
};
|
|
174
|
+
const resolveFinalArguments = (candidate: unknown, streamed: string) => {
|
|
175
|
+
if (typeof candidate !== "string") return streamed;
|
|
176
|
+
// Some compatible providers send an empty final snapshot. Do not let that erase
|
|
177
|
+
// non-empty streamed arguments; genuinely empty calls have no buffered content.
|
|
178
|
+
return candidate.length > 0 || streamed.length === 0 ? candidate : streamed;
|
|
179
|
+
};
|
|
180
|
+
const emitToolCall = (toolIndex: number, callId: string, name: string, args: string) => {
|
|
181
|
+
if (!callId || emittedToolIndexes.has(toolIndex)) return;
|
|
182
|
+
emittedToolIndexes.add(toolIndex);
|
|
183
|
+
ensureRole();
|
|
184
|
+
const frame = chunkBase(id, model, created);
|
|
185
|
+
frame.choices = [{
|
|
186
|
+
index: 0,
|
|
187
|
+
delta: {
|
|
188
|
+
tool_calls: [{
|
|
189
|
+
index: toolIndex,
|
|
190
|
+
id: callId,
|
|
191
|
+
type: "function",
|
|
192
|
+
function: { name, arguments: args },
|
|
193
|
+
}],
|
|
194
|
+
},
|
|
195
|
+
finish_reason: null,
|
|
196
|
+
}];
|
|
197
|
+
emit(frame);
|
|
198
|
+
};
|
|
199
|
+
const flushPendingToolCalls = () => {
|
|
200
|
+
const pending = [...toolCallIdByIndex.entries()].sort(([a], [b]) => a - b);
|
|
201
|
+
for (const [toolIndex, callId] of pending) {
|
|
202
|
+
emitToolCall(
|
|
203
|
+
toolIndex,
|
|
204
|
+
callId,
|
|
205
|
+
toolNameByIndex.get(toolIndex) ?? "",
|
|
206
|
+
toolArgumentsByIndex.get(toolIndex) ?? "",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
169
210
|
const finish = (finishReason: string, usage: unknown) => {
|
|
170
211
|
if (terminated) return;
|
|
212
|
+
// A valid completed/incomplete terminal frame may arrive without output_item.done.
|
|
213
|
+
// Preserve any known tool call before emitting its finish reason.
|
|
214
|
+
flushPendingToolCalls();
|
|
171
215
|
terminated = true;
|
|
172
216
|
ensureRole();
|
|
173
217
|
const frame = chunkBase(id, model, created);
|
|
@@ -199,6 +243,7 @@ export function responsesSseToChatCompletionsSse(
|
|
|
199
243
|
: null,
|
|
200
244
|
},
|
|
201
245
|
})));
|
|
246
|
+
emittedFrames++;
|
|
202
247
|
} catch {
|
|
203
248
|
/* controller may already be closed */
|
|
204
249
|
}
|
|
@@ -232,24 +277,15 @@ export function responsesSseToChatCompletionsSse(
|
|
|
232
277
|
toolIndex = nextToolIndex++;
|
|
233
278
|
toolIndexByCallId.set(callId, toolIndex);
|
|
234
279
|
}
|
|
280
|
+
toolCallIdByIndex.set(toolIndex, callId);
|
|
235
281
|
if (typeof item.id === "string") toolIndexByItemId.set(item.id, toolIndex);
|
|
236
282
|
if (name) toolNameByIndex.set(toolIndex, name);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
id: callId,
|
|
244
|
-
type: "function",
|
|
245
|
-
// Always include name so clients that replace (not merge) tool_calls
|
|
246
|
-
// deltas never end up with function.name-less history.
|
|
247
|
-
function: { name, arguments: "" },
|
|
248
|
-
}],
|
|
249
|
-
},
|
|
250
|
-
finish_reason: null,
|
|
251
|
-
}];
|
|
252
|
-
emit(frame);
|
|
283
|
+
if (!toolArgumentsByIndex.has(toolIndex)) {
|
|
284
|
+
toolArgumentsByIndex.set(
|
|
285
|
+
toolIndex,
|
|
286
|
+
typeof item.arguments === "string" ? item.arguments : "",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
253
289
|
break;
|
|
254
290
|
}
|
|
255
291
|
case "response.function_call_arguments.delta": {
|
|
@@ -258,23 +294,24 @@ export function responsesSseToChatCompletionsSse(
|
|
|
258
294
|
const toolIndex = (itemId ? toolIndexByItemId.get(itemId) : undefined)
|
|
259
295
|
?? (nextToolIndex > 0 ? nextToolIndex - 1 : 0);
|
|
260
296
|
ensureRole();
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
297
|
+
toolArgumentsByIndex.set(
|
|
298
|
+
toolIndex,
|
|
299
|
+
(toolArgumentsByIndex.get(toolIndex) ?? "") + data.delta,
|
|
300
|
+
);
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
case "response.function_call_arguments.done": {
|
|
304
|
+
const itemId = typeof data.item_id === "string" ? data.item_id : undefined;
|
|
305
|
+
const toolIndex = itemId ? toolIndexByItemId.get(itemId) : undefined;
|
|
306
|
+
if (toolIndex === undefined) break;
|
|
307
|
+
sawToolUse = true;
|
|
308
|
+
const name = typeof data.name === "string" ? data.name : "";
|
|
309
|
+
if (name) toolNameByIndex.set(toolIndex, name);
|
|
310
|
+
const streamedArgs = toolArgumentsByIndex.get(toolIndex) ?? "";
|
|
311
|
+
toolArgumentsByIndex.set(
|
|
312
|
+
toolIndex,
|
|
313
|
+
resolveFinalArguments(data.arguments, streamedArgs),
|
|
314
|
+
);
|
|
278
315
|
break;
|
|
279
316
|
}
|
|
280
317
|
case "response.output_item.done": {
|
|
@@ -284,34 +321,25 @@ export function responsesSseToChatCompletionsSse(
|
|
|
284
321
|
sawToolUse = true;
|
|
285
322
|
const callId = typeof item.call_id === "string" ? item.call_id : "";
|
|
286
323
|
const name = typeof item.name === "string" ? item.name : "";
|
|
287
|
-
const args = typeof item.arguments === "string" ? item.arguments : "";
|
|
288
324
|
if (!callId) break;
|
|
289
|
-
const
|
|
290
|
-
const
|
|
325
|
+
const itemId = typeof item.id === "string" ? item.id : undefined;
|
|
326
|
+
const existingIndex = toolIndexByCallId.get(callId)
|
|
327
|
+
?? (itemId ? toolIndexByItemId.get(itemId) : undefined);
|
|
291
328
|
const toolIndex = existingIndex ?? nextToolIndex++;
|
|
292
|
-
|
|
293
|
-
|
|
329
|
+
toolIndexByCallId.set(callId, toolIndex);
|
|
330
|
+
toolCallIdByIndex.set(toolIndex, callId);
|
|
331
|
+
if (itemId) toolIndexByItemId.set(itemId, toolIndex);
|
|
294
332
|
if (name) toolNameByIndex.set(toolIndex, name);
|
|
295
333
|
const finalName = name || toolNameByIndex.get(toolIndex) || "";
|
|
296
|
-
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
index: toolIndex,
|
|
306
|
-
id: callId,
|
|
307
|
-
type: "function",
|
|
308
|
-
function: { name: finalName, arguments: args },
|
|
309
|
-
}],
|
|
310
|
-
},
|
|
311
|
-
finish_reason: null,
|
|
312
|
-
}];
|
|
313
|
-
emit(frame);
|
|
314
|
-
}
|
|
334
|
+
const streamedArgs = toolArgumentsByIndex.get(toolIndex) ?? "";
|
|
335
|
+
// A Responses stream can carry incremental/finalized argument events plus an
|
|
336
|
+
// authoritative output_item.done snapshot. Chat Completions tool-call fields are
|
|
337
|
+
// append-only deltas, so forwarding multiple representations corrupts clients that
|
|
338
|
+
// accumulate them. Emit one complete call here; finish() flushes any item whose
|
|
339
|
+
// done event was omitted, and replace-style clients still get a complete object.
|
|
340
|
+
const args = resolveFinalArguments(item.arguments, streamedArgs);
|
|
341
|
+
toolArgumentsByIndex.set(toolIndex, args);
|
|
342
|
+
emitToolCall(toolIndex, callId, finalName, args);
|
|
315
343
|
}
|
|
316
344
|
break;
|
|
317
345
|
}
|
|
@@ -325,8 +353,20 @@ export function responsesSseToChatCompletionsSse(
|
|
|
325
353
|
const details = isRec(response.incomplete_details) ? response.incomplete_details : {};
|
|
326
354
|
const reason = details.reason === "max_output_tokens" ? "length"
|
|
327
355
|
: details.reason === "content_filter" ? "content_filter"
|
|
328
|
-
:
|
|
329
|
-
|
|
356
|
+
: undefined;
|
|
357
|
+
if (reason !== undefined) {
|
|
358
|
+
// Truthful OpenAI-compatible finish reasons: the turn ended, just early.
|
|
359
|
+
finish(reason, response.usage);
|
|
360
|
+
} else {
|
|
361
|
+
// upstream_stall_timeout / adapter_eof / proxy-synthesized incompletes are
|
|
362
|
+
// failures, not early finishes: emit an error frame and close WITHOUT
|
|
363
|
+
// [DONE] instead of a success-looking stop/tool_calls + [DONE].
|
|
364
|
+
const why = typeof details.reason === "string" ? details.reason : "unknown";
|
|
365
|
+
const message = typeof details.message === "string" && details.message.length > 0
|
|
366
|
+
? details.message
|
|
367
|
+
: `upstream stream ended early (${why})`;
|
|
368
|
+
fail(message);
|
|
369
|
+
}
|
|
330
370
|
break;
|
|
331
371
|
}
|
|
332
372
|
case "response.failed": {
|
|
@@ -345,9 +385,25 @@ export function responsesSseToChatCompletionsSse(
|
|
|
345
385
|
// multi-line data, and a terminal event without a trailing blank line (Sol audit
|
|
346
386
|
// blocker 3 — the hand-rolled "\n\n" splitter misreported those as truncation).
|
|
347
387
|
sseIterator = decodeServerSentEvents(upstream, { signal: upstreamAbort.signal });
|
|
348
|
-
|
|
388
|
+
const step = async () => {
|
|
389
|
+
if (stepping || cancelled) return;
|
|
390
|
+
stepping = true;
|
|
391
|
+
const emittedAtStart = emittedFrames;
|
|
349
392
|
try {
|
|
350
|
-
|
|
393
|
+
while (!cancelled && emittedFrames === emittedAtStart) {
|
|
394
|
+
decoderStarted = true;
|
|
395
|
+
const next = await sseIterator!.next();
|
|
396
|
+
if (next.done) {
|
|
397
|
+
if (!cancelled && !terminated) {
|
|
398
|
+
fail("upstream stream ended before a terminal frame (truncated response)");
|
|
399
|
+
}
|
|
400
|
+
// Success path: close after [DONE]. Failure path closes inside fail().
|
|
401
|
+
if (!cancelled && terminated && !failed) {
|
|
402
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
403
|
+
}
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
const record = next.value;
|
|
351
407
|
const eventName = record.event ?? "";
|
|
352
408
|
const dataLine = record.data.trim();
|
|
353
409
|
if (!eventName || !dataLine) continue;
|
|
@@ -360,15 +416,18 @@ export function responsesSseToChatCompletionsSse(
|
|
|
360
416
|
} catch (err) {
|
|
361
417
|
fail(err instanceof Error ? err.message : String(err));
|
|
362
418
|
} finally {
|
|
363
|
-
|
|
364
|
-
fail("upstream stream ended before a terminal frame (truncated response)");
|
|
365
|
-
}
|
|
366
|
-
// Success path: close after [DONE]. Failure path closes inside fail().
|
|
367
|
-
if (!cancelled && terminated && !failed) {
|
|
368
|
-
try { controller.close(); } catch { /* already closed */ }
|
|
369
|
-
}
|
|
419
|
+
stepping = false;
|
|
370
420
|
}
|
|
371
|
-
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
return new ReadableStream<Uint8Array>({
|
|
424
|
+
start(streamController) {
|
|
425
|
+
controller = streamController;
|
|
426
|
+
// Default HWM=1: one Responses event is translated atomically, then upstream
|
|
427
|
+
// decoding pauses until the chat consumer creates demand again.
|
|
428
|
+
},
|
|
429
|
+
pull() {
|
|
430
|
+
return step();
|
|
372
431
|
},
|
|
373
432
|
cancel(reason) {
|
|
374
433
|
cancelled = true;
|
|
@@ -376,6 +435,9 @@ export function responsesSseToChatCompletionsSse(
|
|
|
376
435
|
// read() so the generator's return() below resolves promptly instead of hanging
|
|
377
436
|
// behind an idle upstream (Sol re-verification blocker).
|
|
378
437
|
upstreamAbort.abort(reason);
|
|
438
|
+
if (!decoderStarted) {
|
|
439
|
+
return upstream.cancel(reason).then(() => undefined, () => undefined);
|
|
440
|
+
}
|
|
379
441
|
return sseIterator?.return(undefined).then(() => undefined, () => undefined) ?? Promise.resolve(undefined);
|
|
380
442
|
},
|
|
381
443
|
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { autoRestoreCodexShim } from "../codex/shim";
|
|
2
|
+
import { codexShimAutoRestoreEnabled, readConfigDiagnostics } from "../config";
|
|
3
|
+
|
|
4
|
+
export interface CodexShimAutoRestoreCliDeps {
|
|
5
|
+
env: NodeJS.ProcessEnv;
|
|
6
|
+
warn: (message: string) => void;
|
|
7
|
+
restore: typeof autoRestoreCodexShim;
|
|
8
|
+
readConfig: typeof readConfigDiagnostics;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_DEPS: CodexShimAutoRestoreCliDeps = {
|
|
12
|
+
env: process.env,
|
|
13
|
+
warn: message => console.warn(message),
|
|
14
|
+
restore: autoRestoreCodexShim,
|
|
15
|
+
readConfig: readConfigDiagnostics,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function skipsCodexShimAutoRestore(command: string | undefined, args: string[]): boolean {
|
|
19
|
+
if (command === "uninstall" || command === "remove") return true;
|
|
20
|
+
return command === "codex-shim" && ["install", "uninstall", "remove"].includes(args[1] ?? "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function maybeAutoRestoreCodexShim(
|
|
24
|
+
command: string | undefined,
|
|
25
|
+
args: string[],
|
|
26
|
+
deps: CodexShimAutoRestoreCliDeps = DEFAULT_DEPS,
|
|
27
|
+
): void {
|
|
28
|
+
if (skipsCodexShimAutoRestore(command, args)) return;
|
|
29
|
+
try {
|
|
30
|
+
const result = deps.restore({
|
|
31
|
+
enabled: () => codexShimAutoRestoreEnabled(deps.readConfig().config, deps.env),
|
|
32
|
+
});
|
|
33
|
+
if (result.status === "restored") {
|
|
34
|
+
deps.warn(`⚠️ ${result.message} (automatic repair after Codex update)`);
|
|
35
|
+
} else if ((result.status === "deferred" || result.status === "ineligible") && result.message) {
|
|
36
|
+
deps.warn(`⚠️ ${result.message}`);
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
deps.warn(
|
|
40
|
+
`⚠️ Codex shim auto-restore failed; continuing without it: ${
|
|
41
|
+
error instanceof Error ? error.message : String(error)
|
|
42
|
+
}. Run 'ocx codex-shim install' after the Codex update finishes.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|