codex-grok-bridge 1.6.1 → 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 +9 -1
- package/README.md +64 -28
- package/package.json +1 -1
- package/src/bridge.mjs +66 -26
- package/src/errors.mjs +8 -1
- package/src/prefix.mjs +163 -0
- package/src/proxy.mjs +152 -36
- package/src/runtime.mjs +5 -8
- package/src/tools.mjs +67 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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`.
|
|
7
|
+
|
|
8
|
+
## 1.7.0 — 2026-09-29
|
|
9
|
+
|
|
10
|
+
- One `x-grok-conv-id` per Codex thread, and the forwarded transcript prefix stays byte-stable so prompt cache can hit. The full transcript is still sent.
|
|
11
|
+
- 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.
|
|
4
12
|
|
|
5
13
|
## 1.6.1 — 2026-09-28
|
|
6
14
|
|
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 #
|
|
75
|
+
npm test # 174 tests, no network, no inference
|
|
76
76
|
node scripts/codex-grok.mjs
|
|
77
77
|
```
|
|
78
78
|
|
|
@@ -120,18 +120,38 @@ 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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
130
147
|
it is slower and more expensive, has no token-by-token streaming, and is capped
|
|
131
148
|
at three minutes per request.
|
|
132
149
|
|
|
133
|
-
The default Responses path streams. Codex
|
|
134
|
-
`x-grok-conv-id
|
|
150
|
+
The default Responses path streams. One Codex thread keeps a single
|
|
151
|
+
`x-grok-conv-id`: the `thread-id` header when Codex sends it, otherwise
|
|
152
|
+
`prompt_cache_key`. That same id is written into `prompt_cache_key`. An
|
|
153
|
+
unchanged transcript prefix is resent byte-for-byte; the new items are
|
|
154
|
+
appended, not substituted for the history.
|
|
135
155
|
|
|
136
156
|
## What works and what does not
|
|
137
157
|
|
|
@@ -236,15 +256,15 @@ ephemeral threads and child agents refuse a switch. Other subscribers can block
|
|
|
236
256
|
the reload; if they do, no inference is sent. Pick the model you want when
|
|
237
257
|
starting a new thread, before the first turn is saved.
|
|
238
258
|
|
|
239
|
-
### Not
|
|
259
|
+
### Not in this release
|
|
240
260
|
|
|
241
|
-
Voice, cloud tasks, and video generation are not
|
|
261
|
+
Voice, cloud tasks, and video generation are not in this release.
|
|
242
262
|
|
|
243
263
|
Upstream sometimes resets the connection mid-response (three measured cases:
|
|
244
|
-
25 s / 27 s / 253 s, 726 KB–22 MB). The bridge
|
|
245
|
-
|
|
246
|
-
to
|
|
247
|
-
|
|
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.
|
|
248
268
|
|
|
249
269
|
## Diagnostics
|
|
250
270
|
|
|
@@ -288,7 +308,7 @@ Start here when something breaks. Do not open `~/.grok/auth.json` or
|
|
|
288
308
|
## Verify
|
|
289
309
|
|
|
290
310
|
```sh
|
|
291
|
-
npm test #
|
|
311
|
+
npm test # 174 tests, no remote inference
|
|
292
312
|
npm run test:coverage # 80% line / branch / function gate
|
|
293
313
|
npm run verify:app-server # real app-server routing; also runs against an installed bundle
|
|
294
314
|
npm audit --omit=dev
|
|
@@ -399,7 +419,7 @@ codex-grok exec --skip-git-repo-check --sandbox workspace-write '작업 내용'
|
|
|
399
419
|
```sh
|
|
400
420
|
git clone https://github.com/deximple/codex-grok-bridge.git
|
|
401
421
|
cd codex-grok-bridge
|
|
402
|
-
npm test #
|
|
422
|
+
npm test # 174건, 네트워크·추론 없음
|
|
403
423
|
node scripts/codex-grok.mjs
|
|
404
424
|
```
|
|
405
425
|
|
|
@@ -444,17 +464,34 @@ Codex 창에도 보일 수 있습니다.
|
|
|
444
464
|
실행으로 5–20초가 비어도 매번 이름을 다시 찾지 않기 위해서입니다.
|
|
445
465
|
`GROK_BRIDGE_TRANSPORT=fetch`로 이전 `fetch` 경로로 되돌릴 수 있습니다.
|
|
446
466
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
+
클라우드 작업, 영상은 이번 릴리스에 없습니다.
|
|
451
486
|
|
|
452
487
|
Responses 경로가 이상하면 `GROK_BRIDGE_INFERENCE=cli`로 이전 CLI 봉투 경로를
|
|
453
488
|
씁니다. 매 턴 전체 JSON을 프롬프트로 넣으므로 더 느리고 비싸며, 토큰 단위
|
|
454
489
|
실시간 출력이 없고, 요청당 3분 제한입니다.
|
|
455
490
|
|
|
456
|
-
기본 Responses 경로는 스트림을 전달합니다. Codex `
|
|
457
|
-
`
|
|
491
|
+
기본 Responses 경로는 스트림을 전달합니다. Codex 스레드마다 `x-grok-conv-id`는
|
|
492
|
+
하나입니다. `thread-id` 헤더가 있으면 그 값이고, 없으면 `prompt_cache_key`입니다.
|
|
493
|
+
같은 id를 `prompt_cache_key`에도 넣습니다. 바뀌지 않은 트랜스크립트 접두는
|
|
494
|
+
바이트 그대로 다시 보내고, 새 항목은 그 뒤에 붙입니다.
|
|
458
495
|
|
|
459
496
|
## 확인된 동작과 제한
|
|
460
497
|
|
|
@@ -553,15 +590,14 @@ OpenAI 경로가 맞습니다.
|
|
|
553
590
|
구독자가 재로드를 막으면 추론을 보내지 않습니다. 첫 턴이 저장되기 전에 새
|
|
554
591
|
작업을 시작할 때 원하는 모델을 고르세요.
|
|
555
592
|
|
|
556
|
-
###
|
|
593
|
+
### 이번 릴리스에 없는 것
|
|
557
594
|
|
|
558
|
-
음성, 클라우드 작업, 영상 생성은
|
|
595
|
+
음성, 클라우드 작업, 영상 생성은 이번 릴리스에 없습니다.
|
|
559
596
|
|
|
560
597
|
상류가 응답 중간에 연결을 리셋하는 경우가 있습니다(실측 3건: 25초 / 27초 /
|
|
561
|
-
253초, 726 KB–22 MB).
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
재시도할 수 있습니다.
|
|
598
|
+
253초, 726 KB–22 MB). 브리지는 응답을 들고 있다가, Codex에 그 응답을 보내기
|
|
599
|
+
전에 끊기면 같은 요청을 최대 두 번 더 시도합니다. Codex가 또 보내지는
|
|
600
|
+
않습니다. 재시도하면 그 시도의 입력 토큰이 다시 과금될 수 있습니다.
|
|
565
601
|
|
|
566
602
|
## 진단 로그
|
|
567
603
|
|
|
@@ -604,7 +640,7 @@ provider에 Codex `request_max_retries` / `stream_max_retries`를 2로 두어
|
|
|
604
640
|
## 검증
|
|
605
641
|
|
|
606
642
|
```sh
|
|
607
|
-
npm test #
|
|
643
|
+
npm test # 174건, 외부 추론 없음
|
|
608
644
|
npm run test:coverage # line/branch/function 80% 게이트
|
|
609
645
|
npm run verify:app-server # 실제 app-server 라우팅. 설치된 앱 번들에서도 실행
|
|
610
646
|
npm audit --omit=dev
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-grok-bridge",
|
|
3
|
-
"version": "1.
|
|
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,13 @@ import {
|
|
|
8
8
|
parseGrokResult,
|
|
9
9
|
runGrok,
|
|
10
10
|
} from "./cli-inference.mjs";
|
|
11
|
-
import {
|
|
11
|
+
import { relayProxySse } from "./proxy.mjs";
|
|
12
|
+
import {
|
|
13
|
+
applyCacheUsage,
|
|
14
|
+
createPrefixMemory,
|
|
15
|
+
readCacheUsage,
|
|
16
|
+
stableConvId,
|
|
17
|
+
} from "./prefix.mjs";
|
|
12
18
|
import { toProxyRequest } from "./tools.mjs";
|
|
13
19
|
import { classifyBridgeError, errorSignature, bridgeErrorMessage } from "./errors.mjs";
|
|
14
20
|
import { createDiagnostics } from "./diagnostics.mjs";
|
|
@@ -36,6 +42,7 @@ export function createBridgeServer(options = {}) {
|
|
|
36
42
|
limit: options.maxConcurrentInference,
|
|
37
43
|
queueLimit: options.maxQueuedInference,
|
|
38
44
|
});
|
|
45
|
+
const prefixes = createPrefixMemory();
|
|
39
46
|
return http.createServer(async (req, res) => {
|
|
40
47
|
const json = (status, data) => {
|
|
41
48
|
res.writeHead(status, { "content-type": "application/json" });
|
|
@@ -94,8 +101,14 @@ export function createBridgeServer(options = {}) {
|
|
|
94
101
|
);
|
|
95
102
|
const id = "resp_" + randomUUID();
|
|
96
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;
|
|
97
109
|
const keepalive = setInterval(() => {
|
|
98
|
-
if (!res.destroyed
|
|
110
|
+
if (!responseStarted || res.destroyed || res.writableEnded) return;
|
|
111
|
+
res.write(": keepalive\n\n");
|
|
99
112
|
}, 10000);
|
|
100
113
|
const useCli =
|
|
101
114
|
Boolean(options.runGrok) ||
|
|
@@ -112,11 +125,13 @@ export function createBridgeServer(options = {}) {
|
|
|
112
125
|
const queuedAt = Date.now();
|
|
113
126
|
let acquired = false;
|
|
114
127
|
let queuedMs = 0;
|
|
128
|
+
let cacheUsage = null;
|
|
115
129
|
try {
|
|
116
130
|
await slots.acquire(controller.signal);
|
|
117
131
|
acquired = true;
|
|
118
132
|
queuedMs = Date.now() - queuedAt;
|
|
119
133
|
if (useCli) {
|
|
134
|
+
responseStarted = true;
|
|
120
135
|
event("response.created", { response: { id } });
|
|
121
136
|
const invocation = buildGrokInvocation(body, options);
|
|
122
137
|
invocation.threadId = req.headers["thread-id"];
|
|
@@ -135,14 +150,18 @@ export function createBridgeServer(options = {}) {
|
|
|
135
150
|
parsed.usage?.input_tokens ?? parsed.usage?.inputTokens ?? 0,
|
|
136
151
|
outputTokens =
|
|
137
152
|
parsed.usage?.output_tokens ?? parsed.usage?.outputTokens ?? 0;
|
|
153
|
+
const usage = {
|
|
154
|
+
input_tokens: inputTokens,
|
|
155
|
+
output_tokens: outputTokens,
|
|
156
|
+
total_tokens: inputTokens + outputTokens,
|
|
157
|
+
};
|
|
158
|
+
cacheUsage = readCacheUsage(parsed.usage);
|
|
159
|
+
if (cacheUsage) Object.assign(usage, cacheUsage);
|
|
160
|
+
applyCacheUsage(usage);
|
|
138
161
|
event("response.completed", {
|
|
139
162
|
response: {
|
|
140
163
|
id,
|
|
141
|
-
usage
|
|
142
|
-
input_tokens: inputTokens,
|
|
143
|
-
output_tokens: outputTokens,
|
|
144
|
-
total_tokens: inputTokens + outputTokens,
|
|
145
|
-
},
|
|
164
|
+
usage,
|
|
146
165
|
},
|
|
147
166
|
});
|
|
148
167
|
} else {
|
|
@@ -150,29 +169,49 @@ export function createBridgeServer(options = {}) {
|
|
|
150
169
|
// Grok takes input_image blocks natively; only unusable attachments
|
|
151
170
|
// are swapped for an explanation, so one bad image cannot make the
|
|
152
171
|
// upstream reject the whole conversation.
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
+
const threadId = req.headers["thread-id"];
|
|
173
|
+
const convId = stableConvId(
|
|
174
|
+
Array.isArray(threadId) ? threadId[0] : threadId,
|
|
175
|
+
body.prompt_cache_key,
|
|
176
|
+
);
|
|
177
|
+
const { request, map, projected } = toProxyRequest(sanitizeImages(body));
|
|
178
|
+
if (convId) request.prompt_cache_key = convId;
|
|
179
|
+
request.input = prefixes.reuse(convId, projected);
|
|
180
|
+
const usageBox = {};
|
|
181
|
+
try {
|
|
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
|
+
);
|
|
207
|
+
} finally {
|
|
208
|
+
cacheUsage = usageBox.cacheUsage ?? null;
|
|
209
|
+
}
|
|
172
210
|
}
|
|
173
211
|
diagnostics.record({
|
|
174
212
|
event: "turn_ok",
|
|
175
213
|
...shape,
|
|
214
|
+
...(cacheUsage ?? {}),
|
|
176
215
|
queuedMs,
|
|
177
216
|
elapsedMs: Date.now() - startedAt,
|
|
178
217
|
});
|
|
@@ -183,6 +222,7 @@ export function createBridgeServer(options = {}) {
|
|
|
183
222
|
kind,
|
|
184
223
|
signature: errorSignature(error),
|
|
185
224
|
...shape,
|
|
225
|
+
...(cacheUsage ?? {}),
|
|
186
226
|
queuedMs,
|
|
187
227
|
elapsedMs: Date.now() - startedAt,
|
|
188
228
|
detail: error?.message,
|
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([
|
|
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/prefix.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// xAI prompt cache hits only when later requests repeat the same prefix
|
|
4
|
+
// unchanged, on the same x-grok-conv-id. Codex resends the whole transcript,
|
|
5
|
+
// but a fresh rewrite (new ids, encrypted reasoning, tool index names) makes
|
|
6
|
+
// that prefix differ. This module pins the conv id and reuses the exact
|
|
7
|
+
// items already forwarded.
|
|
8
|
+
|
|
9
|
+
const CACHE_USAGE_FIELDS = [
|
|
10
|
+
"cached_prompt_tokens",
|
|
11
|
+
"cache_read_input_tokens",
|
|
12
|
+
"cache_creation_input_tokens",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const VOLATILE_KEYS = new Set([
|
|
16
|
+
"id",
|
|
17
|
+
"status",
|
|
18
|
+
"encrypted_content",
|
|
19
|
+
"encrypted_function_args",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function stableConvId(threadId, promptCacheKey) {
|
|
23
|
+
const thread = typeof threadId === "string" ? threadId.trim() : "";
|
|
24
|
+
if (thread) return thread;
|
|
25
|
+
const cache = typeof promptCacheKey === "string" ? promptCacheKey.trim() : "";
|
|
26
|
+
return cache || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function stableProxyName(spec, sanitizedName) {
|
|
30
|
+
const basis = `${spec.kind}\0${spec.namespace ?? ""}\0${spec.name ?? ""}`;
|
|
31
|
+
const hash = createHash("sha256").update(basis).digest("hex").slice(0, 8);
|
|
32
|
+
return `codex_${hash}_${sanitizedName}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeForFingerprint(value) {
|
|
36
|
+
if (Array.isArray(value)) return value.map(normalizeForFingerprint);
|
|
37
|
+
if (!value || typeof value !== "object") return value;
|
|
38
|
+
if (
|
|
39
|
+
value.type === "input_text" ||
|
|
40
|
+
value.type === "output_text" ||
|
|
41
|
+
value.type === "summary_text"
|
|
42
|
+
) {
|
|
43
|
+
return {
|
|
44
|
+
type: value.type,
|
|
45
|
+
text: typeof value.text === "string" ? value.text : "",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (value.type === "input_image") {
|
|
49
|
+
const url =
|
|
50
|
+
value.image_url && typeof value.image_url === "object"
|
|
51
|
+
? value.image_url.url
|
|
52
|
+
: value.image_url;
|
|
53
|
+
const image = { type: "input_image" };
|
|
54
|
+
if (url !== undefined) image.image_url = url;
|
|
55
|
+
if (value.detail !== undefined) image.detail = value.detail;
|
|
56
|
+
return image;
|
|
57
|
+
}
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const key of Object.keys(value).sort()) {
|
|
60
|
+
if (VOLATILE_KEYS.has(key) || key.startsWith("internal_")) continue;
|
|
61
|
+
const child = value[key];
|
|
62
|
+
if (child === undefined || child === null) continue;
|
|
63
|
+
out[key] = normalizeForFingerprint(child);
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
out.role &&
|
|
67
|
+
out.content !== undefined &&
|
|
68
|
+
(out.type == null || out.type === "message")
|
|
69
|
+
) {
|
|
70
|
+
out.type = "message";
|
|
71
|
+
if (typeof out.content === "string")
|
|
72
|
+
out.content = [{ type: "input_text", text: out.content }];
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function logicalFingerprint(item) {
|
|
78
|
+
return JSON.stringify(normalizeForFingerprint(item));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Object key order is part of the cached prefix. Codex does not promise it.
|
|
82
|
+
export function stableJsonValue(value) {
|
|
83
|
+
if (Array.isArray(value)) return value.map(stableJsonValue);
|
|
84
|
+
if (!value || typeof value !== "object") return value;
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const key of Object.keys(value).sort())
|
|
87
|
+
out[key] = stableJsonValue(value[key]);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const MAX_THREADS = 64;
|
|
92
|
+
|
|
93
|
+
export function createPrefixMemory() {
|
|
94
|
+
const threads = new Map();
|
|
95
|
+
return {
|
|
96
|
+
reuse(convId, pairs) {
|
|
97
|
+
const fresh = pairs.map((pair) => pair.item);
|
|
98
|
+
if (!convId || pairs.length === 0) return fresh;
|
|
99
|
+
const previous = threads.get(convId);
|
|
100
|
+
let reused = 0;
|
|
101
|
+
if (previous) {
|
|
102
|
+
const limit = Math.min(previous.length, pairs.length);
|
|
103
|
+
while (
|
|
104
|
+
reused < limit &&
|
|
105
|
+
previous[reused].fingerprint === pairs[reused].fingerprint
|
|
106
|
+
)
|
|
107
|
+
reused += 1;
|
|
108
|
+
}
|
|
109
|
+
const stored =
|
|
110
|
+
reused > 0
|
|
111
|
+
? [...previous.slice(0, reused), ...pairs.slice(reused)]
|
|
112
|
+
: pairs.slice();
|
|
113
|
+
if (threads.has(convId)) threads.delete(convId);
|
|
114
|
+
threads.set(
|
|
115
|
+
convId,
|
|
116
|
+
stored.map((pair) => ({
|
|
117
|
+
fingerprint: pair.fingerprint,
|
|
118
|
+
item: structuredClone(pair.item),
|
|
119
|
+
})),
|
|
120
|
+
);
|
|
121
|
+
while (threads.size > MAX_THREADS) {
|
|
122
|
+
const oldest = threads.keys().next().value;
|
|
123
|
+
threads.delete(oldest);
|
|
124
|
+
}
|
|
125
|
+
if (!previous || reused === 0) return fresh;
|
|
126
|
+
return [
|
|
127
|
+
...previous.slice(0, reused).map((pair) => structuredClone(pair.item)),
|
|
128
|
+
...pairs.slice(reused).map((pair) => pair.item),
|
|
129
|
+
];
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Copy only cache counters the upstream actually sent. Zero is a real miss.
|
|
135
|
+
// Absence is not a miss, so it must not become 0.
|
|
136
|
+
export function readCacheUsage(usage) {
|
|
137
|
+
if (!usage || typeof usage !== "object") return null;
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const key of CACHE_USAGE_FIELDS) {
|
|
140
|
+
const value = usage[key];
|
|
141
|
+
if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
|
|
142
|
+
}
|
|
143
|
+
return Object.keys(out).length ? out : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function applyCacheUsage(usage) {
|
|
147
|
+
const cache = readCacheUsage(usage);
|
|
148
|
+
if (!cache) return usage;
|
|
149
|
+
const cached =
|
|
150
|
+
typeof cache.cached_prompt_tokens === "number"
|
|
151
|
+
? cache.cached_prompt_tokens
|
|
152
|
+
: cache.cache_read_input_tokens;
|
|
153
|
+
if (typeof cached !== "number") return usage;
|
|
154
|
+
const details =
|
|
155
|
+
usage.input_tokens_details && typeof usage.input_tokens_details === "object"
|
|
156
|
+
? usage.input_tokens_details
|
|
157
|
+
: {};
|
|
158
|
+
if (typeof details.cached_tokens !== "number") {
|
|
159
|
+
details.cached_tokens = cached;
|
|
160
|
+
usage.input_tokens_details = details;
|
|
161
|
+
}
|
|
162
|
+
return usage;
|
|
163
|
+
}
|
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() :
|
|
28
|
+
cachedClientVersion = match ? match[1].trim() : UNKNOWN_CLIENT_VERSION;
|
|
24
29
|
} catch {
|
|
25
|
-
cachedClientVersion =
|
|
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
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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
|
-
|
|
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,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 =
|
|
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
|
-
|
|
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
|
|
152
|
-
buffer =
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
256
|
+
} finally {
|
|
257
|
+
if (!failed) reader.releaseLock();
|
|
258
|
+
if (usageBox) usageBox.cacheUsage = rewrite.cacheUsage ?? null;
|
|
169
259
|
}
|
|
170
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
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
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
|
};
|
package/src/tools.mjs
CHANGED
|
@@ -5,6 +5,13 @@ import {
|
|
|
5
5
|
saveGeneratedImage,
|
|
6
6
|
} from "./imagegen.mjs";
|
|
7
7
|
import { DEFAULT_GROK_MODEL, resolveGrokModel } from "./models.mjs";
|
|
8
|
+
import {
|
|
9
|
+
applyCacheUsage,
|
|
10
|
+
logicalFingerprint,
|
|
11
|
+
readCacheUsage,
|
|
12
|
+
stableJsonValue,
|
|
13
|
+
stableProxyName,
|
|
14
|
+
} from "./prefix.mjs";
|
|
8
15
|
|
|
9
16
|
const OBJECT_SCHEMA = { type: "object", properties: {} };
|
|
10
17
|
|
|
@@ -227,7 +234,14 @@ function decodeCustomInput(argumentsValue) {
|
|
|
227
234
|
}
|
|
228
235
|
|
|
229
236
|
function addFunction(flattened, map, spec) {
|
|
230
|
-
|
|
237
|
+
// Index names change when Codex reorders tools, which rewrites every
|
|
238
|
+
// earlier function_call and breaks the prompt-cache prefix.
|
|
239
|
+
let proxyName = stableProxyName(spec, sanitize(spec.name));
|
|
240
|
+
if (map.has(proxyName)) {
|
|
241
|
+
let n = 2;
|
|
242
|
+
while (map.has(`${proxyName}_${n}`)) n += 1;
|
|
243
|
+
proxyName = `${proxyName}_${n}`;
|
|
244
|
+
}
|
|
231
245
|
map.set(proxyName, {
|
|
232
246
|
kind: spec.kind,
|
|
233
247
|
namespace: spec.namespace,
|
|
@@ -239,10 +253,11 @@ function addFunction(flattened, map, spec) {
|
|
|
239
253
|
description: spec.namespace
|
|
240
254
|
? `[${spec.namespace}] ${spec.description || spec.name}`
|
|
241
255
|
: spec.description || spec.name,
|
|
242
|
-
parameters:
|
|
256
|
+
parameters: stableJsonValue(
|
|
243
257
|
spec.kind === "custom"
|
|
244
258
|
? structuredClone(CUSTOM_INPUT_SCHEMA)
|
|
245
259
|
: usableParameters(spec.parameters),
|
|
260
|
+
),
|
|
246
261
|
});
|
|
247
262
|
}
|
|
248
263
|
|
|
@@ -284,7 +299,7 @@ export function flattenCodexTools(tools = []) {
|
|
|
284
299
|
}
|
|
285
300
|
continue;
|
|
286
301
|
}
|
|
287
|
-
flattened.push(structuredClone(tool));
|
|
302
|
+
flattened.push(stableJsonValue(structuredClone(tool)));
|
|
288
303
|
}
|
|
289
304
|
return { tools: flattened, map };
|
|
290
305
|
}
|
|
@@ -392,17 +407,36 @@ function toProxyInputNode(node, map, state) {
|
|
|
392
407
|
return whitelistInputNode(next);
|
|
393
408
|
}
|
|
394
409
|
|
|
395
|
-
function
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
(item.type == null || GROK_INPUT_ITEM_TYPES.has(item.type)),
|
|
410
|
+
function isForwardedItem(item) {
|
|
411
|
+
return (
|
|
412
|
+
item &&
|
|
413
|
+
item !== DROP &&
|
|
414
|
+
typeof item === "object" &&
|
|
415
|
+
!Array.isArray(item) &&
|
|
416
|
+
(item.type == null || GROK_INPUT_ITEM_TYPES.has(item.type))
|
|
403
417
|
);
|
|
404
418
|
}
|
|
405
419
|
|
|
420
|
+
function projectInput(input, map) {
|
|
421
|
+
if (!Array.isArray(input)) {
|
|
422
|
+
const items = toProxyInputNode(input, map, { callIds: new Map() });
|
|
423
|
+
return { items, pairs: [] };
|
|
424
|
+
}
|
|
425
|
+
const state = { callIds: new Map() };
|
|
426
|
+
const pairs = [];
|
|
427
|
+
for (const item of input) {
|
|
428
|
+
const fingerprint = logicalFingerprint(item);
|
|
429
|
+
const next = toProxyInputNode(item, map, state);
|
|
430
|
+
if (!isForwardedItem(next)) continue;
|
|
431
|
+
pairs.push({ fingerprint, item: next });
|
|
432
|
+
}
|
|
433
|
+
return { items: pairs.map((pair) => pair.item), pairs };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function toProxyInput(input, map) {
|
|
437
|
+
return projectInput(input, map).items;
|
|
438
|
+
}
|
|
439
|
+
|
|
406
440
|
// Only the bridge knows a request is being served by the bridge. Codex does not
|
|
407
441
|
// put the provider or model into the prompt, so without this line the model has
|
|
408
442
|
// no way to answer "is Grok actually attached?" and either hedges or goes
|
|
@@ -432,9 +466,10 @@ export const IMAGE_GENERATION_PROVENANCE =
|
|
|
432
466
|
export function toProxyRequest(body) {
|
|
433
467
|
const { tools, map } = flattenCodexTools(body.tools ?? []);
|
|
434
468
|
const effort = EFFORT[body.reasoning?.effort] ?? "high";
|
|
469
|
+
const projected = projectInput(body.input, map);
|
|
435
470
|
const request = {
|
|
436
471
|
model: resolveGrokModel(body.model),
|
|
437
|
-
input:
|
|
472
|
+
input: projected.items,
|
|
438
473
|
tools,
|
|
439
474
|
reasoning: { effort },
|
|
440
475
|
stream: true,
|
|
@@ -463,7 +498,7 @@ export function toProxyRequest(body) {
|
|
|
463
498
|
: provenance;
|
|
464
499
|
if (typeof body.prompt_cache_key === "string" && body.prompt_cache_key)
|
|
465
500
|
request.prompt_cache_key = body.prompt_cache_key;
|
|
466
|
-
return { request, map };
|
|
501
|
+
return { request, map, projected: projected.pairs };
|
|
467
502
|
}
|
|
468
503
|
|
|
469
504
|
function rememberProxyItem(item, origin, state) {
|
|
@@ -508,8 +543,19 @@ function absorbGeneratedImage(item, state) {
|
|
|
508
543
|
};
|
|
509
544
|
}
|
|
510
545
|
|
|
546
|
+
const USAGE_EVENTS = new Set(["response.completed", "response.incomplete"]);
|
|
547
|
+
|
|
511
548
|
function rewriteResponseEvent(value, map, state) {
|
|
512
549
|
if (!value || typeof value !== "object") return value;
|
|
550
|
+
if (
|
|
551
|
+
USAGE_EVENTS.has(value.type) &&
|
|
552
|
+
value.response &&
|
|
553
|
+
typeof value.response.usage === "object"
|
|
554
|
+
) {
|
|
555
|
+
applyCacheUsage(value.response.usage);
|
|
556
|
+
const cache = readCacheUsage(value.response.usage);
|
|
557
|
+
if (cache) state.cacheUsage = cache;
|
|
558
|
+
}
|
|
513
559
|
if (
|
|
514
560
|
typeof value.item === "object" &&
|
|
515
561
|
value.item &&
|
|
@@ -569,6 +615,13 @@ export function createSseRewriter(map, options = {}) {
|
|
|
569
615
|
callIds: new Map(),
|
|
570
616
|
itemIds: new Map(),
|
|
571
617
|
imageOptions: options.imageOptions,
|
|
618
|
+
cacheUsage: null,
|
|
572
619
|
};
|
|
573
|
-
|
|
620
|
+
const rewrite = (block) => rewriteSseBlock(block, map, state);
|
|
621
|
+
Object.defineProperty(rewrite, "cacheUsage", {
|
|
622
|
+
get() {
|
|
623
|
+
return state.cacheUsage;
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
return rewrite;
|
|
574
627
|
}
|