codex-grok-bridge 1.7.0 → 1.7.2

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.
@@ -0,0 +1,150 @@
1
+ // Codex posts OpenAI Images requests on the provider base
2
+ // (`POST /v1/images/generations`, `POST /v1/images/edits`). grok-build sends
3
+ // the same two paths to the Imagine API with the login bearer, not an
4
+ // XAI_API_KEY. The bodies are not the same: Codex hardcodes `gpt-image-2`
5
+ // plus `size` / `quality` / `background`, and edits use `{image_url}` or
6
+ // `{file_id}`. Imagine wants `grok-imagine-image-quality`, `response_format:
7
+ // b64_json`, and `{url}` references.
8
+
9
+ export const DEFAULT_IMAGINE_API_BASE = "https://api.x.ai/v1";
10
+ export const IMAGINE_MODEL = "grok-imagine-image-quality";
11
+
12
+ const IMAGINE_TIMEOUT_MS = 300_000;
13
+
14
+ function promptOf(input) {
15
+ const prompt = typeof input?.prompt === "string" ? input.prompt.trim() : "";
16
+ if (!prompt) {
17
+ const error = new Error("Image generation requires a prompt.");
18
+ error.status = 400;
19
+ throw error;
20
+ }
21
+ return prompt;
22
+ }
23
+
24
+ function basePayload(prompt) {
25
+ return {
26
+ model: IMAGINE_MODEL,
27
+ prompt,
28
+ n: 1,
29
+ resolution: "1k",
30
+ response_format: "b64_json",
31
+ };
32
+ }
33
+
34
+ export function imagineGenerationBody(input) {
35
+ return basePayload(promptOf(input));
36
+ }
37
+
38
+ export function imagineEditBody(input) {
39
+ const prompt = promptOf(input);
40
+ const images = Array.isArray(input?.images) ? input.images : [];
41
+ const urls = [];
42
+ for (const image of images) {
43
+ if (!image || typeof image !== "object") continue;
44
+ if (typeof image.image_url === "string" && image.image_url.trim()) {
45
+ urls.push(image.image_url.trim());
46
+ continue;
47
+ }
48
+ if (typeof image.file_id === "string" && image.file_id.trim()) {
49
+ const error = new Error("OpenAI file ids cannot be edited with Grok.");
50
+ error.status = 400;
51
+ throw error;
52
+ }
53
+ }
54
+ if (!urls.length) {
55
+ const error = new Error("Image edit requires a reference image.");
56
+ error.status = 400;
57
+ throw error;
58
+ }
59
+ const payload = basePayload(prompt);
60
+ if (urls.length === 1) payload.image = { url: urls[0] };
61
+ else {
62
+ payload.images = urls.map((url) => ({ url }));
63
+ payload.aspect_ratio = "auto";
64
+ }
65
+ return payload;
66
+ }
67
+
68
+ export function codexImageResponse(payload) {
69
+ const data = [];
70
+ if (Array.isArray(payload?.data)) {
71
+ for (const item of payload.data) {
72
+ if (item && typeof item.b64_json === "string" && item.b64_json.length)
73
+ data.push({ b64_json: item.b64_json });
74
+ }
75
+ }
76
+ if (!data.length) {
77
+ const error = new Error("Image generation returned no image.");
78
+ error.status = 502;
79
+ throw error;
80
+ }
81
+ const created = Number.isInteger(payload?.created)
82
+ ? payload.created
83
+ : Math.floor(Date.now() / 1000);
84
+ return { created, data };
85
+ }
86
+
87
+ function scrub(text, token) {
88
+ let out = String(text ?? "");
89
+ out = out.replace(/Bearer\s+\S+/gi, "Bearer [redacted]");
90
+ if (token) out = out.split(token).join("[redacted]");
91
+ return out;
92
+ }
93
+
94
+ async function imagineHttp(fetchImpl, url, init) {
95
+ const response = await fetchImpl(url, { ...init, redirect: "manual" });
96
+ const text = await response.text().catch(() => "");
97
+ if (response.status >= 300 && response.status < 400) {
98
+ const error = new Error("Image generation failed (redirect).");
99
+ error.status = 502;
100
+ throw error;
101
+ }
102
+ let body = null;
103
+ if (text) {
104
+ try {
105
+ body = JSON.parse(text);
106
+ } catch {
107
+ body = null;
108
+ }
109
+ }
110
+ return { status: response.status, body };
111
+ }
112
+
113
+ export async function forwardImagine(options) {
114
+ const token = options.token;
115
+ const kind = options.kind === "edits" ? "edits" : "generations";
116
+ const payload =
117
+ kind === "edits" ? imagineEditBody(options.body) : imagineGenerationBody(options.body);
118
+ const fetchImpl = options.fetchImpl ?? fetch;
119
+ const base = (options.baseUrl ?? DEFAULT_IMAGINE_API_BASE).replace(/\/$/, "");
120
+ const timeout = AbortSignal.timeout(IMAGINE_TIMEOUT_MS);
121
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
122
+ let result;
123
+ try {
124
+ result = await imagineHttp(fetchImpl, `${base}/images/${kind}`, {
125
+ method: "POST",
126
+ headers: {
127
+ authorization: `Bearer ${token}`,
128
+ "content-type": "application/json",
129
+ },
130
+ body: JSON.stringify(payload),
131
+ signal,
132
+ });
133
+ } catch (error) {
134
+ if (error?.status) throw error;
135
+ const wrapped = new Error(scrub(error?.message || "Image generation failed.", token));
136
+ wrapped.status = 502;
137
+ throw wrapped;
138
+ }
139
+ if (result.status === 401 || result.status === 403) {
140
+ const error = new Error("Grok login expired. Run grok login.");
141
+ error.status = 401;
142
+ throw error;
143
+ }
144
+ if (result.status < 200 || result.status >= 300) {
145
+ const error = new Error(`Image generation failed (${result.status}).`);
146
+ error.status = result.status >= 400 && result.status < 500 ? result.status : 502;
147
+ throw error;
148
+ }
149
+ return codexImageResponse(result.body);
150
+ }
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,46 +174,196 @@ 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;
255
+ throw error;
256
+ } finally {
257
+ if (!failed) reader.releaseLock();
258
+ if (usageBox) usageBox.cacheUsage = rewrite.cacheUsage ?? null;
259
+ }
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
+ async function relayAttempts(options, consume, 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 consume(response);
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
+ }
287
+
288
+ export async function relayProxySse(options, output, map, usageBox, attempts = PROXY_ATTEMPTS) {
289
+ return relayAttempts(
290
+ options,
291
+ (response) =>
292
+ pipeProxySse(response.body, output, map, usageBox, options.onClientByte),
293
+ attempts,
294
+ );
295
+ }
296
+
297
+ // Read until response.completed (or the other terminal events). Do not write
298
+ // those bytes yet: a video tool call has to be answered before Codex sees the
299
+ // turn, and a reset before that write is still safe to retry.
300
+ export async function readUntilTerminal(stream, output) {
301
+ const reader = stream.getReader();
302
+ const decoder = new TextDecoder();
303
+ let buffer = "";
304
+ const collected = [];
305
+ let released = false;
306
+ const finish = async (error) => {
307
+ if (released) return;
308
+ released = true;
309
+ await reader.cancel(error).catch(() => {});
310
+ };
311
+ try {
312
+ while (true) {
313
+ if (output?.destroyed || output?.writableEnded) throw clientClosed();
314
+ const { done, value } = await reader.read();
315
+ if (done) break;
316
+ buffer += decoder.decode(value, { stream: true });
317
+ const split = completeBlocks(buffer);
318
+ buffer = split.rest;
319
+ collected.push(...split.parts);
320
+ if (collected.some((part) => isTerminalSseBlock(part))) {
321
+ await finish();
322
+ return collected;
323
+ }
324
+ }
325
+ buffer += decoder.decode();
326
+ if (buffer.trim()) collected.push(buffer);
327
+ if (!collected.some((part) => isTerminalSseBlock(part)))
328
+ throw prematureUpstreamClose();
329
+ await finish();
330
+ return collected;
331
+ } catch (error) {
332
+ await finish(error);
333
+ if (error && typeof error === "object" && error.clientBytes == null)
334
+ error.clientBytes = 0;
335
+ throw error;
336
+ }
337
+ }
338
+
339
+ export async function readRelayProxySse(options, output, attempts = PROXY_ATTEMPTS) {
340
+ let parts;
341
+ await relayAttempts(
342
+ options,
343
+ async (response) => {
344
+ parts = await readUntilTerminal(response.body, output);
345
+ },
346
+ attempts,
347
+ );
348
+ return parts;
349
+ }
350
+
351
+ export async function emitProxySse(parts, output, map, usageBox, onClientByte) {
352
+ const rewrite = createSseRewriter(map);
353
+ let clientBytes = 0;
354
+ try {
355
+ for (const part of parts) {
356
+ const rewritten = rewrite(part);
357
+ if (rewritten === null) continue;
358
+ const block = `${rewritten}\n\n`;
359
+ if (output.destroyed || output.writableEnded) throw clientClosed();
360
+ const accepted = output.write(block);
361
+ clientBytes += Buffer.byteLength(block);
362
+ onClientByte?.();
363
+ if (!accepted) await drained(output);
364
+ }
365
+ } catch (error) {
366
+ if (error && typeof error === "object") error.clientBytes = clientBytes;
168
367
  throw error;
169
368
  } finally {
170
369
  if (usageBox) usageBox.cacheUsage = rewrite.cacheUsage ?? null;
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
  };