@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
@@ -0,0 +1,134 @@
1
+ import { isSafeResponseHeader, safeResponseHeaders } from "../safe-response-headers";
2
+
3
+ export const CODEX_WS_METADATA_MAX_BYTES = 32 * 1024;
4
+ export const CODEX_WS_METADATA_MAX_FAMILIES = 16;
5
+ export const CODEX_WS_METADATA_MAX_HEADERS = 128;
6
+ export const CODEX_WS_METADATA_MAX_VALUE_BYTES = 4096;
7
+
8
+ export type CodexWsQuotaObserver = (headers: Headers) => void;
9
+
10
+ function record(value: unknown): value is Record<string, unknown> {
11
+ return value !== null && typeof value === "object" && !Array.isArray(value);
12
+ }
13
+
14
+ function finiteNonnegative(value: unknown): value is number {
15
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
16
+ }
17
+
18
+ function nativeLimitFamily(event: Record<string, unknown>): string | null {
19
+ const raw = event.metered_limit_name ?? event.limit_name ?? "codex";
20
+ if (typeof raw !== "string") return null;
21
+ const name = raw.trim().toLowerCase().replaceAll("_", "-");
22
+ return name.length <= 64 && /^codex(?:-[a-z0-9-]+)?$/.test(name) ? name : null;
23
+ }
24
+
25
+ function writeWindow(headers: Headers, prefix: string, value: unknown): void {
26
+ if (!record(value) || !finiteNonnegative(value.used_percent)) return;
27
+ headers.set(`${prefix}-used-percent`, String(value.used_percent));
28
+ for (const [field, suffix] of [["window_minutes", "window-minutes"], ["reset_at", "reset-at"]]) {
29
+ const number = value[field!];
30
+ if (finiteNonnegative(number) && Number.isSafeInteger(number)) headers.set(`${prefix}-${suffix}`, String(number));
31
+ }
32
+ }
33
+
34
+ function quotaHeaders(event: Record<string, unknown>): Headers {
35
+ const headers = new Headers();
36
+ const family = nativeLimitFamily(event);
37
+ if (family && record(event.rate_limits)) {
38
+ writeWindow(headers, `x-${family}-primary`, event.rate_limits.primary);
39
+ writeWindow(headers, `x-${family}-secondary`, event.rate_limits.secondary);
40
+ }
41
+ if (record(event.credits)) {
42
+ for (const [field, suffix] of [["has_credits", "has-credits"], ["unlimited", "unlimited"]]) {
43
+ const value = event.credits[field!];
44
+ if (typeof value === "boolean") headers.set(`x-codex-credits-${suffix}`, String(value));
45
+ }
46
+ if (typeof event.credits.balance === "string") setMetadataHeader(headers, "x-codex-credits-balance", event.credits.balance);
47
+ }
48
+ return headers;
49
+ }
50
+
51
+ function setMetadataHeader(headers: Headers, name: string, value: string): void {
52
+ if (Buffer.byteLength(value) > CODEX_WS_METADATA_MAX_VALUE_BYTES) throw new Error("codex websocket metadata value exceeds the size limit");
53
+ if (/[\r\n\0]/.test(value)) return;
54
+ try { headers.set(name, value); } catch { /* invalid provider header is not HTTP authority */ }
55
+ }
56
+
57
+ function responseHeaders(value: unknown): Headers {
58
+ const headers = new Headers();
59
+ if (!record(value)) return headers;
60
+ for (const [name, field] of Object.entries(value)) {
61
+ if (!isSafeResponseHeader(name)) continue;
62
+ if (typeof field !== "string" && typeof field !== "number" && typeof field !== "boolean") continue;
63
+ setMetadataHeader(headers, name, String(field));
64
+ }
65
+ return headers;
66
+ }
67
+
68
+ function assertMetadataBounds(headers: Headers): void {
69
+ let bytes = 0;
70
+ let count = 0;
71
+ const families = new Set<string>();
72
+ for (const [name, value] of headers) {
73
+ bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
74
+ count++;
75
+ const family = /^(x-codex(?:-[a-z0-9-]+)?)-(?:(?:primary|secondary|tertiary)-(?:used-percent|window-minutes|reset-at)|limit-name)$/.exec(name);
76
+ if (family) families.add(family[1]!);
77
+ }
78
+ if (bytes > CODEX_WS_METADATA_MAX_BYTES || count > CODEX_WS_METADATA_MAX_HEADERS || families.size > CODEX_WS_METADATA_MAX_FAMILIES) {
79
+ throw new Error("codex websocket metadata exceeds the bounded header budget");
80
+ }
81
+ }
82
+
83
+ /** One exchange's metadata. The Response owns its final snapshot, not a global history ledger. */
84
+ export class CodexWsMetadata {
85
+ private headers = new Headers();
86
+ private ended = false;
87
+ private preludeBytes = 0;
88
+ private committed = false;
89
+
90
+ constructor(private observer?: CodexWsQuotaObserver) {}
91
+
92
+ snapshot(): Headers { return new Headers(this.headers); }
93
+
94
+ private publishQuota(headers: Headers): void {
95
+ // Observation is auxiliary bookkeeping; a consumer exception cannot turn
96
+ // a valid provider frame into a retryable transport failure.
97
+ try { this.observer?.(new Headers(headers)); } catch { /* quota observation is best-effort */ }
98
+ }
99
+
100
+ commit(): void {
101
+ this.committed = true;
102
+ }
103
+
104
+ /** Returns a sanitized control frame, or null for an ordinary Responses event. */
105
+ consume(event: Record<string, unknown>, bytes: number): string | null {
106
+ if (this.ended) return null;
107
+ if (event.type !== "codex.rate_limits" && event.type !== "codex.response.metadata") return null;
108
+ if (bytes > CODEX_WS_METADATA_MAX_BYTES) throw new Error("codex websocket metadata frame exceeds the size limit");
109
+ if (!this.committed) this.preludeBytes += bytes;
110
+ if (this.preludeBytes > CODEX_WS_METADATA_MAX_BYTES) throw new Error("codex websocket metadata prelude exceeds the size limit");
111
+ const updates = event.type === "codex.rate_limits" ? quotaHeaders(event) : responseHeaders(event.headers);
112
+ const next = this.snapshot();
113
+ for (const name of updates.keys()) {
114
+ if (!name.endsWith("-used-percent")) continue;
115
+ const prefix = name.slice(0, -"used-percent".length);
116
+ next.delete(`${prefix}window-minutes`);
117
+ next.delete(`${prefix}reset-at`);
118
+ }
119
+ for (const [name, value] of updates) next.set(name, value);
120
+ assertMetadataBounds(next);
121
+ this.headers = next;
122
+ if (["primary", "secondary", "tertiary"].some(window => updates.has(`x-codex-${window}-used-percent`))) {
123
+ this.publishQuota(updates);
124
+ }
125
+ return event.type === "codex.response.metadata"
126
+ ? JSON.stringify({ type: event.type, headers: safeResponseHeaders(updates) })
127
+ : JSON.stringify(event);
128
+ }
129
+
130
+ finish(): void {
131
+ this.ended = true;
132
+ this.observer = undefined;
133
+ }
134
+ }
@@ -0,0 +1,162 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks";
3
+ import { CODEX_RESPONSES_HTTP_URL } from "./codex-ws-request";
4
+ import { CODEX_WS_ID_MAX_BYTES } from "./codex-ws-correlation";
5
+ import { CodexWsSession } from "./codex-ws-session";
6
+
7
+ export const CODEX_WS_POOL_MAX_SESSIONS = 32;
8
+ export const CODEX_WS_POOL_IDLE_MS = 30_000;
9
+ export const CODEX_WS_POOL_MAX_AGE_MS = 5 * 60_000;
10
+ const MUTABLE_HEADERS = new Set(["x-codex-turn-state", "x-codex-turn-metadata"]);
11
+ let processKey: Buffer | undefined;
12
+ let poolSequence = 0;
13
+
14
+ export interface CodexWsReuseIdentity { key: string; scope: string }
15
+ function value(value: unknown): value is string {
16
+ return typeof value === "string" && value.trim().length > 0
17
+ && !/[\u0000-\u001f\u007f]/.test(value) && Buffer.byteLength(value) <= CODEX_WS_ID_MAX_BYTES;
18
+ }
19
+ function record(value: unknown): value is Record<string, unknown> {
20
+ return value !== null && typeof value === "object" && !Array.isArray(value);
21
+ }
22
+ function digest(input: unknown): string {
23
+ processKey ??= randomBytes(32);
24
+ return createHmac("sha256", processKey).update(JSON.stringify(input)).digest("hex");
25
+ }
26
+
27
+ /** Identity comes from the selected outgoing request, never a model label or caller hint. */
28
+ export function codexWsReuseIdentity(url: string, headers: Record<string, string>, frameText: string): CodexWsReuseIdentity | null {
29
+ if (url !== CODEX_RESPONSES_HTTP_URL) return null;
30
+ let body: unknown;
31
+ try { body = JSON.parse(frameText); } catch { return null; }
32
+ if (!record(body) || !record(body.client_metadata)) return null;
33
+ // Reuse complete HTTP creates only. Cache-dependent continuation, warmup and
34
+ // named WS lanes need a different lifecycle/recovery contract.
35
+ if (body.previous_response_id != null || Object.hasOwn(body, "stream_id")
36
+ || Object.hasOwn(body, "generate") || body.background === true) return null;
37
+ const metadata = body.client_metadata;
38
+ const bodyThread = metadata.thread_id;
39
+ const headerThread = headers["thread-id"];
40
+ if (bodyThread !== undefined && !value(bodyThread)) return null;
41
+ if (headerThread !== undefined && !value(headerThread)) return null;
42
+ if (bodyThread !== undefined && headerThread !== undefined && bodyThread !== headerThread) return null;
43
+ const thread = bodyThread ?? headerThread;
44
+ const turn = metadata.turn_id;
45
+ const account = headers["chatgpt-account-id"];
46
+ const authorization = headers.authorization;
47
+ if (![thread, turn, account, authorization, body.model].every(value)) return null;
48
+ if (body.service_tier !== undefined && !value(body.service_tier)) return null;
49
+ const immutable = Object.entries(headers).filter(([name]) => !MUTABLE_HEADERS.has(name)).sort(([a], [b]) => a.localeCompare(b));
50
+ if (immutable.length > 128 || immutable.some(([, field]) => !value(field))
51
+ || immutable.reduce((bytes, [name, field]) => bytes + Buffer.byteLength(name) + Buffer.byteLength(field), 0) > 32 * 1024) return null;
52
+ const scope = digest([url, account, thread, turn]);
53
+ const lite = metadata.ws_request_header_x_openai_internal_codex_responses_lite;
54
+ if (lite !== undefined && lite !== "true" && lite !== "false") return null;
55
+ return { scope, key: digest([scope, authorization, body.model, body.service_tier ?? null, lite ?? null, immutable]) };
56
+ }
57
+
58
+ interface Entry { identity: CodexWsReuseIdentity; session: CodexWsSession; createdAt: number; idleAt: number; retired: boolean }
59
+ interface PoolOptions { now?: () => number; maxSessions?: number; idleMs?: number; maxAgeMs?: number }
60
+
61
+ /** Bounded retained sockets only. Busy/capacity misses keep the existing one-shot path. */
62
+ export class CodexWsPool {
63
+ private readonly entries = new Map<string, Entry>();
64
+ private timer?: ReturnType<typeof setTimeout>;
65
+ private detachShutdown?: () => void;
66
+ private readonly hookKey = `codex-upstream-ws-pool-${++poolSequence}`;
67
+ private readonly now: () => number;
68
+ private readonly maxSessions: number;
69
+ private readonly idleMs: number;
70
+ private readonly maxAgeMs: number;
71
+ constructor(options: PoolOptions = {}) {
72
+ this.now = options.now ?? Date.now;
73
+ this.maxSessions = options.maxSessions ?? CODEX_WS_POOL_MAX_SESSIONS;
74
+ this.idleMs = options.idleMs ?? CODEX_WS_POOL_IDLE_MS;
75
+ this.maxAgeMs = options.maxAgeMs ?? CODEX_WS_POOL_MAX_AGE_MS;
76
+ }
77
+
78
+ acquire(identity: CodexWsReuseIdentity, url: string, headers: Record<string, string>): CodexWsSession | null {
79
+ this.sweep();
80
+ for (const entry of this.entries.values()) {
81
+ if (entry.identity.scope !== identity.scope || entry.identity.key === identity.key) continue;
82
+ entry.retired = true;
83
+ if (!entry.session.busy) this.remove(entry);
84
+ }
85
+ const existing = this.entries.get(identity.key);
86
+ if (existing) {
87
+ if (existing.retired || existing.session.busy) return null;
88
+ if (existing.session.reserve()) { this.arm(); return existing.session; }
89
+ this.remove(existing);
90
+ }
91
+ if (this.entries.size >= this.maxSessions) {
92
+ const oldest = [...this.entries.values()].filter(entry => !entry.session.busy).sort((a, b) => a.idleAt - b.idleAt)[0];
93
+ if (!oldest) return null;
94
+ this.remove(oldest);
95
+ }
96
+ const createdAt = this.now();
97
+ const session = new CodexWsSession(url, headers, true, () => this.changed(entry));
98
+ const entry: Entry = { identity, session, createdAt, idleAt: createdAt, retired: false };
99
+ session.reserve();
100
+ this.entries.set(identity.key, entry);
101
+ this.detachShutdown ??= registerOptionalShutdownHook(this.hookKey, () => this.dispose());
102
+ return session;
103
+ }
104
+
105
+ private changed(entry: Entry): void {
106
+ if (this.entries.get(entry.identity.key) !== entry) return;
107
+ if (entry.session.closed) this.entries.delete(entry.identity.key);
108
+ else if (!entry.session.busy) {
109
+ entry.idleAt = this.now();
110
+ if (entry.retired || entry.idleAt - entry.createdAt >= this.maxAgeMs) this.remove(entry);
111
+ }
112
+ this.arm();
113
+ }
114
+
115
+ private remove(entry: Entry): void {
116
+ if (this.entries.get(entry.identity.key) === entry) this.entries.delete(entry.identity.key);
117
+ entry.session.dispose(new Error("codex websocket retained session expired"));
118
+ this.arm();
119
+ }
120
+
121
+ sweep(): void {
122
+ const now = this.now();
123
+ for (const entry of this.entries.values()) {
124
+ if (!entry.session.busy && (entry.session.closed || entry.retired
125
+ || now - entry.idleAt >= this.idleMs || now - entry.createdAt >= this.maxAgeMs)) this.remove(entry);
126
+ }
127
+ this.arm();
128
+ }
129
+
130
+ private arm(): void {
131
+ clearTimeout(this.timer);
132
+ this.timer = undefined;
133
+ if (!this.entries.size) {
134
+ this.detachShutdown?.();
135
+ this.detachShutdown = undefined;
136
+ return;
137
+ }
138
+ let deadline = Infinity;
139
+ for (const entry of this.entries.values()) if (!entry.session.busy) {
140
+ deadline = Math.min(deadline, entry.idleAt + this.idleMs, entry.createdAt + this.maxAgeMs);
141
+ }
142
+ if (!Number.isFinite(deadline)) return;
143
+ this.timer = setTimeout(() => { this.timer = undefined; this.sweep(); }, Math.max(1, deadline - this.now()));
144
+ this.timer.unref?.();
145
+ }
146
+
147
+ dispose(): void {
148
+ clearTimeout(this.timer);
149
+ this.timer = undefined;
150
+ this.detachShutdown?.();
151
+ this.detachShutdown = undefined;
152
+ const entries = [...this.entries.values()];
153
+ this.entries.clear();
154
+ for (const entry of entries) entry.session.dispose(new DOMException("codex websocket pool shutdown", "AbortError"));
155
+ }
156
+
157
+ snapshot(): { size: number; active: number; timer: boolean } {
158
+ return { size: this.entries.size, active: [...this.entries.values()].filter(entry => entry.session.busy).length, timer: this.timer !== undefined };
159
+ }
160
+ }
161
+
162
+ export const codexWsPool = new CodexWsPool();
@@ -0,0 +1,87 @@
1
+ import {
2
+ applyCodexRoutingHint,
3
+ CODEX_RESPONSES_LITE_HEADER,
4
+ CODEX_RESPONSES_LITE_METADATA_KEY,
5
+ } from "../../codex/forward-transport-headers";
6
+
7
+ export const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
8
+ export const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
9
+ export const WS_BETA = "responses_websockets=2026-02-06";
10
+
11
+ export interface PreparedCodexWsRequest {
12
+ /** Fully synthesized frame; the transport measures this exact text before dialing. */
13
+ frameText: string;
14
+ headers: Record<string, string>;
15
+ /** Original HTTP body/framing/options with only the canonical routing hint re-derived. */
16
+ httpInit: RequestInit;
17
+ canonical: boolean;
18
+ }
19
+
20
+ function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === "object" && value !== null && !Array.isArray(value);
22
+ }
23
+
24
+ function applyLiteMetadata(body: Record<string, unknown>, headers: Headers): boolean {
25
+ const metadata = body.client_metadata;
26
+ // Native client metadata is a string map. Do not spread malformed input or
27
+ // turn boolean/number values into plausible but invented protocol strings.
28
+ if (metadata !== undefined && (!isRecord(metadata)
29
+ || Object.values(metadata).some(value => typeof value !== "string"))) return false;
30
+ const lite = headers.get(CODEX_RESPONSES_LITE_HEADER);
31
+ if (lite === "true" || lite === "false") {
32
+ body.client_metadata = { ...(metadata as Record<string, string> | undefined),
33
+ [CODEX_RESPONSES_LITE_METADATA_KEY]: lite };
34
+ }
35
+ for (const name of ["x-codex-turn-state", "x-codex-turn-metadata"]) {
36
+ const value = headers.get(name);
37
+ const current = body.client_metadata as Record<string, string> | undefined;
38
+ if (value !== null && !Object.hasOwn(current ?? {}, name)) {
39
+ body.client_metadata = { ...current, [name]: value };
40
+ }
41
+ }
42
+ return true;
43
+ }
44
+
45
+ /** Pure preparation; null keeps malformed requests on the existing HTTP fallback path. */
46
+ export function prepareCodexHttpInit(url: string, init: RequestInit): RequestInit {
47
+ if (url !== CODEX_RESPONSES_HTTP_URL || typeof init.body !== "string") return init;
48
+ const headers = new Headers(init.headers);
49
+ let body: unknown;
50
+ try { body = JSON.parse(init.body); } catch { /* malformed body cannot authorize a hint */ }
51
+ applyCodexRoutingHint(headers, body);
52
+ return { ...init, headers };
53
+ }
54
+
55
+ /** Null refuses only WS conversion; canonical HTTP normalization is independently reusable. */
56
+ export function prepareCodexWsRequest(url: string, init: RequestInit): PreparedCodexWsRequest | null {
57
+ if (typeof init.body !== "string") return null;
58
+ try {
59
+ const parsed: unknown = JSON.parse(init.body);
60
+ if (!isRecord(parsed)) return null;
61
+ const body = { ...parsed };
62
+ const canonical = url === CODEX_RESPONSES_HTTP_URL;
63
+ const httpHeaders = new Headers(init.headers);
64
+ if (canonical) {
65
+ if (!applyLiteMetadata(body, httpHeaders)) return null;
66
+ applyCodexRoutingHint(httpHeaders, body);
67
+ }
68
+ const httpInit = { ...init, headers: httpHeaders };
69
+ // WS is implicitly streaming; retain every other caller field except type.
70
+ delete body.stream;
71
+ const frameText = JSON.stringify({ ...body, type: "response.create" });
72
+ const headers: Record<string, string> = {};
73
+ httpHeaders.forEach((value, key) => {
74
+ if (key === "content-type" || key === "content-length" || key === "accept" || key === "accept-encoding") return;
75
+ headers[key] = value;
76
+ });
77
+ // Preserve the existing beta composition for both canonical and opted-in gateways.
78
+ headers["openai-beta"] = headers["openai-beta"]
79
+ ? headers["openai-beta"].includes("responses_websockets")
80
+ ? headers["openai-beta"]
81
+ : `${headers["openai-beta"]}, ${WS_BETA}`
82
+ : WS_BETA;
83
+ return { frameText, headers, httpInit, canonical };
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
@@ -0,0 +1,93 @@
1
+ export const MAX_CODEX_WS_SESSION_EXCHANGES = 32;
2
+
3
+ /** Owns one physical socket; request listeners belong to the exchange, not this object. */
4
+ export class CodexWsSession {
5
+ readonly socket: WebSocket;
6
+ opened = false;
7
+ closed = false;
8
+ busy = false;
9
+ private owner?: (reason: Error) => void;
10
+ private readonly completedIds = new Set<string>();
11
+
12
+ constructor(url: string, headers: Record<string, string>, readonly retainable = false,
13
+ private readonly changed: () => void = () => {}) {
14
+ this.socket = new WebSocket(url, { headers } as unknown as string[]);
15
+ this.socket.addEventListener("open", this.onOpen);
16
+ this.socket.addEventListener("message", this.onIdleMessage);
17
+ this.socket.addEventListener("close", this.onClose);
18
+ this.socket.addEventListener("error", this.onIdleError);
19
+ }
20
+
21
+ get reused(): boolean { return this.completedIds.size > 0; }
22
+ hasCompleted(id: string): boolean { return this.completedIds.has(id); }
23
+
24
+ reserve(): boolean {
25
+ if (this.closed || this.busy || (this.opened && this.socket.readyState !== undefined && this.socket.readyState !== 1)) return false;
26
+ this.busy = true;
27
+ const socket = this.socket as WebSocket & { ref?: () => void };
28
+ try { socket.ref?.(); } catch { /* optional keepalive hint */ }
29
+ return true;
30
+ }
31
+
32
+ bindOwner(owner: (reason: Error) => void): () => void {
33
+ if (!this.busy || this.closed || this.owner) throw new Error("codex websocket lease is unavailable");
34
+ this.owner = owner;
35
+ return () => { if (this.owner === owner) this.owner = undefined; };
36
+ }
37
+
38
+ release(completedId: string | null): void {
39
+ this.owner = undefined;
40
+ if (this.closed) return;
41
+ if (!this.retainable || !completedId || !this.opened
42
+ || (this.socket.readyState !== undefined && this.socket.readyState !== 1)) {
43
+ this.dispose();
44
+ return;
45
+ }
46
+ this.completedIds.add(completedId);
47
+ if (this.completedIds.size >= MAX_CODEX_WS_SESSION_EXCHANGES) {
48
+ this.dispose();
49
+ return;
50
+ }
51
+ this.busy = false;
52
+ const socket = this.socket as WebSocket & { unref?: () => void };
53
+ try { socket.unref?.(); } catch { /* optional hint; shutdown/expiry still owns cleanup */ }
54
+ this.changed();
55
+ }
56
+
57
+ dispose(reason = new Error("codex websocket session disposed")): void {
58
+ if (this.closed) return;
59
+ this.closed = true;
60
+ const owner = this.owner;
61
+ this.owner = undefined;
62
+ this.detach();
63
+ try { owner?.(reason); } finally {
64
+ this.busy = false;
65
+ this.completedIds.clear();
66
+ try { this.socket.close(); } catch { /* already closing */ }
67
+ if (this.retainable) {
68
+ try { (this.socket as WebSocket & { terminate?: () => void }).terminate?.(); } catch { /* already closed */ }
69
+ }
70
+ this.changed();
71
+ }
72
+ }
73
+
74
+ private onOpen = (): void => { this.opened = true; };
75
+ private onIdleMessage = (): void => {
76
+ if (!this.busy) this.dispose(new Error("codex websocket received unsolicited idle data"));
77
+ };
78
+ private onIdleError = (): void => { if (!this.busy) this.dispose(); };
79
+ private onClose = (): void => {
80
+ this.closed = true;
81
+ this.busy = false;
82
+ this.completedIds.clear();
83
+ this.detach();
84
+ this.changed();
85
+ // The active exchange's close listener retains pre-send fallback semantics.
86
+ };
87
+ private detach(): void {
88
+ this.socket.removeEventListener("open", this.onOpen);
89
+ this.socket.removeEventListener("message", this.onIdleMessage);
90
+ this.socket.removeEventListener("close", this.onClose);
91
+ this.socket.removeEventListener("error", this.onIdleError);
92
+ }
93
+ }
@@ -0,0 +1,144 @@
1
+ import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
2
+ // If the 101 never arrives (network black hole), give SSE a chance well before
3
+ // the caller's connect timeout (default 200s) would fire.
4
+ export const UPGRADE_DEADLINE_MS = 10_000;
5
+ export const CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS = 30_000;
6
+ // Keep the push-based WS transport inside the same memory envelope as the
7
+ // bounded SSE relays that consume this response. Unlike fetch response bodies,
8
+ // a WebSocket cannot be paused when a ReadableStream applies backpressure, so
9
+ // an upstream that outruns the consumer must be disconnected.
10
+ export const MAX_CODEX_WS_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES;
11
+ export const MAX_CODEX_WS_QUEUE_BYTES = 8 * 1024 * 1024;
12
+ // The backend drops any inbound message of 16 MiB or more: it closes the socket
13
+ // (1009) without a Responses terminal event, which reaches clients as a bare
14
+ // 502 upstream_server_error. Measured against the live endpoint 2026-08-23:
15
+ // 16,777,000 B completed, 16,777,300 B closed in ~1s, every time. The same
16
+ // request body succeeds over HTTP SSE, so the ceiling belongs to this transport
17
+ // alone (see #2426). A full-replay thread reaches it with ~11 pasted
18
+ // screenshots, and then never recovers, because each retry resends the frame.
19
+ export const MAX_CODEX_WS_CREATE_FRAME_BYTES = 16 * 1024 * 1024;
20
+ // Bun frames the payload it is handed, so the send-side budget is the JSON text
21
+ // itself, and nothing is appended between the check and the send. The margin is
22
+ // a conservative cushion, not a computed requirement: it covers RFC 6455 frame
23
+ // overhead in case the backend counts it (14 bytes at this payload size — an
24
+ // 8-byte extended length plus a 4-byte client mask, leaving ~65.5 KiB spare),
25
+ // and it leaves room for a future caller that appends to the frame.
26
+ const CODEX_WS_CREATE_FRAME_MARGIN_BYTES = 64 * 1024;
27
+ export const CODEX_WS_CREATE_FRAME_LIMIT_BYTES =
28
+ MAX_CODEX_WS_CREATE_FRAME_BYTES - CODEX_WS_CREATE_FRAME_MARGIN_BYTES;
29
+ /** Close code the backend uses for an oversized message (RFC 6455 "message too big"). */
30
+ const WS_CLOSE_MESSAGE_TOO_BIG = 1009;
31
+
32
+ const codexWsUpstreamResponses = new WeakSet<Response>();
33
+ const quotaObservedResponses = new WeakSet<Response>();
34
+
35
+ /** Quota arrived directly at its captured account; do not replay old HTTP prelude headers. */
36
+ export function isCodexWsQuotaObservedResponse(response: Response): boolean {
37
+ return quotaObservedResponses.has(response);
38
+ }
39
+
40
+ /** True only for a successful Codex WebSocket upgrade, never an HTTP fallback. */
41
+ export function isCodexWsUpstreamResponse(response: Response): boolean {
42
+ return codexWsUpstreamResponses.has(response);
43
+ }
44
+
45
+
46
+ export function markCodexWsResponse(response: Response, observed: boolean): void {
47
+ codexWsUpstreamResponses.add(response);
48
+ if (observed) quotaObservedResponses.add(response);
49
+ }
50
+
51
+ const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event";
52
+
53
+ export type ResponsesWsRelayEvent = {
54
+ type: string;
55
+ text: string;
56
+ payload: Record<string, unknown>;
57
+ };
58
+
59
+ /**
60
+ * Responses WebSocket uses `response.done` as its terminal event, while the
61
+ * SSE Responses surface uses status-specific terminal events. Normalize the
62
+ * WS-only discriminator before relaying so the existing SSE consumers can
63
+ * settle the turn and the socket close cannot be mistaken for a drop. Unknown
64
+ * or missing status values fail closed instead of being reported as success.
65
+ */
66
+ export function normalizeResponsesWsRelayEvent(text: string): ResponsesWsRelayEvent | null {
67
+ let payload: unknown;
68
+ try {
69
+ payload = JSON.parse(text);
70
+ } catch {
71
+ return null;
72
+ }
73
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
74
+ const record = payload as Record<string, unknown>;
75
+ if (typeof record.type !== "string") return null;
76
+ // A native error may be pretty-printed JSON. SSE prefixes one data line;
77
+ // embedded physical newlines would otherwise truncate the JSON for readers.
78
+ if (record.type !== "response.done") return {
79
+ type: record.type, text: /[\r\n]/.test(text) ? JSON.stringify(record) : text, payload: record,
80
+ };
81
+
82
+ const response = record.response;
83
+ const status = response && typeof response === "object" && !Array.isArray(response)
84
+ ? (response as Record<string, unknown>).status
85
+ : undefined;
86
+ const type = status === "completed"
87
+ ? "response.completed"
88
+ : status === "failed"
89
+ ? "response.failed"
90
+ : status === "incomplete" || status === "cancelled"
91
+ ? "response.incomplete"
92
+ : "response.failed";
93
+ const normalizedRecord: Record<string, unknown> = { ...record, type };
94
+ if (type === "response.failed" && status !== "failed") {
95
+ normalizedRecord.response = response && typeof response === "object" && !Array.isArray(response)
96
+ ? { ...(response as Record<string, unknown>), status: "failed" }
97
+ : { status: "failed" };
98
+ }
99
+ return { type, text: JSON.stringify(normalizedRecord), payload: normalizedRecord };
100
+ }
101
+
102
+ /**
103
+ * The close code is the only thing that separates "the backend refused this
104
+ * payload" from "the network dropped", and both used to reach the caller as the
105
+ * same bare 502. Naming the oversized case here puts that distinction in the
106
+ * message the client receives.
107
+ *
108
+ * It does NOT reach the request log as a typed code. The eager relay turns any
109
+ * stream error into a generic `upstream_reset` synthetic terminal
110
+ * (`relay.ts`, `relay-eager.ts`) without feeding that frame back through the
111
+ * inspector, so `/api/logs` keeps neither this message nor a specific code —
112
+ * only `streamAborted`. Machine-readable typing would mean changing the error
113
+ * taxonomy, which is deliberately out of scope for this transport fix.
114
+ */
115
+ export function closedBeforeTerminalMessage(event: unknown): string {
116
+ const detail = event as { code?: unknown; reason?: unknown } | null | undefined;
117
+ const code = typeof detail?.code === "number" ? detail.code : null;
118
+ const reason = typeof detail?.reason === "string" ? detail.reason.trim() : "";
119
+ if (code === null) return CLOSED_BEFORE_TERMINAL;
120
+ const suffix = reason ? ` ${code} ${reason}` : ` ${code}`;
121
+ if (code === WS_CLOSE_MESSAGE_TOO_BIG) {
122
+ return `codex websocket rejected the request frame as too large (close${suffix});`
123
+ + ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport`;
124
+ }
125
+ return `${CLOSED_BEFORE_TERMINAL} (close${suffix})`;
126
+ }
127
+
128
+ /**
129
+ * True when the `response.create` frame is at or above the backend's inbound
130
+ * message ceiling, so this turn must take the HTTP SSE path instead.
131
+ *
132
+ * Sizing a 16 MiB string should not cost a 16 MiB copy. UTF-8 never encodes
133
+ * below one byte per UTF-16 code unit and never above three, so both tails are
134
+ * settled from the string length alone; only the narrow band between them pays
135
+ * for a real byte count, and `Buffer.byteLength` measures without allocating.
136
+ */
137
+ export function codexWsCreateFrameExceedsLimit(
138
+ frameText: string,
139
+ limitBytes: number = CODEX_WS_CREATE_FRAME_LIMIT_BYTES,
140
+ ): boolean {
141
+ if (frameText.length >= limitBytes) return true;
142
+ if (frameText.length * 3 < limitBytes) return false;
143
+ return Buffer.byteLength(frameText, "utf8") >= limitBytes;
144
+ }