@bitkyc08/opencodex 2.55.0 → 2.56.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.
Files changed (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,565 @@
1
+ import {
2
+ buildWarmupCompletionFrames,
3
+ buildWsErrorFrame,
4
+ selectForwardHeaders,
5
+ sendJsonFrame,
6
+ buildResponsesWsData,
7
+ sendResponseToWebSocket,
8
+ sendTextFrame,
9
+ type LiveSidebandUpstreamFailure,
10
+ type LiveSidebandUpstreamHandoff,
11
+ type WsData,
12
+ } from "../ws-bridge";
13
+ import type { Server, ServerWebSocket } from "bun";
14
+ import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "../live";
15
+ import { RESPONSE_TTL_MS } from "../../responses/state";
16
+
17
+ export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
18
+ /**
19
+ * 0 means Bun never closes an idle socket, and this one value covers every socket kind the
20
+ * server accepts — the live sideband relay, where a quiet call is normal, and the Responses data
21
+ * plane, where quiet means the client is simply between turns.
22
+ *
23
+ * It is coupled to `RESPONSE_TTL_MS` whether or not anyone says so, which is why it is said here.
24
+ * A codex-rs client caches its `WebsocketSession` across turns and chains `previous_response_id`
25
+ * onto it; it only clears `last_request`/`last_response_rx` when it finds the connection closed.
26
+ * So a socket that outlives retention is a client that keeps referencing continuation state this
27
+ * process has already evicted. Two settings can hold that line and only these two:
28
+ *
29
+ * - a FINITE idle timeout below `MAX_WEBSOCKET_IDLE_TIMEOUT_SECONDS`, which closes the socket
30
+ * first and lets the client reset its own chain, or
31
+ * - this 0, which obliges the proxy to fail closed on the expired reference instead —
32
+ * `server/responses/request-prepare.ts` returns `previous_response_not_found`, the error
33
+ * codex-rs recognizes on a WebSocket turn and answers by replaying its full input.
34
+ *
35
+ * What must never happen is neither: an immortal socket plus a destination that silently accepts
36
+ * the orphaned delta. `tests/responses/ws-endpoint.test.ts` holds exactly that pair together.
37
+ * Raising the timeout off 0 is still worth doing for its own reasons (a dead peer holds a socket
38
+ * forever today), and Bun caps the value at 255 seconds, well inside the bound below.
39
+ */
40
+ export const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
41
+ /** Ceiling a finite websocket idle timeout must stay under, in seconds. See above. */
42
+ export const MAX_WEBSOCKET_IDLE_TIMEOUT_SECONDS = Math.floor(RESPONSE_TTL_MS / 1_000);
43
+
44
+ const LIVE_SIDEBAND_PENDING_MAX = 32;
45
+ const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024;
46
+ const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000;
47
+ /**
48
+ * Bound the pre-upgrade upstream handshake. A sideband join that cannot reach 101
49
+ * must fail the client upgrade promptly rather than hold it open indefinitely.
50
+ */
51
+ export const LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS = 10_000;
52
+
53
+ /**
54
+ * Outcome of the upstream sideband handshake performed before the client upgrade.
55
+ *
56
+ * `ok: false` carries the HTTP status the client upgrade must fail with. Only an
57
+ * upgrade failure reaches codex-rs as a connect error, and only a connect error
58
+ * ends its sideband reconnect loop (`realtime_conversation/sideband.rs`: the `Err`
59
+ * arm always breaks). A 101 followed by a close is instead read as `TransportLost`
60
+ * and retried forever against the same, permanently dead call id.
61
+ */
62
+ export type LiveSidebandUpstreamOpenResult =
63
+ | {
64
+ ok: true;
65
+ socket: WebSocket;
66
+ /** Owns capture and terminal events until the downstream relay attaches. */
67
+ handoff: LiveSidebandUpstreamHandoff;
68
+ }
69
+ | { ok: false; status: number; code: string; message: string; socket?: WebSocket };
70
+
71
+ export function exceedsLiveSidebandFrameByteLimit(frameBytes: number): boolean {
72
+ return frameBytes > MAX_WS_FRAME_BYTES;
73
+ }
74
+
75
+ export function exceedsLiveSidebandPendingByteLimit(pendingBytes: number, incomingBytes: number): boolean {
76
+ return incomingBytes > LIVE_SIDEBAND_PENDING_BYTES_MAX - pendingBytes;
77
+ }
78
+
79
+ export function webSocketFrameBytes(frame: string | ArrayBuffer | ArrayBufferView | Blob | Buffer): number {
80
+ if (typeof frame === "string") return Buffer.byteLength(frame);
81
+ if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) return frame.byteLength;
82
+ return frame.size;
83
+ }
84
+
85
+ export type LiveSidebandPendingEnqueueResult = "queued" | "too-many-frames" | "too-many-bytes";
86
+
87
+ export function enqueueLiveSidebandPendingFrame(
88
+ data: Pick<WsData, "livePending" | "livePendingBytes">,
89
+ frame: string | Buffer,
90
+ frameBytes = webSocketFrameBytes(frame),
91
+ ): LiveSidebandPendingEnqueueResult {
92
+ const pending = data.livePending ?? (data.livePending = []);
93
+ if (pending.length >= LIVE_SIDEBAND_PENDING_MAX) return "too-many-frames";
94
+ const pendingBytes = data.livePendingBytes ?? 0;
95
+ if (exceedsLiveSidebandPendingByteLimit(pendingBytes, frameBytes)) return "too-many-bytes";
96
+ pending.push(frame);
97
+ data.livePendingBytes = pendingBytes + frameBytes;
98
+ return "queued";
99
+ }
100
+
101
+ export type LiveSidebandWebSocketFactory = (
102
+ url: string,
103
+ headers: Record<string, string>,
104
+ protocols?: string[],
105
+ ) => WebSocket;
106
+
107
+ function releaseLiveSidebandAdmission(ws: ServerWebSocket<WsData>): void {
108
+ ws.data.liveTurnAdmissionLease?.release();
109
+ ws.data.liveTurnAdmissionLease = undefined;
110
+ }
111
+
112
+ /**
113
+ * Send one live-sideband frame to the upstream socket.
114
+ *
115
+ * Bun's `WebSocket.send` accepts `string | Blob | BufferSource`, but the DOM-lib
116
+ * `Buffer` can be backed by a `SharedArrayBuffer`, which `BufferSource` rejects.
117
+ * `Uint8Array.from` copies into a fresh `ArrayBuffer`-backed view, so a frame
118
+ * arriving from `node:buffer` still round-trips byte-for-byte.
119
+ */
120
+ export function sendUpstreamFrame(upstream: WebSocket, frame: string | Buffer): void {
121
+ if (typeof frame === "string") {
122
+ upstream.send(frame);
123
+ return;
124
+ }
125
+ upstream.send(Uint8Array.from(frame));
126
+ }
127
+
128
+ function finalizeLiveSideband(ws: ServerWebSocket<WsData>, upstream?: WebSocket): void {
129
+ if (upstream && ws.data.liveUpstream !== upstream) return;
130
+ if (ws.data.liveCloseFallback !== undefined) {
131
+ clearTimeout(ws.data.liveCloseFallback);
132
+ ws.data.liveCloseFallback = undefined;
133
+ }
134
+ ws.data.liveUpstream = undefined;
135
+ ws.data.livePending = undefined;
136
+ ws.data.livePendingBytes = undefined;
137
+ if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
138
+ if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer);
139
+ ws.data.liveConnectTimer = undefined;
140
+ ws.data.liveSessionTimer = undefined;
141
+ ws.data.liveUpstreamHeaders = undefined;
142
+ ws.data.liveUpstreamProtocols = undefined;
143
+ ws.data.liveValidateFrame = undefined;
144
+ if (ws.data.liveAbortListener) ws.data.liveAbortSignal?.removeEventListener("abort", ws.data.liveAbortListener);
145
+ ws.data.liveAbortSignal = undefined;
146
+ ws.data.liveAbortListener = undefined;
147
+ ws.data.cancel = undefined;
148
+ const finish = ws.data.liveFinish;
149
+ ws.data.liveFinish = undefined;
150
+ try { finish?.(ws.data.liveOutcome); }
151
+ catch { console.warn("[audio] upstream accounting failed during close"); }
152
+ finally { releaseLiveSidebandAdmission(ws); }
153
+ }
154
+
155
+ function armLiveSidebandCloseFallback(ws: ServerWebSocket<WsData>, upstream: WebSocket): void {
156
+ if (ws.data.liveCloseFallback !== undefined) return;
157
+ ws.data.liveCloseFallback = setTimeout(() => {
158
+ ws.data.liveCloseFallback = undefined;
159
+ if (ws.data.liveUpstream !== upstream) return;
160
+ if (upstream.readyState === WebSocket.CLOSED) {
161
+ finalizeLiveSideband(ws, upstream);
162
+ return;
163
+ }
164
+ // A close frame was already sent below. Retry once, but never surrender
165
+ // native-main ownership while the authenticated transport remains live.
166
+ try {
167
+ upstream.close(1000, "upstream close timeout");
168
+ } catch {
169
+ /* upstream is already unusable */
170
+ }
171
+ // Some implementations transition synchronously without delivering the
172
+ // close event. That is still an observed CLOSED transport and is safe to
173
+ // finalize. CONNECTING/CLOSING peers keep the lease so profile switching
174
+ // fails at its own bounded drain deadline instead of racing live traffic.
175
+ // The earlier CLOSED check narrowed `readyState` to 0|1|2 in the type
176
+ // system, but the socket can still transition to CLOSED (3) before this
177
+ // fallback fires; the cast keeps the runtime-identical check.
178
+ if ((upstream.readyState as number) === 3) finalizeLiveSideband(ws, upstream);
179
+ }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS);
180
+ }
181
+
182
+ export function closeLiveSidebandBeforeUpgrade(
183
+ upstream: WebSocket,
184
+ release: () => void,
185
+ code = 1000,
186
+ reason = "",
187
+ ): void {
188
+ // There is no downstream socket to own this transport yet. Mirror
189
+ // closeLiveSideband's bounded close contract directly: release only after a
190
+ // close event or an observed CLOSED state, never merely after requesting close.
191
+ let released = false;
192
+ let fallback: ReturnType<typeof setTimeout> | undefined;
193
+ const releaseOnce = (): void => {
194
+ if (released) return;
195
+ released = true;
196
+ if (fallback !== undefined) clearTimeout(fallback);
197
+ release();
198
+ };
199
+ upstream.addEventListener("close", releaseOnce, { once: true });
200
+ if (upstream.readyState === WebSocket.CLOSED) {
201
+ releaseOnce();
202
+ return;
203
+ }
204
+ fallback = setTimeout(() => {
205
+ if (upstream.readyState === WebSocket.CLOSED) {
206
+ releaseOnce();
207
+ return;
208
+ }
209
+ try {
210
+ upstream.close(1000, "upstream close timeout");
211
+ } catch {
212
+ /* retain ownership until CLOSED is observed */
213
+ }
214
+ if ((upstream.readyState as number) === 3) releaseOnce();
215
+ }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS);
216
+ try {
217
+ upstream.close(code, reason);
218
+ } catch {
219
+ /* the bounded fallback retries without releasing ownership */
220
+ }
221
+ if ((upstream.readyState as number) === 3) releaseOnce();
222
+ }
223
+
224
+ export function closeLiveSideband(ws: ServerWebSocket<WsData>, code = 1000, reason = ""): void {
225
+ if (ws.data.liveClosing) return;
226
+ ws.data.liveClosing = true;
227
+ if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
228
+ if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer);
229
+ ws.data.liveConnectTimer = undefined;
230
+ ws.data.liveSessionTimer = undefined;
231
+ ws.data.livePending = undefined;
232
+ ws.data.livePendingBytes = undefined;
233
+ ws.data.cancel = undefined;
234
+ const upstream = ws.data.liveUpstream;
235
+ // Bun's `WebSocket` type narrows `readyState` to 0|1|2 even though the DOM
236
+ // constant CLOSED is 3; the numeric literal is the runtime-identical check.
237
+ if (!upstream || upstream.readyState === 3) {
238
+ finalizeLiveSideband(ws, upstream);
239
+ } else {
240
+ // The sideband holds a native-main admission lease. Do not release it just
241
+ // because the downstream left: its authenticated upstream remains live
242
+ // until the close event arrives or the transport is observed CLOSED. The
243
+ // bounded fallback only retries close; it does not release ownership.
244
+ armLiveSidebandCloseFallback(ws, upstream);
245
+ try {
246
+ upstream.close(code, reason);
247
+ } catch {
248
+ /* the fallback retries close without releasing ownership */
249
+ }
250
+ }
251
+ try {
252
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
253
+ ws.close(code, reason);
254
+ }
255
+ } catch {
256
+ /* client already gone */
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Dial the upstream sideband and report whether its handshake reached 101.
262
+ *
263
+ * Bun's client WebSocket does not surface the upstream handshake status, so the
264
+ * result is "opened" or "failed" and nothing finer. That is sufficient for the
265
+ * property this exists to guarantee: the client is never told the relay is live
266
+ * when it is not. Frames the upstream sends before the client socket exists are
267
+ * captured and handed back by `drain`, because a session preamble such as
268
+ * `session.created` arrives immediately after the upstream opens.
269
+ */
270
+ export function openLiveSidebandUpstream(
271
+ url: string,
272
+ headers: Record<string, string>,
273
+ createWebSocket: LiveSidebandWebSocketFactory = (socketUrl, socketHeaders) => (
274
+ new WebSocket(socketUrl, { headers: socketHeaders } as unknown as string[])
275
+ ),
276
+ timeoutMs: number = LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
277
+ signal?: AbortSignal,
278
+ ): Promise<LiveSidebandUpstreamOpenResult> {
279
+ return new Promise(resolve => {
280
+ let socket: WebSocket;
281
+ try {
282
+ socket = createWebSocket(url, headers);
283
+ } catch {
284
+ resolve({ ok: false, status: 502, code: "upstream_error", message: "voice upstream connect failed" });
285
+ return;
286
+ }
287
+
288
+ const buffered: Array<string | Buffer> = [];
289
+ let bufferedBytes = 0;
290
+ let capturing = true;
291
+ let settled = false;
292
+ let terminalFailure: LiveSidebandUpstreamFailure | undefined;
293
+ let removeAbortListener = (): void => {};
294
+
295
+ const finish = (result: LiveSidebandUpstreamOpenResult): void => {
296
+ if (settled) return;
297
+ settled = true;
298
+ clearTimeout(timer);
299
+ removeAbortListener();
300
+ resolve(result);
301
+ };
302
+ const timer = setTimeout(() => {
303
+ const failure = { status: 504, code: "upstream_timeout", message: "voice upstream did not open in time" };
304
+ terminalFailure = failure;
305
+ capturing = false;
306
+ buffered.length = 0;
307
+ bufferedBytes = 0;
308
+ finish({ ok: false, ...failure, socket });
309
+ try {
310
+ socket.close();
311
+ } catch {
312
+ /* ignore */
313
+ }
314
+ }, timeoutMs);
315
+
316
+ const failCapture = (failure: LiveSidebandUpstreamFailure): void => {
317
+ if (!capturing || terminalFailure) return;
318
+ terminalFailure = failure;
319
+ capturing = false;
320
+ buffered.length = 0;
321
+ bufferedBytes = 0;
322
+ finish({ ok: false, ...failure, socket });
323
+ try {
324
+ socket.close(1009, "sideband preamble overflow");
325
+ } catch {
326
+ /* the terminal failure is already retained for the downstream handoff */
327
+ }
328
+ };
329
+ const handoff: LiveSidebandUpstreamHandoff = {
330
+ failure: () => terminalFailure,
331
+ take: () => {
332
+ capturing = false;
333
+ if (terminalFailure) return { ok: false, failure: terminalFailure };
334
+ const frames = buffered.slice();
335
+ buffered.length = 0;
336
+ bufferedBytes = 0;
337
+ return { ok: true, frames };
338
+ },
339
+ };
340
+
341
+ socket.addEventListener("message", event => {
342
+ if (!capturing) return;
343
+ const frameBytes = webSocketFrameBytes(event.data);
344
+ if (exceedsLiveSidebandFrameByteLimit(frameBytes)) {
345
+ failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble frame is too large" });
346
+ return;
347
+ }
348
+ if (buffered.length >= LIVE_SIDEBAND_PENDING_MAX) {
349
+ failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream sent too many preamble frames" });
350
+ return;
351
+ }
352
+ if (exceedsLiveSidebandPendingByteLimit(bufferedBytes, frameBytes)) {
353
+ failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble is too large" });
354
+ return;
355
+ }
356
+ if (typeof event.data === "string") buffered.push(event.data);
357
+ else if (event.data instanceof ArrayBuffer) buffered.push(Buffer.from(new Uint8Array(event.data)));
358
+ else if (ArrayBuffer.isView(event.data)) {
359
+ buffered.push(Buffer.from(new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength)));
360
+ } else return;
361
+ bufferedBytes += frameBytes;
362
+ });
363
+ socket.addEventListener("open", () => {
364
+ finish({
365
+ ok: true,
366
+ socket,
367
+ handoff,
368
+ });
369
+ });
370
+ socket.addEventListener("error", () => {
371
+ const failure = { status: 502, code: "upstream_error", message: "voice upstream rejected the sideband join" };
372
+ terminalFailure ??= failure;
373
+ capturing = false;
374
+ buffered.length = 0;
375
+ bufferedBytes = 0;
376
+ finish({ ok: false, ...terminalFailure, socket });
377
+ try {
378
+ socket.close();
379
+ } catch {
380
+ /* the terminal failure is already retained */
381
+ }
382
+ });
383
+ socket.addEventListener("close", event => {
384
+ const failure = {
385
+ status: 502,
386
+ code: "upstream_error",
387
+ message: `voice upstream closed before opening (code ${event.code})`,
388
+ closeCode: event.code,
389
+ closeReason: event.reason,
390
+ };
391
+ terminalFailure ??= failure;
392
+ capturing = false;
393
+ buffered.length = 0;
394
+ bufferedBytes = 0;
395
+ finish({ ok: false, ...terminalFailure, socket });
396
+ });
397
+ const abortOpen = (): void => {
398
+ const failure = { status: 499, code: "request_cancelled", message: "voice sideband join was cancelled" };
399
+ terminalFailure ??= failure;
400
+ capturing = false;
401
+ buffered.length = 0;
402
+ bufferedBytes = 0;
403
+ finish({ ok: false, ...terminalFailure, socket });
404
+ try {
405
+ socket.close();
406
+ } catch {
407
+ /* the cancelled join no longer owns the socket */
408
+ }
409
+ };
410
+ if (signal) {
411
+ signal.addEventListener("abort", abortOpen, { once: true });
412
+ removeAbortListener = () => signal.removeEventListener("abort", abortOpen);
413
+ if (signal.aborted) abortOpen();
414
+ }
415
+ });
416
+ }
417
+
418
+ export function attachLiveSidebandUpstream(
419
+ ws: ServerWebSocket<WsData>,
420
+ createWebSocket: LiveSidebandWebSocketFactory = (url, headers, protocols) => (
421
+ new WebSocket(url, { headers, protocols } as unknown as string[])
422
+ ),
423
+ ): void {
424
+ if (ws.data.liveAbortSignal?.aborted) {
425
+ closeLiveSideband(ws, 1000, "audio connection canceled");
426
+ return;
427
+ }
428
+ const preOpened = ws.data.liveUpstream;
429
+ let upstream: WebSocket;
430
+ if (preOpened) {
431
+ upstream = preOpened;
432
+ } else {
433
+ const url = ws.data.liveUpstreamUrl;
434
+ if (!url) {
435
+ closeLiveSideband(ws, 1011, "missing upstream");
436
+ return;
437
+ }
438
+ try {
439
+ // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
440
+ upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}, ws.data.liveUpstreamProtocols);
441
+ } catch {
442
+ closeLiveSideband(ws, 1011, "upstream connect failed");
443
+ return;
444
+ }
445
+ }
446
+ ws.data.liveUpstream = upstream;
447
+ ws.data.liveUpstreamHeaders = undefined;
448
+ ws.data.liveUpstreamProtocols = undefined;
449
+ ws.data.liveClosing = false;
450
+ ws.data.cancel = () => closeLiveSideband(ws, 1000, "client closed");
451
+ if (ws.data.liveMaxSessionMs !== undefined) {
452
+ ws.data.liveConnectTimer = setTimeout(() => {
453
+ ws.data.liveOutcome = "timeout";
454
+ closeLiveSideband(ws, 1011, "audio connection timed out");
455
+ }, 10_000);
456
+ ws.data.liveSessionTimer = setTimeout(() => closeLiveSideband(ws, 1000, "audio session expired"), ws.data.liveMaxSessionMs);
457
+ }
458
+
459
+ upstream.addEventListener("close", (event) => {
460
+ if (ws.data.liveUpstream !== upstream) return;
461
+ if (ws.data.liveFinish && !ws.data.liveClosing && event.code !== 1000) ws.data.liveOutcome = "connect_error";
462
+ ws.data.liveClosing = true;
463
+ finalizeLiveSideband(ws, upstream);
464
+ try {
465
+ const external = ws.data.liveMaxSessionMs !== undefined;
466
+ const validCode = event.code === 1000 || (event.code >= 1001 && event.code <= 1014 && ![1004, 1005, 1006].includes(event.code))
467
+ || (event.code >= 3000 && event.code <= 4999);
468
+ ws.close(external && !validCode ? 1011 : event.code || 1000, external ? "audio upstream closed" : event.reason || "");
469
+ } catch {
470
+ /* ignore */
471
+ }
472
+ });
473
+ upstream.addEventListener("error", () => {
474
+ if (ws.data.liveUpstream !== upstream) return;
475
+ if (ws.data.liveFinish && !ws.data.liveClosing) ws.data.liveOutcome = "connect_error";
476
+ closeLiveSideband(ws, 1011, "upstream error");
477
+ });
478
+ if (ws.data.liveAbortSignal) {
479
+ ws.data.liveAbortListener = () => closeLiveSideband(ws, 1000, "audio connection canceled");
480
+ ws.data.liveAbortSignal.addEventListener("abort", ws.data.liveAbortListener, { once: true });
481
+ if (ws.data.liveAbortSignal.aborted) closeLiveSideband(ws, 1000, "audio connection canceled");
482
+ }
483
+
484
+ if (preOpened) {
485
+ // The upstream opened before this socket existed, so its `open` event has already
486
+ // fired and the listener below will never run. Its early frames were captured for
487
+ // us; forward the capture now rather than dropping the session preamble.
488
+ const handoff = ws.data.liveUpstreamHandoff;
489
+ ws.data.liveUpstreamHandoff = undefined;
490
+ const takeover = handoff?.take();
491
+ if (!takeover?.ok || preOpened.readyState !== WebSocket.OPEN) {
492
+ const failure = takeover && !takeover.ok ? takeover.failure : undefined;
493
+ closeLiveSideband(
494
+ ws,
495
+ failure?.closeCode ?? 1011,
496
+ failure?.closeReason ?? "upstream closed before relay attachment",
497
+ );
498
+ return;
499
+ }
500
+ ws.data.liveOpened = true;
501
+ // The upstream opened before this socket existed, so the "open" listener
502
+ // below can never fire for it. Disarm the connect watchdog exactly as that
503
+ // listener would, or every session with a max lifetime is force-closed ten
504
+ // seconds after attach. The session timer stays armed: it bounds the whole
505
+ // session, not the connect phase.
506
+ if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
507
+ ws.data.liveConnectTimer = undefined;
508
+ for (const frame of takeover.frames) {
509
+ try {
510
+ // Mirror the live message listener exactly: same ceiling, same diagnostic
511
+ // record. These frames are upstream-to-client like any other.
512
+ if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(frame))) {
513
+ closeLiveSideband(ws, 1009, "message too large");
514
+ return;
515
+ }
516
+ logLiveSidebandFrame("u2c", frame);
517
+ ws.send(frame);
518
+ } catch {
519
+ closeLiveSideband(ws, 1011, "client send failed");
520
+ return;
521
+ }
522
+ }
523
+ }
524
+
525
+ upstream.addEventListener("open", () => {
526
+ if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return;
527
+ ws.data.liveOpened = true;
528
+ if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
529
+ ws.data.liveConnectTimer = undefined;
530
+ // An accepted transport alone does not prove inference/quota recovery.
531
+ // Keep healthy closes neutral; explicit transport failures are recorded below.
532
+ const pending = ws.data.livePending ?? [];
533
+ ws.data.livePending = undefined;
534
+ ws.data.livePendingBytes = undefined;
535
+ for (const frame of pending) {
536
+ try {
537
+ sendUpstreamFrame(upstream, frame);
538
+ } catch {
539
+ closeLiveSideband(ws, 1011, "upstream send failed");
540
+ return;
541
+ }
542
+ }
543
+ });
544
+ upstream.addEventListener("message", (event) => {
545
+ if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return;
546
+ try {
547
+ if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(event.data))) {
548
+ closeLiveSideband(ws, 1009, "message too large");
549
+ return;
550
+ }
551
+ logLiveSidebandFrame("u2c", event.data);
552
+ let sent: number;
553
+ if (typeof event.data === "string") sent = ws.send(event.data);
554
+ else if (event.data instanceof ArrayBuffer) sent = ws.send(event.data);
555
+ else if (ArrayBuffer.isView(event.data)) {
556
+ sent = ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength));
557
+ } else sent = ws.send(event.data as Buffer);
558
+ if (ws.data.liveMaxSessionMs !== undefined && (sent === 0 || ws.getBufferedAmount() > MAX_WS_FRAME_BYTES)) {
559
+ closeLiveSideband(ws, 1013, "audio client backpressure");
560
+ }
561
+ } catch {
562
+ closeLiveSideband(ws, 1011, "client send failed");
563
+ }
564
+ });
565
+ }