@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-preview.20260707
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/README.md +1 -0
- package/bin/ocx.mjs +4 -4
- package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
- package/gui/dist/assets/index-CkV5xFA8.js +15 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/anthropic-image-guard.ts +195 -0
- package/src/adapters/anthropic.ts +85 -14
- package/src/adapters/cursor/cursor-errors.ts +2 -2
- package/src/adapters/cursor/live-transport.ts +1 -1
- package/src/adapters/cursor/transport-retry.ts +2 -2
- package/src/adapters/google-errors.ts +1 -1
- package/src/adapters/google-http.ts +1 -1
- package/src/adapters/google-truncation.ts +1 -1
- package/src/adapters/google.ts +1 -1
- package/src/adapters/kiro-errors.ts +1 -1
- package/src/adapters/kiro-retry.ts +1 -1
- package/src/adapters/kiro-truncation.ts +1 -1
- package/src/adapters/kiro.ts +1 -1
- package/src/adapters/openai-chat.ts +12 -2
- package/src/adapters/openai-responses.ts +126 -3
- package/src/bridge.ts +164 -7
- package/src/{doctor.ts → cli/doctor.ts} +21 -4
- package/src/{cli-help.ts → cli/help.ts} +1 -1
- package/src/cli/index.ts +584 -0
- package/src/{init.ts → cli/init.ts} +6 -6
- package/src/{cli-models.ts → cli/models.ts} +2 -2
- package/src/{cli-provider.ts → cli/provider.ts} +12 -8
- package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
- package/src/{cli-status.ts → cli/status.ts} +7 -7
- package/src/cli.ts +9 -575
- package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
- package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
- package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
- package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
- package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
- package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
- package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
- package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
- package/src/codex/history-migration-guardian.ts +102 -0
- package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
- package/src/{codex-home.ts → codex/home.ts} +1 -1
- package/src/{codex-inject.ts → codex/inject.ts} +204 -26
- package/src/{codex-journal.ts → codex/journal.ts} +2 -2
- package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
- package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
- package/src/{codex-paths.ts → codex/paths.ts} +2 -2
- package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
- package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
- package/src/{codex-routing.ts → codex/routing.ts} +8 -8
- package/src/{codex-shim.ts → codex/shim.ts} +6 -5
- package/src/{codex-sync.ts → codex/sync.ts} +4 -4
- package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
- package/src/config.ts +2 -0
- package/src/generated/jawcode-model-metadata.ts +2 -0
- package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
- package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
- package/src/{process-control.ts → lib/process-control.ts} +1 -1
- package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
- package/src/oauth/callback-server.ts +1 -1
- package/src/oauth/google-antigravity.ts +7 -4
- package/src/oauth/index.ts +67 -16
- package/src/oauth/login-cli.ts +2 -2
- package/src/oauth/store.ts +236 -20
- package/src/oauth/token-guardian.ts +24 -20
- package/src/oauth/types.ts +16 -0
- package/src/providers/api-keys.ts +121 -0
- package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
- package/src/providers/derive.ts +2 -0
- package/src/providers/key-failover.ts +145 -0
- package/src/{provider-label.ts → providers/label.ts} +1 -1
- package/src/{provider-quota.ts → providers/quota.ts} +12 -7
- package/src/providers/registry.ts +66 -4
- package/src/responses/compaction.ts +117 -0
- package/src/responses/parser.ts +89 -13
- package/src/responses/reasoning-envelope.ts +52 -0
- package/src/responses/schema.ts +15 -3
- package/src/responses/state.ts +117 -2
- package/src/router.ts +2 -0
- package/src/server/auth-cors.ts +231 -0
- package/src/server/index.ts +523 -0
- package/src/server/lifecycle.ts +73 -0
- package/src/server/management-api.ts +628 -0
- package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
- package/src/server/relay.ts +534 -0
- package/src/server/request-decompress.ts +46 -0
- package/src/server/request-log.ts +310 -0
- package/src/server/responses.ts +775 -0
- package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
- package/src/service.ts +19 -11
- package/src/types.ts +27 -0
- package/src/{update.ts → update/index.ts} +5 -5
- package/src/{update-job.ts → update/job.ts} +7 -6
- package/src/{update-notify.ts → update/notify.ts} +3 -3
- package/src/{usage-debug.ts → usage/debug.ts} +3 -3
- package/src/{usage-log.ts → usage/log.ts} +3 -3
- package/src/{usage-summary.ts → usage/summary.ts} +3 -3
- package/src/{usage-totals.ts → usage/totals.ts} +1 -1
- package/src/vision/describe.ts +3 -3
- package/src/vision/index.ts +21 -1
- package/src/web-search/executor.ts +4 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +84 -24
- package/gui/dist/assets/index-BcHhxo1I.css +0 -1
- package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
- package/src/server.ts +0 -2501
- /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
- /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
- /package/src/{abort.ts → lib/abort.ts} +0 -0
- /package/src/{debug.ts → lib/debug.ts} +0 -0
- /package/src/{errors.ts → lib/errors.ts} +0 -0
- /package/src/{open-url.ts → lib/open-url.ts} +0 -0
- /package/src/{privacy.ts → lib/privacy.ts} +0 -0
- /package/src/{redact.ts → lib/redact.ts} +0 -0
- /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
- /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
- /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
- /package/src/{ports.ts → server/ports.ts} +0 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import type { ResponsesTerminalStatus } from "../bridge";
|
|
2
|
+
import { isUsageDebugEnabled } from "../usage/debug";
|
|
3
|
+
import {
|
|
4
|
+
addRequestLog,
|
|
5
|
+
addFinalRequestLog,
|
|
6
|
+
httpStatusForTerminalStatus,
|
|
7
|
+
inspectResponseLogJson,
|
|
8
|
+
inspectResponseLogSsePayload,
|
|
9
|
+
type RequestLogContext,
|
|
10
|
+
type RequestLogEntry,
|
|
11
|
+
} from "./request-log";
|
|
12
|
+
|
|
13
|
+
const nativePassthroughSseResponses = new WeakSet<Response>();
|
|
14
|
+
|
|
15
|
+
export function relayWithAbort(
|
|
16
|
+
body: ReadableStream<Uint8Array> | null,
|
|
17
|
+
upstream: AbortController,
|
|
18
|
+
): ReadableStream<Uint8Array> | null {
|
|
19
|
+
if (!body) return null;
|
|
20
|
+
const reader = body.getReader();
|
|
21
|
+
return new ReadableStream<Uint8Array>({
|
|
22
|
+
async pull(controller) {
|
|
23
|
+
try {
|
|
24
|
+
const { done, value } = await reader.read();
|
|
25
|
+
if (done) {
|
|
26
|
+
controller.close();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
controller.enqueue(value);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
try { controller.error(err); } catch { /* already torn down */ }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
cancel(reason) {
|
|
35
|
+
// Client disconnected: abort the upstream fetch and release the reader so we do not leak it.
|
|
36
|
+
upstream.abort(reason);
|
|
37
|
+
reader.cancel(reason).catch(() => {});
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Relay a passthrough SSE body like relayWithAbort, but convert a MID-STREAM failure (upstream
|
|
44
|
+
* reset after headers) into a clean terminal: any partial block is closed off, then a synthetic
|
|
45
|
+
* `response.failed` event and `data: [DONE]` are emitted and the stream closes. Without this the
|
|
46
|
+
* client sees a raw socket teardown with no terminal SSE event. Deliberately NOT a resend: the
|
|
47
|
+
* upstream already committed the request (duplicate-completion risk — same policy as cursor's
|
|
48
|
+
* committed=non-replayable transport retry).
|
|
49
|
+
*/
|
|
50
|
+
export function relaySseWithFailedTail(
|
|
51
|
+
body: ReadableStream<Uint8Array>,
|
|
52
|
+
upstream: AbortController,
|
|
53
|
+
): ReadableStream<Uint8Array> {
|
|
54
|
+
const reader = body.getReader();
|
|
55
|
+
const encoder = new TextEncoder();
|
|
56
|
+
return new ReadableStream<Uint8Array>({
|
|
57
|
+
async pull(controller) {
|
|
58
|
+
try {
|
|
59
|
+
const { done, value } = await reader.read();
|
|
60
|
+
if (done) {
|
|
61
|
+
controller.close();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
controller.enqueue(value);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const failure = {
|
|
67
|
+
type: "upstream_error",
|
|
68
|
+
code: "upstream_reset",
|
|
69
|
+
message: `Upstream stream terminated unexpectedly: ${err instanceof Error ? err.message : String(err)}`,
|
|
70
|
+
};
|
|
71
|
+
const payload = JSON.stringify({
|
|
72
|
+
type: "response.failed",
|
|
73
|
+
response: { status: "failed", error: failure, last_error: failure },
|
|
74
|
+
});
|
|
75
|
+
try {
|
|
76
|
+
// Leading blank line terminates a partial SSE block so the failed frame parses cleanly.
|
|
77
|
+
controller.enqueue(encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\ndata: [DONE]\n\n`));
|
|
78
|
+
controller.close();
|
|
79
|
+
} catch { /* client already torn down */ }
|
|
80
|
+
upstream.abort();
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
cancel(reason) {
|
|
84
|
+
upstream.abort(reason);
|
|
85
|
+
reader.cancel(reason).catch(() => {});
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function nextSseBlock(buffer: string): { block: string; rest: string } | null {
|
|
91
|
+
const match = buffer.match(/\r?\n\r?\n/);
|
|
92
|
+
if (!match || match.index === undefined) return null;
|
|
93
|
+
return {
|
|
94
|
+
block: buffer.slice(0, match.index),
|
|
95
|
+
rest: buffer.slice(match.index + match[0].length),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function sseDataPayload(block: string): string | null {
|
|
100
|
+
const data: string[] = [];
|
|
101
|
+
for (const line of block.split(/\r?\n/)) {
|
|
102
|
+
if (!line.startsWith("data:")) continue;
|
|
103
|
+
const value = line.slice(5);
|
|
104
|
+
data.push(value.startsWith(" ") ? value.slice(1) : value);
|
|
105
|
+
}
|
|
106
|
+
return data.length > 0 ? data.join("\n") : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function terminalStatusFromSsePayload(payload: string): ResponsesTerminalStatus | null {
|
|
110
|
+
if (payload === "[DONE]") return null;
|
|
111
|
+
try {
|
|
112
|
+
const json = JSON.parse(payload) as { type?: unknown };
|
|
113
|
+
switch (json.type) {
|
|
114
|
+
case "response.completed":
|
|
115
|
+
return "completed";
|
|
116
|
+
case "response.failed":
|
|
117
|
+
return "failed";
|
|
118
|
+
case "response.incomplete":
|
|
119
|
+
return "incomplete";
|
|
120
|
+
default:
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Extract the response object from a `response.completed` SSE payload, or null. */
|
|
129
|
+
export function completedResponseFromSsePayload(payload: string): { id?: unknown; output?: unknown; status?: unknown } | null {
|
|
130
|
+
if (payload === "[DONE]") return null;
|
|
131
|
+
try {
|
|
132
|
+
const json = JSON.parse(payload) as { type?: unknown; response?: unknown };
|
|
133
|
+
if (json.type !== "response.completed") return null;
|
|
134
|
+
const response = json.response;
|
|
135
|
+
if (!response || typeof response !== "object" || Array.isArray(response)) return null;
|
|
136
|
+
return response as { id?: unknown; output?: unknown; status?: unknown };
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function trackSseForRequestLog(
|
|
143
|
+
body: ReadableStream<Uint8Array>,
|
|
144
|
+
onTerminal: (status: ResponsesTerminalStatus) => void,
|
|
145
|
+
onCancel: () => void,
|
|
146
|
+
logCtx?: RequestLogContext,
|
|
147
|
+
): ReadableStream<Uint8Array> {
|
|
148
|
+
const reader = body.getReader();
|
|
149
|
+
const decoder = new TextDecoder();
|
|
150
|
+
let buffer = "";
|
|
151
|
+
let terminalReported = false;
|
|
152
|
+
|
|
153
|
+
const reportTerminal = (status: ResponsesTerminalStatus) => {
|
|
154
|
+
if (terminalReported) return;
|
|
155
|
+
terminalReported = true;
|
|
156
|
+
onTerminal(status);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const inspectPayload = (payload: string | null) => {
|
|
160
|
+
if (!payload) return;
|
|
161
|
+
if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
162
|
+
const status = terminalStatusFromSsePayload(payload);
|
|
163
|
+
if (status) reportTerminal(status);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const inspectChunk = (value: Uint8Array) => {
|
|
167
|
+
buffer += decoder.decode(value, { stream: true });
|
|
168
|
+
let next: { block: string; rest: string } | null;
|
|
169
|
+
while ((next = nextSseBlock(buffer))) {
|
|
170
|
+
buffer = next.rest;
|
|
171
|
+
inspectPayload(sseDataPayload(next.block));
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return new ReadableStream<Uint8Array>({
|
|
176
|
+
async pull(controller) {
|
|
177
|
+
try {
|
|
178
|
+
const { done, value } = await reader.read();
|
|
179
|
+
if (done) {
|
|
180
|
+
buffer += decoder.decode();
|
|
181
|
+
if (buffer.trim()) inspectPayload(sseDataPayload(buffer));
|
|
182
|
+
if (!terminalReported) reportTerminal("incomplete");
|
|
183
|
+
controller.close();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
inspectChunk(value);
|
|
187
|
+
controller.enqueue(value);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (!terminalReported) reportTerminal("incomplete");
|
|
190
|
+
try { controller.error(err); } catch { /* already torn down */ }
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
cancel(reason) {
|
|
194
|
+
onCancel();
|
|
195
|
+
reader.cancel(reason).catch(() => {});
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function responseWithDeferredRequestLog(
|
|
201
|
+
response: Response,
|
|
202
|
+
requestId: string,
|
|
203
|
+
start: number,
|
|
204
|
+
logCtx: RequestLogContext,
|
|
205
|
+
addLog: (entry: RequestLogEntry) => void = addRequestLog,
|
|
206
|
+
): Response {
|
|
207
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
208
|
+
if (isUsageDebugEnabled() && !logCtx.usageDebugContentType && contentType) {
|
|
209
|
+
logCtx.usageDebugContentType = contentType;
|
|
210
|
+
}
|
|
211
|
+
if (isNativePassthroughSseResponse(response)) {
|
|
212
|
+
return response;
|
|
213
|
+
}
|
|
214
|
+
if (!response.body || !contentType.includes("text/event-stream")) {
|
|
215
|
+
if (response.body && contentType.includes("application/json")) {
|
|
216
|
+
const finalizeJsonLog = async () => {
|
|
217
|
+
const text = await response.text();
|
|
218
|
+
inspectResponseLogJson(logCtx, text);
|
|
219
|
+
addFinalRequestLog(requestId, start, logCtx, response.status, { closeReason: "non_stream" }, addLog);
|
|
220
|
+
return text;
|
|
221
|
+
};
|
|
222
|
+
const body = new ReadableStream<Uint8Array>({
|
|
223
|
+
async start(controller) {
|
|
224
|
+
try {
|
|
225
|
+
controller.enqueue(new TextEncoder().encode(await finalizeJsonLog()));
|
|
226
|
+
controller.close();
|
|
227
|
+
} catch (err) {
|
|
228
|
+
addFinalRequestLog(requestId, start, logCtx, 502, { closeReason: "non_stream" }, addLog);
|
|
229
|
+
try { controller.error(err); } catch { /* already torn down */ }
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
return new Response(body, {
|
|
234
|
+
status: response.status,
|
|
235
|
+
statusText: response.statusText,
|
|
236
|
+
headers: response.headers,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
|
|
240
|
+
logCtx.usageDebugBodyKind = response.body ? "other" : "none";
|
|
241
|
+
}
|
|
242
|
+
addFinalRequestLog(requestId, start, logCtx, response.status, { closeReason: "non_stream" }, addLog);
|
|
243
|
+
return response;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let logged = false;
|
|
247
|
+
const body = trackSseForRequestLog(
|
|
248
|
+
response.body,
|
|
249
|
+
status => {
|
|
250
|
+
if (logged) return;
|
|
251
|
+
logged = true;
|
|
252
|
+
addFinalRequestLog(requestId, start, logCtx, httpStatusForTerminalStatus(status), {
|
|
253
|
+
terminalStatus: status,
|
|
254
|
+
closeReason: "terminal",
|
|
255
|
+
}, addLog);
|
|
256
|
+
},
|
|
257
|
+
() => {
|
|
258
|
+
if (logged) return;
|
|
259
|
+
logged = true;
|
|
260
|
+
addFinalRequestLog(requestId, start, logCtx, 499, { closeReason: "client_cancel" }, addLog);
|
|
261
|
+
},
|
|
262
|
+
logCtx,
|
|
263
|
+
);
|
|
264
|
+
return new Response(body, {
|
|
265
|
+
status: response.status,
|
|
266
|
+
statusText: response.statusText,
|
|
267
|
+
headers: response.headers,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function markNativePassthroughSseResponse(response: Response): Response {
|
|
272
|
+
nativePassthroughSseResponses.add(response);
|
|
273
|
+
return response;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function isNativePassthroughSseResponse(response: Response): boolean {
|
|
277
|
+
return nativePassthroughSseResponses.has(response);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function relaySseWithHeartbeat(
|
|
281
|
+
body: ReadableStream<Uint8Array> | null,
|
|
282
|
+
upstream: AbortController,
|
|
283
|
+
heartbeatMs = 15_000,
|
|
284
|
+
onTerminal?: (status: ResponsesTerminalStatus) => void,
|
|
285
|
+
options?: { onStart?: () => void; onDone?: () => void },
|
|
286
|
+
): ReadableStream<Uint8Array> | null {
|
|
287
|
+
if (!body) return null;
|
|
288
|
+
const reader = body.getReader();
|
|
289
|
+
const decoder = new TextDecoder();
|
|
290
|
+
const heartbeat = new TextEncoder().encode(": opencodex keepalive\n\n");
|
|
291
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
292
|
+
let closed = false;
|
|
293
|
+
let clientCancelled = false;
|
|
294
|
+
let terminalReported = false;
|
|
295
|
+
let buffer = "";
|
|
296
|
+
|
|
297
|
+
const reportTerminal = (status: ResponsesTerminalStatus) => {
|
|
298
|
+
if (terminalReported || clientCancelled || closed) return;
|
|
299
|
+
terminalReported = true;
|
|
300
|
+
onTerminal?.(status);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const inspectPayload = (payload: string | null) => {
|
|
304
|
+
if (!payload) return;
|
|
305
|
+
const status = terminalStatusFromSsePayload(payload);
|
|
306
|
+
if (status) reportTerminal(status);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const inspectChunk = (value: Uint8Array) => {
|
|
310
|
+
buffer += decoder.decode(value, { stream: true });
|
|
311
|
+
let next: { block: string; rest: string } | null;
|
|
312
|
+
while ((next = nextSseBlock(buffer))) {
|
|
313
|
+
buffer = next.rest;
|
|
314
|
+
inspectPayload(sseDataPayload(next.block));
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const cleanup = () => {
|
|
319
|
+
if (closed) return;
|
|
320
|
+
closed = true;
|
|
321
|
+
if (timer) clearInterval(timer);
|
|
322
|
+
timer = undefined;
|
|
323
|
+
options?.onDone?.();
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return new ReadableStream<Uint8Array>({
|
|
327
|
+
start(controller) {
|
|
328
|
+
options?.onStart?.();
|
|
329
|
+
timer = setInterval(() => {
|
|
330
|
+
if (closed) return;
|
|
331
|
+
try {
|
|
332
|
+
controller.enqueue(heartbeat);
|
|
333
|
+
} catch {
|
|
334
|
+
cleanup();
|
|
335
|
+
}
|
|
336
|
+
}, heartbeatMs);
|
|
337
|
+
},
|
|
338
|
+
async pull(controller) {
|
|
339
|
+
try {
|
|
340
|
+
const { done, value } = await reader.read();
|
|
341
|
+
if (done) {
|
|
342
|
+
buffer += decoder.decode();
|
|
343
|
+
if (buffer.trim()) inspectPayload(sseDataPayload(buffer));
|
|
344
|
+
if (!terminalReported && !clientCancelled) reportTerminal("incomplete");
|
|
345
|
+
cleanup();
|
|
346
|
+
controller.close();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
inspectChunk(value);
|
|
350
|
+
controller.enqueue(value);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
if (!clientCancelled) reportTerminal("incomplete");
|
|
353
|
+
cleanup();
|
|
354
|
+
try { controller.error(err); } catch { /* already torn down */ }
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
cancel(reason) {
|
|
358
|
+
clientCancelled = true;
|
|
359
|
+
cleanup();
|
|
360
|
+
upstream.abort(reason);
|
|
361
|
+
reader.cancel(reason).catch(() => {});
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Background-consume an SSE stream purely for terminal-outcome inspection (quota tracking).
|
|
368
|
+
* Does not produce output; safe to ignore errors (the client-facing stream is separate).
|
|
369
|
+
*/
|
|
370
|
+
export function consumeForInspection(
|
|
371
|
+
body: ReadableStream<Uint8Array>,
|
|
372
|
+
onTerminal: (status: ResponsesTerminalStatus) => void,
|
|
373
|
+
signal?: AbortSignal,
|
|
374
|
+
onDone?: () => void,
|
|
375
|
+
logCtx?: RequestLogContext,
|
|
376
|
+
onCancel?: () => void,
|
|
377
|
+
onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void,
|
|
378
|
+
): void {
|
|
379
|
+
const reader = body.getReader();
|
|
380
|
+
const decoder = new TextDecoder();
|
|
381
|
+
let buffer = "";
|
|
382
|
+
let reported = false;
|
|
383
|
+
let cancelled = false;
|
|
384
|
+
if (signal) {
|
|
385
|
+
if (signal.aborted) {
|
|
386
|
+
// Aborted before we could read anything (Codex disconnects the instant it finishes reading).
|
|
387
|
+
// Finalize as a client-cancel and release the turn — the early return skips pump()'s finally,
|
|
388
|
+
// so onDone/onCancel must run here or the entry is silently dropped (#44).
|
|
389
|
+
cancelled = true;
|
|
390
|
+
reader.cancel(signal.reason).catch(() => {});
|
|
391
|
+
onCancel?.();
|
|
392
|
+
onDone?.();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
signal.addEventListener("abort", () => {
|
|
396
|
+
// Mid-drain disconnect: record a client-cancel entry (idempotent downstream) instead of the
|
|
397
|
+
// suppressed onTerminal path. onDone still fires via pump()'s finally after the read rejects.
|
|
398
|
+
cancelled = true;
|
|
399
|
+
reader.cancel(signal.reason).catch(() => {});
|
|
400
|
+
onCancel?.();
|
|
401
|
+
}, { once: true });
|
|
402
|
+
}
|
|
403
|
+
const pump = async () => {
|
|
404
|
+
try {
|
|
405
|
+
for (;;) {
|
|
406
|
+
const { done, value } = await reader.read();
|
|
407
|
+
if (done) {
|
|
408
|
+
buffer += decoder.decode();
|
|
409
|
+
if (buffer.trim() && !reported) {
|
|
410
|
+
const payload = sseDataPayload(buffer);
|
|
411
|
+
if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
412
|
+
if (payload) {
|
|
413
|
+
const status = terminalStatusFromSsePayload(payload);
|
|
414
|
+
if (status) { reported = true; onTerminal(status); }
|
|
415
|
+
if (onCompletedResponse) {
|
|
416
|
+
const response = completedResponseFromSsePayload(payload);
|
|
417
|
+
if (response) onCompletedResponse(response);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (!reported && !cancelled) onTerminal("incomplete");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
buffer += decoder.decode(value, { stream: true });
|
|
425
|
+
let next: { block: string; rest: string } | null;
|
|
426
|
+
while ((next = nextSseBlock(buffer))) {
|
|
427
|
+
buffer = next.rest;
|
|
428
|
+
if (reported && !onCompletedResponse) continue;
|
|
429
|
+
const payload = sseDataPayload(next.block);
|
|
430
|
+
if (!reported && logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
431
|
+
if (!payload) continue;
|
|
432
|
+
if (!reported) {
|
|
433
|
+
const status = terminalStatusFromSsePayload(payload);
|
|
434
|
+
if (status) { reported = true; onTerminal(status); }
|
|
435
|
+
}
|
|
436
|
+
if (onCompletedResponse) {
|
|
437
|
+
const response = completedResponseFromSsePayload(payload);
|
|
438
|
+
if (response) onCompletedResponse(response);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
if (!reported && !cancelled) onTerminal("incomplete");
|
|
444
|
+
} finally {
|
|
445
|
+
onDone?.();
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
pump();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function consumeForResponseLogMetadata(
|
|
452
|
+
body: ReadableStream<Uint8Array>,
|
|
453
|
+
logCtx: RequestLogContext,
|
|
454
|
+
signal?: AbortSignal,
|
|
455
|
+
onDone?: () => void,
|
|
456
|
+
onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void,
|
|
457
|
+
): void {
|
|
458
|
+
const reader = body.getReader();
|
|
459
|
+
const decoder = new TextDecoder();
|
|
460
|
+
let buffer = "";
|
|
461
|
+
if (signal) {
|
|
462
|
+
if (signal.aborted) {
|
|
463
|
+
reader.cancel(signal.reason).catch(() => {});
|
|
464
|
+
onDone?.();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
signal.addEventListener("abort", () => {
|
|
468
|
+
reader.cancel(signal.reason).catch(() => {});
|
|
469
|
+
}, { once: true });
|
|
470
|
+
}
|
|
471
|
+
const pump = async () => {
|
|
472
|
+
try {
|
|
473
|
+
for (;;) {
|
|
474
|
+
const { done, value } = await reader.read();
|
|
475
|
+
if (done) {
|
|
476
|
+
buffer += decoder.decode();
|
|
477
|
+
if (buffer.trim()) {
|
|
478
|
+
const payload = sseDataPayload(buffer);
|
|
479
|
+
inspectResponseLogSsePayload(logCtx, payload);
|
|
480
|
+
if (payload && onCompletedResponse) {
|
|
481
|
+
const response = completedResponseFromSsePayload(payload);
|
|
482
|
+
if (response) onCompletedResponse(response);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
buffer += decoder.decode(value, { stream: true });
|
|
488
|
+
let next: { block: string; rest: string } | null;
|
|
489
|
+
while ((next = nextSseBlock(buffer))) {
|
|
490
|
+
buffer = next.rest;
|
|
491
|
+
const payload = sseDataPayload(next.block);
|
|
492
|
+
inspectResponseLogSsePayload(logCtx, payload);
|
|
493
|
+
if (payload && onCompletedResponse) {
|
|
494
|
+
const response = completedResponseFromSsePayload(payload);
|
|
495
|
+
if (response) onCompletedResponse(response);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
} catch {
|
|
500
|
+
/* metadata inspection must not affect the client-facing stream */
|
|
501
|
+
} finally {
|
|
502
|
+
onDone?.();
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
pump();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
|
|
510
|
+
* (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
|
|
511
|
+
* body makes the caller (Codex) double-decode / truncate → "stream error" on every gpt passthrough.
|
|
512
|
+
* Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim.
|
|
513
|
+
*/
|
|
514
|
+
export function sanitizePassthroughHeaders(upstream: Headers): Headers {
|
|
515
|
+
const DROP = new Set([
|
|
516
|
+
"content-encoding",
|
|
517
|
+
"content-length",
|
|
518
|
+
"transfer-encoding",
|
|
519
|
+
"connection",
|
|
520
|
+
"keep-alive",
|
|
521
|
+
"proxy-authenticate",
|
|
522
|
+
"proxy-authorization",
|
|
523
|
+
"set-cookie",
|
|
524
|
+
"set-cookie2",
|
|
525
|
+
"te",
|
|
526
|
+
"trailer",
|
|
527
|
+
"upgrade",
|
|
528
|
+
]);
|
|
529
|
+
const out = new Headers();
|
|
530
|
+
upstream.forEach((value, key) => {
|
|
531
|
+
if (!DROP.has(key.toLowerCase())) out.set(key, value);
|
|
532
|
+
});
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-body decompression for the /v1/responses data plane.
|
|
3
|
+
*
|
|
4
|
+
* Codex CLI compresses Responses HTTP bodies with zstd when its
|
|
5
|
+
* `enable_request_compression` feature fires (default ON): auth is the codex
|
|
6
|
+
* backend AND the provider is the built-in `openai` id (codex-rs client.rs
|
|
7
|
+
* responses_request_compression). Under Design B injection the provider id IS
|
|
8
|
+
* `openai`, so the HTTP fallback path (WebSocket unavailable) delivers
|
|
9
|
+
* `content-encoding: zstd` bodies that `req.json()` cannot parse.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Cap decompressed request bodies (a compressed bomb must not inflate unbounded). */
|
|
13
|
+
export const MAX_DECOMPRESSED_BODY_BYTES = 64 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export class UnsupportedContentEncodingError extends Error {
|
|
16
|
+
constructor(readonly encoding: string) {
|
|
17
|
+
super(`Unsupported content-encoding: ${encoding}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class DecompressedBodyTooLargeError extends Error {
|
|
22
|
+
constructor(readonly bytes: number) {
|
|
23
|
+
super(`Decompressed request body exceeds ${MAX_DECOMPRESSED_BODY_BYTES} bytes`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function decodeRequestBody(raw: Uint8Array<ArrayBuffer>, contentEncoding: string | null): Uint8Array {
|
|
28
|
+
const encoding = (contentEncoding ?? "").trim().toLowerCase();
|
|
29
|
+
if (encoding === "" || encoding === "identity") return raw;
|
|
30
|
+
let decoded: Uint8Array;
|
|
31
|
+
if (encoding === "zstd") decoded = Bun.zstdDecompressSync(raw);
|
|
32
|
+
else if (encoding === "gzip" || encoding === "x-gzip") decoded = Bun.gunzipSync(raw);
|
|
33
|
+
else if (encoding === "deflate") decoded = Bun.inflateSync(raw);
|
|
34
|
+
// Multi-codings ("zstd, gzip") and unknown tokens are rejected rather than guessed.
|
|
35
|
+
else throw new UnsupportedContentEncodingError(encoding);
|
|
36
|
+
if (decoded.byteLength > MAX_DECOMPRESSED_BODY_BYTES) throw new DecompressedBodyTooLargeError(decoded.byteLength);
|
|
37
|
+
return decoded;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parse a JSON request body, transparently decoding compressed payloads. */
|
|
41
|
+
export async function readJsonRequestBody(req: Request): Promise<unknown> {
|
|
42
|
+
const encoding = req.headers.get("content-encoding");
|
|
43
|
+
if (!encoding || encoding.trim().toLowerCase() === "identity") return await req.json();
|
|
44
|
+
const decoded = decodeRequestBody(new Uint8Array(await req.arrayBuffer()), encoding);
|
|
45
|
+
return JSON.parse(new TextDecoder().decode(decoded));
|
|
46
|
+
}
|