@wlv-zedd/dsh-chatgpt-web 1.0.0 → 1.0.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.
Files changed (81) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +140 -140
  3. package/cordis.patch.yml +4 -4
  4. package/lib/cli.js +13269 -11860
  5. package/lib/plugin.js +3 -50
  6. package/package.json +89 -88
  7. package/src/adapters/base.ts +16 -16
  8. package/src/adapters/chatgpt-web/adapter-error.ts +59 -59
  9. package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -513
  10. package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -27
  11. package/src/adapters/chatgpt-web/browser-worker.ts +4944 -4944
  12. package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -628
  13. package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -533
  14. package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -142
  15. package/src/adapters/chatgpt-web/concurrency.ts +6 -6
  16. package/src/adapters/chatgpt-web/conversation-key.ts +58 -58
  17. package/src/adapters/chatgpt-web/environment.ts +669 -669
  18. package/src/adapters/chatgpt-web/index.ts +1544 -1544
  19. package/src/adapters/chatgpt-web/input-tokens.ts +74 -74
  20. package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -695
  21. package/src/adapters/chatgpt-web/markdown.ts +418 -418
  22. package/src/adapters/chatgpt-web/mcp-main.ts +25 -25
  23. package/src/adapters/chatgpt-web/mcp-server.ts +933 -933
  24. package/src/adapters/chatgpt-web/model.ts +70 -70
  25. package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -74
  26. package/src/adapters/chatgpt-web/output-validation.ts +62 -62
  27. package/src/adapters/chatgpt-web/process-line-writer.ts +46 -46
  28. package/src/adapters/chatgpt-web/prompt.ts +702 -702
  29. package/src/adapters/chatgpt-web/retry-policy.ts +73 -73
  30. package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -384
  31. package/src/adapters/chatgpt-web/thread-environment.ts +238 -238
  32. package/src/adapters/chatgpt-web/tool-stream-parser.ts +1 -1
  33. package/src/adapters/chatgpt-web/turn-broker.ts +1481 -1481
  34. package/src/adapters/chatgpt-web/turn-execution.ts +816 -816
  35. package/src/adapters/chatgpt-web/turn-progress.ts +292 -292
  36. package/src/adapters/chatgpt-web/usage.ts +121 -121
  37. package/src/adapters/image.ts +9 -9
  38. package/src/bridge.ts +1083 -1083
  39. package/src/browser-login.ts +521 -521
  40. package/src/chatgpt-session.ts +240 -240
  41. package/src/chatgpt-web-models.ts +400 -400
  42. package/src/cli.ts +568 -568
  43. package/src/codex-integration-document.ts +824 -824
  44. package/src/codex-integration-journal.ts +212 -212
  45. package/src/codex-integration-route.ts +515 -515
  46. package/src/codex-integration-shared.ts +332 -332
  47. package/src/codex-integration.ts +529 -529
  48. package/src/codex-interrupt-hook.ts +158 -158
  49. package/src/config.ts +616 -616
  50. package/src/dev-chat/cli.ts +432 -432
  51. package/src/dev-chat/constants.ts +3 -3
  52. package/src/dev-chat/driver.ts +655 -655
  53. package/src/dev-chat/profile.ts +223 -223
  54. package/src/dev-chat/session.ts +287 -287
  55. package/src/dev-chat/transport.ts +54 -54
  56. package/src/doctor.ts +237 -237
  57. package/src/event-queue.ts +45 -45
  58. package/src/http-body.ts +30 -30
  59. package/src/launcher-browser-host.ts +695 -695
  60. package/src/lib/errors.ts +281 -281
  61. package/src/lib/token-estimate.ts +42 -42
  62. package/src/login-helper.cjs +1 -1
  63. package/src/model-catalog.ts +197 -197
  64. package/src/native-passthrough.ts +261 -261
  65. package/src/plugin.ts +191 -191
  66. package/src/process.ts +45 -45
  67. package/src/responses/compaction.ts +199 -199
  68. package/src/responses/parser.ts +633 -633
  69. package/src/responses/reasoning-envelope.ts +49 -49
  70. package/src/responses/schema.ts +172 -172
  71. package/src/responses/state.ts +230 -230
  72. package/src/server.ts +1111 -1111
  73. package/src/service.ts +315 -315
  74. package/src/setup.ts +671 -671
  75. package/src/stall-timeout.ts +23 -23
  76. package/src/tunnel-service.ts +160 -160
  77. package/src/tunnel.ts +417 -417
  78. package/src/turndown-plugin-gfm.d.ts +5 -5
  79. package/src/types.ts +307 -307
  80. package/src/usage/totals.ts +12 -12
  81. package/src/version.ts +1 -1
@@ -1,261 +1,261 @@
1
- import { readJsonRequestBody } from "./http-body";
2
- import {
3
- BRIDGE_COMPACTION_PREFIX,
4
- SUMMARY_PREFIX,
5
- decodeCompactionSummary,
6
- } from "./responses/compaction";
7
- import { BRIDGE_REASONING_PREFIX } from "./responses/reasoning-envelope";
8
-
9
- const CODEX_BACKEND = "https://chatgpt.com/backend-api/codex";
10
- const FIRST_PARTY_CODEX_ORIGINATORS = new Set([
11
- "codex_cli_rs",
12
- "codex-tui",
13
- "codex_vscode",
14
- "codex_atlas",
15
- "codex_chatgpt_desktop",
16
- ]);
17
- const HOP_BY_HOP_HEADERS = new Set([
18
- "connection",
19
- "keep-alive",
20
- "proxy-authenticate",
21
- "proxy-authorization",
22
- "te",
23
- "trailer",
24
- "transfer-encoding",
25
- "upgrade",
26
- "host",
27
- ]);
28
-
29
- export type NativeFetch = (request: Request) => Promise<Response>;
30
- export type NativeCodexEndpoint = "models" | "responses" | "responses/compact" | "alpha/search";
31
-
32
- type JsonObject = Record<string, unknown>;
33
- type BridgeCompactionItem = JsonObject & { type: "compaction"; encrypted_content: string };
34
-
35
- function firstPartyCodexOriginator(value: string): boolean {
36
- return FIRST_PARTY_CODEX_ORIGINATORS.has(value)
37
- || /^Codex [A-Za-z0-9][A-Za-z0-9._ -]{0,63}$/.test(value);
38
- }
39
-
40
- /**
41
- * Current Codex clients identify themselves as `<originator>/<cargo semver> (...)`. The models
42
- * backend requires the release-only `major.minor.patch` value even when the client is an alpha.
43
- * Derive it only from the documented first-party Codex prefix; an arbitrary browser or proxy
44
- * User-Agent is not evidence of a Codex version and leaves the original request untouched.
45
- */
46
- export function codexClientVersionFromUserAgent(userAgent: string | null): string | undefined {
47
- if (!userAgent) return undefined;
48
- const separator = userAgent.indexOf("/");
49
- if (separator < 1) return undefined;
50
- const originator = userAgent.slice(0, separator);
51
- if (!firstPartyCodexOriginator(originator)) return undefined;
52
- const version = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:[-+][0-9A-Za-z.-]+)?(?:\s|$)/
53
- .exec(userAgent.slice(separator + 1));
54
- return version ? `${version[1]}.${version[2]}.${version[3]}` : undefined;
55
- }
56
-
57
- function isObject(value: unknown): value is JsonObject {
58
- return value !== null && typeof value === "object" && !Array.isArray(value);
59
- }
60
-
61
- function isBridgeReasoningItem(value: unknown): value is JsonObject {
62
- if (!isObject(value) || value.type !== "reasoning") return false;
63
- const encrypted = value.encrypted_content;
64
- if (typeof encrypted === "string" && encrypted.startsWith(BRIDGE_REASONING_PREFIX)) return true;
65
- return typeof value.id === "string"
66
- && /^rs_[0-9a-f]{32}$/i.test(value.id)
67
- && (encrypted === undefined || encrypted === null)
68
- && (Array.isArray(value.summary) || Array.isArray(value.content));
69
- }
70
-
71
- function isBridgeCompactionItem(value: unknown): value is BridgeCompactionItem {
72
- return isObject(value)
73
- && value.type === "compaction"
74
- && typeof value.encrypted_content === "string"
75
- && value.encrypted_content.startsWith(BRIDGE_COMPACTION_PREFIX);
76
- }
77
-
78
- /**
79
- * Response item ids are scoped to the backend that created them. A ChatGPT Web response is
80
- * generated locally, so replaying its `rs_*` id after switching back to native Codex makes the
81
- * official backend try to load an item it has never stored. The same boundary applies to local
82
- * `ocx1:` compaction checkpoints: preserve their decoded summary as a normal input message rather
83
- * than asking the official backend to decrypt a bridge-owned envelope. Once either artifact proves
84
- * that the history crossed providers, send the complete item content without provider-local ids.
85
- */
86
- export function scrubBridgeArtifactsForNative(value: unknown): { value: unknown; changed: boolean } {
87
- if (!isObject(value)
88
- || !Array.isArray(value.input)
89
- || !value.input.some(item => isBridgeReasoningItem(item) || isBridgeCompactionItem(item))) {
90
- return { value, changed: false };
91
- }
92
-
93
- const input = value.input.flatMap(item => {
94
- if (!isObject(item)) return [item];
95
- const clean = { ...item };
96
- delete clean.id;
97
- if (isBridgeCompactionItem(clean)) {
98
- const summary = decodeCompactionSummary(clean.encrypted_content);
99
- if (summary === null) throw new Error("Invalid ChatGPT Web compaction checkpoint");
100
- return [{
101
- type: "message",
102
- role: "user",
103
- content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\n${summary}` }],
104
- }];
105
- }
106
- if (clean.type !== "reasoning") return [clean];
107
-
108
- if (typeof clean.encrypted_content === "string"
109
- && clean.encrypted_content.startsWith(BRIDGE_REASONING_PREFIX)) {
110
- delete clean.encrypted_content;
111
- } else if (clean.encrypted_content === null) {
112
- delete clean.encrypted_content;
113
- }
114
-
115
- const hasSummary = Array.isArray(clean.summary) && clean.summary.length > 0;
116
- const hasContent = Array.isArray(clean.content) && clean.content.length > 0;
117
- const hasNativeEncryptedContent = typeof clean.encrypted_content === "string";
118
- return hasSummary || hasContent || hasNativeEncryptedContent ? [clean] : [];
119
- });
120
- const clean: JsonObject = { ...value, input };
121
- delete clean.previous_response_id;
122
- return { value: clean, changed: true };
123
- }
124
-
125
- function endToEndHeaders(source: Headers): Headers {
126
- const headers = new Headers();
127
- for (const [name, value] of source) {
128
- if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase())) headers.append(name, value);
129
- }
130
- headers.delete("content-length");
131
- return headers;
132
- }
133
-
134
- /** Terminator every Responses SSE stream ends with; nothing after it carries meaning. */
135
- const SSE_TERMINATOR = "data: [DONE]";
136
-
137
- /**
138
- * ChatGPT's backend routinely resets the native Codex connection instead of closing it cleanly,
139
- * which Bun surfaces as ECONNRESET while reading the body. Passed through untouched that reaches
140
- * Codex as a truncated HTTP body and the opaque "error decoding response body".
141
- *
142
- * A reset that arrives after the stream already delivered `data: [DONE]` is an unclean TCP close on
143
- * a turn that finished: every byte the protocol defines has been forwarded, so the stream is closed
144
- * normally rather than failed. A reset before that genuinely truncated the turn and is still raised,
145
- * because inventing a terminal event there would tell Codex a turn ended when it did not.
146
- */
147
- function withUncleanCloseTolerance(
148
- body: ReadableStream<Uint8Array>,
149
- isEventStream: boolean,
150
- onUncleanClose?: (bytes: number) => void,
151
- ): ReadableStream<Uint8Array> {
152
- if (!isEventStream) return body;
153
- const reader = body.getReader();
154
- const decoder = new TextDecoder();
155
- let lineBuffer = "";
156
- let completed = false;
157
- let bytes = 0;
158
- const inspectLines = (text: string): void => {
159
- lineBuffer += text;
160
- let newline = lineBuffer.indexOf("\n");
161
- while (newline >= 0) {
162
- const line = lineBuffer.slice(0, newline).replace(/\r$/, "");
163
- lineBuffer = lineBuffer.slice(newline + 1);
164
- if (line === SSE_TERMINATOR) completed = true;
165
- newline = lineBuffer.indexOf("\n");
166
- }
167
- };
168
- const inspectTrailingLine = (): void => {
169
- // A reset can arrive before the final line separator. Treat only an exact unterminated
170
- // terminator line as complete; text embedded in a JSON data payload must not qualify.
171
- if (lineBuffer.replace(/\r$/, "") === SSE_TERMINATOR) completed = true;
172
- };
173
- return new ReadableStream<Uint8Array>({
174
- async pull(controller) {
175
- try {
176
- const chunk = await reader.read();
177
- if (chunk.done) {
178
- inspectLines(decoder.decode());
179
- inspectTrailingLine();
180
- controller.close();
181
- return;
182
- }
183
- bytes += chunk.value.byteLength;
184
- inspectLines(decoder.decode(chunk.value, { stream: true }));
185
- controller.enqueue(chunk.value);
186
- } catch (error) {
187
- inspectTrailingLine();
188
- if (!completed) {
189
- controller.error(error);
190
- return;
191
- }
192
- onUncleanClose?.(bytes);
193
- controller.close();
194
- }
195
- },
196
- cancel(reason) {
197
- return reader.cancel(reason);
198
- },
199
- });
200
- }
201
-
202
- export async function forwardNativeCodexRequest(
203
- request: Request,
204
- endpoint: NativeCodexEndpoint,
205
- fetchUpstream: NativeFetch = fetch,
206
- decodedBody?: unknown,
207
- ): Promise<Response> {
208
- const authorization = request.headers.get("authorization") ?? "";
209
- if (!authorization.startsWith("Bearer ") || authorization.length <= "Bearer ".length) {
210
- throw new Error("Native Codex passthrough requires the incoming Bearer authorization");
211
- }
212
-
213
- const incomingUrl = new URL(request.url);
214
- if (endpoint === "models" && !incomingUrl.searchParams.has("client_version")) {
215
- const clientVersion = codexClientVersionFromUserAgent(request.headers.get("user-agent"));
216
- if (clientVersion) incomingUrl.searchParams.set("client_version", clientVersion);
217
- }
218
- const headers = endToEndHeaders(request.headers);
219
- if (endpoint === "models") headers.delete("if-none-match");
220
- const method = endpoint === "models" ? "GET" : "POST";
221
- let body: BodyInit | undefined;
222
- if (method === "POST") {
223
- const parseRequest = decodedBody === undefined ? request.clone() : undefined;
224
- const originalBody = await request.arrayBuffer();
225
- const scrubbed = scrubBridgeArtifactsForNative(
226
- decodedBody === undefined ? await readJsonRequestBody(parseRequest!) : decodedBody,
227
- );
228
- if (scrubbed.changed) {
229
- headers.delete("content-encoding");
230
- body = JSON.stringify(scrubbed.value);
231
- } else {
232
- body = originalBody;
233
- }
234
- }
235
- const upstreamRequest = new Request(`${CODEX_BACKEND}/${endpoint}${incomingUrl.search}`, {
236
- method,
237
- headers,
238
- ...(body ? { body } : {}),
239
- signal: request.signal,
240
- });
241
- const upstream = await fetchUpstream(upstreamRequest);
242
- const responseHeaders = endToEndHeaders(upstream.headers);
243
- const isEventStream = (upstream.headers.get("content-type") ?? "")
244
- .toLowerCase()
245
- .includes("text/event-stream");
246
- return new Response(
247
- upstream.body
248
- ? withUncleanCloseTolerance(upstream.body, isEventStream, bytes => {
249
- console.warn(
250
- `[codex-chatgpt-web] native_upstream_unclean_close endpoint=${endpoint} bytes=${bytes}`
251
- + " (turn had already completed; closing the client stream normally)",
252
- );
253
- })
254
- : upstream.body,
255
- {
256
- status: upstream.status,
257
- statusText: upstream.statusText,
258
- headers: responseHeaders,
259
- },
260
- );
261
- }
1
+ import { readJsonRequestBody } from "./http-body";
2
+ import {
3
+ BRIDGE_COMPACTION_PREFIX,
4
+ SUMMARY_PREFIX,
5
+ decodeCompactionSummary,
6
+ } from "./responses/compaction";
7
+ import { BRIDGE_REASONING_PREFIX } from "./responses/reasoning-envelope";
8
+
9
+ const CODEX_BACKEND = "https://chatgpt.com/backend-api/codex";
10
+ const FIRST_PARTY_CODEX_ORIGINATORS = new Set([
11
+ "codex_cli_rs",
12
+ "codex-tui",
13
+ "codex_vscode",
14
+ "codex_atlas",
15
+ "codex_chatgpt_desktop",
16
+ ]);
17
+ const HOP_BY_HOP_HEADERS = new Set([
18
+ "connection",
19
+ "keep-alive",
20
+ "proxy-authenticate",
21
+ "proxy-authorization",
22
+ "te",
23
+ "trailer",
24
+ "transfer-encoding",
25
+ "upgrade",
26
+ "host",
27
+ ]);
28
+
29
+ export type NativeFetch = (request: Request) => Promise<Response>;
30
+ export type NativeCodexEndpoint = "models" | "responses" | "responses/compact" | "alpha/search";
31
+
32
+ type JsonObject = Record<string, unknown>;
33
+ type BridgeCompactionItem = JsonObject & { type: "compaction"; encrypted_content: string };
34
+
35
+ function firstPartyCodexOriginator(value: string): boolean {
36
+ return FIRST_PARTY_CODEX_ORIGINATORS.has(value)
37
+ || /^Codex [A-Za-z0-9][A-Za-z0-9._ -]{0,63}$/.test(value);
38
+ }
39
+
40
+ /**
41
+ * Current Codex clients identify themselves as `<originator>/<cargo semver> (...)`. The models
42
+ * backend requires the release-only `major.minor.patch` value even when the client is an alpha.
43
+ * Derive it only from the documented first-party Codex prefix; an arbitrary browser or proxy
44
+ * User-Agent is not evidence of a Codex version and leaves the original request untouched.
45
+ */
46
+ export function codexClientVersionFromUserAgent(userAgent: string | null): string | undefined {
47
+ if (!userAgent) return undefined;
48
+ const separator = userAgent.indexOf("/");
49
+ if (separator < 1) return undefined;
50
+ const originator = userAgent.slice(0, separator);
51
+ if (!firstPartyCodexOriginator(originator)) return undefined;
52
+ const version = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:[-+][0-9A-Za-z.-]+)?(?:\s|$)/
53
+ .exec(userAgent.slice(separator + 1));
54
+ return version ? `${version[1]}.${version[2]}.${version[3]}` : undefined;
55
+ }
56
+
57
+ function isObject(value: unknown): value is JsonObject {
58
+ return value !== null && typeof value === "object" && !Array.isArray(value);
59
+ }
60
+
61
+ function isBridgeReasoningItem(value: unknown): value is JsonObject {
62
+ if (!isObject(value) || value.type !== "reasoning") return false;
63
+ const encrypted = value.encrypted_content;
64
+ if (typeof encrypted === "string" && encrypted.startsWith(BRIDGE_REASONING_PREFIX)) return true;
65
+ return typeof value.id === "string"
66
+ && /^rs_[0-9a-f]{32}$/i.test(value.id)
67
+ && (encrypted === undefined || encrypted === null)
68
+ && (Array.isArray(value.summary) || Array.isArray(value.content));
69
+ }
70
+
71
+ function isBridgeCompactionItem(value: unknown): value is BridgeCompactionItem {
72
+ return isObject(value)
73
+ && value.type === "compaction"
74
+ && typeof value.encrypted_content === "string"
75
+ && value.encrypted_content.startsWith(BRIDGE_COMPACTION_PREFIX);
76
+ }
77
+
78
+ /**
79
+ * Response item ids are scoped to the backend that created them. A ChatGPT Web response is
80
+ * generated locally, so replaying its `rs_*` id after switching back to native Codex makes the
81
+ * official backend try to load an item it has never stored. The same boundary applies to local
82
+ * `ocx1:` compaction checkpoints: preserve their decoded summary as a normal input message rather
83
+ * than asking the official backend to decrypt a bridge-owned envelope. Once either artifact proves
84
+ * that the history crossed providers, send the complete item content without provider-local ids.
85
+ */
86
+ export function scrubBridgeArtifactsForNative(value: unknown): { value: unknown; changed: boolean } {
87
+ if (!isObject(value)
88
+ || !Array.isArray(value.input)
89
+ || !value.input.some(item => isBridgeReasoningItem(item) || isBridgeCompactionItem(item))) {
90
+ return { value, changed: false };
91
+ }
92
+
93
+ const input = value.input.flatMap(item => {
94
+ if (!isObject(item)) return [item];
95
+ const clean = { ...item };
96
+ delete clean.id;
97
+ if (isBridgeCompactionItem(clean)) {
98
+ const summary = decodeCompactionSummary(clean.encrypted_content);
99
+ if (summary === null) throw new Error("Invalid ChatGPT Web compaction checkpoint");
100
+ return [{
101
+ type: "message",
102
+ role: "user",
103
+ content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\n${summary}` }],
104
+ }];
105
+ }
106
+ if (clean.type !== "reasoning") return [clean];
107
+
108
+ if (typeof clean.encrypted_content === "string"
109
+ && clean.encrypted_content.startsWith(BRIDGE_REASONING_PREFIX)) {
110
+ delete clean.encrypted_content;
111
+ } else if (clean.encrypted_content === null) {
112
+ delete clean.encrypted_content;
113
+ }
114
+
115
+ const hasSummary = Array.isArray(clean.summary) && clean.summary.length > 0;
116
+ const hasContent = Array.isArray(clean.content) && clean.content.length > 0;
117
+ const hasNativeEncryptedContent = typeof clean.encrypted_content === "string";
118
+ return hasSummary || hasContent || hasNativeEncryptedContent ? [clean] : [];
119
+ });
120
+ const clean: JsonObject = { ...value, input };
121
+ delete clean.previous_response_id;
122
+ return { value: clean, changed: true };
123
+ }
124
+
125
+ function endToEndHeaders(source: Headers): Headers {
126
+ const headers = new Headers();
127
+ for (const [name, value] of source) {
128
+ if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase())) headers.append(name, value);
129
+ }
130
+ headers.delete("content-length");
131
+ return headers;
132
+ }
133
+
134
+ /** Terminator every Responses SSE stream ends with; nothing after it carries meaning. */
135
+ const SSE_TERMINATOR = "data: [DONE]";
136
+
137
+ /**
138
+ * ChatGPT's backend routinely resets the native Codex connection instead of closing it cleanly,
139
+ * which Bun surfaces as ECONNRESET while reading the body. Passed through untouched that reaches
140
+ * Codex as a truncated HTTP body and the opaque "error decoding response body".
141
+ *
142
+ * A reset that arrives after the stream already delivered `data: [DONE]` is an unclean TCP close on
143
+ * a turn that finished: every byte the protocol defines has been forwarded, so the stream is closed
144
+ * normally rather than failed. A reset before that genuinely truncated the turn and is still raised,
145
+ * because inventing a terminal event there would tell Codex a turn ended when it did not.
146
+ */
147
+ function withUncleanCloseTolerance(
148
+ body: ReadableStream<Uint8Array>,
149
+ isEventStream: boolean,
150
+ onUncleanClose?: (bytes: number) => void,
151
+ ): ReadableStream<Uint8Array> {
152
+ if (!isEventStream) return body;
153
+ const reader = body.getReader();
154
+ const decoder = new TextDecoder();
155
+ let lineBuffer = "";
156
+ let completed = false;
157
+ let bytes = 0;
158
+ const inspectLines = (text: string): void => {
159
+ lineBuffer += text;
160
+ let newline = lineBuffer.indexOf("\n");
161
+ while (newline >= 0) {
162
+ const line = lineBuffer.slice(0, newline).replace(/\r$/, "");
163
+ lineBuffer = lineBuffer.slice(newline + 1);
164
+ if (line === SSE_TERMINATOR) completed = true;
165
+ newline = lineBuffer.indexOf("\n");
166
+ }
167
+ };
168
+ const inspectTrailingLine = (): void => {
169
+ // A reset can arrive before the final line separator. Treat only an exact unterminated
170
+ // terminator line as complete; text embedded in a JSON data payload must not qualify.
171
+ if (lineBuffer.replace(/\r$/, "") === SSE_TERMINATOR) completed = true;
172
+ };
173
+ return new ReadableStream<Uint8Array>({
174
+ async pull(controller) {
175
+ try {
176
+ const chunk = await reader.read();
177
+ if (chunk.done) {
178
+ inspectLines(decoder.decode());
179
+ inspectTrailingLine();
180
+ controller.close();
181
+ return;
182
+ }
183
+ bytes += chunk.value.byteLength;
184
+ inspectLines(decoder.decode(chunk.value, { stream: true }));
185
+ controller.enqueue(chunk.value);
186
+ } catch (error) {
187
+ inspectTrailingLine();
188
+ if (!completed) {
189
+ controller.error(error);
190
+ return;
191
+ }
192
+ onUncleanClose?.(bytes);
193
+ controller.close();
194
+ }
195
+ },
196
+ cancel(reason) {
197
+ return reader.cancel(reason);
198
+ },
199
+ });
200
+ }
201
+
202
+ export async function forwardNativeCodexRequest(
203
+ request: Request,
204
+ endpoint: NativeCodexEndpoint,
205
+ fetchUpstream: NativeFetch = fetch,
206
+ decodedBody?: unknown,
207
+ ): Promise<Response> {
208
+ const authorization = request.headers.get("authorization") ?? "";
209
+ if (!authorization.startsWith("Bearer ") || authorization.length <= "Bearer ".length) {
210
+ throw new Error("Native Codex passthrough requires the incoming Bearer authorization");
211
+ }
212
+
213
+ const incomingUrl = new URL(request.url);
214
+ if (endpoint === "models" && !incomingUrl.searchParams.has("client_version")) {
215
+ const clientVersion = codexClientVersionFromUserAgent(request.headers.get("user-agent"));
216
+ if (clientVersion) incomingUrl.searchParams.set("client_version", clientVersion);
217
+ }
218
+ const headers = endToEndHeaders(request.headers);
219
+ if (endpoint === "models") headers.delete("if-none-match");
220
+ const method = endpoint === "models" ? "GET" : "POST";
221
+ let body: BodyInit | undefined;
222
+ if (method === "POST") {
223
+ const parseRequest = decodedBody === undefined ? request.clone() : undefined;
224
+ const originalBody = await request.arrayBuffer();
225
+ const scrubbed = scrubBridgeArtifactsForNative(
226
+ decodedBody === undefined ? await readJsonRequestBody(parseRequest!) : decodedBody,
227
+ );
228
+ if (scrubbed.changed) {
229
+ headers.delete("content-encoding");
230
+ body = JSON.stringify(scrubbed.value);
231
+ } else {
232
+ body = originalBody;
233
+ }
234
+ }
235
+ const upstreamRequest = new Request(`${CODEX_BACKEND}/${endpoint}${incomingUrl.search}`, {
236
+ method,
237
+ headers,
238
+ ...(body ? { body } : {}),
239
+ signal: request.signal,
240
+ });
241
+ const upstream = await fetchUpstream(upstreamRequest);
242
+ const responseHeaders = endToEndHeaders(upstream.headers);
243
+ const isEventStream = (upstream.headers.get("content-type") ?? "")
244
+ .toLowerCase()
245
+ .includes("text/event-stream");
246
+ return new Response(
247
+ upstream.body
248
+ ? withUncleanCloseTolerance(upstream.body, isEventStream, bytes => {
249
+ console.warn(
250
+ `[codex-chatgpt-web] native_upstream_unclean_close endpoint=${endpoint} bytes=${bytes}`
251
+ + " (turn had already completed; closing the client stream normally)",
252
+ );
253
+ })
254
+ : upstream.body,
255
+ {
256
+ status: upstream.status,
257
+ statusText: upstream.statusText,
258
+ headers: responseHeaders,
259
+ },
260
+ );
261
+ }