@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.
Files changed (66) hide show
  1. package/README.ja.md +8 -1
  2. package/README.ko.md +7 -1
  3. package/README.md +7 -1
  4. package/README.ru.md +7 -1
  5. package/README.zh-CN.md +7 -1
  6. package/gui/dist/assets/index-BhUTxmCy.js +52 -0
  7. package/gui/dist/assets/index-oOZcqVmj.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +22 -2
  11. package/src/adapters/cursor/live-transport.ts +7 -0
  12. package/src/adapters/cursor/message-mapper.ts +3 -0
  13. package/src/adapters/cursor/protobuf-request.ts +223 -27
  14. package/src/adapters/cursor/request-builder.ts +41 -15
  15. package/src/adapters/cursor/thread-continuity.ts +67 -0
  16. package/src/adapters/cursor/types.ts +3 -1
  17. package/src/adapters/cursor.ts +44 -9
  18. package/src/adapters/google.ts +115 -62
  19. package/src/adapters/kiro.ts +3 -17
  20. package/src/adapters/openai-chat.ts +16 -5
  21. package/src/adapters/openai-responses.ts +56 -1
  22. package/src/adapters/run-turn-queue.ts +11 -1
  23. package/src/bridge.ts +139 -69
  24. package/src/chat/outbound.ts +135 -73
  25. package/src/cli/codex-shim-autorestore.ts +45 -0
  26. package/src/cli/doctor.ts +197 -2
  27. package/src/cli/index.ts +17 -3
  28. package/src/cli/status.ts +80 -0
  29. package/src/cli/v2.ts +14 -2
  30. package/src/codex/auth-context.ts +18 -2
  31. package/src/codex/catalog/bundled.ts +83 -27
  32. package/src/codex/catalog/effort.ts +95 -3
  33. package/src/codex/catalog/parsing.ts +17 -0
  34. package/src/codex/catalog/provider-fetch.ts +31 -8
  35. package/src/codex/exec-invocation.ts +22 -0
  36. package/src/codex/model-cache.ts +44 -0
  37. package/src/codex/runtime.ts +529 -0
  38. package/src/codex/shim.ts +608 -10
  39. package/src/combos/resolve.ts +7 -2
  40. package/src/config.ts +32 -1
  41. package/src/lib/bun-stream-caps.ts +88 -0
  42. package/src/lib/crash-guard.ts +3 -1
  43. package/src/lib/sse-decoder.ts +25 -6
  44. package/src/responses/parser.ts +2 -1
  45. package/src/responses/state.ts +10 -2
  46. package/src/server/auth-cors.ts +4 -1
  47. package/src/server/index.ts +191 -1
  48. package/src/server/live.ts +491 -0
  49. package/src/server/management/config-routes.ts +79 -3
  50. package/src/server/management/provider-routes.ts +2 -0
  51. package/src/server/management/shared.ts +6 -6
  52. package/src/server/management/system-routes.ts +65 -0
  53. package/src/server/management-api.ts +3 -1
  54. package/src/server/memory-watchdog.ts +112 -0
  55. package/src/server/relay-eager.ts +199 -0
  56. package/src/server/relay.ts +131 -81
  57. package/src/server/responses/collaboration.ts +20 -3
  58. package/src/server/responses/core.ts +236 -21
  59. package/src/server/responses/encrypted-payload.ts +118 -41
  60. package/src/server/ws-bridge.ts +7 -0
  61. package/src/types.ts +25 -0
  62. package/src/usage/cost.ts +0 -0
  63. package/src/usage/expected-prices.ts +19 -0
  64. package/src/usage/summary.ts +11 -8
  65. package/gui/dist/assets/index-BpX-hoSd.css +0 -1
  66. 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
- return new ReadableStream<Uint8Array>({
163
- async start(controller) {
164
- let emittedSinceYield = false;
165
- const emit = (name: string, data: Record<string, unknown>) => {
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
- emittedSinceYield = true;
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
- let macrotaskFired = true;
436
- let macrotaskTimer: ReturnType<typeof setTimeout> | undefined;
437
-
438
- try {
439
- for await (const event of events) {
440
- if (!macrotaskFired && emittedSinceYield) {
441
- await new Promise<void>(r => setTimeout(r, 0));
442
- macrotaskFired = true;
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
- emittedSinceYield = false;
445
- macrotaskFired = false;
446
- if (macrotaskTimer !== undefined) clearTimeout(macrotaskTimer);
447
- macrotaskTimer = setTimeout(() => { macrotaskFired = true; macrotaskTimer = undefined; }, 0);
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 hit its output token cap. Surface as incomplete so the client can
655
- // distinguish a truncated turn from a genuinely finished one (issue #246).
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: { reason: "max_output_tokens" },
657
+ incomplete_details: {
658
+ reason: event.stopReason === "max_tokens" ? "max_output_tokens" : "content_filter",
659
+ },
660
660
  };
661
- // Still cache the partial output so previous_response_id replay works.
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
- terminated = true;
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
- terminated = true;
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
- terminated = true;
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
- flushHiddenRawReasoning();
726
- if (currentWebSearch) closeCurrentWebSearch("failed", []);
727
- emit("response.failed", {
728
- response: {
729
- ...responseSnapshot("failed", finishedItems),
730
- error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
731
- last_error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
732
- },
733
- });
734
- reportTerminal("failed");
735
- terminated = true;
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 (beat) clearInterval(beat);
739
- if (macrotaskTimer !== undefined) clearTimeout(macrotaskTimer);
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
  }
@@ -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
- return new ReadableStream<Uint8Array>({
141
- start(controller) {
142
- let failed = false;
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
- const frame = chunkBase(id, model, created);
238
- frame.choices = [{
239
- index: 0,
240
- delta: {
241
- tool_calls: [{
242
- index: toolIndex,
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
- const knownName = toolNameByIndex.get(toolIndex) ?? "";
262
- const fn: Rec = { arguments: data.delta };
263
- // Re-emit name on every args delta: GitHub Copilot App / some ChatGPT clients
264
- // replace the whole function object instead of merging, which otherwise drops name.
265
- if (knownName) fn.name = knownName;
266
- const frame = chunkBase(id, model, created);
267
- frame.choices = [{
268
- index: 0,
269
- delta: {
270
- tool_calls: [{
271
- index: toolIndex,
272
- function: fn,
273
- }],
274
- },
275
- finish_reason: null,
276
- }];
277
- emit(frame);
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 existingIndex = toolIndexByCallId.get(callId);
290
- const isNew = existingIndex === undefined;
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
- if (isNew) toolIndexByCallId.set(callId, toolIndex);
293
- if (typeof item.id === "string") toolIndexByItemId.set(item.id, toolIndex);
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
- // Last-write-wins: the done-frame snapshot is authoritative final arguments.
297
- // Always re-emit full identity (id/type/name) so replace-style clients keep function.name.
298
- if (args.length > 0 || isNew || finalName) {
299
- ensureRole();
300
- const frame = chunkBase(id, model, created);
301
- frame.choices = [{
302
- index: 0,
303
- delta: {
304
- tool_calls: [{
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
- : sawToolUse ? "tool_calls" : "stop";
329
- finish(reason, response.usage);
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
- void (async () => {
388
+ const step = async () => {
389
+ if (stepping || cancelled) return;
390
+ stepping = true;
391
+ const emittedAtStart = emittedFrames;
349
392
  try {
350
- for await (const record of sseIterator!) {
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
- if (!cancelled && !terminated) {
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
+ }