@bitkyc08/opencodex 2.49.0 → 2.51.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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/bin/ocx.mjs +222 -71
  4. package/gui/dist/assets/index-D7BdZpZm.js +115 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/qoder/adapter.ts +69 -1
  8. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  9. package/src/claude/agents-inject.ts +29 -5
  10. package/src/claude/desktop-3p.ts +31 -3
  11. package/src/claude/gateway-cache.ts +12 -21
  12. package/src/claude/inbound.ts +17 -5
  13. package/src/cli/account-api.ts +18 -3
  14. package/src/cli/account-auth.ts +8 -1
  15. package/src/cli/account-extended.ts +2 -1
  16. package/src/cli/account.ts +1 -0
  17. package/src/cli/capabilities.ts +43 -1
  18. package/src/cli/claude-agent-startup-sync.ts +26 -1
  19. package/src/cli/claude.ts +138 -20
  20. package/src/cli/config-command.ts +67 -1
  21. package/src/cli/connect.ts +181 -14
  22. package/src/cli/dispatch.ts +53 -9
  23. package/src/cli/doctor.ts +9 -2
  24. package/src/cli/ensure-desired-integrations.ts +10 -0
  25. package/src/cli/gui-pair-client.ts +1 -12
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/hub.ts +367 -0
  28. package/src/cli/index.ts +99 -31
  29. package/src/cli/launcher-context.ts +1 -1
  30. package/src/cli/models-runtime.ts +8 -3
  31. package/src/cli/observe.ts +13 -3
  32. package/src/cli/registry.ts +43 -3
  33. package/src/cli/status.ts +325 -5
  34. package/src/cli/version-skew.ts +4 -1
  35. package/src/cli.ts +2 -2
  36. package/src/client/catalog-compatibility.ts +192 -0
  37. package/src/client/connect.ts +31 -0
  38. package/src/client/hub-client.ts +52 -0
  39. package/src/client/hub-state.ts +214 -0
  40. package/src/clients/config-export/zcode.ts +24 -0
  41. package/src/codex/account-runtime-state.ts +6 -1
  42. package/src/codex/account-store.ts +72 -9
  43. package/src/codex/account-usability.ts +50 -13
  44. package/src/codex/auth-api.ts +156 -28
  45. package/src/codex/auth-context.ts +21 -0
  46. package/src/codex/catalog/effort.ts +67 -8
  47. package/src/codex/catalog/parsing.ts +23 -0
  48. package/src/codex/catalog/provider-fetch.ts +71 -2
  49. package/src/codex/catalog/sync.ts +99 -0
  50. package/src/codex/codex-write-lock.ts +11 -2
  51. package/src/codex/desired-state.ts +47 -1
  52. package/src/codex/inject-coordination.ts +10 -5
  53. package/src/codex/inject.ts +29 -12
  54. package/src/codex/loopback-target.ts +45 -0
  55. package/src/codex/quota-auto-refresh.ts +6 -1
  56. package/src/codex/quota.ts +54 -8
  57. package/src/codex/routing.ts +48 -1
  58. package/src/codex/runtime.ts +37 -3
  59. package/src/codex/sync.ts +29 -9
  60. package/src/codex/warmup.ts +21 -4
  61. package/src/combos/index.ts +2 -0
  62. package/src/combos/resolve.ts +52 -0
  63. package/src/config/pending-teardown.ts +1 -1
  64. package/src/config.ts +184 -12
  65. package/src/generated/compatibility-version.json +188 -116
  66. package/src/grok/status.ts +9 -1
  67. package/src/integrations/config-io.ts +54 -1
  68. package/src/lib/bun-runtime.ts +1 -1
  69. package/src/lib/errors.ts +8 -0
  70. package/src/lib/gui-pair-capability.ts +27 -0
  71. package/src/lib/local-destinations.ts +162 -0
  72. package/src/lib/package-tree-integrity.ts +1 -1
  73. package/src/lib/privacy.ts +25 -0
  74. package/src/lib/process-control.ts +130 -20
  75. package/src/lib/service-secrets.ts +28 -0
  76. package/src/lib/test-home-guard.ts +49 -0
  77. package/src/oauth/health.ts +47 -12
  78. package/src/oauth/index.ts +46 -8
  79. package/src/oauth/token-guardian.ts +32 -6
  80. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  81. package/src/providers/opencode-go-transport.ts +9 -1
  82. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  83. package/src/providers/quota.ts +20 -1
  84. package/src/providers/registry.ts +35 -6
  85. package/src/remote/hub-state.ts +182 -0
  86. package/src/server/auth-cors.ts +11 -0
  87. package/src/server/chat-completions.ts +10 -7
  88. package/src/server/chat-native.ts +10 -1
  89. package/src/server/claude-messages.ts +12 -6
  90. package/src/server/hub-state.ts +98 -0
  91. package/src/server/images.ts +2 -2
  92. package/src/server/index.ts +149 -8
  93. package/src/server/management/api-access.ts +14 -3
  94. package/src/server/management/config-routes.ts +2 -2
  95. package/src/server/management/cursor-integration-routes.ts +13 -4
  96. package/src/server/management/logs-usage-routes.ts +4 -1
  97. package/src/server/management/model-rows.ts +16 -1
  98. package/src/server/management/oauth-account-routes.ts +6 -2
  99. package/src/server/management/provider-routes.ts +9 -2
  100. package/src/server/management/request-history-routes.ts +4 -2
  101. package/src/server/management/route-registry.ts +5 -4
  102. package/src/server/management/shared.ts +66 -3
  103. package/src/server/management-api.ts +1 -1
  104. package/src/server/proxy-liveness.ts +7 -1
  105. package/src/server/request-decompress.ts +91 -3
  106. package/src/server/request-log-conversation.ts +41 -1
  107. package/src/server/request-log.ts +10 -0
  108. package/src/server/responses/codex-auth-error.ts +18 -1
  109. package/src/server/responses/codex-ws-exchange.ts +36 -4
  110. package/src/server/responses/codex-ws-wire.ts +76 -5
  111. package/src/server/responses/compact.ts +28 -11
  112. package/src/server/responses/context-overflow.ts +11 -0
  113. package/src/server/responses/core.ts +201 -48
  114. package/src/server/responses/policy-fallback.ts +13 -3
  115. package/src/server/search.ts +2 -2
  116. package/src/server/system-env-shell.ts +14 -2
  117. package/src/server/system-env.ts +106 -14
  118. package/src/service.ts +965 -68
  119. package/src/types/accounts.ts +18 -0
  120. package/src/types/config.ts +93 -4
  121. package/src/types/provider.ts +56 -0
  122. package/src/types.ts +4 -0
  123. package/src/update/badge.ts +3 -2
  124. package/src/update/index.ts +317 -64
  125. package/src/update/install-detection.d.mts +6 -0
  126. package/src/update/install-detection.mjs +73 -0
  127. package/src/update/job.ts +101 -49
  128. package/src/update/pnpm-global-install.d.mts +144 -0
  129. package/src/update/pnpm-global-install.mjs +591 -0
  130. package/src/update/pnpm-invocation.d.mts +43 -0
  131. package/src/update/pnpm-invocation.mjs +141 -0
  132. package/src/update/registry-integrity.d.mts +16 -0
  133. package/src/update/registry-integrity.mjs +37 -0
  134. package/src/update/transactional-install.d.mts +1 -1
  135. package/src/update/transactional-install.mjs +101 -7
  136. package/src/update/tray-update-plan.mjs +1 -1
  137. package/src/vision/plan.ts +13 -3
  138. package/src/vision/routed-describe.ts +51 -20
  139. package/src/web-search/ollama-executor.ts +127 -0
  140. package/src/web-search/passthrough-bridge.ts +761 -0
  141. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -10,6 +10,7 @@
10
10
  * Lives outside cli.ts (which dispatches argv at module top level) so tests can import it.
11
11
  */
12
12
  import { loadConfig } from "../config";
13
+ import { isWildcardHostname } from "../codex/loopback-target";
13
14
  import { readAlivePid, readRuntimePort, verifyPidIdentity } from "../config/process-state";
14
15
  import { directLocalHttpFetch } from "./direct-local-http";
15
16
 
@@ -82,10 +83,15 @@ export interface LiveProxy {
82
83
  /**
83
84
  * Host to probe for a given bind hostname: wildcards answer on IPv4 loopback, and raw
84
85
  * IPv6 addresses must be bracketed or the composed URL is invalid.
86
+ *
87
+ * The wildcard test is `isWildcardHostname`, not a list of spellings. This function used to
88
+ * know exactly three (`0.0.0.0`, `::`, `[::]`) while the bind-scope predicate knew every
89
+ * all-zero form, so `ocx` composed `http://0.0.0.0.:10100` or `http://*:10100` — unreachable
90
+ * URLs — for a config the server itself treated as a wildcard bind. One predicate, both sides.
85
91
  */
86
92
  export function probeHostname(hostname: string | undefined): string {
87
93
  const trimmed = (hostname ?? "").trim();
88
- if (!trimmed || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "127.0.0.1";
94
+ if (!trimmed || isWildcardHostname(trimmed)) return "127.0.0.1";
89
95
  if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
90
96
  return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
91
97
  }
@@ -21,6 +21,58 @@ import type { TranslatorBudget } from "../lib/translator-budget";
21
21
  */
22
22
  export const MAX_DECOMPRESSED_BODY_BYTES = 256 * 1024 * 1024;
23
23
 
24
+ /**
25
+ * Hard ceiling on the opt-in `maxInboundBodyBytes` (#3573).
26
+ *
27
+ * The opt-in exists because a 922k-token session serializes past the 256 MiB default, and the
28
+ * request that crosses it is the compaction request itself — so the session can no longer
29
+ * shrink and is stuck. An UNBOUNDED inbound cap is not an acceptable answer: this admission
30
+ * limit is the only thing standing between one request and the process heap, and
31
+ * `readBoundedJsonRequestBody` materializes the body several times over (retained wire bytes,
32
+ * decoded bytes, the decoded string, the re-encoded measurement copies, and the parsed object
33
+ * graph), so peak RSS is a MULTIPLE of whatever is admitted here. 512 MiB is the largest value
34
+ * that keeps that multiple survivable on an ordinary machine, and it is what #3573 asked for.
35
+ */
36
+ export const MAX_CONFIGURABLE_INBOUND_BODY_BYTES = 512 * 1024 * 1024;
37
+
38
+ /** Floor for the opt-in. Below this an ordinary multi-image turn cannot be admitted at all. */
39
+ export const MIN_CONFIGURABLE_INBOUND_BODY_BYTES = 1024 * 1024;
40
+
41
+ /**
42
+ * Resolve the configured inbound admission limit, clamped to the supported range.
43
+ *
44
+ * Pure and total on purpose: the schema in `src/config.ts` degrades an invalid hand edit to
45
+ * `undefined` rather than failing the parse, so the schema cannot be the place the ceiling is
46
+ * enforced. Every caller resolves through here, which makes this the single auditable bound
47
+ * regardless of how the config object was produced.
48
+ *
49
+ * Omitted, zero, or non-finite = the 256 MiB default, so an unconfigured proxy admits exactly
50
+ * what it admits today.
51
+ */
52
+ export function resolveInboundBodyLimitBytes(configured: number | undefined): number {
53
+ if (configured === undefined || !Number.isFinite(configured) || configured <= 0) {
54
+ return MAX_DECOMPRESSED_BODY_BYTES;
55
+ }
56
+ return Math.min(
57
+ Math.max(Math.floor(configured), MIN_CONFIGURABLE_INBOUND_BODY_BYTES),
58
+ MAX_CONFIGURABLE_INBOUND_BODY_BYTES,
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Render a byte count, or nothing at all. `DecompressedBodyTooLargeError` accepts non-finite
64
+ * and untyped values from legacy callers and deliberately keeps them out of its own message;
65
+ * the client-facing message inherits that rule rather than printing `NaN MB`.
66
+ */
67
+ function megabytes(bytes: number): string | null {
68
+ return Number.isFinite(bytes) && bytes >= 0 && bytes <= Number.MAX_SAFE_INTEGER
69
+ ? (bytes / (1024 * 1024)).toFixed(1)
70
+ : null;
71
+ }
72
+
73
+ const INBOUND_CEILING_MB = (MAX_CONFIGURABLE_INBOUND_BODY_BYTES / (1024 * 1024)).toFixed(1);
74
+
75
+
24
76
  export class UnsupportedContentEncodingError extends Error {
25
77
  constructor(readonly encoding: string) {
26
78
  super(`Unsupported content-encoding: ${encoding}`);
@@ -54,6 +106,33 @@ export class DecompressedBodyTooLargeError extends Error {
54
106
  }
55
107
  }
56
108
 
109
+ /**
110
+ * Name OpenCodex as the refuser, and name the lever.
111
+ *
112
+ * #4112 gave the UPSTREAM context refusal on `/v1/responses` its own HTTP 413 with
113
+ * `context_length_exceeded`. That makes the two 413s on this surface look alike to a client
114
+ * while having opposite remedies: the upstream one means the provider will not take the turn,
115
+ * this one means the proxy never read it and a config key would have let it through. The
116
+ * wording deliberately avoids "context window"/"context length", which `classifyError` treats
117
+ * as evidence of an upstream context verdict.
118
+ */
119
+ export function describeInboundBodyRefusal(error: DecompressedBodyTooLargeError): string {
120
+ // A lower-bound measurement stopped counting at the cap; reporting it as exact would be a lie.
121
+ const approximate = error.measurement === "declared_wire" || error.measurement === "decoded_exact"
122
+ ? "" : "at least ";
123
+ const observed = megabytes(error.bytes);
124
+ const limit = megabytes(error.limit);
125
+ const sizes = limit === null
126
+ ? "the body is above the inbound admission limit"
127
+ : observed === null
128
+ ? `the body is above the ${limit} MB inbound admission limit`
129
+ : `the body is ${approximate}${observed} MB, above the ${limit} MB inbound admission limit`;
130
+ return `OpenCodex refused this request before reading it: ${sizes}. `
131
+ + "This is a local proxy limit, not a provider refusal. Raise \"maxInboundBodyBytes\" in "
132
+ + `config.json (ceiling ${INBOUND_CEILING_MB} MB) and restart the proxy, or compact the `
133
+ + "conversation earlier.";
134
+ }
135
+
57
136
  function assertBodySizeWithinLimit(
58
137
  body: Uint8Array,
59
138
  maxBytes: number,
@@ -259,7 +338,16 @@ export async function readBoundedJsonRequestBody(
259
338
  }
260
339
  }
261
340
 
262
- /** Parse a JSON data-plane body using the shared 256 MiB admission cap. */
263
- export function readJsonRequestBody(req: Request, budget?: TranslatorBudget): Promise<unknown> {
264
- return readBoundedJsonRequestBody(req, MAX_DECOMPRESSED_BODY_BYTES, budget);
341
+ /**
342
+ * Parse a JSON data-plane body using the shared admission cap.
343
+ *
344
+ * `maxBytes` is the resolved per-deployment limit from `resolveInboundBodyLimitBytes()`;
345
+ * omitting it keeps the 256 MiB default for callers with no config in scope.
346
+ */
347
+ export function readJsonRequestBody(
348
+ req: Request,
349
+ budget?: TranslatorBudget,
350
+ maxBytes: number = MAX_DECOMPRESSED_BODY_BYTES,
351
+ ): Promise<unknown> {
352
+ return readBoundedJsonRequestBody(req, maxBytes, budget);
265
353
  }
@@ -2,7 +2,7 @@
2
2
  * Best-effort chat/session correlation for Logs / usage.jsonl (#330).
3
3
  * Opaque ids only — never persist raw emails or Claude Desktop system-hash fallbacks.
4
4
  */
5
- import { createHash } from "node:crypto";
5
+ import { createHash, randomUUID } from "node:crypto";
6
6
 
7
7
  /** Reject absurdly long client strings before hashing (DoS / JSONL bloat). */
8
8
  export const LOG_CONVERSATION_ID_INPUT_MAX = 4096;
@@ -217,3 +217,43 @@ export function summarizeConversationLogs(entries: readonly TotalsSource[]): Con
217
217
  unmeteredRequests,
218
218
  };
219
219
  }
220
+
221
+ /**
222
+ * Request-scoped Go affinity for requests that carry no conversation identity.
223
+ *
224
+ * A sessionless request must still reach OpenCode Go with `x-opencode-session`, because the upstream
225
+ * began rejecting requests without it on 2026-09-06. It must not reuse one global value either, which
226
+ * would smear unrelated probes into a single conversation. So the lane is allocated once per admitted
227
+ * `Request` object and retained for that object's lifetime.
228
+ *
229
+ * The identity has to survive every place the proxy rebuilds a `Request`: translation to the internal
230
+ * Responses shape, compaction, and — the boundary that matters most — the policy fallback retry, where
231
+ * a second candidate would otherwise be handed a freshly minted lane after a retryable failure.
232
+ * `linkRequestSessionLane` carries the allocation across those boundaries.
233
+ */
234
+ const requestAllocatedSessionLanes = new WeakMap<Request, string>();
235
+
236
+ /**
237
+ * Resolve the session lane for a request: real conversation identity when the client supplied it,
238
+ * otherwise a per-request value allocated once and reused for retries on the same object.
239
+ */
240
+ export function getOrAllocateRequestSessionLane(req: Request): string {
241
+ const explicit = sessionLaneIdFromRequest(req.headers)
242
+ ?? normalizeLogConversationId(req.headers.get("x-opencode-session"));
243
+ if (explicit) return explicit;
244
+
245
+ const existing = requestAllocatedSessionLanes.get(req);
246
+ if (existing) return existing;
247
+ const allocated = randomUUID();
248
+ requestAllocatedSessionLanes.set(req, allocated);
249
+ return allocated;
250
+ }
251
+
252
+ /**
253
+ * Carry a source request's lane onto a request the proxy built from it, so a rebuilt request keeps
254
+ * the conversation it belongs to instead of looking sessionless again.
255
+ */
256
+ export function linkRequestSessionLane(sourceReq: Request, targetReq: Request): void {
257
+ requestAllocatedSessionLanes.set(targetReq, getOrAllocateRequestSessionLane(sourceReq));
258
+ }
259
+
@@ -1119,6 +1119,16 @@ export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchPara
1119
1119
  filtered = filtered.filter(entry => entry.model === model
1120
1120
  || entry.attempts?.some(attempt => attempt.model === model));
1121
1121
  }
1122
+ // #4057: "which account served this request" is the first question asked when one provider
1123
+ // holds several accounts, and until now the only way to answer it was to grep usage.jsonl by
1124
+ // hand. Attempts are matched for the same reason `provider` and `model` match them: when a
1125
+ // request failed over between pool accounts, a search for the account that finally served it
1126
+ // has to find that request, not only the account that first refused it.
1127
+ const account = params.get("account")?.trim();
1128
+ if (account) {
1129
+ filtered = filtered.filter(entry => entry.accountLogLabel === account
1130
+ || entry.attempts?.some(attempt => attempt.accountLogLabel === account));
1131
+ }
1122
1132
  const status = params.get("status")?.trim().toLowerCase();
1123
1133
  if (status) {
1124
1134
  filtered = /^[1-5]xx$/.test(status)
@@ -29,10 +29,27 @@ export function nativeMainRefreshFailureResponse(error: unknown): Response {
29
29
  if (error instanceof MainAccountTokenRefreshError
30
30
  || error instanceof MainAuthJsonChangedDuringRefreshError
31
31
  || (error instanceof NativeProfileError && error.retryable)) {
32
+ // A bare "retry this request" reads as a transient server fault, which is how #4212's reporter
33
+ // concluded the proxy had broken while one account was the thing that needed them. The refusal
34
+ // stays a retryable 503 because the refresh genuinely may succeed, but it now names what is
35
+ // failing and what to do when retrying stops helping.
36
+ //
37
+ // It says "sign in to the main Codex account again" and deliberately does NOT say
38
+ // "reauthentication", for the same reason the pool counterpart does not — see
39
+ // `poolCredentialRefreshIncompleteResponse` in ./core.ts. `classifyError` runs
40
+ // `isAuthenticationMessage` before it reaches the `status === 503` arm, and that check is
41
+ // status-blind on the bare substring "authentication", which "reauthentication" contains.
42
+ // A body carrying that word is reclassified to `authentication_error` / `invalid_api_key`
43
+ // even though the HTTP status stays 503, and Codex keys its retry-after backoff on
44
+ // `server_is_overloaded` — so the word alone turns a transient refresh into what reads as a
45
+ // bad API key and the client stops retrying. The pool path documented this trap and this one
46
+ // walked into it anyway, which is why the test below now asserts the classification and not
47
+ // just the sentence.
32
48
  const response = formatErrorResponse(
33
49
  503,
34
50
  "server_busy",
35
- "Codex main credential refresh did not complete; retry this request",
51
+ "Codex main credential refresh did not complete; retry this request. "
52
+ + "If it keeps failing, sign in to the main Codex account again.",
36
53
  );
37
54
  const headers = new Headers(response.headers);
38
55
  headers.set("Retry-After", "1");
@@ -5,7 +5,8 @@ import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-w
5
5
  import { CodexWsCorrelation } from "./codex-ws-correlation";
6
6
  import type { CodexWsSession } from "./codex-ws-session";
7
7
  import { UPGRADE_DEADLINE_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
8
- MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage } from "./codex-ws-wire";
8
+ MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
9
+ codexWsFailureDetail, type CodexWsFailureStage } from "./codex-ws-wire";
9
10
 
10
11
  interface ExchangeOptions {
11
12
  session: CodexWsSession;
@@ -94,6 +95,14 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
94
95
  let received = false;
95
96
  let responseCommitted = false;
96
97
  let terminal = false;
98
+ // #4191: the counters behind the failure classification. A user whose long
99
+ // thread died here could not tell an unanswered socket from one that carried
100
+ // only quota frames, because both arrived as the same one-line message.
101
+ let upstreamFrames = 0;
102
+ let controlFrames = 0;
103
+ let relayedEvents = 0;
104
+ let sentAt: number | null = null;
105
+ let firstFrameAt: number | null = null;
97
106
  let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
98
107
  const encoder = new TextEncoder();
99
108
  const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null;
@@ -123,6 +132,21 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
123
132
  ws.removeEventListener("error", onError);
124
133
  };
125
134
 
135
+ /**
136
+ * Snapshot the stage for a failure message. Measuring the frame is deferred
137
+ * to here so the happy path never pays for it: a full-replay thread's frame
138
+ * runs to megabytes, and this is the only place its size is worth knowing.
139
+ */
140
+ const failureStage = (): CodexWsFailureStage => ({
141
+ requestBytes: Buffer.byteLength(frameText, "utf8"),
142
+ sent,
143
+ upstreamFrames,
144
+ controlFrames,
145
+ relayedEvents,
146
+ firstFrameMs: sentAt !== null && firstFrameAt !== null ? Math.max(0, firstFrameAt - sentAt) : null,
147
+ elapsedMs: sentAt !== null ? Math.max(0, Date.now() - sentAt) : null,
148
+ });
149
+
126
150
  const commitResponse = () => {
127
151
  if (responseCommitted) return;
128
152
  responseCommitted = true;
@@ -192,6 +216,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
192
216
  sent = true;
193
217
  try {
194
218
  ws.send(frameText);
219
+ sentAt = Date.now();
195
220
  } catch {
196
221
  if (received || responseCommitted) {
197
222
  if (terminal) session.dispose();
@@ -211,13 +236,18 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
211
236
  }
212
237
  if (!metadata) commitResponse();
213
238
  else if (!responseCommitted && !terminal) {
214
- preludeTimer = setTimeout(() => failStream("codex websocket response prelude timed out"), CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS);
239
+ preludeTimer = setTimeout(
240
+ () => failStream(`codex websocket response prelude timed out${codexWsFailureDetail(failureStage())}`),
241
+ CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS,
242
+ );
215
243
  }
216
244
  };
217
245
 
218
246
  const onMessage = (event: MessageEvent) => {
219
247
  if (!controller || terminal) return;
220
248
  received = true;
249
+ upstreamFrames += 1;
250
+ if (firstFrameAt === null) firstFrameAt = Date.now();
221
251
  const text = typeof event.data === "string" ? event.data : "";
222
252
  if (!text) return;
223
253
  // UTF-8 byte length is always at least the JS string length. Reject this
@@ -243,6 +273,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
243
273
  if (sanitized !== null) {
244
274
  relayText = sanitized;
245
275
  controlFrame = true;
276
+ controlFrames += 1;
246
277
  }
247
278
  } catch (error) {
248
279
  failStream(error);
@@ -296,6 +327,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
296
327
  failStream("codex websocket response stream closed while enqueueing");
297
328
  return;
298
329
  }
330
+ if (!controlFrame) relayedEvents += 1;
299
331
  if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {
300
332
  const completedId = correlation?.completed(normalized.payload) ?? null;
301
333
  terminal = true;
@@ -316,7 +348,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
316
348
  resolve(sseFallback(url, init));
317
349
  return;
318
350
  }
319
- if (sent && !terminal) failStream(closedBeforeTerminalMessage(event));
351
+ if (sent && !terminal) failStream(closedBeforeTerminalMessage(event, failureStage()));
320
352
  };
321
353
 
322
354
  const onError = () => {
@@ -327,7 +359,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
327
359
  cleanup();
328
360
  session.dispose();
329
361
  resolve(sseFallback(url, init));
330
- } else failStream("codex websocket transport error");
362
+ } else failStream(`codex websocket transport error${codexWsFailureDetail(failureStage())}`);
331
363
  };
332
364
  detachOwner = session.bindOwner(reason => cancelExchange(reason));
333
365
  ws.addEventListener("open", onOpen);
@@ -2,7 +2,7 @@ import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
2
2
  // If the 101 never arrives (network black hole), give SSE a chance well before
3
3
  // the caller's connect timeout (default 200s) would fire.
4
4
  export const UPGRADE_DEADLINE_MS = 10_000;
5
- export const CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS = 30_000;
5
+ export const CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS = 90_000;
6
6
  // Keep the push-based WS transport inside the same memory envelope as the
7
7
  // bounded SSE relays that consume this response. Unlike fetch response bodies,
8
8
  // a WebSocket cannot be paused when a ReadableStream applies backpressure, so
@@ -50,6 +50,72 @@ export function markCodexWsResponse(response: Response, observed: boolean): void
50
50
 
51
51
  const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event";
52
52
 
53
+ /**
54
+ * Content-free stage record for an exchange that ended without a Responses
55
+ * terminal event (#4191).
56
+ *
57
+ * The field report that drove this could not be told apart from a network
58
+ * outage, because every such failure reached the user as one of two bare
59
+ * sentences. Both are true of a socket that was never answered, a socket that
60
+ * carried only quota control frames, and a socket that died mid-response —
61
+ * three different upstream stories with three different owners. These counters
62
+ * are the smallest set that separates them, and every one of them is a size, a
63
+ * count, or a duration: no request body, no header, no account identifier, and
64
+ * no conversation text can reach a message built from this record.
65
+ */
66
+ export type CodexWsFailureStage = {
67
+ /** UTF-8 size of the `response.create` frame this exchange dialled with. */
68
+ requestBytes: number;
69
+ /** True once `ws.send()` returned, so the turn may be executing upstream. */
70
+ sent: boolean;
71
+ /** Frames the socket delivered, of any kind, including ones that did not parse. */
72
+ upstreamFrames: number;
73
+ /** Frames the metadata channel claimed (quota, response metadata). */
74
+ controlFrames: number;
75
+ /** Responses events actually written to the downstream SSE body. */
76
+ relayedEvents: number;
77
+ /** Milliseconds from send to the first upstream frame; null when none arrived. */
78
+ firstFrameMs: number | null;
79
+ /** Milliseconds from send to this failure; null when the failure predates the send. */
80
+ elapsedMs: number | null;
81
+ };
82
+
83
+ /**
84
+ * Which upstream story the counters tell. Ordered by how much the upstream had
85
+ * committed to, because that is what decides who owns the failure — and, for a
86
+ * future maintainer reading #4191, it is deliberately NOT a fallback-eligibility
87
+ * signal. `no-upstream-frame` does not mean the frame was not accepted; the
88
+ * no-replay-after-send contract in `codex-ws-exchange.ts` stands regardless of
89
+ * what this classifier says.
90
+ */
91
+ export type CodexWsFailureCause =
92
+ | "before-send"
93
+ | "no-upstream-frame"
94
+ | "no-response-event"
95
+ | "after-response-started";
96
+
97
+ export function classifyCodexWsFailure(stage: CodexWsFailureStage): CodexWsFailureCause {
98
+ if (!stage.sent) return "before-send";
99
+ if (stage.relayedEvents > 0) return "after-response-started";
100
+ if (stage.upstreamFrames === 0) return "no-upstream-frame";
101
+ return "no-response-event";
102
+ }
103
+
104
+ /**
105
+ * Render the stage as a suffix appended to an existing failure message.
106
+ *
107
+ * It is a suffix, not an interpolation, on purpose: the close-code tail these
108
+ * messages already carry is matched as a contiguous substring by the callers
109
+ * and tests that read it, so nothing may be inserted ahead of it.
110
+ */
111
+ export function codexWsFailureDetail(stage: CodexWsFailureStage): string {
112
+ const duration = (value: number | null): string => (value === null ? "n/a" : `${value}ms`);
113
+ return ` [cause=${classifyCodexWsFailure(stage)} request=${stage.requestBytes}B`
114
+ + ` sent=${stage.sent ? "yes" : "no"} frames=${stage.upstreamFrames}`
115
+ + ` control=${stage.controlFrames} relayed=${stage.relayedEvents}`
116
+ + ` first-frame=${duration(stage.firstFrameMs)} elapsed=${duration(stage.elapsedMs)}]`;
117
+ }
118
+
53
119
  export type ResponsesWsRelayEvent = {
54
120
  type: string;
55
121
  text: string;
@@ -111,18 +177,23 @@ export function normalizeResponsesWsRelayEvent(text: string): ResponsesWsRelayEv
111
177
  * inspector, so `/api/logs` keeps neither this message nor a specific code —
112
178
  * only `streamAborted`. Machine-readable typing would mean changing the error
113
179
  * taxonomy, which is deliberately out of scope for this transport fix.
180
+ *
181
+ * When a stage is supplied its detail is appended last, after the close-code
182
+ * tail, so the code and reason stay one contiguous substring.
114
183
  */
115
- export function closedBeforeTerminalMessage(event: unknown): string {
184
+ export function closedBeforeTerminalMessage(event: unknown, stage?: CodexWsFailureStage): string {
116
185
  const detail = event as { code?: unknown; reason?: unknown } | null | undefined;
117
186
  const code = typeof detail?.code === "number" ? detail.code : null;
118
187
  const reason = typeof detail?.reason === "string" ? detail.reason.trim() : "";
119
- if (code === null) return CLOSED_BEFORE_TERMINAL;
188
+ const stageDetail = stage ? codexWsFailureDetail(stage) : "";
189
+ if (code === null) return `${CLOSED_BEFORE_TERMINAL}${stageDetail}`;
120
190
  const suffix = reason ? ` ${code} ${reason}` : ` ${code}`;
121
191
  if (code === WS_CLOSE_MESSAGE_TOO_BIG) {
122
192
  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`;
193
+ + ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport`
194
+ + stageDetail;
124
195
  }
125
- return `${CLOSED_BEFORE_TERMINAL} (close${suffix})`;
196
+ return `${CLOSED_BEFORE_TERMINAL} (close${suffix})${stageDetail}`;
126
197
  }
127
198
 
128
199
  /**
@@ -104,7 +104,12 @@ import { fastPolicyForModel } from "../../providers/service-tier";
104
104
  import { parseFastOnlyRowId } from "../fast-row";
105
105
  import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
106
106
  import { isUsageDebugEnabled } from "../../usage/debug";
107
- import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress";
107
+ import {
108
+ readJsonRequestBody,
109
+ resolveInboundBodyLimitBytes,
110
+ DecompressedBodyTooLargeError,
111
+ UnsupportedContentEncodingError,
112
+ } from "../request-decompress";
108
113
  import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
109
114
  import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../../providers/key-failover";
110
115
  import { shouldAttemptImageTierRetry } from "../image-retry";
@@ -147,12 +152,13 @@ import {
147
152
  decodeRequestErrorResponse,
148
153
  handleResponses,
149
154
  preAuthUpstreamHostCircuitKey,
155
+ poolCredentialRefreshIncompleteResponse,
150
156
  upstreamHostCircuitOpenResponse,
151
157
  usesCodexForwardPoolAuth,
152
158
  } from "./core";
153
159
  import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
154
160
  import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error";
155
- import { sessionLaneIdFromRequest } from "../request-log-conversation";
161
+ import { linkRequestSessionLane, sessionLaneIdFromRequest } from "../request-log-conversation";
156
162
  import { recallComboForLane } from "./combo-session-recall";
157
163
 
158
164
  export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024;
@@ -312,6 +318,13 @@ async function refreshPoolCompactContext(args: {
312
318
  authCtx: CodexAuthContext & { kind: "pool" };
313
319
  provider: OcxProviderConfig;
314
320
  codexAccountMode?: CodexAccountMode;
321
+ /**
322
+ * Public selector for the account this refresh is for, when the request carried one. The
323
+ * caller has it and this function does not, because compact takes no `RouteResult` — which
324
+ * is the whole reason the refusal here used to be less specific than the one core returns
325
+ * for the identical failure.
326
+ */
327
+ codexAccountNamespace?: string;
315
328
  substituteMainCredential: boolean;
316
329
  options: HandleResponsesCompactOptions;
317
330
  }): Promise<
@@ -372,14 +385,15 @@ async function refreshPoolCompactContext(args: {
372
385
  if (isTerminalCompactPoolRefreshFailure(error)) {
373
386
  return { ok: false, quarantine: true, response: reauthResponse() };
374
387
  }
375
- const response = formatErrorResponse(
376
- 503,
377
- "server_busy",
378
- "Codex credential refresh did not complete; retry this request",
379
- );
380
- const headers = new Headers(response.headers);
381
- headers.set("Retry-After", "1");
382
- return { ok: false, quarantine: false, response: new Response(response.body, { status: response.status, headers }) };
388
+ return {
389
+ ok: false,
390
+ quarantine: false,
391
+ response: poolCredentialRefreshIncompleteResponse({
392
+ authCtx,
393
+ config,
394
+ accountSelector: args.codexAccountNamespace,
395
+ }),
396
+ };
383
397
  }
384
398
  }
385
399
 
@@ -522,7 +536,7 @@ export async function handleResponsesCompact(
522
536
  ): Promise<Response> {
523
537
  let body: unknown;
524
538
  try {
525
- body = await readJsonRequestBody(req);
539
+ body = await readJsonRequestBody(req, undefined, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
526
540
  } catch (err) {
527
541
  return decodeRequestErrorResponse(err, "responses-compact");
528
542
  }
@@ -912,6 +926,7 @@ export async function handleResponsesCompact(
912
926
  authCtx: poolAuthCtx,
913
927
  provider: compactProvider,
914
928
  codexAccountMode: route.codexAccountMode,
929
+ codexAccountNamespace: route.codexAccountNamespace,
915
930
  substituteMainCredential,
916
931
  options,
917
932
  })
@@ -1015,6 +1030,7 @@ export async function handleResponsesCompact(
1015
1030
  upstream.headers,
1016
1031
  authCtx.writerGeneration,
1017
1032
  authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined,
1033
+ { modelId: route.modelId },
1018
1034
  );
1019
1035
  }
1020
1036
  recordCompactPoolOutcome(authCtx, upstream.status, {
@@ -1143,6 +1159,7 @@ export async function handleResponsesCompact(
1143
1159
  headers: internalHeaders,
1144
1160
  body: JSON.stringify(internalBody),
1145
1161
  });
1162
+ linkRequestSessionLane(req, internalReq);
1146
1163
  const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) });
1147
1164
  if (!response.ok) return response;
1148
1165
  let json: { output?: unknown[]; status?: unknown; error?: unknown };
@@ -5,6 +5,17 @@ import type { AdapterEvent } from "../../types";
5
5
  export const PROVIDER_INPUT_TOO_LARGE_MESSAGE =
6
6
  "The provider rejected this turn because its input exceeds the provider size or context limit. Reduce the current input or compact the conversation before retrying.";
7
7
 
8
+ /** Preserve non-streaming HTTP failure semantics without exposing an upstream body. */
9
+ export function jsonContextOverflowResponse(): Response {
10
+ return Response.json({
11
+ error: {
12
+ message: PROVIDER_INPUT_TOO_LARGE_MESSAGE,
13
+ type: "invalid_request_error",
14
+ code: "context_length_exceeded",
15
+ },
16
+ }, { status: 413, headers: { "Cache-Control": "no-store" } });
17
+ }
18
+
8
19
  async function* contextOverflowEvents(): AsyncGenerator<AdapterEvent> {
9
20
  yield {
10
21
  type: "error",