@bitkyc08/opencodex 2.1.12-preview.1 → 2.5.0

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BxdKA4PR.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-C_EmUrcx.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-xJdeQjzZ.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.1.12-preview.1",
3
+ "version": "2.5.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/cli.ts CHANGED
@@ -6,7 +6,7 @@ import { restoreLegacyOpenaiHistory } from "./codex-history-provider";
6
6
  import { codexAutoStartEnabled, getConfigDir, loadConfig, readPid, removePid, saveConfig, writePid } from "./config";
7
7
  import { findAvailablePort } from "./ports";
8
8
  import { serviceCommand, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
9
- import { startServer } from "./server";
9
+ import { drainAndShutdown, startServer } from "./server";
10
10
  import { maybeShowStarPrompt } from "./star-prompt";
11
11
 
12
12
  const args = process.argv.slice(2);
@@ -200,14 +200,15 @@ async function handleStart(options: { block?: boolean } = {}) {
200
200
  const server = startServer(port);
201
201
  writePid(process.pid);
202
202
 
203
+ const config = loadConfig();
203
204
  const shutdown = () => {
204
205
  console.log("\n🛑 Shutting down opencodex proxy...");
205
- server.stop(true);
206
- removePid();
207
- // Under the service (OCX_SERVICE), a restart re-injects on start — don't churn Codex config.
208
- // `ocx service stop/uninstall` restore explicitly.
209
- if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
210
- process.exit(0);
206
+ void (async () => {
207
+ await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000);
208
+ removePid();
209
+ if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
210
+ process.exit(0);
211
+ })();
211
212
  };
212
213
 
213
214
  process.on("SIGINT", shutdown);
package/src/server.ts CHANGED
@@ -57,6 +57,66 @@ import {
57
57
  } from "./codex-routing";
58
58
  import { registerCodexWebSocket, unregisterCodexWebSocket } from "./codex-websocket-registry";
59
59
 
60
+ // ---------------------------------------------------------------------------
61
+ // Active turn tracking + graceful shutdown drain
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const activeTurns = new Set<AbortController>();
65
+ let draining = false;
66
+
67
+ export function registerTurn(ac: AbortController): void { activeTurns.add(ac); }
68
+ export function unregisterTurn(ac: AbortController): void { activeTurns.delete(ac); }
69
+ export function isDraining(): boolean { return draining; }
70
+ export function getActiveTurnCount(): number { return activeTurns.size; }
71
+
72
+ export function trackStreamLifetime(
73
+ body: ReadableStream<Uint8Array>,
74
+ ac: AbortController,
75
+ ): ReadableStream<Uint8Array> {
76
+ registerTurn(ac);
77
+ const reader = body.getReader();
78
+ return new ReadableStream<Uint8Array>({
79
+ async pull(controller) {
80
+ try {
81
+ const { done, value } = await reader.read();
82
+ if (done) { unregisterTurn(ac); controller.close(); return; }
83
+ controller.enqueue(value);
84
+ } catch (err) {
85
+ unregisterTurn(ac);
86
+ try { controller.error(err); } catch { /* already closed */ }
87
+ }
88
+ },
89
+ cancel(reason) {
90
+ unregisterTurn(ac);
91
+ ac.abort(reason);
92
+ reader.cancel(reason).catch(() => {});
93
+ },
94
+ });
95
+ }
96
+
97
+ let _serverRef: ReturnType<typeof Bun.serve> | undefined;
98
+
99
+ export async function drainAndShutdown(
100
+ server: ReturnType<typeof Bun.serve> | undefined,
101
+ timeoutMs: number,
102
+ ): Promise<void> {
103
+ const s = server ?? _serverRef;
104
+ draining = true;
105
+ const deadline = Date.now() + timeoutMs;
106
+ while (activeTurns.size > 0 && Date.now() < deadline) {
107
+ await Bun.sleep(100);
108
+ }
109
+ if (activeTurns.size > 0) {
110
+ console.log(`⚠️ Aborting ${activeTurns.size} in-flight turn(s) after ${timeoutMs}ms deadline`);
111
+ for (const ac of activeTurns) {
112
+ ac.abort(new Error("server shutdown"));
113
+ }
114
+ activeTurns.clear();
115
+ }
116
+ s?.stop(true);
117
+ draining = false;
118
+ }
119
+
60
120
  // Single source of truth = package.json (../ from src/), so /healthz + the GUI badge match the
61
121
  // installed npm version instead of a stale hardcode.
62
122
  const VERSION = (() => {
@@ -316,15 +376,28 @@ async function handleResponses(
316
376
  }
317
377
  }
318
378
 
319
- const body = isEventStream
320
- ? relaySseWithHeartbeat(
321
- upstreamResponse.body,
322
- upstream,
323
- 15_000,
324
- terminalBodyWillRecord && recordTerminalOutcomes ? terminalRecorder : undefined,
325
- )
326
- : relayWithAbort(upstreamResponse.body, upstream);
327
- return new Response(body, {
379
+ // Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
380
+ // async-pull segfault on Windows. Branch[0] goes directly to the Response (Bun
381
+ // native relay, never enters JS Sink.write); branch[1] is consumed in the
382
+ // background for terminal-outcome/quota inspection only.
383
+ if (isEventStream && upstreamResponse.body) {
384
+ const [nativeBody, inspectBody] = upstreamResponse.body.tee();
385
+ const turnAc = new AbortController();
386
+ const trackedNative = trackStreamLifetime(nativeBody, turnAc);
387
+ if (terminalBodyWillRecord && recordTerminalOutcomes && terminalRecorder) {
388
+ consumeForInspection(inspectBody, terminalRecorder);
389
+ } else {
390
+ inspectBody.cancel().catch(() => {});
391
+ }
392
+ return new Response(trackedNative, {
393
+ status: upstreamResponse.status,
394
+ headers,
395
+ });
396
+ }
397
+ const body = relayWithAbort(upstreamResponse.body, upstream);
398
+ const turnAc = new AbortController();
399
+ const tracked = body ? trackStreamLifetime(body, turnAc) : null;
400
+ return new Response(tracked, {
328
401
  status: upstreamResponse.status,
329
402
  headers,
330
403
  });
@@ -391,7 +464,9 @@ async function handleResponses(
391
464
  hideThinkingSummary: parsed.options.hideThinkingSummary,
392
465
  },
393
466
  );
394
- return new Response(sseStream, {
467
+ const bridgeTurnAc = new AbortController();
468
+ const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc);
469
+ return new Response(trackedSse, {
395
470
  headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
396
471
  });
397
472
  }
@@ -608,6 +683,54 @@ export function relaySseWithHeartbeat(
608
683
  });
609
684
  }
610
685
 
686
+ /**
687
+ * Background-consume an SSE stream purely for terminal-outcome inspection (quota tracking).
688
+ * Does not produce output; safe to ignore errors (the client-facing stream is separate).
689
+ */
690
+ function consumeForInspection(
691
+ body: ReadableStream<Uint8Array>,
692
+ onTerminal: (status: ResponsesTerminalStatus) => void,
693
+ ): void {
694
+ const reader = body.getReader();
695
+ const decoder = new TextDecoder();
696
+ let buffer = "";
697
+ let reported = false;
698
+ const pump = async () => {
699
+ try {
700
+ for (;;) {
701
+ const { done, value } = await reader.read();
702
+ if (done) {
703
+ buffer += decoder.decode();
704
+ if (buffer.trim() && !reported) {
705
+ const payload = sseDataPayload(buffer);
706
+ if (payload) {
707
+ const status = terminalStatusFromSsePayload(payload);
708
+ if (status) { reported = true; onTerminal(status); }
709
+ }
710
+ }
711
+ if (!reported) onTerminal("incomplete");
712
+ return;
713
+ }
714
+ buffer += decoder.decode(value, { stream: true });
715
+ let next: { block: string; rest: string } | null;
716
+ while ((next = nextSseBlock(buffer))) {
717
+ buffer = next.rest;
718
+ if (!reported) {
719
+ const payload = sseDataPayload(next.block);
720
+ if (payload) {
721
+ const status = terminalStatusFromSsePayload(payload);
722
+ if (status) { reported = true; onTerminal(status); }
723
+ }
724
+ }
725
+ }
726
+ }
727
+ } catch {
728
+ if (!reported) onTerminal("incomplete");
729
+ }
730
+ };
731
+ pump();
732
+ }
733
+
611
734
  /**
612
735
  * Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
613
736
  * (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
@@ -967,7 +1090,10 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
967
1090
  const { stopServiceIfInstalled } = await import("./service");
968
1091
  stopServiceIfInstalled();
969
1092
  restoreNativeCodex();
970
- setTimeout(() => process.exit(0), 200);
1093
+ setTimeout(async () => {
1094
+ await drainAndShutdown(undefined, config.shutdownTimeoutMs ?? 5000);
1095
+ process.exit(0);
1096
+ }, 200);
971
1097
  return jsonResponse({ success: true, message: "Proxy stopping, native Codex restored." });
972
1098
  }
973
1099
 
@@ -1026,6 +1152,9 @@ export function startServer(port?: number) {
1026
1152
  // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
1027
1153
  // handshake-time only, so capture inbound headers and thread them into the pipeline.
1028
1154
  if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
1155
+ if (draining) {
1156
+ return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(), "Retry-After": "5" } });
1157
+ }
1029
1158
  const apiAuthError = requireApiAuth(req, config, "data-plane");
1030
1159
  if (apiAuthError) return apiAuthError;
1031
1160
  if (!isLocalOrigin(req)) {
@@ -1090,6 +1219,9 @@ export function startServer(port?: number) {
1090
1219
  }
1091
1220
 
1092
1221
  if (url.pathname === "/v1/responses" && req.method === "POST") {
1222
+ if (draining) {
1223
+ return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(), "Retry-After": "5" } });
1224
+ }
1093
1225
  const apiAuthError = requireApiAuth(req, config, "data-plane");
1094
1226
  if (apiAuthError) return apiAuthError;
1095
1227
  if (!isLocalOrigin(req)) {
@@ -1151,6 +1283,8 @@ export function startServer(port?: number) {
1151
1283
 
1152
1284
  const payload: Record<string, unknown> = { ...frame };
1153
1285
  delete payload.type;
1286
+ const wsTurnAc = new AbortController();
1287
+ registerTurn(wsTurnAc);
1154
1288
  void (async () => {
1155
1289
  const logCtx = { model: "unknown", provider: "unknown" };
1156
1290
  const fwd = new Headers({ "content-type": "application/json" });
@@ -1192,6 +1326,7 @@ export function startServer(port?: number) {
1192
1326
  /* socket already gone or send dropped */
1193
1327
  }
1194
1328
  } finally {
1329
+ unregisterTurn(wsTurnAc);
1195
1330
  if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
1196
1331
  }
1197
1332
  })();
@@ -1203,6 +1338,8 @@ export function startServer(port?: number) {
1203
1338
  },
1204
1339
  });
1205
1340
 
1341
+ _serverRef = server;
1342
+
1206
1343
  console.log(`🚀 opencodex proxy running on http://localhost:${listenPort}`);
1207
1344
  console.log(` POST /v1/responses → provider translation`);
1208
1345
  console.log(` GET /healthz → health check`);
package/src/types.ts CHANGED
@@ -183,6 +183,8 @@ export interface OcxConfig {
183
183
  stallTimeoutSec?: number;
184
184
  /** Connect timeout (ms) for upstream fetch — covers DNS, TCP, TLS, and response header. Default 30000. */
185
185
  connectTimeoutMs?: number;
186
+ /** Graceful shutdown drain timeout (ms). Active turns are aborted after this deadline. Default 5000. */
187
+ shutdownTimeoutMs?: number;
186
188
  /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */
187
189
  websockets?: boolean;
188
190
  /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */