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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.7.2 — 2026-09-30
4
+
5
+ - Readable compaction summaries are passed through as user messages. A compaction item that only carries encrypted or internal fields is still dropped.
6
+ - A Grok thread can generate a video without an API key. The bridge declares `grok_bridge_generate_video` on the upstream request and, when the model calls it, posts to `https://api.x.ai/v1/videos/generations` with the grok login bearer, polls `GET /videos/{request_id}`, and returns the video URL to the model as the tool result. A refusal from that API stays a tool error and does not fail the turn.
7
+ - Codex input items Grok does not accept (`local_shell_call`, `web_search_call`, `tool_search_call`, `tool_search_output`, `additional_tools`, `image_generation_call`, and unmapped custom tool items) keep their readable text as user `input_text` messages, the same shape as a compaction summary. That text includes shell commands, search queries, tool-search text, an image `revised_prompt`, and custom tool text. `encrypted_content`, `encrypted_function_args`, `internal_*` fields, and image-byte `result` values stay dropped.
8
+ - `POST /v1/images/generations` and `POST /v1/images/edits` are forwarded to the Imagine API with the grok login bearer. Codex's `gpt-image-2` body is rewritten to `grok-imagine-image-quality` and `b64_json`. OpenAI `file_id` edits are refused. No `XAI_API_KEY`.
9
+ - Codex `web_search` is kept as one server-side `{type:"web_search"}` tool. `web_search_call` results are passed through to Codex.
10
+ - Voice WebRTC (`POST /v1/realtime/calls`) and Codex cloud tasks are not in this package.
11
+
12
+ ## 1.7.1 — 2026-09-30
13
+
14
+ - 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.
15
+ - When `grok --version` fails or does not report a version, the client version is `unknown` instead of the stale `1.0.24`.
4
16
 
5
17
  ## 1.7.0 — 2026-09-29
6
18
 
package/README.md CHANGED
@@ -30,13 +30,45 @@ The bridge does **not** execute Grok-native tools. It translates Codex tools int
30
30
  function calling, streams the upstream Responses events, and rewrites names back
31
31
  so Codex still recognizes them.
32
32
 
33
- Fourteen files under `src/`. Zero runtime dependencies. Node.js ≥ 22.
33
+ Eighteen files under `src/`. Zero runtime dependencies. Node.js ≥ 22.
34
34
 
35
35
  This is not a second-opinion review product and not a Grok-native search
36
36
  product. The picker shows `grok-4.7` (Grok 4.7 / xAI) and keeps `grok-4.6`.
37
37
  Any other `grok-*` id uses the same route. `GROK_BRIDGE_MODELS` adds further
38
- `grok-*` ids. If Codex exposes a web-search tool, Grok can call
39
- that tool the same way it calls any other Codex tool.
38
+ `grok-*` ids. Codex `web_search` is xAI's server-side tool, not a function
39
+ the bridge runs. See Release history.
40
+
41
+ ## Release history
42
+
43
+ What each recent version added. Older cuts are in `CHANGELOG.md`.
44
+
45
+ ### 1.7.2 — 2026-09-30
46
+
47
+ - Readable compaction summaries are passed through as user messages. A compaction item that only carries encrypted or internal fields is still dropped.
48
+ - `grok_bridge_generate_video`. The bridge declares this tool and, when the model calls it, posts to `https://api.x.ai/v1/videos/generations` with the grok login bearer, polls `GET /videos/{request_id}`, and returns the video URL as the tool result. A refusal from that API stays a tool error and does not fail the turn.
49
+ - Readable text from Codex input items that used to be dropped is forwarded as user `input_text`: shell, search, tool-search, a revised image prompt, and custom tool text. Encrypted blobs and image-byte results stay dropped.
50
+ - `POST /v1/images/generations` and `POST /v1/images/edits` are forwarded to the Imagine API with the grok login bearer. Codex's `gpt-image-2` body is rewritten to `grok-imagine-image-quality` and `b64_json`. OpenAI `file_id` edits are refused. No `XAI_API_KEY`.
51
+ - Codex `web_search` is kept as one server-side `{type:"web_search"}` tool. `web_search_call` results are passed through to Codex.
52
+ - Voice WebRTC (`POST /v1/realtime/calls`) and Codex cloud tasks are not in this package.
53
+
54
+ ### 1.7.1 — 2026-09-30
55
+
56
+ - 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.
57
+ - When `grok --version` fails or does not report a version, the client version is `unknown` instead of the stale `1.0.24`.
58
+
59
+ ### 1.7.0 — 2026-09-29
60
+
61
+ - One `x-grok-conv-id` per Codex thread, and the forwarded transcript prefix stays byte-stable so the prompt cache can hit. The full transcript is still sent.
62
+ - Upstream `cached_prompt_tokens` and `cache_read_input_tokens` are copied onto `response.completed` usage and the diagnostics log when the proxy sends them, including `0`. Missing counters are not invented.
63
+
64
+ ### 1.6.1 — 2026-09-28
65
+
66
+ - Darwin bundled CLI resolves `Codex.app/Contents/Resources/codex-cli/bin/codex` when that file exists (Codex 26.924). The legacy `Contents/Resources/codex` path remains the fallback. `CODEX_BINARY` still wins. Linux and Windows layouts are unchanged.
67
+
68
+ ### 1.6.0 — 2026-09-27
69
+
70
+ - Default catalog model is `grok-4.7` (`Grok 4.7 / xAI`). `grok-4.6` stays listed so existing threads still resolve.
71
+ - `grok-*` still routes to `grok_build_cli`. No other model ids were added. `GROK_BRIDGE_MODELS` still appends extra `grok-*` ids.
40
72
 
41
73
  ## Requirements
42
74
 
@@ -72,7 +104,7 @@ starts, and tears the provider down with that process.
72
104
  ```sh
73
105
  git clone https://github.com/deximple/codex-grok-bridge.git
74
106
  cd codex-grok-bridge
75
- npm test # 160 tests, no network, no inference
107
+ npm test # 191 tests, no network, no inference
76
108
  node scripts/codex-grok.mjs
77
109
  ```
78
110
 
@@ -107,8 +139,8 @@ launches from its usual icon.
107
139
  2. The wrapper adds Grok to the model catalog and sets the provider of a new
108
140
  Grok thread to `grok_build_cli`.
109
141
  3. Codex `/v1/responses` requests go to the localhost bridge.
110
- 4. The bridge flattens Codex tools (plain functions, namespaced functions,
111
- freeform custom tools, `web_search`) into function tools, then pipes the
142
+ 4. The bridge flattens Codex function and custom tools into function tools,
143
+ forwards `web_search` as xAI's server-side tool, then pipes the
112
144
  `cli-chat-proxy.grok.com` Responses stream through.
113
145
  5. Tool results return as the next Codex request `input`.
114
146
 
@@ -120,10 +152,27 @@ address when one exists, so a 5–20 s tool gap does not force a fresh name
120
152
  lookup every time. `GROK_BRIDGE_TRANSPORT=fetch` restores the older `fetch`
121
153
  path.
122
154
 
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.
155
+ The bridge holds Grok’s reply until it finishes (`response.completed`). No
156
+ byte of that reply is sent to Codex before then. If the connection drops
157
+ first — a reset (`ECONNRESET`, `EPIPE`, `UND_ERR_SOCKET`) or a close before
158
+ the reply finishes — the bridge sends the same request again, up to two more
159
+ times. DNS and connection failures use that same budget. This provider sets
160
+ Codex `request_max_retries` and `stream_max_retries` to 0, so Codex does not
161
+ send the prompt again as well. If any byte of the reply was already sent to
162
+ Codex, the bridge does not resubmit. A retry sends the prompt to Grok again,
163
+ so that attempt’s input tokens can be billed again. This replaces Codex
164
+ resending the prompt up to two extra times. User aborts and deterministic 422
165
+ responses are not retried. Codex still gives the stream five minutes of
166
+ silence (`stream_idle_timeout_ms`); a reply that takes longer can be cut off
167
+ before any of it is sent.
168
+
169
+ ### In short
170
+
171
+ Codex keeps the conversation and sends the whole turn each time. Grok can
172
+ treat the unchanged beginning as a cache. If the connection drops before
173
+ Codex has been sent any bytes, the bridge tries again. Codex does not send
174
+ that same request again. Voice WebRTC (`POST /v1/realtime/calls`) and
175
+ Codex cloud tasks are not in this package.
127
176
 
128
177
  If the Responses path misbehaves, `GROK_BRIDGE_INFERENCE=cli` falls back to the
129
178
  older CLI envelope. That path pastes the whole JSON into a prompt each turn, so
@@ -239,15 +288,15 @@ ephemeral threads and child agents refuse a switch. Other subscribers can block
239
288
  the reload; if they do, no inference is sent. Pick the model you want when
240
289
  starting a new thread, before the first turn is saved.
241
290
 
242
- ### Not guaranteed
291
+ ### Not in this release
243
292
 
244
- Voice, cloud tasks, and video generation are not promised.
293
+ Voice WebRTC (`POST /v1/realtime/calls`) and Codex cloud tasks are not in this package.
245
294
 
246
295
  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.
296
+ 25 s / 27 s / 253 s, 726 KB–22 MB). The bridge holds the reply and, if that
297
+ drop happens before Codex has been sent any of it, tries the same request up
298
+ to two more times. Codex does not also resend it. A retry can bill the input
299
+ tokens for that attempt again.
251
300
 
252
301
  ## Diagnostics
253
302
 
@@ -291,7 +340,7 @@ Start here when something breaks. Do not open `~/.grok/auth.json` or
291
340
  ## Verify
292
341
 
293
342
  ```sh
294
- npm test # 160 tests, no remote inference
343
+ npm test # 191 tests, no remote inference
295
344
  npm run test:coverage # 80% line / branch / function gate
296
345
  npm run verify:app-server # real app-server routing; also runs against an installed bundle
297
346
  npm audit --omit=dev
@@ -360,14 +409,46 @@ Grok 4.7을 Codex 모델 목록에 넣고, Codex의 `/v1/responses`를
360
409
  calling으로 옮기고, 상류 Responses 스트림을 전달한 뒤, Codex가 알아보는
361
410
  이름으로 되돌립니다.
362
411
 
363
- `src/` 아래 파일 14개. 런타임 의존성 없음. Node.js 22 이상. 1.5.0부터 npm
412
+ `src/` 아래 파일 18개. 런타임 의존성 없음. Node.js 22 이상. 1.5.0부터 npm
364
413
  `"os"`는 `darwin` / `linux` / `win32`입니다.
365
414
 
366
415
  코드 리뷰 전용 제품이 아니고 Grok 네이티브 검색 제품도 아닙니다. 피커에는
367
416
  `grok-4.7`(Grok 4.7 / xAI)이 기본으로 보이고 `grok-4.6`도 남습니다. 그 외
368
417
  `grok-*` id도 같은 경로로 붙고, `GROK_BRIDGE_MODELS`로 카탈로그에 더합니다.
369
- Codex가 웹 검색 도구를 노출하면 Grok은 다른 Codex 도구와 같이 그 도구를
370
- 호출할 수 있습니다.
418
+ Codex `web_search`는 브리지가 실행하는 함수가 아니라 xAI 서버 도구입니다.
419
+ 릴리스 기록을 보세요.
420
+
421
+ ## 릴리스 기록
422
+
423
+ 최근 버전이 더한 것입니다. 그 이전은 `CHANGELOG.md`에 있습니다.
424
+
425
+ ### 1.7.2 — 2026-09-30
426
+
427
+ - 읽을 수 있는 compaction 요약을 사용자 메시지로 넘깁니다. 암호화된 내용이나 내부 필드만 있는 compaction 항목은 그대로 버립니다.
428
+ - `grok_bridge_generate_video`. 브리지가 이 도구를 선언하고, 모델이 호출하면 grok 로그인 bearer로 `https://api.x.ai/v1/videos/generations`에 보낸 뒤 `GET /videos/{request_id}`를 폴링하고, 영상 URL을 도구 결과로 돌려줍니다. 그 API의 거절은 도구 오류로 남고 턴을 실패시키지 않습니다.
429
+ - 예전에는 버리던 Codex 입력 항목의 읽을 수 있는 글을 사용자 `input_text`로 넘깁니다. 셸, 검색, tool-search, 수정된 이미지 프롬프트, 커스텀 도구 텍스트입니다. 암호화된 blob과 이미지 바이트 결과는 그대로 버립니다.
430
+ - `POST /v1/images/generations`와 `POST /v1/images/edits`를 grok 로그인 bearer로 Imagine API에 넘깁니다. Codex의 `gpt-image-2` 본문은 `grok-imagine-image-quality`와 `b64_json`으로 바꿉니다. OpenAI `file_id` 편집은 거절합니다. `XAI_API_KEY`는 쓰지 않습니다.
431
+ - Codex `web_search`는 서버 측 `{type:"web_search"}` 도구 하나로 유지합니다. `web_search_call` 결과는 Codex로 통과합니다.
432
+ - 음성 WebRTC(`POST /v1/realtime/calls`)와 Codex 클라우드 작업은 이 패키지에 없습니다.
433
+
434
+ ### 1.7.1 — 2026-09-30
435
+
436
+ - 브리지는 상류 응답을 `response.completed` 이후에만 Codex로 보냅니다. 그 전의 리셋(`ECONNRESET`, `EPIPE`, `UND_ERR_SOCKET`, 또는 응답이 끝나기 전의 닫힘)은 최대 두 번 더 재시도하며, Codex에 바이트를 아직 보내지 않았을 때만 합니다. 이 provider의 Codex `request_max_retries`와 `stream_max_retries`는 0입니다. 재시도는 프롬프트를 다시 보내므로 그 시도의 입력 토큰이 다시 과금될 수 있습니다.
437
+ - `grok --version`이 실패하거나 버전을 보고하지 않으면 클라이언트 버전은 오래된 `1.0.24` 대신 `unknown`입니다.
438
+
439
+ ### 1.7.0 — 2026-09-29
440
+
441
+ - Codex 스레드마다 `x-grok-conv-id`는 하나이고, 전달하는 트랜스크립트 접두는 바이트 그대로라 프롬프트 캐시가 맞을 수 있습니다. 전체 트랜스크립트는 그대로 보냅니다.
442
+ - 상류가 보내면 `cached_prompt_tokens`와 `cache_read_input_tokens`를 `response.completed` usage와 진단 로그에 복사합니다. `0`도 포함합니다. 없는 카운터는 만들지 않습니다.
443
+
444
+ ### 1.6.1 — 2026-09-28
445
+
446
+ - Darwin 번들 CLI는 파일이 있으면 `Codex.app/Contents/Resources/codex-cli/bin/codex`를 씁니다(Codex 26.924). 예전 `Contents/Resources/codex`는 폴백으로 남습니다. `CODEX_BINARY`가 우선합니다. Linux와 Windows 배치는 그대로입니다.
447
+
448
+ ### 1.6.0 — 2026-09-27
449
+
450
+ - 기본 카탈로그 모델은 `grok-4.7`(Grok 4.7 / xAI)입니다. `grok-4.6`은 기존 스레드가 계속 맞게 목록에 남습니다.
451
+ - `grok-*`는 여전히 `grok_build_cli`로 갑니다. 다른 모델 id는 추가하지 않았습니다. `GROK_BRIDGE_MODELS`는 그 외 `grok-*` id를 카탈로그에 더합니다.
371
452
 
372
453
  ## 필요한 것
373
454
 
@@ -402,7 +483,7 @@ codex-grok exec --skip-git-repo-check --sandbox workspace-write '작업 내용'
402
483
  ```sh
403
484
  git clone https://github.com/deximple/codex-grok-bridge.git
404
485
  cd codex-grok-bridge
405
- npm test # 160건, 네트워크·추론 없음
486
+ npm test # 191건, 네트워크·추론 없음
406
487
  node scripts/codex-grok.mjs
407
488
  ```
408
489
 
@@ -436,8 +517,8 @@ Codex 창에도 보일 수 있습니다.
436
517
  2. 래퍼가 모델 목록에 Grok를 추가하고, 새 Grok 작업의 제공자를
437
518
  `grok_build_cli`로 설정합니다.
438
519
  3. Codex의 `/v1/responses` 요청은 localhost 브리지로 갑니다.
439
- 4. 브리지는 Codex 도구(일반 함수, namespace 함수, freeform 커스텀, `web_search`)를
440
- function tool로 펼친 뒤 `cli-chat-proxy.grok.com` Responses 스트림을 이어줍니다.
520
+ 4. 브리지는 Codex 함수·커스텀 도구를 function tool로 펼치고, `web_search`는
521
+ xAI 서버 도구로 넘긴 뒤 `cli-chat-proxy.grok.com` Responses 스트림을 이어줍니다.
441
522
  5. 도구 결과는 다음 Codex 요청의 `input`으로 돌아갑니다.
442
523
 
443
524
  인증은 `grok login` 세션입니다. `XAI_API_KEY`는 쓰지 않습니다.
@@ -447,10 +528,25 @@ Codex 창에도 보일 수 있습니다.
447
528
  실행으로 5–20초가 비어도 매번 이름을 다시 찾지 않기 위해서입니다.
448
529
  `GROK_BRIDGE_TRANSPORT=fetch`로 이전 `fetch` 경로로 되돌릴 수 있습니다.
449
530
 
450
- Codex에 첫 SSE 블록을 쓰기 **전**에 죽은 요청은 한 번 다시 보냅니다. 아직
451
- 아무것도 전달하지 않았으므로 재전송이 대화를 오염시키지 않습니다. 첫 블록을
452
- 쓴 뒤에는 절대 재시도하지 않습니다. 422 같은 결정적 거절과 사용자 중단도
453
- 재시도하지 않습니다.
531
+ 브리지는 Grok의 응답이 끝날 때까지(`response.completed`) 들고 있습니다.
532
+ 그 전에 Codex로 응답 바이트를 보내지 않습니다. 그 전에 연결이 끊기면 —
533
+ 리셋(`ECONNRESET`, `EPIPE`, `UND_ERR_SOCKET`)이거나, 응답이 끝나기 전에
534
+ 닫힌 경우 — 브리지가 같은 요청을 최대 두 번 더 보냅니다. DNS·연결 실패도
535
+ 같은 횟수를 씁니다. 이 provider는 Codex `request_max_retries`와
536
+ `stream_max_retries`를 0으로 두어, Codex가 그 프롬프트를 또 보내지 않게
537
+ 합니다. 응답 바이트를 Codex에 이미 보냈다면 브리지는 다시 보내지 않습니다.
538
+ 재시도는 프롬프트를 Grok에 다시 보내므로, 그 시도의 입력 토큰이 다시 과금될
539
+ 수 있습니다. Codex가 프롬프트를 최대 두 번 더 보내던 것을 이것으로 대신합니다.
540
+ 사용자 중단과 422 같은 결정적 거절은 재시도하지 않습니다. Codex는 응답
541
+ 바이트 없이 5분(`stream_idle_timeout_ms`)을 기다립니다. 그보다 오래 걸리면
542
+ 보내기 전에 끊길 수 있습니다.
543
+
544
+ ### 쉽게 말하면
545
+
546
+ Codex가 대화를 갖고 있고, 턴마다 그 턴 전체를 보냅니다. 앞부분이 그대로면
547
+ Grok는 그 부분을 캐시로 볼 수 있습니다. Codex에 바이트를 보내기 전에 연결이
548
+ 끊기면 브리지가 다시 시도합니다. Codex는 그 요청을 또 보내지 않습니다. 음성
549
+ WebRTC(`POST /v1/realtime/calls`)와 Codex 클라우드 작업은 이 패키지에 없습니다.
454
550
 
455
551
  Responses 경로가 이상하면 `GROK_BRIDGE_INFERENCE=cli`로 이전 CLI 봉투 경로를
456
552
  씁니다. 매 턴 전체 JSON을 프롬프트로 넣으므로 더 느리고 비싸며, 토큰 단위
@@ -558,15 +654,14 @@ OpenAI 경로가 맞습니다.
558
654
  구독자가 재로드를 막으면 추론을 보내지 않습니다. 첫 턴이 저장되기 전에 새
559
655
  작업을 시작할 때 원하는 모델을 고르세요.
560
656
 
561
- ### 아직 보장하지 않는 것
657
+ ### 이번 릴리스에 없는 것
562
658
 
563
- 음성, 클라우드 작업, 영상 생성은 약속하지 않습니다.
659
+ 음성 WebRTC(`POST /v1/realtime/calls`)와 Codex 클라우드 작업은 이 패키지에 없습니다.
564
660
 
565
661
  상류가 응답 중간에 연결을 리셋하는 경우가 있습니다(실측 3건: 25초 / 27초 /
566
- 253초, 726 KB–22 MB). 첫 SSE 바이트 이후에는 브리지가 재시도할 수 없습니다.
567
- provider에 Codex `request_max_retries` / `stream_max_retries`를 2로 두어
568
- **Codex**가 같은 요청을 다시 보내게 했습니다. 대화를 소유한 쪽만 안전하게
569
- 재시도할 수 있습니다.
662
+ 253초, 726 KB–22 MB). 브리지는 응답을 들고 있다가, Codex에 그 응답을 보내기
663
+ 전에 끊기면 같은 요청을 최대 두 번 더 시도합니다. Codex가 또 보내지는
664
+ 않습니다. 재시도하면 그 시도의 입력 토큰이 다시 과금될 수 있습니다.
570
665
 
571
666
  ## 진단 로그
572
667
 
@@ -609,7 +704,7 @@ provider에 Codex `request_max_retries` / `stream_max_retries`를 2로 두어
609
704
  ## 검증
610
705
 
611
706
  ```sh
612
- npm test # 160건, 외부 추론 없음
707
+ npm test # 191건, 외부 추론 없음
613
708
  npm run test:coverage # line/branch/function 80% 게이트
614
709
  npm run verify:app-server # 실제 app-server 라우팅. 설치된 앱 번들에서도 실행
615
710
  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.2",
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,9 @@ import {
8
8
  parseGrokResult,
9
9
  runGrok,
10
10
  } from "./cli-inference.mjs";
11
- import { openProxyStreamWithRetry, pipeProxySse } from "./proxy.mjs";
11
+ import { emitProxySse, readRelayProxySse } from "./proxy.mjs";
12
+ import { videoCallsFromParts, videoToolOutput } from "./videogen.mjs";
13
+ import { forwardImagine } from "./imagine.mjs";
12
14
  import {
13
15
  applyCacheUsage,
14
16
  createPrefixMemory,
@@ -23,6 +25,89 @@ import { catalogModelInfos, isGrokModel, MODEL_INFO } from "./models.mjs";
23
25
 
24
26
  export { MODEL_INFO };
25
27
 
28
+ const VIDEO_FOLLOW_UPS = 2;
29
+
30
+ async function relayWithVideo(options, output, map, usageBox, onClientByte) {
31
+ let body = options.body;
32
+ for (let step = 0; step <= VIDEO_FOLLOW_UPS; step += 1) {
33
+ const parts = await readRelayProxySse({ ...options, body }, output);
34
+ const calls =
35
+ process.env.GROK_BRIDGE_VIDEO_GEN === "off" ? [] : videoCallsFromParts(parts);
36
+ if (!calls.length || step === VIDEO_FOLLOW_UPS) {
37
+ await emitProxySse(parts, output, map, usageBox, onClientByte);
38
+ return;
39
+ }
40
+ // The videos API poll can sit for minutes. Keepalive is safe here: this
41
+ // upstream reply is finished, and nothing has been written to Codex yet.
42
+ onClientByte();
43
+ const additions = [];
44
+ for (const call of calls) {
45
+ const toolOutput = await videoToolOutput(call, options.video);
46
+ additions.push(
47
+ {
48
+ type: "function_call",
49
+ name: call.name,
50
+ call_id: call.call_id,
51
+ arguments: call.arguments,
52
+ },
53
+ {
54
+ type: "function_call_output",
55
+ name: call.name,
56
+ call_id: call.call_id,
57
+ output: toolOutput,
58
+ },
59
+ );
60
+ }
61
+ body = { ...body, input: [...body.input, ...additions] };
62
+ }
63
+ }
64
+
65
+ async function relayImagine(req, res, json, kind, options) {
66
+ const controller = new AbortController();
67
+ res.on("close", () => controller.abort());
68
+ let body;
69
+ try {
70
+ const chunks = [];
71
+ let size = 0;
72
+ const limit = options.maxBodyBytes ?? 40 * 1024 * 1024;
73
+ for await (const chunk of req) {
74
+ size += chunk.length;
75
+ if (size > limit) return json(413, { error: "Request too large" });
76
+ chunks.push(chunk);
77
+ }
78
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
79
+ } catch {
80
+ return json(400, { error: "Invalid request" });
81
+ }
82
+ try {
83
+ const session = options.grokSession ?? readGrokBearerToken(options.grokHome);
84
+ const response = await forwardImagine({
85
+ kind,
86
+ body,
87
+ token: session.token,
88
+ fetchImpl: options.imagineFetch,
89
+ baseUrl: options.imagineBaseUrl,
90
+ signal: controller.signal,
91
+ });
92
+ if (!res.writableEnded && !res.destroyed) json(200, response);
93
+ } catch (error) {
94
+ if (res.writableEnded || res.destroyed) return;
95
+ if (error instanceof GrokAuthError) return json(401, { error: error.message });
96
+ const status = Number(error?.status);
97
+ const message = String(error?.message ?? "");
98
+ if (
99
+ status >= 400 &&
100
+ status < 600 &&
101
+ (message.startsWith("Image generation") ||
102
+ message.startsWith("Image edit") ||
103
+ message.startsWith("OpenAI file") ||
104
+ message.startsWith("Grok login"))
105
+ )
106
+ return json(status, { error: message });
107
+ return json(502, { error: "Image generation failed." });
108
+ }
109
+ }
110
+
26
111
  export function publicBridgeError(error, context = {}) {
27
112
  if (error instanceof GrokAuthError) return error.message;
28
113
  const message = String(error?.message ?? "");
@@ -51,13 +136,20 @@ export function createBridgeServer(options = {}) {
51
136
  const route = new URL(req.url, "http://127.0.0.1").pathname;
52
137
  if (req.method === "GET" && route === "/v1/models")
53
138
  return json(200, { models: catalogModelInfos() });
54
- if (req.method !== "POST" || route !== "/v1/responses")
139
+ const imagineKind =
140
+ route === "/v1/images/generations"
141
+ ? "generations"
142
+ : route === "/v1/images/edits"
143
+ ? "edits"
144
+ : null;
145
+ if (req.method !== "POST" || (!imagineKind && route !== "/v1/responses"))
55
146
  return json(404, { error: "Not found" });
56
147
  const auth = Buffer.from(req.headers.authorization ?? "");
57
148
  if (auth.length !== token.length || !timingSafeEqual(auth, token))
58
149
  return json(401, { error: "Unauthorized" });
59
150
  if (req.headers.origin)
60
151
  return json(403, { error: "Browser requests are not accepted" });
152
+ if (imagineKind) return relayImagine(req, res, json, imagineKind, options);
61
153
  let body;
62
154
  let requestBytes = 0;
63
155
  try {
@@ -101,8 +193,14 @@ export function createBridgeServer(options = {}) {
101
193
  );
102
194
  const id = "resp_" + randomUUID();
103
195
  const startedAt = Date.now();
196
+ // A keepalive comment is an SSE byte. Writing one before the reply finishes
197
+ // would make a later upstream reset unsafe to retry, so the proxy path
198
+ // stays quiet until the first real event is handed to Codex. The CLI path
199
+ // writes its own events and may keep the socket warm while it runs.
200
+ let responseStarted = false;
104
201
  const keepalive = setInterval(() => {
105
- if (!res.destroyed && !res.writableEnded) res.write(": keepalive\n\n");
202
+ if (!responseStarted || res.destroyed || res.writableEnded) return;
203
+ res.write(": keepalive\n\n");
106
204
  }, 10000);
107
205
  const useCli =
108
206
  Boolean(options.runGrok) ||
@@ -125,6 +223,7 @@ export function createBridgeServer(options = {}) {
125
223
  acquired = true;
126
224
  queuedMs = Date.now() - queuedAt;
127
225
  if (useCli) {
226
+ responseStarted = true;
128
227
  event("response.created", { response: { id } });
129
228
  const invocation = buildGrokInvocation(body, options);
130
229
  invocation.threadId = req.headers["thread-id"];
@@ -158,7 +257,7 @@ export function createBridgeServer(options = {}) {
158
257
  },
159
258
  });
160
259
  } else {
161
- const session = readGrokBearerToken(options.grokHome);
260
+ const session = options.grokSession ?? readGrokBearerToken(options.grokHome);
162
261
  // Grok takes input_image blocks natively; only unusable attachments
163
262
  // are swapped for an explanation, so one bad image cannot make the
164
263
  // upstream reject the whole conversation.
@@ -171,25 +270,39 @@ export function createBridgeServer(options = {}) {
171
270
  if (convId) request.prompt_cache_key = convId;
172
271
  request.input = prefixes.reuse(convId, projected);
173
272
  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
273
  try {
192
- await pipeProxySse(proxy.body, res, map, usageBox);
274
+ await relayWithVideo(
275
+ {
276
+ token: session.token,
277
+ userId: session.userId,
278
+ body: request,
279
+ signal: controller.signal,
280
+ fetchImpl: options.proxyFetch,
281
+ baseUrl: options.proxyBaseUrl,
282
+ convId,
283
+ sessionId: Array.isArray(threadId) ? threadId[0] : threadId,
284
+ onRetry: ({ attempt, kind }) =>
285
+ diagnostics.record({
286
+ event: "turn_retried",
287
+ kind,
288
+ attempt,
289
+ elapsedMs: Date.now() - startedAt,
290
+ }),
291
+ video: {
292
+ token: session.token,
293
+ fetchImpl: options.videoFetch,
294
+ baseUrl: options.videoBaseUrl,
295
+ pause: options.videoPause,
296
+ signal: controller.signal,
297
+ },
298
+ },
299
+ res,
300
+ map,
301
+ usageBox,
302
+ () => {
303
+ responseStarted = true;
304
+ },
305
+ );
193
306
  } finally {
194
307
  cacheUsage = usageBox.cacheUsage ?? null;
195
308
  }
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
  ]);