codex-grok-bridge 1.7.0 → 1.7.1

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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.7.1 — 2026-09-30
4
+
5
+ - The bridge holds the upstream reply and sends it to Codex only after `response.completed`. A reset before that (`ECONNRESET`, `EPIPE`, `UND_ERR_SOCKET`, or a close before the reply finishes) is retried up to two more times, and only if Codex has not been sent a byte. This provider's Codex `request_max_retries` and `stream_max_retries` are 0. A retry sends the prompt again, so that attempt's input tokens can be billed again.
6
+ - When `grok --version` fails or does not report a version, the client version is `unknown` instead of the stale `1.0.24`.
4
7
 
5
8
  ## 1.7.0 — 2026-09-29
6
9
 
package/README.md CHANGED
@@ -72,7 +72,7 @@ starts, and tears the provider down with that process.
72
72
  ```sh
73
73
  git clone https://github.com/deximple/codex-grok-bridge.git
74
74
  cd codex-grok-bridge
75
- npm test # 160 tests, no network, no inference
75
+ npm test # 174 tests, no network, no inference
76
76
  node scripts/codex-grok.mjs
77
77
  ```
78
78
 
@@ -120,10 +120,27 @@ address when one exists, so a 5–20 s tool gap does not force a fresh name
120
120
  lookup every time. `GROK_BRIDGE_TRANSPORT=fetch` restores the older `fetch`
121
121
  path.
122
122
 
123
- A request that dies **before** the first SSE block is written to Codex is
124
- retried once. After the first block, the bridge never retries — Codex already
125
- saw bytes, so a replay would duplicate them. Deterministic refusals (422) and
126
- user aborts are not retried either.
123
+ The bridge holds Grok’s reply until it finishes (`response.completed`). No
124
+ byte of that reply is sent to Codex before then. If the connection drops
125
+ first — a reset (`ECONNRESET`, `EPIPE`, `UND_ERR_SOCKET`) or a close before
126
+ the reply finishes — the bridge sends the same request again, up to two more
127
+ times. DNS and connection failures use that same budget. This provider sets
128
+ Codex `request_max_retries` and `stream_max_retries` to 0, so Codex does not
129
+ send the prompt again as well. If any byte of the reply was already sent to
130
+ Codex, the bridge does not resubmit. A retry sends the prompt to Grok again,
131
+ so that attempt’s input tokens can be billed again. This replaces Codex
132
+ resending the prompt up to two extra times. User aborts and deterministic 422
133
+ responses are not retried. Codex still gives the stream five minutes of
134
+ silence (`stream_idle_timeout_ms`); a reply that takes longer can be cut off
135
+ before any of it is sent.
136
+
137
+ ### In short
138
+
139
+ Codex keeps the conversation and sends the whole turn each time. Grok can
140
+ treat the unchanged beginning as a cache. If the connection drops before
141
+ Codex has been sent any bytes, the bridge tries again. Codex does not send
142
+ that same request again. Voice, cloud tasks, and video are not in this
143
+ release.
127
144
 
128
145
  If the Responses path misbehaves, `GROK_BRIDGE_INFERENCE=cli` falls back to the
129
146
  older CLI envelope. That path pastes the whole JSON into a prompt each turn, so
@@ -239,15 +256,15 @@ ephemeral threads and child agents refuse a switch. Other subscribers can block
239
256
  the reload; if they do, no inference is sent. Pick the model you want when
240
257
  starting a new thread, before the first turn is saved.
241
258
 
242
- ### Not guaranteed
259
+ ### Not in this release
243
260
 
244
- Voice, cloud tasks, and video generation are not promised.
261
+ Voice, cloud tasks, and video generation are not in this release.
245
262
 
246
263
  Upstream sometimes resets the connection mid-response (three measured cases:
247
- 25 s / 27 s / 253 s, 726 KB–22 MB). The bridge cannot retry after the first
248
- SSE byte. The provider sets Codex `request_max_retries` / `stream_max_retries`
249
- to 2 so **Codex** can resend the same request. Only the side that owns the
250
- conversation can retry safely.
264
+ 25 s / 27 s / 253 s, 726 KB–22 MB). The bridge holds the reply and, if that
265
+ drop happens before Codex has been sent any of it, tries the same request up
266
+ to two more times. Codex does not also resend it. A retry can bill the input
267
+ tokens for that attempt again.
251
268
 
252
269
  ## Diagnostics
253
270
 
@@ -291,7 +308,7 @@ Start here when something breaks. Do not open `~/.grok/auth.json` or
291
308
  ## Verify
292
309
 
293
310
  ```sh
294
- npm test # 160 tests, no remote inference
311
+ npm test # 174 tests, no remote inference
295
312
  npm run test:coverage # 80% line / branch / function gate
296
313
  npm run verify:app-server # real app-server routing; also runs against an installed bundle
297
314
  npm audit --omit=dev
@@ -402,7 +419,7 @@ codex-grok exec --skip-git-repo-check --sandbox workspace-write '작업 내용'
402
419
  ```sh
403
420
  git clone https://github.com/deximple/codex-grok-bridge.git
404
421
  cd codex-grok-bridge
405
- npm test # 160건, 네트워크·추론 없음
422
+ npm test # 174건, 네트워크·추론 없음
406
423
  node scripts/codex-grok.mjs
407
424
  ```
408
425
 
@@ -447,10 +464,25 @@ Codex 창에도 보일 수 있습니다.
447
464
  실행으로 5–20초가 비어도 매번 이름을 다시 찾지 않기 위해서입니다.
448
465
  `GROK_BRIDGE_TRANSPORT=fetch`로 이전 `fetch` 경로로 되돌릴 수 있습니다.
449
466
 
450
- Codex에 첫 SSE 블록을 쓰기 **전**에 죽은 요청은 한 번 다시 보냅니다. 아직
451
- 아무것도 전달하지 않았으므로 재전송이 대화를 오염시키지 않습니다. 첫 블록을
452
- 쓴 뒤에는 절대 재시도하지 않습니다. 422 같은 결정적 거절과 사용자 중단도
453
- 재시도하지 않습니다.
467
+ 브리지는 Grok의 응답이 끝날 때까지(`response.completed`) 들고 있습니다.
468
+ 그 전에 Codex로 응답 바이트를 보내지 않습니다. 그 전에 연결이 끊기면 —
469
+ 리셋(`ECONNRESET`, `EPIPE`, `UND_ERR_SOCKET`)이거나, 응답이 끝나기 전에
470
+ 닫힌 경우 — 브리지가 같은 요청을 최대 두 번 더 보냅니다. DNS·연결 실패도
471
+ 같은 횟수를 씁니다. 이 provider는 Codex `request_max_retries`와
472
+ `stream_max_retries`를 0으로 두어, Codex가 그 프롬프트를 또 보내지 않게
473
+ 합니다. 응답 바이트를 Codex에 이미 보냈다면 브리지는 다시 보내지 않습니다.
474
+ 재시도는 프롬프트를 Grok에 다시 보내므로, 그 시도의 입력 토큰이 다시 과금될
475
+ 수 있습니다. Codex가 프롬프트를 최대 두 번 더 보내던 것을 이것으로 대신합니다.
476
+ 사용자 중단과 422 같은 결정적 거절은 재시도하지 않습니다. Codex는 응답
477
+ 바이트 없이 5분(`stream_idle_timeout_ms`)을 기다립니다. 그보다 오래 걸리면
478
+ 보내기 전에 끊길 수 있습니다.
479
+
480
+ ### 쉽게 말하면
481
+
482
+ Codex가 대화를 갖고 있고, 턴마다 그 턴 전체를 보냅니다. 앞부분이 그대로면
483
+ Grok는 그 부분을 캐시로 볼 수 있습니다. Codex에 바이트를 보내기 전에 연결이
484
+ 끊기면 브리지가 다시 시도합니다. Codex는 그 요청을 또 보내지 않습니다. 음성,
485
+ 클라우드 작업, 영상은 이번 릴리스에 없습니다.
454
486
 
455
487
  Responses 경로가 이상하면 `GROK_BRIDGE_INFERENCE=cli`로 이전 CLI 봉투 경로를
456
488
  씁니다. 매 턴 전체 JSON을 프롬프트로 넣으므로 더 느리고 비싸며, 토큰 단위
@@ -558,15 +590,14 @@ OpenAI 경로가 맞습니다.
558
590
  구독자가 재로드를 막으면 추론을 보내지 않습니다. 첫 턴이 저장되기 전에 새
559
591
  작업을 시작할 때 원하는 모델을 고르세요.
560
592
 
561
- ### 아직 보장하지 않는 것
593
+ ### 이번 릴리스에 없는 것
562
594
 
563
- 음성, 클라우드 작업, 영상 생성은 약속하지 않습니다.
595
+ 음성, 클라우드 작업, 영상 생성은 이번 릴리스에 없습니다.
564
596
 
565
597
  상류가 응답 중간에 연결을 리셋하는 경우가 있습니다(실측 3건: 25초 / 27초 /
566
- 253초, 726 KB–22 MB). 첫 SSE 바이트 이후에는 브리지가 재시도할 수 없습니다.
567
- provider에 Codex `request_max_retries` / `stream_max_retries`를 2로 두어
568
- **Codex**가 같은 요청을 다시 보내게 했습니다. 대화를 소유한 쪽만 안전하게
569
- 재시도할 수 있습니다.
598
+ 253초, 726 KB–22 MB). 브리지는 응답을 들고 있다가, Codex에 그 응답을 보내기
599
+ 전에 끊기면 같은 요청을 최대 두 번 더 시도합니다. Codex가 또 보내지는
600
+ 않습니다. 재시도하면 그 시도의 입력 토큰이 다시 과금될 수 있습니다.
570
601
 
571
602
  ## 진단 로그
572
603
 
@@ -609,7 +640,7 @@ provider에 Codex `request_max_retries` / `stream_max_retries`를 2로 두어
609
640
  ## 검증
610
641
 
611
642
  ```sh
612
- npm test # 160건, 외부 추론 없음
643
+ npm test # 174건, 외부 추론 없음
613
644
  npm run test:coverage # line/branch/function 80% 게이트
614
645
  npm run verify:app-server # 실제 app-server 라우팅. 설치된 앱 번들에서도 실행
615
646
  npm audit --omit=dev
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-grok-bridge",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "Run Grok 4.7 as the model inside Codex, with Codex still owning tools, permissions, history and MCP. Uses the grok login session, not an API key.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/bridge.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  parseGrokResult,
9
9
  runGrok,
10
10
  } from "./cli-inference.mjs";
11
- import { openProxyStreamWithRetry, pipeProxySse } from "./proxy.mjs";
11
+ import { relayProxySse } from "./proxy.mjs";
12
12
  import {
13
13
  applyCacheUsage,
14
14
  createPrefixMemory,
@@ -101,8 +101,14 @@ export function createBridgeServer(options = {}) {
101
101
  );
102
102
  const id = "resp_" + randomUUID();
103
103
  const startedAt = Date.now();
104
+ // A keepalive comment is an SSE byte. Writing one before the reply finishes
105
+ // would make a later upstream reset unsafe to retry, so the proxy path
106
+ // stays quiet until the first real event is handed to Codex. The CLI path
107
+ // writes its own events and may keep the socket warm while it runs.
108
+ let responseStarted = false;
104
109
  const keepalive = setInterval(() => {
105
- if (!res.destroyed && !res.writableEnded) res.write(": keepalive\n\n");
110
+ if (!responseStarted || res.destroyed || res.writableEnded) return;
111
+ res.write(": keepalive\n\n");
106
112
  }, 10000);
107
113
  const useCli =
108
114
  Boolean(options.runGrok) ||
@@ -125,6 +131,7 @@ export function createBridgeServer(options = {}) {
125
131
  acquired = true;
126
132
  queuedMs = Date.now() - queuedAt;
127
133
  if (useCli) {
134
+ responseStarted = true;
128
135
  event("response.created", { response: { id } });
129
136
  const invocation = buildGrokInvocation(body, options);
130
137
  invocation.threadId = req.headers["thread-id"];
@@ -171,25 +178,32 @@ export function createBridgeServer(options = {}) {
171
178
  if (convId) request.prompt_cache_key = convId;
172
179
  request.input = prefixes.reuse(convId, projected);
173
180
  const usageBox = {};
174
- const proxy = await openProxyStreamWithRetry({
175
- token: session.token,
176
- userId: session.userId,
177
- body: request,
178
- signal: controller.signal,
179
- fetchImpl: options.proxyFetch,
180
- baseUrl: options.proxyBaseUrl,
181
- convId,
182
- sessionId: Array.isArray(threadId) ? threadId[0] : threadId,
183
- onRetry: ({ attempt, kind }) =>
184
- diagnostics.record({
185
- event: "turn_retried",
186
- kind,
187
- attempt,
188
- elapsedMs: Date.now() - startedAt,
189
- }),
190
- });
191
181
  try {
192
- await pipeProxySse(proxy.body, res, map, usageBox);
182
+ await relayProxySse(
183
+ {
184
+ token: session.token,
185
+ userId: session.userId,
186
+ body: request,
187
+ signal: controller.signal,
188
+ fetchImpl: options.proxyFetch,
189
+ baseUrl: options.proxyBaseUrl,
190
+ convId,
191
+ sessionId: Array.isArray(threadId) ? threadId[0] : threadId,
192
+ onClientByte: () => {
193
+ responseStarted = true;
194
+ },
195
+ onRetry: ({ attempt, kind }) =>
196
+ diagnostics.record({
197
+ event: "turn_retried",
198
+ kind,
199
+ attempt,
200
+ elapsedMs: Date.now() - startedAt,
201
+ }),
202
+ },
203
+ res,
204
+ map,
205
+ usageBox,
206
+ );
193
207
  } finally {
194
208
  cacheUsage = usageBox.cacheUsage ?? null;
195
209
  }
package/src/errors.mjs CHANGED
@@ -40,7 +40,14 @@ const CONNECT_CODES = new Set([
40
40
  "DEPTH_ZERO_SELF_SIGNED_CERT", "ERR_TLS_CERT_ALTNAME_INVALID",
41
41
  ]);
42
42
  const TIMEOUT_CODES = new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "ETIMEDOUT"]);
43
- const CLOSED_CODES = new Set(["UND_ERR_SOCKET", "ECONNRESET", "EPIPE"]);
43
+ const CLOSED_CODES = new Set([
44
+ "UND_ERR_SOCKET",
45
+ "ECONNRESET",
46
+ "EPIPE",
47
+ // The body ended without response.completed. Same class as a socket reset:
48
+ // the reply never finished, and Codex has not been given a partial one.
49
+ "ERR_UPSTREAM_PREMATURE_CLOSE",
50
+ ]);
44
51
  const ABORT_CODES = new Set([
45
52
  "ABORT_ERR", "ERR_STREAM_DESTROYED", "ERR_STREAM_WRITE_AFTER_END", "ERR_STREAM_PREMATURE_CLOSE",
46
53
  ]);
package/src/proxy.mjs CHANGED
@@ -8,8 +8,13 @@ import { BRIDGE_ERROR, classifyBridgeError } from "./errors.mjs";
8
8
  export const DEFAULT_PROXY_BASE = "https://cli-chat-proxy.grok.com/v1";
9
9
  export const DEFAULT_CLIENT_IDENTIFIER = "grok-shell";
10
10
 
11
+ const UNKNOWN_CLIENT_VERSION = "unknown";
11
12
  let cachedClientVersion;
12
13
 
14
+ export function clearClientVersionCache() {
15
+ cachedClientVersion = undefined;
16
+ }
17
+
13
18
  export function detectGrokClientVersion(home = homedir()) {
14
19
  if (cachedClientVersion) return cachedClientVersion;
15
20
  try {
@@ -20,9 +25,9 @@ export function detectGrokClientVersion(home = homedir()) {
20
25
  timeout: 5000,
21
26
  });
22
27
  const match = output.match(/grok\s+(\S+(?:\s+\([^)]+\))?)/i);
23
- cachedClientVersion = match ? match[1].trim() : "1.0.24";
28
+ cachedClientVersion = match ? match[1].trim() : UNKNOWN_CLIENT_VERSION;
24
29
  } catch {
25
- cachedClientVersion = "1.0.24";
30
+ cachedClientVersion = UNKNOWN_CLIENT_VERSION;
26
31
  }
27
32
  return cachedClientVersion;
28
33
  }
@@ -74,9 +79,9 @@ export async function openProxyStream(options) {
74
79
  return response;
75
80
  }
76
81
 
77
- // Resolve when the consumer has drained, reject if it goes away first. Without
78
- // this, a slow client makes the bridge buffer the whole Grok response in memory,
79
- // and a client that vanishes mid-stream is never noticed.
82
+ // The upstream reply is already buffered until it finishes. This wait applies
83
+ // only while that buffer is flushed: a slow Codex exerts backpressure, and a
84
+ // client that vanishes is noticed instead of being written forever.
80
85
  function drained(output) {
81
86
  return new Promise((resolve, reject) => {
82
87
  const settle = (fn, value) => {
@@ -100,24 +105,68 @@ function drained(output) {
100
105
  });
101
106
  }
102
107
 
103
- async function writeBlock(output, block) {
104
- if (output.destroyed || output.writableEnded)
105
- throw Object.assign(new Error("The client closed the stream"), {
106
- code: "ERR_STREAM_PREMATURE_CLOSE",
107
- });
108
- if (!output.write(block)) await drained(output);
109
- }
108
+ // First try, then two more. That is the budget Codex used to spend on
109
+ // request_max_retries / stream_max_retries. A retry sends the prompt again, so
110
+ // the input tokens for that attempt can be billed again.
111
+ export const PROXY_ATTEMPTS = 3;
110
112
 
111
- // Nothing has reached Codex until the first SSE block is written, so a stream
112
- // that dies before it opens is safe to send again. After that it never is:
113
- // re-sending would duplicate items Codex has already recorded.
113
+ // DNS and connect failures are included. A socket reset (ECONNRESET, EPIPE,
114
+ // UND_ERR_SOCKET) or a body that ends before response.completed is included
115
+ // only while Codex has not been sent a byte. User aborts and deterministic
116
+ // 422s are not in this set.
114
117
  const RETRYABLE = new Set([
115
118
  BRIDGE_ERROR.DNS,
116
119
  BRIDGE_ERROR.CONNECT,
117
120
  BRIDGE_ERROR.UPSTREAM_CLOSED,
118
121
  ]);
119
122
 
120
- export async function openProxyStreamWithRetry(options, attempts = 2) {
123
+ // response.incomplete is the other usage event this code already treats as the
124
+ // end of a response. response.failed here is a finished upstream event, not a
125
+ // dropped socket, so it is forwarded instead of retried.
126
+ const TERMINAL_EVENTS = new Set([
127
+ "response.completed",
128
+ "response.incomplete",
129
+ "response.failed",
130
+ ]);
131
+
132
+ export function isTerminalSseBlock(part) {
133
+ for (const line of part.split("\n")) {
134
+ if (line.startsWith("event:") && TERMINAL_EVENTS.has(line.slice(6).trim()))
135
+ return true;
136
+ if (!line.startsWith("data:")) continue;
137
+ const payload = line.slice(5).trim();
138
+ if (!payload || payload === "[DONE]") continue;
139
+ try {
140
+ const value = JSON.parse(payload);
141
+ if (TERMINAL_EVENTS.has(value?.type)) return true;
142
+ } catch {
143
+ // A non-JSON data line cannot be the terminal event.
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+
149
+ function prematureUpstreamClose() {
150
+ return Object.assign(new Error("Grok closed the connection before finishing"), {
151
+ code: "ERR_UPSTREAM_PREMATURE_CLOSE",
152
+ });
153
+ }
154
+
155
+ function clientClosed() {
156
+ return Object.assign(new Error("The client closed the stream"), {
157
+ code: "ERR_STREAM_PREMATURE_CLOSE",
158
+ });
159
+ }
160
+
161
+ // Returns the retry kind, or null when this failure must surface as-is.
162
+ function retryKind(error, options, attempt, rounds, clientBytes) {
163
+ if (clientBytes > 0 || options.signal?.aborted || attempt === rounds)
164
+ return null;
165
+ const kind = classifyBridgeError(error);
166
+ return RETRYABLE.has(kind) ? kind : null;
167
+ }
168
+
169
+ export async function openProxyStreamWithRetry(options, attempts = PROXY_ATTEMPTS) {
121
170
  const rounds = Math.max(1, attempts);
122
171
  let lastError;
123
172
  for (let attempt = 1; attempt <= rounds; attempt += 1) {
@@ -125,48 +174,113 @@ export async function openProxyStreamWithRetry(options, attempts = 2) {
125
174
  return await openProxyStream(options);
126
175
  } catch (error) {
127
176
  lastError = error;
128
- const kind = classifyBridgeError(error);
129
- if (
130
- !RETRYABLE.has(kind) ||
131
- options.signal?.aborted ||
132
- attempt === rounds
133
- )
134
- break;
177
+ const kind = retryKind(error, options, attempt, rounds, 0);
178
+ if (!kind) break;
135
179
  options.onRetry?.({ attempt, kind });
136
180
  }
137
181
  }
138
182
  throw lastError;
139
183
  }
140
184
 
141
- export async function pipeProxySse(stream, output, map, usageBox) {
185
+ function completeBlocks(buffer) {
186
+ const parts = buffer.split("\n\n");
187
+ const rest = parts.pop() ?? "";
188
+ return { parts: parts.filter((part) => part.trim()), rest };
189
+ }
190
+
191
+ // Read the upstream body to memory. Write to Codex only after a terminal
192
+ // event, so a reset before that has not shown Codex a partial reply.
193
+ export async function pipeProxySse(stream, output, map, usageBox, onClientByte) {
142
194
  const reader = stream.getReader();
143
195
  const decoder = new TextDecoder();
144
196
  let buffer = "";
145
197
  const rewrite = createSseRewriter(map);
198
+ const pending = [];
199
+ let clientBytes = 0;
200
+ let finished = false;
201
+ let failed = false;
202
+
203
+ const flush = async (parts) => {
204
+ for (const part of parts) {
205
+ const rewritten = rewrite(part);
206
+ if (rewritten === null) continue;
207
+ const block = `${rewritten}\n\n`;
208
+ if (output.destroyed || output.writableEnded) throw clientClosed();
209
+ // Count the byte as soon as the client stream accepts it. A later drain
210
+ // error must not look like "Codex saw nothing" and resubmit the prompt.
211
+ const accepted = output.write(block);
212
+ clientBytes += Buffer.byteLength(block);
213
+ onClientByte?.();
214
+ if (!accepted) await drained(output);
215
+ }
216
+ };
217
+
218
+ const accept = async (parts) => {
219
+ if (!parts.length) return;
220
+ if (finished) {
221
+ await flush(parts);
222
+ return;
223
+ }
224
+ const terminalAt = parts.findIndex((part) => isTerminalSseBlock(part));
225
+ if (terminalAt === -1) {
226
+ pending.push(...parts);
227
+ return;
228
+ }
229
+ pending.push(...parts.slice(0, terminalAt + 1));
230
+ await flush(pending);
231
+ pending.length = 0;
232
+ finished = true;
233
+ await flush(parts.slice(terminalAt + 1));
234
+ };
235
+
146
236
  try {
147
237
  while (true) {
238
+ if (output.destroyed || output.writableEnded) throw clientClosed();
148
239
  const { done, value } = await reader.read();
149
240
  if (done) break;
150
241
  buffer += decoder.decode(value, { stream: true });
151
- const parts = buffer.split("\n\n");
152
- buffer = parts.pop();
153
- for (const part of parts) {
154
- if (!part.trim()) continue;
155
- const rewritten = rewrite(part);
156
- if (rewritten !== null) await writeBlock(output, rewritten + "\n\n");
157
- }
242
+ const split = completeBlocks(buffer);
243
+ buffer = split.rest;
244
+ await accept(split.parts);
158
245
  }
159
246
  buffer += decoder.decode();
160
- if (buffer.trim()) {
161
- const rewritten = rewrite(buffer);
162
- if (rewritten !== null) await writeBlock(output, rewritten + "\n\n");
163
- }
247
+ if (buffer.trim()) await accept([buffer]);
248
+ if (!finished) throw prematureUpstreamClose();
164
249
  } catch (error) {
165
- // Stop pulling from Grok the moment the client is gone; leaving the body
250
+ failed = true;
251
+ // Stop pulling from Grok the moment this attempt is over; leaving the body
166
252
  // unread holds the upstream socket open for the rest of the response.
167
253
  await reader.cancel(error).catch(() => {});
254
+ if (error && typeof error === "object") error.clientBytes = clientBytes;
168
255
  throw error;
169
256
  } finally {
257
+ if (!failed) reader.releaseLock();
170
258
  if (usageBox) usageBox.cacheUsage = rewrite.cacheUsage ?? null;
171
259
  }
172
260
  }
261
+
262
+ // Open and read one attempt. If the socket dies before Codex has a byte, send
263
+ // the same request again. Once a byte has been written, never resubmit.
264
+ export async function relayProxySse(options, output, map, usageBox, attempts = PROXY_ATTEMPTS) {
265
+ const rounds = Math.max(1, attempts);
266
+ let lastError;
267
+ for (let attempt = 1; attempt <= rounds; attempt += 1) {
268
+ try {
269
+ const response = await openProxyStream(options);
270
+ await pipeProxySse(response.body, output, map, usageBox, options.onClientByte);
271
+ return response;
272
+ } catch (error) {
273
+ lastError = error;
274
+ const kind = retryKind(
275
+ error,
276
+ options,
277
+ attempt,
278
+ rounds,
279
+ error?.clientBytes ?? 0,
280
+ );
281
+ if (!kind) break;
282
+ options.onRetry?.({ attempt, kind });
283
+ }
284
+ }
285
+ throw lastError;
286
+ }
package/src/runtime.mjs CHANGED
@@ -29,14 +29,11 @@ export async function startRuntime(options = {}) {
29
29
  env_key: "CODEX_GROK_BRIDGE_TOKEN",
30
30
  wire_api: "responses",
31
31
  requires_openai_auth: false,
32
- // The upstream resets a response mid-stream now and then — measured three
33
- // times as ECONNRESET at 25s, 27s and 253s, on requests from 726 KB to
34
- // 22 MB. The bridge cannot retry that: Codex has already received part of
35
- // the answer, so re-sending would duplicate it. Codex can, because it owns
36
- // the conversation and re-sends the same request. These were both 0, which
37
- // turned every such reset into a dead turn.
38
- request_max_retries: 2,
39
- stream_max_retries: 2,
32
+ // The bridge buffers the upstream reply and retries a reset itself, up to
33
+ // two more attempts, before Codex has seen any SSE byte. Codex must not
34
+ // also resend the full prompt.
35
+ request_max_retries: 0,
36
+ stream_max_retries: 0,
40
37
  // Do not inherit whatever Codex's default happens to be across upgrades.
41
38
  stream_idle_timeout_ms: 300000,
42
39
  };