@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -9,6 +9,7 @@ import type { OcxProviderConfig } from "../../types";
9
9
  import type { WsData } from "../ws-bridge";
10
10
  import { waitForProviderRequestSlot } from "../../providers/request-pacing";
11
11
  import { withUpstreamHttpVersion } from "../../lib/upstream-http-version";
12
+ import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
12
13
 
13
14
  export { withUpstreamHttpVersion };
14
15
 
@@ -56,6 +57,10 @@ export interface ProviderFetchOptions {
56
57
  modelId?: string;
57
58
  /** One pacing slot was acquired immediately before this fetch wrapper was created. */
58
59
  pacingSlotAcquired?: boolean;
60
+ /** Captured selected-account observer, attached before the native WS send. */
61
+ onCodexWsQuota?: CodexWsQuotaObserver;
62
+ /** Synchronous admission at actual credential dispatch, after pacing/backoff. */
63
+ beforeDispatch?: (headers: Headers) => void;
59
64
  }
60
65
 
61
66
  export function providerFetch(
@@ -68,8 +73,10 @@ export function providerFetch(
68
73
  base.preconnect?.(...args);
69
74
  };
70
75
  const httpFetch = Object.assign(
71
- (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
72
- base(input, { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }),
76
+ async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
77
+ options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)));
78
+ return base(input, { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 });
79
+ },
73
80
  { preconnect },
74
81
  ) as typeof globalThis.fetch;
75
82
  // ChatGPT Codex backend: streaming turns ride the responses_websockets
@@ -82,7 +89,7 @@ export function providerFetch(
82
89
  // used, protocol pin included: a WS turn that falls back is serving the
83
90
  // request over HTTP, and dropping the provider's `upstreamHttpVersion`
84
91
  // there would silently negotiate a transport the operator ruled out.
85
- return codexWsUpstreamFetch(input, init, httpFetch, runtime);
92
+ return codexWsUpstreamFetch(input, init, httpFetch, runtime, options.onCodexWsQuota, options.beforeDispatch);
86
93
  }
87
94
  return httpFetch(input, init);
88
95
  };
@@ -19,16 +19,23 @@ import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../
19
19
  /**
20
20
  * Multiplier applied to the ceiling before refusing.
21
21
  *
22
- * 2.5, not something tighter, because `estimateTokens` can overshoot by 1.6x on its own.
23
- * `cjkRatio` samples every `stride`-th character, so a payload of fixed-width records whose
24
- * length aligns with the stride samples as 100% CJK while being ~1.6% CJK, firing the
25
- * 2.5-chars/token clamp instead of the 4.0 default. Measured on Bun 1.3.14: 126,046 chars,
26
- * true CJK ratio 0.0161, sampled ratio 1.0, estimate inflated 1.6x. Since 4.0 / 2.5 = 1.6
27
- * is that branch maximum divergence, a threshold at or under 1.6 would convert the
28
- * estimator error bar into false 413s.
22
+ * 2.5, not something tighter, because the estimate is a heuristic and the cost of being wrong
23
+ * is asymmetric: a false refusal fails a turn the provider would have answered, while an
24
+ * over-admission merely pays for one round trip the provider then rejects itself.
29
25
  *
30
- * 2.5 sits above it with room for the ~10% model-family ratio spread, and still refuses the
31
- * #1412 shape (10x) four times over.
26
+ * The original margin was sized against a sampling artifact `cjkRatio` read every stride-th
27
+ * character, so a payload of fixed-width records could sample as 100% CJK while being 1.6% CJK
28
+ * and inflate the estimate by 4.0/2.5 = 1.6x. That sampler is gone: CJK characters are now
29
+ * counted exactly, so that particular 1.6x divergence cannot occur and the headroom it bought is
30
+ * no longer spent on it.
31
+ *
32
+ * The margin is still 2.5 because the estimator it guards got LARGER, not smaller. Counting the
33
+ * two scripts separately raises a pure-Latin estimate by 1.25x and a Korean one by up to 1.67x
34
+ * against the previous model, which consumes real headroom: measured against the old estimator's
35
+ * scale, 2.5 now behaves like roughly 2.0x for Latin and 1.5x for Korean-dominant input. That is
36
+ * the intended direction — the estimates are closer to what providers actually charge, so the
37
+ * same multiplier is a tighter and more honest bound — and it still refuses the #1412 shape
38
+ * (10x compounding) several times over.
32
39
  */
33
40
  export const ADMISSION_TOLERANCE = 2.5;
34
41
 
@@ -174,7 +174,7 @@ function backfillOutputItem(item: unknown, slot: ItemIdSlot, inferredStatus: str
174
174
  *
175
175
  * Deliberately does NOT backfill `created_at`. #2639 proposed it for the same
176
176
  * strict-decoder reason as `status`, but the proxy also relays some upstream
177
- * responses verbatim, and `tests/server-combo-failover-e2e.test.ts` asserts a
177
+ * responses verbatim, and `tests/server/server-combo-failover-e2e.test.ts` asserts a
178
178
  * combo backup response is returned byte-exact. Injecting a field the upstream
179
179
  * never sent breaks that contract. Both cannot hold for the same body, so the
180
180
  * `created_at` half needs its own decision about which contract yields; it is
@@ -12,12 +12,17 @@
12
12
  // returned event frames as an SSE byte stream, so every downstream consumer
13
13
  // (passthrough relay, adapter parsers, usage sniffing) is unchanged.
14
14
 
15
- import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
16
15
  import { compareBunVersions } from "../../lib/bun-stream-caps";
17
-
18
- const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
19
- const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
20
- const WS_BETA = "responses_websockets=2026-02-06";
16
+ import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
17
+ import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request";
18
+ import { codexWsExchange } from "./codex-ws-exchange";
19
+ import { CodexWsSession } from "./codex-ws-session";
20
+ import { codexWsPool, codexWsReuseIdentity } from "./codex-ws-pool";
21
+ import { codexWsCreateFrameExceedsLimit } from "./codex-ws-wire";
22
+ export { CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES,
23
+ MAX_CODEX_WS_CREATE_FRAME_BYTES, CODEX_WS_CREATE_FRAME_LIMIT_BYTES, codexWsCreateFrameExceedsLimit,
24
+ isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse } from "./codex-ws-wire";
25
+ export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
21
26
 
22
27
  /**
23
28
  * Dial URL for a request URL. The canonical ChatGPT backend keeps its constant;
@@ -48,36 +53,6 @@ function isResponsesWebsocketEligibleUrl(url: string): boolean {
48
53
  return parsed.protocol === "https:"
49
54
  && parsed.pathname.endsWith("/responses");
50
55
  }
51
- // If the 101 never arrives (network black hole), give SSE a chance well before
52
- // the caller's connect timeout (default 200s) would fire.
53
- const UPGRADE_DEADLINE_MS = 10_000;
54
- // Keep the push-based WS transport inside the same memory envelope as the
55
- // bounded SSE relays that consume this response. Unlike fetch response bodies,
56
- // a WebSocket cannot be paused when a ReadableStream applies backpressure, so
57
- // an upstream that outruns the consumer must be disconnected.
58
- export const MAX_CODEX_WS_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES;
59
- export const MAX_CODEX_WS_QUEUE_BYTES = 8 * 1024 * 1024;
60
- export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
61
- // The backend drops any inbound message of 16 MiB or more: it closes the socket
62
- // (1009) without a Responses terminal event, which reaches clients as a bare
63
- // 502 upstream_server_error. Measured against the live endpoint 2026-08-23:
64
- // 16,777,000 B completed, 16,777,300 B closed in ~1s, every time. The same
65
- // request body succeeds over HTTP SSE, so the ceiling belongs to this transport
66
- // alone (see #2426). A full-replay thread reaches it with ~11 pasted
67
- // screenshots, and then never recovers, because each retry resends the frame.
68
- export const MAX_CODEX_WS_CREATE_FRAME_BYTES = 16 * 1024 * 1024;
69
- // Bun frames the payload it is handed, so the send-side budget is the JSON text
70
- // itself, and nothing is appended between the check and the send. The margin is
71
- // a conservative cushion, not a computed requirement: it covers RFC 6455 frame
72
- // overhead in case the backend counts it (14 bytes at this payload size — an
73
- // 8-byte extended length plus a 4-byte client mask, leaving ~65.5 KiB spare),
74
- // and it leaves room for a future caller that appends to the frame.
75
- const CODEX_WS_CREATE_FRAME_MARGIN_BYTES = 64 * 1024;
76
- export const CODEX_WS_CREATE_FRAME_LIMIT_BYTES =
77
- MAX_CODEX_WS_CREATE_FRAME_BYTES - CODEX_WS_CREATE_FRAME_MARGIN_BYTES;
78
- /** Close code the backend uses for an oversized message (RFC 6455 "message too big"). */
79
- const WS_CLOSE_MESSAGE_TOO_BIG = 1009;
80
-
81
56
  export type BunRuntimeIdentity = {
82
57
  version: string;
83
58
  versionWithSha: string;
@@ -85,13 +60,6 @@ export type BunRuntimeIdentity = {
85
60
 
86
61
  export type BunRuntimeGateInput = string | BunRuntimeIdentity;
87
62
 
88
- const codexWsUpstreamResponses = new WeakSet<Response>();
89
-
90
- /** True only for a successful Codex WebSocket upgrade, never an HTTP fallback. */
91
- export function isCodexWsUpstreamResponse(response: Response): boolean {
92
- return codexWsUpstreamResponses.has(response);
93
- }
94
-
95
63
  export function currentBunRuntimeIdentity(): BunRuntimeIdentity {
96
64
  return {
97
65
  version: Bun.version,
@@ -153,102 +121,17 @@ export function shouldUseCodexWsUpstream(
153
121
  }
154
122
  }
155
123
 
156
- const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event";
157
-
158
- type ResponsesWsRelayEvent = {
159
- type: string;
160
- text: string;
161
- };
162
-
163
- /**
164
- * Responses WebSocket uses `response.done` as its terminal event, while the
165
- * SSE Responses surface uses status-specific terminal events. Normalize the
166
- * WS-only discriminator before relaying so the existing SSE consumers can
167
- * settle the turn and the socket close cannot be mistaken for a drop. Unknown
168
- * or missing status values fail closed instead of being reported as success.
169
- */
170
- function normalizeResponsesWsRelayEvent(text: string): ResponsesWsRelayEvent | null {
171
- let payload: unknown;
172
- try {
173
- payload = JSON.parse(text);
174
- } catch {
175
- return null;
176
- }
177
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
178
- const record = payload as Record<string, unknown>;
179
- if (typeof record.type !== "string") return null;
180
- if (record.type !== "response.done") return { type: record.type, text };
181
-
182
- const response = record.response;
183
- const status = response && typeof response === "object" && !Array.isArray(response)
184
- ? (response as Record<string, unknown>).status
185
- : undefined;
186
- const type = status === "completed"
187
- ? "response.completed"
188
- : status === "failed"
189
- ? "response.failed"
190
- : status === "incomplete" || status === "cancelled"
191
- ? "response.incomplete"
192
- : "response.failed";
193
- const normalizedRecord: Record<string, unknown> = { ...record, type };
194
- if (type === "response.failed" && status !== "failed") {
195
- normalizedRecord.response = response && typeof response === "object" && !Array.isArray(response)
196
- ? { ...(response as Record<string, unknown>), status: "failed" }
197
- : { status: "failed" };
198
- }
199
- return { type, text: JSON.stringify(normalizedRecord) };
200
- }
201
-
202
- /**
203
- * The close code is the only thing that separates "the backend refused this
204
- * payload" from "the network dropped", and both used to reach the caller as the
205
- * same bare 502. Naming the oversized case here puts that distinction in the
206
- * message the client receives.
207
- *
208
- * It does NOT reach the request log as a typed code. The eager relay turns any
209
- * stream error into a generic `upstream_reset` synthetic terminal
210
- * (`relay.ts`, `relay-eager.ts`) without feeding that frame back through the
211
- * inspector, so `/api/logs` keeps neither this message nor a specific code —
212
- * only `streamAborted`. Machine-readable typing would mean changing the error
213
- * taxonomy, which is deliberately out of scope for this transport fix.
214
- */
215
- function closedBeforeTerminalMessage(event: unknown): string {
216
- const detail = event as { code?: unknown; reason?: unknown } | null | undefined;
217
- const code = typeof detail?.code === "number" ? detail.code : null;
218
- const reason = typeof detail?.reason === "string" ? detail.reason.trim() : "";
219
- if (code === null) return CLOSED_BEFORE_TERMINAL;
220
- const suffix = reason ? ` ${code} ${reason}` : ` ${code}`;
221
- if (code === WS_CLOSE_MESSAGE_TOO_BIG) {
222
- return `codex websocket rejected the request frame as too large (close${suffix});`
223
- + ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport`;
224
- }
225
- return `${CLOSED_BEFORE_TERMINAL} (close${suffix})`;
226
- }
227
-
228
- /**
229
- * True when the `response.create` frame is at or above the backend's inbound
230
- * message ceiling, so this turn must take the HTTP SSE path instead.
231
- *
232
- * Sizing a 16 MiB string should not cost a 16 MiB copy. UTF-8 never encodes
233
- * below one byte per UTF-16 code unit and never above three, so both tails are
234
- * settled from the string length alone; only the narrow band between them pays
235
- * for a real byte count, and `Buffer.byteLength` measures without allocating.
236
- */
237
- export function codexWsCreateFrameExceedsLimit(
238
- frameText: string,
239
- limitBytes: number = CODEX_WS_CREATE_FRAME_LIMIT_BYTES,
240
- ): boolean {
241
- if (frameText.length >= limitBytes) return true;
242
- if (frameText.length * 3 < limitBytes) return false;
243
- return Buffer.byteLength(frameText, "utf8") >= limitBytes;
244
- }
245
-
246
124
  export function codexWsUpstreamFetch(
247
125
  url: string,
248
126
  init: RequestInit,
249
127
  sseFallback: typeof globalThis.fetch,
250
128
  runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
129
+ onQuota?: CodexWsQuotaObserver,
130
+ beforeDispatch?: (headers: Headers) => void,
251
131
  ): Promise<Response> {
132
+ const prepared = prepareCodexWsRequest(url, init);
133
+ if (!prepared) return sseFallback(url, prepareCodexHttpInit(url, init));
134
+ init = prepared.httpInit;
252
135
  if (!bunSupportsBoundedCodexWsRelay(runtime)) {
253
136
  return sseFallback(url, init);
254
137
  }
@@ -257,16 +140,7 @@ export function codexWsUpstreamFetch(
257
140
  return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
258
141
  }
259
142
 
260
- let frameText: string;
261
- try {
262
- const body = JSON.parse(init.body as string) as Record<string, unknown>;
263
- // The WS create frame is implicitly streaming; the backend rejects the
264
- // HTTP-only `stream` flag inside a frame.
265
- delete body.stream;
266
- frameText = JSON.stringify({ ...body, type: "response.create" });
267
- } catch {
268
- return sseFallback(url, init);
269
- }
143
+ const { frameText, headers } = prepared;
270
144
 
271
145
  // Decide before dialing. Once the socket is open the caller already holds a
272
146
  // streaming Response, so the oversized close can only be surfaced as a stream
@@ -276,187 +150,29 @@ export function codexWsUpstreamFetch(
276
150
  return sseFallback(url, init);
277
151
  }
278
152
 
279
- const headers: Record<string, string> = {};
280
- new Headers(init.headers ?? {}).forEach((value, key) => {
281
- // HTTP-body framing headers do not apply to a WS handshake.
282
- if (key === "content-type" || key === "content-length" || key === "accept" || key === "accept-encoding") return;
283
- headers[key] = value;
284
- });
285
- headers["openai-beta"] = headers["openai-beta"]
286
- ? headers["openai-beta"].includes("responses_websockets")
287
- ? headers["openai-beta"]
288
- : `${headers["openai-beta"]}, ${WS_BETA}`
289
- : WS_BETA;
290
153
  // A genuine caller `originator` is already in these headers via the forward
291
154
  // set. Never fabricate one here: pool/forward traffic must not impersonate
292
155
  // Codex CLI, per the metadata-integrity contract. (The backend's fast lane
293
156
  // keys on WS + originator, so callers without the tag simply keep their own
294
157
  // provenance and scheduling.)
295
158
 
296
- return new Promise<Response>((resolve, reject) => {
297
- let ws: WebSocket;
298
- try {
299
- // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
300
- ws = new WebSocket(wsUpstreamUrlFor(url), { headers } as unknown as string[]);
301
- } catch {
302
- resolve(sseFallback(url, init));
303
- return;
159
+ // A local refusal is not a failed upgrade and must never enter the SSE fallback path.
160
+ try {
161
+ beforeDispatch?.(new Headers(headers));
162
+ } catch (error) {
163
+ return Promise.reject(error);
164
+ }
165
+ let session: CodexWsSession;
166
+ try {
167
+ const identity = codexWsReuseIdentity(url, headers, frameText);
168
+ session = (identity ? codexWsPool.acquire(identity, wsUpstreamUrlFor(url), headers) : null)
169
+ ?? new CodexWsSession(wsUpstreamUrlFor(url), headers);
170
+ if (!session.busy && !session.reserve()) {
171
+ session.dispose();
172
+ return sseFallback(url, init);
304
173
  }
305
-
306
- let opened = false;
307
- let settledPreOpen = false;
308
- let terminal = false;
309
- let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
310
- const encoder = new TextEncoder();
311
-
312
- const failStream = (message: string) => {
313
- if (terminal) return;
314
- terminal = true;
315
- try { controller?.error(new Error(message)); } catch { /* stream already done */ }
316
- try { ws.close(); } catch { /* already closing */ }
317
- };
318
-
319
- const upgradeTimer = setTimeout(() => {
320
- if (opened || settledPreOpen) return;
321
- settledPreOpen = true;
322
- try { ws.close(); } catch { /* already closing */ }
323
- resolve(sseFallback(url, init));
324
- }, UPGRADE_DEADLINE_MS);
325
-
326
- const onAbort = () => {
327
- if (!opened) {
328
- if (settledPreOpen) return;
329
- // Settle BEFORE close(): the close handler treats a pre-open close as
330
- // an upgrade rejection and would dial the SSE fallback for a request
331
- // the caller just cancelled.
332
- settledPreOpen = true;
333
- clearTimeout(upgradeTimer);
334
- try { ws.close(); } catch { /* already closing */ }
335
- reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
336
- return;
337
- }
338
- if (controller && !terminal) {
339
- terminal = true;
340
- // Mirror an aborted fetch: the body read rejects with the abort reason.
341
- try { controller.error(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError")); } catch { /* stream already done */ }
342
- }
343
- // Error the body before close(): test doubles and some runtimes dispatch
344
- // close synchronously, and the caller's abort reason must stay authoritative.
345
- try { ws.close(); } catch { /* already closing */ }
346
- };
347
- signal?.addEventListener("abort", onAbort, { once: true });
348
-
349
- ws.addEventListener("open", () => {
350
- if (settledPreOpen) return;
351
- clearTimeout(upgradeTimer);
352
- try {
353
- ws.send(frameText);
354
- } catch {
355
- // send() throwing means the frame never left, so no upstream turn
356
- // started and the SSE resend cannot double-generate. Falling back
357
- // (instead of erroring a synthetic 200 body) keeps the pre-stream
358
- // HTTP error/refresh/failover machinery in charge.
359
- settledPreOpen = true;
360
- try { ws.close(); } catch { /* already closing */ }
361
- resolve(sseFallback(url, init));
362
- return;
363
- }
364
- opened = true;
365
- const stream = new ReadableStream<Uint8Array>({
366
- start(c) { controller = c; },
367
- cancel() { try { ws.close(); } catch { /* already closing */ } },
368
- }, new ByteLengthQueuingStrategy({ highWaterMark: MAX_CODEX_WS_QUEUE_BYTES }));
369
- const response = new Response(stream, {
370
- status: 200,
371
- // The 101 response headers (x-codex-*-reset-at quota hints) are not
372
- // exposed by Bun's WebSocket; the periodic quota poller covers those.
373
- headers: { "content-type": "text/event-stream; charset=utf-8" },
374
- });
375
- codexWsUpstreamResponses.add(response);
376
- resolve(response);
377
- });
378
-
379
- ws.addEventListener("message", (event) => {
380
- if (!controller || terminal) return;
381
- const text = typeof event.data === "string" ? event.data : "";
382
- if (!text) return;
383
- // UTF-8 byte length is always at least the JS string length. Reject this
384
- // cheap lower bound before parsing so an obviously oversized frame does
385
- // not create another large object graph.
386
- if (text.length > MAX_CODEX_WS_FRAME_BYTES) {
387
- failStream("codex websocket frame exceeds the response size limit");
388
- return;
389
- }
390
- const rawEncodedText = encoder.encode(text);
391
- if (rawEncodedText.byteLength > MAX_CODEX_WS_FRAME_BYTES) {
392
- failStream("codex websocket frame exceeds the response size limit");
393
- return;
394
- }
395
- const normalized = normalizeResponsesWsRelayEvent(text);
396
- if (!normalized) return;
397
- const { type } = normalized;
398
- const encodedText = normalized.text === text ? rawEncodedText : encoder.encode(normalized.text);
399
- if (encodedText.byteLength > MAX_CODEX_WS_FRAME_BYTES) {
400
- failStream("codex websocket frame exceeds the response size limit");
401
- return;
402
- }
403
- // Relay only the event surface the SSE path produces today. WS-only
404
- // frames (codex.rate_limits, responsesapi.websocket_timing) are dropped
405
- // so downstream clients see exactly the stream shape they always got.
406
- if (!type.startsWith("response.") && type !== "error") return;
407
- const prefix = encoder.encode(`event: ${type}\ndata: `);
408
- const suffix = encoder.encode("\n\n");
409
- const frameBytes = prefix.byteLength + encodedText.byteLength + suffix.byteLength;
410
- if (frameBytes > MAX_CLIENT_SSE_FRAME_BYTES) {
411
- failStream("codex websocket frame exceeds the response size limit");
412
- return;
413
- }
414
- const availableBytes = controller.desiredSize ?? 0;
415
- if (frameBytes > availableBytes) {
416
- failStream("codex websocket response exceeded the buffered queue limit");
417
- return;
418
- }
419
- const sseFrame = new Uint8Array(frameBytes);
420
- sseFrame.set(prefix);
421
- sseFrame.set(encodedText, prefix.byteLength);
422
- sseFrame.set(suffix, prefix.byteLength + encodedText.byteLength);
423
- try {
424
- controller.enqueue(sseFrame);
425
- } catch {
426
- failStream("codex websocket response stream closed while enqueueing");
427
- return;
428
- }
429
- if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {
430
- terminal = true;
431
- try { controller.close(); } catch { /* already closed */ }
432
- try { ws.close(); } catch { /* already closing */ }
433
- }
434
- });
435
-
436
- ws.addEventListener("close", (event: unknown) => {
437
- signal?.removeEventListener("abort", onAbort);
438
- if (!opened) {
439
- if (settledPreOpen) return;
440
- settledPreOpen = true;
441
- clearTimeout(upgradeTimer);
442
- // Upgrade rejected (401/403/429/5xx). Retry over plain SSE so the real
443
- // HTTP status reaches the existing refresh/rotation handlers. No turn
444
- // started upstream, so the resend cannot double-generate.
445
- resolve(sseFallback(url, init));
446
- return;
447
- }
448
- if (controller && !terminal) {
449
- terminal = true;
450
- // Connection dropped before a Responses terminal event. A clean EOF
451
- // here would reach clients with no response.completed/failed at all —
452
- // relaySseWithFailedTail() only synthesizes a failed terminal when the
453
- // body read THROWS. Error the stream like a reset TCP socket.
454
- try { controller.error(new Error(closedBeforeTerminalMessage(event))); } catch { /* stream already done */ }
455
- }
456
- });
457
-
458
- ws.addEventListener("error", () => {
459
- /* Bun always follows error with close; the close handler settles. */
460
- });
461
- });
174
+ } catch {
175
+ return sseFallback(url, init);
176
+ }
177
+ return codexWsExchange({ session, url, init, prepared, sseFallback, onQuota, beforeDispatch });
462
178
  }
@@ -1,6 +1,7 @@
1
1
  import type { TranslatorBudget } from "../lib/translator-budget";
2
- import { normalizeApplyPatchDelimiters } from "../responses/apply-patch-envelope";
3
- import { compileCodeModeHelperInput } from "../responses/code-mode-helper-compat";
2
+ import { mayBecomePatchEnvelope, normalizeApplyPatchDelimiters } from "../responses/apply-patch-envelope";
3
+ import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat";
4
+ import { declaresCodeModeExec } from "../types/tools";
4
5
  import {
5
6
  customToolItemId,
6
7
  restoreRoutedCustomCalls,
@@ -316,6 +317,16 @@ export function createRoutedCustomToolRestoreBlockRewrite(
316
317
  if (FREEFORM_WRAP_PREFIX.startsWith(open.argumentsText)) return [];
317
318
  const fullInput = partialCustomToolInput(open.argumentsText);
318
319
  if (fullInput === null) return [];
320
+ // Hold a buffer that could still become a complete patch envelope. The done event
321
+ // recompiles such a body into an apply_patch helper call, so streaming the envelope
322
+ // bytes first and replacing them at completion is the rewind this path forbids.
323
+ // Mirrors the same hold in `src/bridge.ts`.
324
+ if (
325
+ declaresCodeModeExec(declaredNames)
326
+ && itemNames.get(upstreamItemId)?.namespace === undefined
327
+ && itemNames.get(upstreamItemId)?.name === "exec"
328
+ && mayBecomePatchEnvelope(fullInput)
329
+ ) return [];
319
330
  if (!fullInput.startsWith(open.emittedInput) || fullInput.length === open.emittedInput.length) return [];
320
331
  const inputDelta = fullInput.slice(open.emittedInput.length);
321
332
  open.emittedInput = fullInput;
@@ -340,12 +351,17 @@ export function createRoutedCustomToolRestoreBlockRewrite(
340
351
  : openCalls.get(upstreamItemId)?.argumentsText ?? "";
341
352
  const { arguments: _arguments, ...rest } = parsed;
342
353
  const itemName = itemNames.get(upstreamItemId);
354
+ // Name-based alias first; otherwise a raw patch envelope submitted as the `exec` body
355
+ // resolves to the same apply_patch helper (devlog/_plan/260905_apply_patch_envelope_gap).
356
+ const helper = itemName?.aliased
357
+ ? itemName.name
358
+ : resolveCodeModeHelperName(undefined, itemName?.name ?? "", source, itemName?.namespace, declaredNames);
343
359
  const next = {
344
360
  ...rest,
345
361
  type: nextType,
346
362
  item_id: customToolItemId(upstreamItemId),
347
- input: itemName?.aliased
348
- ? compileCodeModeHelperInput(source, itemName.name)
363
+ input: helper
364
+ ? compileCodeModeHelperInput(source, helper)
349
365
  : unwrapRoutedCustomToolArguments(source, itemName?.name ?? "", itemName?.namespace),
350
366
  };
351
367
  return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))];