@wlv-zedd/dsh-chatgpt-web 1.0.0

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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/assets/demo.gif +0 -0
  4. package/assets/hero-demo.png +0 -0
  5. package/assets/promo-dshmarket-official.png +0 -0
  6. package/cordis.patch.yml +4 -0
  7. package/lib/cli.js +239642 -0
  8. package/lib/plugin.js +195 -0
  9. package/package.json +88 -0
  10. package/screenshots.json +5 -0
  11. package/src/adapters/base.ts +16 -0
  12. package/src/adapters/chatgpt-web/adapter-error.ts +59 -0
  13. package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -0
  14. package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -0
  15. package/src/adapters/chatgpt-web/browser-worker.ts +4944 -0
  16. package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -0
  17. package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -0
  18. package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -0
  19. package/src/adapters/chatgpt-web/concurrency.ts +6 -0
  20. package/src/adapters/chatgpt-web/conversation-key.ts +58 -0
  21. package/src/adapters/chatgpt-web/environment.ts +669 -0
  22. package/src/adapters/chatgpt-web/index.ts +1544 -0
  23. package/src/adapters/chatgpt-web/input-tokens.ts +74 -0
  24. package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -0
  25. package/src/adapters/chatgpt-web/markdown.ts +418 -0
  26. package/src/adapters/chatgpt-web/mcp-main.ts +25 -0
  27. package/src/adapters/chatgpt-web/mcp-server.ts +933 -0
  28. package/src/adapters/chatgpt-web/model.ts +70 -0
  29. package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -0
  30. package/src/adapters/chatgpt-web/output-validation.ts +62 -0
  31. package/src/adapters/chatgpt-web/process-line-writer.ts +46 -0
  32. package/src/adapters/chatgpt-web/prompt.ts +702 -0
  33. package/src/adapters/chatgpt-web/retry-policy.ts +73 -0
  34. package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -0
  35. package/src/adapters/chatgpt-web/thread-environment.ts +238 -0
  36. package/src/adapters/chatgpt-web/tool-stream-parser.ts +601 -0
  37. package/src/adapters/chatgpt-web/turn-broker.ts +1481 -0
  38. package/src/adapters/chatgpt-web/turn-execution.ts +816 -0
  39. package/src/adapters/chatgpt-web/turn-progress.ts +292 -0
  40. package/src/adapters/chatgpt-web/usage.ts +121 -0
  41. package/src/adapters/image.ts +9 -0
  42. package/src/bridge.ts +1083 -0
  43. package/src/browser-login.ts +521 -0
  44. package/src/chatgpt-session.ts +240 -0
  45. package/src/chatgpt-web-models.ts +400 -0
  46. package/src/cli.ts +568 -0
  47. package/src/codex-integration-document.ts +824 -0
  48. package/src/codex-integration-journal.ts +212 -0
  49. package/src/codex-integration-route.ts +515 -0
  50. package/src/codex-integration-shared.ts +332 -0
  51. package/src/codex-integration.ts +529 -0
  52. package/src/codex-interrupt-hook.ts +158 -0
  53. package/src/config.ts +616 -0
  54. package/src/dev-chat/cli.ts +432 -0
  55. package/src/dev-chat/constants.ts +3 -0
  56. package/src/dev-chat/driver.ts +655 -0
  57. package/src/dev-chat/profile.ts +223 -0
  58. package/src/dev-chat/session.ts +287 -0
  59. package/src/dev-chat/transport.ts +54 -0
  60. package/src/doctor.ts +237 -0
  61. package/src/event-queue.ts +45 -0
  62. package/src/http-body.ts +30 -0
  63. package/src/launcher-browser-host.ts +695 -0
  64. package/src/lib/errors.ts +281 -0
  65. package/src/lib/token-estimate.ts +42 -0
  66. package/src/login-helper.cjs +140 -0
  67. package/src/model-catalog.ts +197 -0
  68. package/src/native-passthrough.ts +261 -0
  69. package/src/plugin.ts +191 -0
  70. package/src/process.ts +45 -0
  71. package/src/responses/compaction.ts +199 -0
  72. package/src/responses/parser.ts +633 -0
  73. package/src/responses/reasoning-envelope.ts +49 -0
  74. package/src/responses/schema.ts +172 -0
  75. package/src/responses/state.ts +230 -0
  76. package/src/server.ts +1111 -0
  77. package/src/service.ts +315 -0
  78. package/src/setup.ts +671 -0
  79. package/src/stall-timeout.ts +23 -0
  80. package/src/tunnel-service.ts +160 -0
  81. package/src/tunnel.ts +417 -0
  82. package/src/turndown-plugin-gfm.d.ts +5 -0
  83. package/src/types.ts +307 -0
  84. package/src/usage/totals.ts +12 -0
  85. package/src/version.ts +1 -0
package/src/server.ts ADDED
@@ -0,0 +1,1111 @@
1
+ import { chatGptWebTraceId, createChatGptWebAdapter } from "./adapters/chatgpt-web";
2
+ import { closeChatGptBrowserWorkers } from "./adapters/chatgpt-web/browser-worker";
3
+ import { closeTurnBrokers, TurnBroker } from "./adapters/chatgpt-web/turn-broker";
4
+ import { timingSafeEqual, createHash } from "node:crypto";
5
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
6
+ import { Readable } from "node:stream";
7
+ import { chatGptTurnSessions } from "./adapters/chatgpt-web/turn-execution";
8
+ import {
9
+ cancelAllStructuredCompactions,
10
+ cancelStructuredCompactionNativeTurn,
11
+ cancelStructuredCompactionTrace,
12
+ } from "./adapters/chatgpt-web/compaction-handoff";
13
+ import { chatGptBrowserTabClosedError } from "./adapters/chatgpt-web/adapter-error";
14
+ import {
15
+ CHATGPT_TURN_REVISION_CONFLICT_MESSAGE,
16
+ extractChatGptTurnIdentity,
17
+ extractCodexTurnIdentityFromBody,
18
+ } from "./adapters/chatgpt-web/environment";
19
+ import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "./bridge";
20
+ import type { AppConfig } from "./config";
21
+ import { providerConfig } from "./config";
22
+ import { AsyncEventQueue } from "./event-queue";
23
+ import { readJsonRequestBody } from "./http-body";
24
+ import { httpStatusFromTerminalError } from "./lib/errors";
25
+ import { augmentNativeModelCatalog } from "./model-catalog";
26
+ import {
27
+ readCodexModelContextOverride,
28
+ readCodexSubagentProtocol,
29
+ type CodexModelContextOverride,
30
+ } from "./codex-integration";
31
+ import {
32
+ CHATGPT_WEB_LUNA_BACKEND_MODEL,
33
+ isChatGptWebModelSlug,
34
+ requireChatGptWebModelRoute,
35
+ type ChatGptWebModelRoute,
36
+ } from "./chatgpt-web-models";
37
+ import { forwardNativeCodexRequest, type NativeFetch } from "./native-passthrough";
38
+ import {
39
+ buildCompactV1Output,
40
+ COMPACT_PROMPT,
41
+ decodeCompactionSummary,
42
+ extractCompactUserMessages,
43
+ } from "./responses/compaction";
44
+ import { parseRequest } from "./responses/parser";
45
+ import { expandPreviousResponseInput, flushResponseState, rememberResponseState } from "./responses/state";
46
+ import { namespacedToolName, type AdapterEvent, type CodexParsedRequest } from "./types";
47
+ import type { CodexProviderConfig } from "./types";
48
+ import type { ProviderAdapter } from "./adapters/base";
49
+ import { VERSION } from "./version";
50
+
51
+ type HttpTrackedEndpoint = "models" | "responses" | "compact" | "search" | "unspecified";
52
+
53
+ export interface NativeCodexTurnIdentity {
54
+ threadId: string;
55
+ turnId: string;
56
+ }
57
+
58
+ export interface HttpStreamFailureEvidence {
59
+ httpTurnId: number;
60
+ endpoint: HttpTrackedEndpoint;
61
+ reader: "client" | "windows_lifecycle";
62
+ platform: NodeJS.Platform;
63
+ chunks: number;
64
+ bytes: number;
65
+ errorName: string;
66
+ errorCode: string;
67
+ }
68
+
69
+ type HttpStreamFailureReporter = (evidence: HttpStreamFailureEvidence) => void;
70
+
71
+ function safeStreamErrorField(value: unknown, fallback: string): string {
72
+ return typeof value === "string" && /^[A-Za-z0-9_.-]{1,64}$/.test(value)
73
+ ? value
74
+ : fallback;
75
+ }
76
+
77
+ function streamFailureEvidence(
78
+ error: unknown,
79
+ httpTurnId: number,
80
+ endpoint: HttpTrackedEndpoint,
81
+ reader: HttpStreamFailureEvidence["reader"],
82
+ platform: NodeJS.Platform,
83
+ chunks: number,
84
+ bytes: number,
85
+ ): HttpStreamFailureEvidence {
86
+ const candidate = error !== null && typeof error === "object"
87
+ ? error as { name?: unknown; code?: unknown }
88
+ : {};
89
+ return {
90
+ httpTurnId,
91
+ endpoint,
92
+ reader,
93
+ platform,
94
+ chunks,
95
+ bytes,
96
+ errorName: safeStreamErrorField(candidate.name, "Error"),
97
+ errorCode: safeStreamErrorField(candidate.code, "unknown"),
98
+ };
99
+ }
100
+
101
+ const reportHttpStreamFailure: HttpStreamFailureReporter = evidence => {
102
+ console.warn(`[dsh-chatgpt-web] http_stream_failed ${JSON.stringify(evidence)}`);
103
+ };
104
+
105
+ function emitHttpStreamFailure(
106
+ reporter: HttpStreamFailureReporter,
107
+ evidence: HttpStreamFailureEvidence,
108
+ ): void {
109
+ try {
110
+ reporter(evidence);
111
+ } catch {
112
+ // Diagnostics are a side channel: they must never replace the source stream error or retain
113
+ // HTTP turn ownership after the client has already observed that failure.
114
+ }
115
+ }
116
+
117
+ export class HttpTurnCounter {
118
+ private readonly active = new Map<number, {
119
+ abort: AbortController;
120
+ done: Promise<void>;
121
+ finish: () => void;
122
+ identity?: NativeCodexTurnIdentity;
123
+ }>();
124
+ private readonly interrupted = new Map<string, unknown>();
125
+ private nextId = 1;
126
+
127
+ private identityKey(identity: NativeCodexTurnIdentity): string {
128
+ return `${identity.threadId}\u0000${identity.turnId}`;
129
+ }
130
+
131
+ private rememberInterrupted(identity: NativeCodexTurnIdentity, reason: unknown): void {
132
+ const key = this.identityKey(identity);
133
+ this.interrupted.delete(key);
134
+ this.interrupted.set(key, reason);
135
+ while (this.interrupted.size > 1_024) {
136
+ const oldest = this.interrupted.keys().next().value as string | undefined;
137
+ if (oldest === undefined) break;
138
+ this.interrupted.delete(oldest);
139
+ }
140
+ }
141
+
142
+ constructor(private readonly reportStreamFailure: HttpStreamFailureReporter = reportHttpStreamFailure) {}
143
+
144
+ count(): number {
145
+ return this.active.size;
146
+ }
147
+
148
+ async cancelAll(reason: unknown = new Error("Active HTTP turns cancelled")): Promise<number> {
149
+ const turns = [...this.active.values()];
150
+ for (const turn of turns) {
151
+ if (!turn.abort.signal.aborted) turn.abort.abort(reason);
152
+ }
153
+ await Promise.all(turns.map(turn => turn.done));
154
+ return turns.length;
155
+ }
156
+
157
+ async cancelTurn(
158
+ identity: NativeCodexTurnIdentity,
159
+ reason: unknown = new DOMException("Codex turn interrupted", "AbortError"),
160
+ ): Promise<number> {
161
+ const cancellation = this.beginCancelTurn(identity, reason);
162
+ await cancellation.settlement;
163
+ return cancellation.cancelled;
164
+ }
165
+
166
+ beginCancelTurn(
167
+ identity: NativeCodexTurnIdentity,
168
+ reason: unknown = new DOMException("Codex turn interrupted", "AbortError"),
169
+ ): { cancelled: number; settlement: Promise<void> } {
170
+ this.rememberInterrupted(identity, reason);
171
+ const turns = [...this.active.values()].filter(turn => (
172
+ turn.identity?.threadId === identity.threadId && turn.identity.turnId === identity.turnId
173
+ ));
174
+ for (const turn of turns) {
175
+ if (!turn.abort.signal.aborted) turn.abort.abort(reason);
176
+ }
177
+ return {
178
+ cancelled: turns.length,
179
+ settlement: Promise.all(turns.map(turn => turn.done)).then(() => undefined),
180
+ };
181
+ }
182
+
183
+ async track(
184
+ run: (
185
+ signal: AbortSignal,
186
+ bindIdentity: (identity: NativeCodexTurnIdentity) => void,
187
+ ) => Promise<Response>,
188
+ clientSignal?: AbortSignal,
189
+ platform: NodeJS.Platform = process.platform,
190
+ endpoint: HttpTrackedEndpoint = "unspecified",
191
+ ): Promise<Response> {
192
+ const id = this.nextId++;
193
+ const abort = new AbortController();
194
+ let finish!: () => void;
195
+ const done = new Promise<void>(resolve => { finish = resolve; });
196
+ const tracked: {
197
+ abort: AbortController;
198
+ done: Promise<void>;
199
+ finish: () => void;
200
+ identity?: NativeCodexTurnIdentity;
201
+ } = { abort, done, finish };
202
+ this.active.set(id, tracked);
203
+ let released = false;
204
+ let clientAbortListener: (() => void) | undefined;
205
+ let streamAbortListener: (() => void) | undefined;
206
+ const release = () => {
207
+ if (released) return;
208
+ released = true;
209
+ this.active.delete(id);
210
+ if (clientSignal && clientAbortListener) {
211
+ clientSignal.removeEventListener("abort", clientAbortListener);
212
+ clientAbortListener = undefined;
213
+ }
214
+ if (streamAbortListener) abort.signal.removeEventListener("abort", streamAbortListener);
215
+ finish();
216
+ };
217
+ clientAbortListener = () => abort.abort(clientSignal?.reason);
218
+ if (clientSignal?.aborted) abort.abort(clientSignal.reason);
219
+ else clientSignal?.addEventListener("abort", clientAbortListener, { once: true });
220
+
221
+ try {
222
+ const response = await run(abort.signal, identity => {
223
+ if (!identity.threadId.trim() || !identity.turnId.trim()) {
224
+ throw new Error("Native Codex turn identity must contain a threadId and turnId");
225
+ }
226
+ if (tracked.identity
227
+ && (tracked.identity.threadId !== identity.threadId || tracked.identity.turnId !== identity.turnId)) {
228
+ throw new Error("An HTTP request cannot change its native Codex turn identity");
229
+ }
230
+ tracked.identity = identity;
231
+ const interruptedReason = this.interrupted.get(this.identityKey(identity));
232
+ if (interruptedReason !== undefined && !abort.signal.aborted) abort.abort(interruptedReason);
233
+ });
234
+ if (!response.body) {
235
+ release();
236
+ return response;
237
+ }
238
+ if (abort.signal.aborted) {
239
+ await response.body.cancel(abort.signal.reason).catch(() => {});
240
+ release();
241
+ return new Response(null, { status: 499, statusText: "Client Closed Request" });
242
+ }
243
+
244
+ if (platform !== "win32") {
245
+ // Bun's async-pull teardown bug is Windows-only. On Darwin/Linux, preserve the direct
246
+ // pull chain: it keeps HTTP backpressure native and lets a client body cancellation reach
247
+ // the original SSE reader without an eagerly drained tee branch racing the socket writer.
248
+ const reader = response.body.getReader();
249
+ const reportStreamFailure = this.reportStreamFailure;
250
+ let chunks = 0;
251
+ let bytes = 0;
252
+ streamAbortListener = () => {
253
+ void reader.cancel(abort.signal.reason).catch(() => {}).finally(release);
254
+ };
255
+ abort.signal.addEventListener("abort", streamAbortListener, { once: true });
256
+ const body = new ReadableStream<Uint8Array>({
257
+ async pull(controller) {
258
+ try {
259
+ const chunk = await reader.read();
260
+ if (chunk.done) {
261
+ release();
262
+ controller.close();
263
+ return;
264
+ }
265
+ chunks += 1;
266
+ bytes += chunk.value.byteLength;
267
+ controller.enqueue(chunk.value);
268
+ } catch (error) {
269
+ if (!abort.signal.aborted) {
270
+ emitHttpStreamFailure(reportStreamFailure, streamFailureEvidence(
271
+ error,
272
+ id,
273
+ endpoint,
274
+ "client",
275
+ platform,
276
+ chunks,
277
+ bytes,
278
+ ));
279
+ }
280
+ release();
281
+ controller.error(error);
282
+ }
283
+ },
284
+ async cancel(reason) {
285
+ try {
286
+ await reader.cancel(reason);
287
+ } finally {
288
+ release();
289
+ }
290
+ },
291
+ });
292
+ return new Response(body, {
293
+ status: response.status,
294
+ statusText: response.statusText,
295
+ headers: response.headers,
296
+ });
297
+ }
298
+
299
+ // Windows-safe Bun#32111 shape: the client gets a native tee branch,
300
+ // never a JS ReadableStream with async pull(). The second branch is consumed only
301
+ // to observe completion. The request signal releases lifecycle ownership immediately
302
+ // when the client disconnects and cancels the observer branch.
303
+ const [clientBody, lifecycleBody] = response.body.tee();
304
+ const reader = lifecycleBody.getReader();
305
+ let chunks = 0;
306
+ let bytes = 0;
307
+ streamAbortListener = () => {
308
+ void Promise.allSettled([
309
+ reader.cancel(abort.signal.reason),
310
+ clientBody.cancel(abort.signal.reason),
311
+ ]).finally(release);
312
+ };
313
+ abort.signal.addEventListener("abort", streamAbortListener, { once: true });
314
+ void (async () => {
315
+ try {
316
+ for (;;) {
317
+ const chunk = await reader.read();
318
+ if (chunk.done) break;
319
+ chunks += 1;
320
+ bytes += chunk.value.byteLength;
321
+ // Consume eagerly so the lifecycle branch never backpressures the client branch.
322
+ }
323
+ } catch (error) {
324
+ if (!abort.signal.aborted) {
325
+ emitHttpStreamFailure(this.reportStreamFailure, streamFailureEvidence(
326
+ error,
327
+ id,
328
+ endpoint,
329
+ "windows_lifecycle",
330
+ platform,
331
+ chunks,
332
+ bytes,
333
+ ));
334
+ }
335
+ // Stream failure is delivered to the client branch; lifecycle cleanup stays best-effort.
336
+ } finally {
337
+ release();
338
+ }
339
+ })();
340
+ return new Response(clientBody, {
341
+ status: response.status,
342
+ statusText: response.statusText,
343
+ headers: response.headers,
344
+ });
345
+ } catch (error) {
346
+ release();
347
+ throw error;
348
+ }
349
+ }
350
+ }
351
+
352
+ type ChatGptWebAdapterFactory = (provider: CodexProviderConfig) => ProviderAdapter;
353
+
354
+ export interface ResponseRequestOptions {
355
+ /** DEV and other in-process harnesses can keep continuation state in their own canonical store. */
356
+ rememberState?: boolean;
357
+ /** Observe the exact production adapter stream when invoking the handler in-process. */
358
+ onAdapterEvent?: (event: AdapterEvent) => void;
359
+ /** Bind the physical HTTP stream to the exact native Codex turn that owns it. */
360
+ onTurnIdentity?: (identity: NativeCodexTurnIdentity) => void;
361
+ }
362
+
363
+ export function routeChatGptWebRequest(parsed: CodexParsedRequest, config: AppConfig): ChatGptWebModelRoute {
364
+ const route = requireChatGptWebModelRoute(parsed.modelId, config);
365
+ parsed.modelId = route.backendModel;
366
+ // Zero Risk preserves a distinct backend identity. Its immutable Codex effort is only a
367
+ // protocol/catalog value; the manual adapter must never reinterpret it as a ChatGPT selection.
368
+ parsed.options.reasoning = route.interactionMode === "automatic"
369
+ ? route.adapterEffort
370
+ : route.codexEffort;
371
+ return route;
372
+ }
373
+
374
+ export async function modelsRequest(
375
+ req: Request,
376
+ config: AppConfig,
377
+ fetchUpstream?: NativeFetch,
378
+ contextOverride?: () => CodexModelContextOverride | undefined,
379
+ ): Promise<Response> {
380
+ let upstream: Response;
381
+ try {
382
+ upstream = await forwardNativeCodexRequest(req, "models", fetchUpstream);
383
+ } catch (error) {
384
+ return formatErrorResponse(502, "upstream_error", error instanceof Error ? error.message : String(error));
385
+ }
386
+ if (!upstream.ok) return upstream;
387
+ let catalog: Record<string, unknown>;
388
+ try {
389
+ catalog = augmentNativeModelCatalog(await upstream.json(), config, contextOverride?.());
390
+ } catch (error) {
391
+ return formatErrorResponse(502, "invalid_response_error", error instanceof Error ? error.message : String(error));
392
+ }
393
+ const body = JSON.stringify(catalog);
394
+ const headers = new Headers(upstream.headers);
395
+ headers.delete("content-encoding");
396
+ headers.delete("content-length");
397
+ headers.set("content-type", "application/json");
398
+ headers.set("etag", `W/\"${createHash("sha256").update(body).digest("base64url")}\"`);
399
+ return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers });
400
+ }
401
+
402
+ export async function nativeSearchRequest(
403
+ req: Request,
404
+ fetchUpstream?: NativeFetch,
405
+ ): Promise<Response> {
406
+ try {
407
+ return await forwardNativeCodexRequest(req, "alpha/search", fetchUpstream);
408
+ } catch (error) {
409
+ return formatErrorResponse(502, "upstream_error", error instanceof Error ? error.message : String(error));
410
+ }
411
+ }
412
+
413
+ function toolBridgeMaps(parsed: CodexParsedRequest): {
414
+ toolNsMap: Map<string, { namespace: string; name: string }>;
415
+ freeformToolNames: Set<string>;
416
+ toolSearchToolNames: Set<string>;
417
+ } {
418
+ const toolNsMap = new Map<string, { namespace: string; name: string }>();
419
+ const freeformToolNames = new Set<string>();
420
+ const toolSearchToolNames = new Set<string>();
421
+ for (const tool of parsed.context.tools ?? []) {
422
+ if (tool.namespace) toolNsMap.set(namespacedToolName(tool.namespace, tool.name), { namespace: tool.namespace, name: tool.name });
423
+ if (tool.freeform) freeformToolNames.add(tool.name);
424
+ if (tool.toolSearch) toolSearchToolNames.add(tool.name);
425
+ }
426
+ return { toolNsMap, freeformToolNames, toolSearchToolNames };
427
+ }
428
+
429
+ export async function responseRequest(
430
+ req: Request,
431
+ config: AppConfig,
432
+ adapterFactory: ChatGptWebAdapterFactory = createChatGptWebAdapter,
433
+ options: ResponseRequestOptions = {},
434
+ ): Promise<Response> {
435
+ const nativeRequest = req.clone();
436
+ let raw: unknown;
437
+ try {
438
+ raw = await readJsonRequestBody(req);
439
+ } catch (error) {
440
+ return formatErrorResponse(
441
+ 400,
442
+ "invalid_request_error",
443
+ error instanceof Error ? error.message : "Request body must be valid JSON",
444
+ );
445
+ }
446
+ const requestedModel = raw && typeof raw === "object" && !Array.isArray(raw)
447
+ ? (raw as { model?: unknown }).model
448
+ : undefined;
449
+ try {
450
+ const identity = extractCodexTurnIdentityFromBody(raw);
451
+ if (identity.threadId && identity.turnId) {
452
+ options.onTurnIdentity?.({ threadId: identity.threadId, turnId: identity.turnId });
453
+ }
454
+ } catch (error) {
455
+ return formatErrorResponse(400, "invalid_request_error", error instanceof Error ? error.message : String(error));
456
+ }
457
+ if (typeof requestedModel === "string" && !isChatGptWebModelSlug(requestedModel)) {
458
+ try {
459
+ return await forwardNativeCodexRequest(nativeRequest, "responses", undefined, raw);
460
+ } catch (error) {
461
+ return formatErrorResponse(502, "upstream_error", error instanceof Error ? error.message : String(error));
462
+ }
463
+ }
464
+ const requestedPreviousResponseId = raw && typeof raw === "object" && !Array.isArray(raw)
465
+ ? (raw as { previous_response_id?: unknown }).previous_response_id
466
+ : undefined;
467
+ const expanded = expandPreviousResponseInput(raw);
468
+ let parsed: CodexParsedRequest;
469
+ let route: ChatGptWebModelRoute;
470
+ try {
471
+ parsed = parseRequest(expanded);
472
+ route = routeChatGptWebRequest(parsed, config);
473
+ const identity = extractChatGptTurnIdentity(parsed);
474
+ if (identity.threadId && identity.turnId) {
475
+ options.onTurnIdentity?.({ threadId: identity.threadId, turnId: identity.turnId });
476
+ }
477
+ } catch (error) {
478
+ return formatErrorResponse(400, "invalid_request_error", error instanceof Error ? error.message : String(error));
479
+ }
480
+ if (parsed._opaqueMultiAgentV2Payload) {
481
+ return formatErrorResponse(
482
+ 400,
483
+ "invalid_request_error",
484
+ "ChatGPT Web cannot read this encrypted cross-backend subagent payload. "
485
+ + "Start a new Compatibility V1 task, or delegate from a Web model whose collaboration call uses the plaintext-delivery marker.",
486
+ );
487
+ }
488
+ if (typeof requestedPreviousResponseId === "string" && expanded === raw) {
489
+ return formatErrorResponse(
490
+ 409,
491
+ "invalid_request_error",
492
+ "Local continuation state for previous_response_id is unavailable; refusing to run ChatGPT Web with partial Codex context. Compact the Codex task or start a new task before retrying.",
493
+ );
494
+ }
495
+
496
+ const compaction = parsed._compactionRequest === true;
497
+ if (compaction && route.backendModel === CHATGPT_WEB_LUNA_BACKEND_MODEL) {
498
+ return formatErrorResponse(
499
+ 409,
500
+ "invalid_request_error",
501
+ "ChatGPT Web Luna uses a rolling checkpoint on every completed browser turn; separate Codex compaction is disabled for this route.",
502
+ );
503
+ }
504
+ if (compaction) {
505
+ // History compaction is a dedicated summarization turn. It must never bind the active Codex
506
+ // tool bridge or continue an in-flight MCP round; the returned summary becomes the next turn's
507
+ // replacement history through the Responses compaction contract.
508
+ delete parsed.context.tools;
509
+ delete parsed.options.toolChoice;
510
+ delete parsed.options.parallelToolCalls;
511
+ parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() });
512
+ }
513
+
514
+ const provider = providerConfig(config);
515
+ let traceId: string | undefined;
516
+ try {
517
+ traceId = chatGptWebTraceId(provider, parsed);
518
+ } catch (error) {
519
+ // A cancelled browser session can only exist after the adapter accepted canonical native
520
+ // turn identity and user-revision metadata. Requests without that identity have no matching
521
+ // trace tombstone; preserve the adapter's existing strict validation/error path below.
522
+ const message = error instanceof Error ? error.message : String(error);
523
+ if (message === CHATGPT_TURN_REVISION_CONFLICT_MESSAGE) {
524
+ // Codex can reopen an interrupted task with only refreshed developer/skill context under a
525
+ // new turn_id. Its last human prompt still belongs to the stopped turn and must not be
526
+ // replayed as new work. HTTP 400 makes that malformed recovery request terminal instead of
527
+ // allowing Codex to retry it as an upstream 502.
528
+ return formatErrorResponse(400, "invalid_request_error", message);
529
+ }
530
+ if (!message.includes("requires native Codex turn_id metadata")
531
+ && !message.includes("requires a current-turn user message")) throw error;
532
+ }
533
+ const cancelledError = traceId ? chatGptTurnSessions.cancelledError(traceId) : undefined;
534
+ if (cancelledError) {
535
+ // Codex retries unknown streamed response.failed codes. A replay after the user explicitly
536
+ // closed the only browser document is instead a terminal client state: repeating that exact
537
+ // request is invalid and must not recreate the DOM. Codex maps HTTP 400 to its non-retryable
538
+ // InvalidRequest category while the body preserves the real client_cancelled classification.
539
+ return new Response(JSON.stringify({
540
+ error: {
541
+ type: "client_closed_request",
542
+ code: "client_cancelled",
543
+ message: cancelledError.message,
544
+ },
545
+ }), {
546
+ status: 400,
547
+ headers: { "content-type": "application/json" },
548
+ });
549
+ }
550
+ const adapter = adapterFactory(provider);
551
+ const queue = new AsyncEventQueue<AdapterEvent>();
552
+ const abort = new AbortController();
553
+ if (req.signal.aborted) abort.abort();
554
+ else req.signal.addEventListener("abort", () => abort.abort(), { once: true });
555
+ const run = async () => {
556
+ try {
557
+ await adapter.runTurn!(parsed, { headers: req.headers, abortSignal: abort.signal }, event => {
558
+ options.onAdapterEvent?.(event);
559
+ queue.push(event);
560
+ });
561
+ } catch (error) {
562
+ const event: AdapterEvent = { type: "error", message: error instanceof Error ? error.message : String(error) };
563
+ options.onAdapterEvent?.(event);
564
+ queue.push(event);
565
+ } finally {
566
+ queue.close();
567
+ }
568
+ };
569
+ const maps = toolBridgeMaps(parsed);
570
+ const responseModel = route.slug;
571
+
572
+ if (parsed.stream) {
573
+ void run();
574
+ const stream = bridgeToResponsesSSE(
575
+ queue,
576
+ responseModel,
577
+ maps.toolNsMap,
578
+ maps.freeformToolNames,
579
+ maps.toolSearchToolNames,
580
+ () => abort.abort(),
581
+ 2_000,
582
+ {
583
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
584
+ ...(provider.chatgptWeb?.stallTimeoutSec !== undefined
585
+ ? { stallTimeoutSec: provider.chatgptWeb.stallTimeoutSec }
586
+ : {}),
587
+ ...(compaction ? { compaction: true } : {
588
+ ...(options.rememberState === false ? {} : {
589
+ onCompletedResponse: (response: Record<string, unknown>) => rememberResponseState(parsed._rawBody, response, { force: true }),
590
+ }),
591
+ }),
592
+ },
593
+ );
594
+ return new Response(stream, {
595
+ headers: {
596
+ "Content-Type": "text/event-stream",
597
+ "Cache-Control": "no-cache",
598
+ "Connection": "keep-alive",
599
+ "X-Accel-Buffering": "no",
600
+ },
601
+ });
602
+ }
603
+
604
+ await run();
605
+ const events = await queue.collect();
606
+ const json = buildResponseJSON(events, responseModel, {
607
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
608
+ toolNsMap: maps.toolNsMap,
609
+ freeformToolNames: maps.freeformToolNames,
610
+ toolSearchToolNames: maps.toolSearchToolNames,
611
+ ...(compaction ? { compaction: true } : {}),
612
+ });
613
+ if (!compaction && options.rememberState !== false) {
614
+ rememberResponseState(parsed._rawBody, json, { force: true });
615
+ }
616
+ return Response.json(json);
617
+ }
618
+
619
+ export async function compactRequest(
620
+ req: Request,
621
+ config: AppConfig,
622
+ adapterFactory: ChatGptWebAdapterFactory = createChatGptWebAdapter,
623
+ options: Pick<ResponseRequestOptions, "onTurnIdentity"> = {},
624
+ ): Promise<Response> {
625
+ const nativeRequest = req.clone();
626
+ let raw: Record<string, unknown>;
627
+ try {
628
+ const parsed = await readJsonRequestBody(req);
629
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
630
+ raw = parsed as Record<string, unknown>;
631
+ } catch (error) {
632
+ return formatErrorResponse(
633
+ 400,
634
+ "invalid_request_error",
635
+ error instanceof Error ? error.message : "Compaction request body must be a JSON object",
636
+ );
637
+ }
638
+ const headerTurnMetadata = req.headers.get("x-codex-turn-metadata");
639
+ if (headerTurnMetadata) {
640
+ const existingMetadata = raw.client_metadata;
641
+ const clientMetadata = existingMetadata && typeof existingMetadata === "object" && !Array.isArray(existingMetadata)
642
+ ? existingMetadata as Record<string, unknown>
643
+ : {};
644
+ raw = {
645
+ ...raw,
646
+ client_metadata: {
647
+ ...clientMetadata,
648
+ // `/responses/compact` carries native turn authority in this canonical Codex header,
649
+ // unlike ordinary `/responses` payloads where the same value also appears in the body.
650
+ "x-codex-turn-metadata": headerTurnMetadata,
651
+ },
652
+ };
653
+ }
654
+ try {
655
+ const identity = extractCodexTurnIdentityFromBody(raw);
656
+ if (identity.threadId && identity.turnId) {
657
+ options.onTurnIdentity?.({ threadId: identity.threadId, turnId: identity.turnId });
658
+ }
659
+ } catch (error) {
660
+ return formatErrorResponse(400, "invalid_request_error", error instanceof Error ? error.message : String(error));
661
+ }
662
+ if (typeof raw.model !== "string" || !raw.model) {
663
+ return formatErrorResponse(400, "invalid_request_error", "Compaction request requires a model");
664
+ }
665
+ if (!isChatGptWebModelSlug(raw.model)) {
666
+ try {
667
+ return await forwardNativeCodexRequest(nativeRequest, "responses/compact", undefined, raw);
668
+ } catch (error) {
669
+ return formatErrorResponse(502, "upstream_error", error instanceof Error ? error.message : String(error));
670
+ }
671
+ }
672
+ let route: ChatGptWebModelRoute;
673
+ try {
674
+ route = requireChatGptWebModelRoute(raw.model, config);
675
+ } catch (error) {
676
+ return formatErrorResponse(400, "invalid_request_error", error instanceof Error ? error.message : String(error));
677
+ }
678
+ if (route.backendModel === CHATGPT_WEB_LUNA_BACKEND_MODEL) {
679
+ return formatErrorResponse(
680
+ 409,
681
+ "invalid_request_error",
682
+ "ChatGPT Web Luna uses a rolling checkpoint on every completed browser turn; separate Codex compaction is disabled for this route.",
683
+ );
684
+ }
685
+ const input = Array.isArray(raw.input) ? raw.input : [];
686
+ const headers = new Headers(req.headers);
687
+ headers.set("content-type", "application/json");
688
+ const internal = new Request("http://127.0.0.1/v1/responses", {
689
+ method: "POST",
690
+ headers,
691
+ body: JSON.stringify({ ...raw, stream: false, input: [...input, { type: "compaction_trigger" }] }),
692
+ signal: req.signal,
693
+ });
694
+ const response = await responseRequest(internal, config, adapterFactory, options);
695
+ if (!response.ok) return response;
696
+ let body: {
697
+ output?: unknown[];
698
+ status?: unknown;
699
+ error?: { message?: unknown; type?: unknown; code?: unknown } | null;
700
+ };
701
+ try {
702
+ body = await response.json() as typeof body;
703
+ } catch {
704
+ return formatErrorResponse(502, "invalid_response_error", "Compaction turn returned invalid JSON");
705
+ }
706
+ if (body.error) {
707
+ const error = {
708
+ message: typeof body.error.message === "string" ? body.error.message : "Compaction turn failed",
709
+ type: typeof body.error.type === "string" ? body.error.type : "upstream_error",
710
+ code: typeof body.error.code === "string" ? body.error.code : null,
711
+ };
712
+ return Response.json(
713
+ { error },
714
+ { status: httpStatusFromTerminalError(error) },
715
+ );
716
+ }
717
+ if (body.status !== "completed") {
718
+ return formatErrorResponse(502, "upstream_error", `Compaction turn failed (status: ${String(body.status ?? "unknown")})`);
719
+ }
720
+ const items = (body.output ?? []).filter(
721
+ (item): item is { type: "compaction"; encrypted_content?: string } =>
722
+ Boolean(item && typeof item === "object" && (item as { type?: string }).type === "compaction"),
723
+ );
724
+ if (items.length !== 1) {
725
+ return formatErrorResponse(502, "invalid_response_error", `Compaction turn produced ${items.length} compaction items; expected one`);
726
+ }
727
+ const summary = typeof items[0]!.encrypted_content === "string"
728
+ ? decodeCompactionSummary(items[0]!.encrypted_content)
729
+ : null;
730
+ if (!summary?.trim()) {
731
+ return formatErrorResponse(502, "invalid_response_error", "Compaction turn produced an empty summary");
732
+ }
733
+ return Response.json({ output: buildCompactV1Output(extractCompactUserMessages(input), summary) });
734
+ }
735
+
736
+ function nodeReqToWebRequest(req: IncomingMessage, host: string, port: number): Request {
737
+ const url = `http://${req.headers.host || `${host}:${port}`}${req.url}`;
738
+ const method = req.method || "GET";
739
+ const headers = new Headers();
740
+ for (const [key, value] of Object.entries(req.headers)) {
741
+ if (Array.isArray(value)) {
742
+ for (const v of value) headers.append(key, v);
743
+ } else if (typeof value === "string") {
744
+ headers.set(key, value);
745
+ }
746
+ }
747
+
748
+ const hasBody = method !== "GET" && method !== "HEAD";
749
+ const body = hasBody ? (Readable.toWeb(req) as unknown as ReadableStream<Uint8Array>) : null;
750
+
751
+ return new Request(url, {
752
+ method,
753
+ headers,
754
+ body,
755
+ // @ts-expect-error duplex required by node fetch for stream body
756
+ duplex: hasBody ? "half" : undefined,
757
+ });
758
+ }
759
+
760
+ async function sendWebResponse(webRes: Response, res: ServerResponse): Promise<void> {
761
+ res.statusCode = webRes.status;
762
+ res.statusMessage = webRes.statusText;
763
+ webRes.headers.forEach((val, key) => {
764
+ res.setHeader(key, val);
765
+ });
766
+
767
+ if (!webRes.body) {
768
+ res.end();
769
+ return;
770
+ }
771
+
772
+ const reader = webRes.body.getReader();
773
+ res.on("close", () => {
774
+ void reader.cancel().catch(() => {});
775
+ });
776
+
777
+ try {
778
+ while (true) {
779
+ const { done, value } = await reader.read();
780
+ if (done) break;
781
+ res.write(value);
782
+ }
783
+ res.end();
784
+ } catch (err) {
785
+ if (!res.writableEnded) {
786
+ res.destroy(err instanceof Error ? err : new Error(String(err)));
787
+ }
788
+ }
789
+ }
790
+
791
+ export interface RunningServer {
792
+ port: number;
793
+ stop: (closeActiveConnections?: boolean) => Promise<void> | void;
794
+ }
795
+
796
+ export function startServer(
797
+ config: AppConfig,
798
+ dependencies: { fetchUpstream?: NativeFetch; adapterFactory?: ChatGptWebAdapterFactory } = {},
799
+ ): RunningServer {
800
+ if (config.purpose === "dev-harness") {
801
+ throw new Error("DEV harness configuration cannot start a Responses listener");
802
+ }
803
+ const startedAt = Date.now();
804
+ const turnBroker = config.mode === "full" ? TurnBroker.forSocket(config.brokerSocketPath) : undefined;
805
+ if (config.mode === "full") {
806
+ void turnBroker!.listen().catch(error => {
807
+ console.error(
808
+ `[chatgpt-web] turn broker endpoint is unavailable: ${error instanceof Error ? error.message : String(error)}`,
809
+ );
810
+ });
811
+ }
812
+ let draining = false;
813
+ let shutdownPromise: Promise<void> | undefined;
814
+ let successfulModelCatalogRequests = 0;
815
+ let lastSuccessfulModelCatalogRequestAt: string | null = null;
816
+ const httpTurns = new HttpTurnCounter();
817
+ const activity = () => ({
818
+ active_http_turns: httpTurns.count(),
819
+ active_browser_turns: chatGptTurnSessions.activeCount() + (turnBroker?.externalOwnerActiveCount() ?? 0),
820
+ });
821
+ const controlAuthorized = (req: Request): boolean => {
822
+ const header = req.headers.get("authorization") ?? "";
823
+ const expected = Buffer.from(`Bearer ${config.controlToken}`);
824
+ const actual = Buffer.from(header);
825
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
826
+ };
827
+ const handleFetch = async (req: Request): Promise<Response> => {
828
+ const url = new URL(req.url);
829
+ if (req.method === "GET" && url.pathname === "/healthz") {
830
+ return Response.json({
831
+ status: "ok",
832
+ service: "dsh-chatgpt-web",
833
+ version: VERSION,
834
+ mode: config.mode,
835
+ pid: process.pid,
836
+ port: config.port,
837
+ uptime: (Date.now() - startedAt) / 1_000,
838
+ accepting_turns: !draining,
839
+ successful_model_catalog_requests: successfulModelCatalogRequests,
840
+ last_successful_model_catalog_request_at: lastSuccessfulModelCatalogRequestAt,
841
+ ...activity(),
842
+ });
843
+ }
844
+ if (req.method === "POST" && (url.pathname === "/admin/drain" || url.pathname === "/admin/resume")) {
845
+ if (!controlAuthorized(req)) return new Response("Unauthorized", { status: 401 });
846
+ draining = url.pathname === "/admin/drain";
847
+ turnBroker?.setExternalOwnersAccepted(!draining);
848
+ return Response.json({ status: "ok", accepting_turns: !draining, ...activity() });
849
+ }
850
+ if (req.method === "POST" && url.pathname === "/admin/cancel-turn") {
851
+ if (!controlAuthorized(req)) return new Response("Unauthorized", { status: 401 });
852
+ let traceId: string;
853
+ try {
854
+ const body = await req.json() as { traceId?: unknown };
855
+ traceId = typeof body?.traceId === "string" ? body.traceId : "";
856
+ if (!/^[A-Za-z0-9_-]{6,128}$/.test(traceId)) throw new Error("traceId is invalid");
857
+ } catch (error) {
858
+ return Response.json(
859
+ { status: "error", error: error instanceof Error ? error.message : String(error) },
860
+ { status: 400 },
861
+ );
862
+ }
863
+ const reason = chatGptBrowserTabClosedError();
864
+ // Revoke the owner first. This prevents a compaction callback that observes its retained
865
+ // source being cancelled below from starting a fresh fallback during operator shutdown.
866
+ const compactionCancellation = cancelStructuredCompactionTrace(traceId, reason);
867
+ const browserCancellation = chatGptTurnSessions.cancelTrace(traceId, reason);
868
+ const [cancelledBrowserTurns, cancelledCompactionRuns] = await Promise.all([
869
+ browserCancellation,
870
+ compactionCancellation,
871
+ ]);
872
+ const cancelledBrokerTurns = turnBroker?.revokeTrace(traceId, reason) ?? 0;
873
+ return Response.json({
874
+ status: "ok",
875
+ trace_id: traceId,
876
+ cancelled_browser_turns: cancelledBrowserTurns,
877
+ cancelled_broker_turns: cancelledBrokerTurns,
878
+ cancelled_compaction_runs: cancelledCompactionRuns,
879
+ ...activity(),
880
+ });
881
+ }
882
+ if (req.method === "POST" && url.pathname === "/admin/interrupt-turn") {
883
+ if (!controlAuthorized(req)) return new Response("Unauthorized", { status: 401 });
884
+ let identity: NativeCodexTurnIdentity;
885
+ try {
886
+ const body = await req.json() as { threadId?: unknown; turnId?: unknown };
887
+ const threadId = typeof body?.threadId === "string" ? body.threadId.trim() : "";
888
+ const turnId = typeof body?.turnId === "string" ? body.turnId.trim() : "";
889
+ if (!/^[A-Za-z0-9_-]{6,128}$/.test(threadId) || !/^[A-Za-z0-9_-]{6,128}$/.test(turnId)) {
890
+ throw new Error("native Codex threadId or turnId is invalid");
891
+ }
892
+ identity = { threadId, turnId };
893
+ } catch (error) {
894
+ return Response.json(
895
+ { status: "error", error: error instanceof Error ? error.message : String(error) },
896
+ { status: 400 },
897
+ );
898
+ }
899
+ const reason = new DOMException("Codex turn interrupted", "AbortError");
900
+ const browserCancellation = chatGptTurnSessions.cancelNativeTurn(
901
+ identity.threadId,
902
+ identity.turnId,
903
+ reason,
904
+ );
905
+ const compactionCancellation = cancelStructuredCompactionNativeTurn(
906
+ identity.threadId,
907
+ identity.turnId,
908
+ reason,
909
+ );
910
+ const httpCancellation = httpTurns.beginCancelTurn(identity, reason);
911
+ const settlement = Promise.allSettled([
912
+ browserCancellation.settlement,
913
+ compactionCancellation.settlement,
914
+ httpCancellation.settlement,
915
+ ]);
916
+ void settlement.then(results => {
917
+ for (const result of results) {
918
+ if (result.status === "rejected") {
919
+ console.error(
920
+ `[chatgpt-web] interrupted turn cleanup failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,
921
+ );
922
+ }
923
+ }
924
+ });
925
+ return Response.json({
926
+ status: "ok",
927
+ cancelled_http_turns: httpCancellation.cancelled,
928
+ cancelled_browser_turns: browserCancellation.cancelled,
929
+ cancelled_compaction_runs: compactionCancellation.cancelled,
930
+ });
931
+ }
932
+ if (req.method === "POST" && url.pathname === "/admin/cancel-turns") {
933
+ if (!controlAuthorized(req)) return new Response("Unauthorized", { status: 401 });
934
+ const reason = new Error("Active turn cancelled by launcher");
935
+ // Abort shared compaction owners before clearing their retained source sessions. The
936
+ // owner signal is the only cancellation boundary for a fresh fallback not in the session
937
+ // registry.
938
+ const compactionCancellation = cancelAllStructuredCompactions(reason);
939
+ const cancelledBrowserTurns = chatGptTurnSessions.clear() + (turnBroker?.revokeExternalOwners() ?? 0);
940
+ const [cancelledHttpTurns, cancelledCompactionRuns] = await Promise.all([
941
+ httpTurns.cancelAll(reason),
942
+ compactionCancellation,
943
+ ]);
944
+ return Response.json({
945
+ status: "ok",
946
+ cancelled_http_turns: cancelledHttpTurns,
947
+ cancelled_browser_turns: cancelledBrowserTurns,
948
+ cancelled_compaction_runs: cancelledCompactionRuns,
949
+ ...activity(),
950
+ });
951
+ }
952
+ if (req.method === "POST" && url.pathname === "/admin/shutdown") {
953
+ if (!controlAuthorized(req)) return new Response("Unauthorized", { status: 401 });
954
+ const current = activity();
955
+ if (!draining || current.active_http_turns > 0 || current.active_browser_turns > 0) {
956
+ return Response.json(
957
+ {
958
+ status: "refused",
959
+ accepting_turns: !draining,
960
+ ...current,
961
+ },
962
+ { status: 409 },
963
+ );
964
+ }
965
+ setTimeout(shutdown, 0);
966
+ return Response.json({ status: "ok", accepting_turns: false, ...current });
967
+ }
968
+ if (req.method === "GET" && url.pathname === "/v1/models") {
969
+ if (draining) {
970
+ return formatErrorResponse(
971
+ 503,
972
+ "server_error",
973
+ "dsh-chatgpt-web is draining for a requested service operation",
974
+ );
975
+ }
976
+ return httpTurns.track(async signal => {
977
+ let catalogConfig: AppConfig;
978
+ try {
979
+ catalogConfig = {
980
+ ...config,
981
+ subagentProtocol: readCodexSubagentProtocol(config.subagentProtocol),
982
+ };
983
+ } catch (error) {
984
+ return formatErrorResponse(
985
+ 500,
986
+ "server_error",
987
+ `Could not resolve the installed subagent protocol: ${error instanceof Error ? error.message : String(error)}`,
988
+ );
989
+ }
990
+ const response = await modelsRequest(
991
+ new Request(req, { signal }),
992
+ catalogConfig,
993
+ dependencies.fetchUpstream,
994
+ readCodexModelContextOverride,
995
+ );
996
+ if (response.ok) {
997
+ successfulModelCatalogRequests += 1;
998
+ lastSuccessfulModelCatalogRequestAt = new Date().toISOString();
999
+ }
1000
+ return response;
1001
+ }, req.signal, process.platform, "models");
1002
+ }
1003
+ if (req.method === "GET" && url.pathname === "/v1/responses") {
1004
+ return new Response("Responses WebSocket transport is not enabled on this local route", {
1005
+ status: 426,
1006
+ headers: { "content-type": "text/plain; charset=utf-8" },
1007
+ });
1008
+ }
1009
+ if (req.method === "POST" && url.pathname === "/v1/responses") {
1010
+ if (draining) return formatErrorResponse(503, "server_error", "dsh-chatgpt-web is draining for a requested service operation");
1011
+ return httpTurns.track(
1012
+ (signal, bindIdentity) => responseRequest(
1013
+ new Request(req, { signal }),
1014
+ config,
1015
+ dependencies.adapterFactory,
1016
+ { onTurnIdentity: bindIdentity },
1017
+ ),
1018
+ req.signal,
1019
+ process.platform,
1020
+ "responses",
1021
+ );
1022
+ }
1023
+ if (req.method === "POST" && url.pathname === "/v1/responses/compact") {
1024
+ if (draining) return formatErrorResponse(503, "server_error", "dsh-chatgpt-web is draining for a requested service operation");
1025
+ return httpTurns.track(
1026
+ (signal, bindIdentity) => compactRequest(
1027
+ new Request(req, { signal }),
1028
+ config,
1029
+ dependencies.adapterFactory,
1030
+ { onTurnIdentity: bindIdentity },
1031
+ ),
1032
+ req.signal,
1033
+ process.platform,
1034
+ "compact",
1035
+ );
1036
+ }
1037
+ if (req.method === "POST" && url.pathname === "/v1/alpha/search") {
1038
+ if (draining) return formatErrorResponse(503, "server_error", "dsh-chatgpt-web is draining for a requested service operation");
1039
+ return httpTurns.track(
1040
+ signal => nativeSearchRequest(new Request(req, { signal }), dependencies.fetchUpstream),
1041
+ req.signal,
1042
+ process.platform,
1043
+ "search",
1044
+ );
1045
+ }
1046
+ return new Response("Not found", { status: 404 });
1047
+ };
1048
+
1049
+ let server: RunningServer;
1050
+ if (typeof Bun !== "undefined" && typeof Bun.serve === "function") {
1051
+ const bunServer = Bun.serve({
1052
+ hostname: config.host,
1053
+ port: config.port,
1054
+ idleTimeout: 0,
1055
+ fetch: handleFetch,
1056
+ });
1057
+ server = {
1058
+ port: bunServer.port ?? config.port,
1059
+ stop: async (force) => {
1060
+ await bunServer.stop(force);
1061
+ },
1062
+ };
1063
+ } else {
1064
+ const nodeServer = createServer(async (req, res) => {
1065
+ try {
1066
+ const webReq = nodeReqToWebRequest(req, config.host, config.port);
1067
+ const webRes = await handleFetch(webReq);
1068
+ await sendWebResponse(webRes, res);
1069
+ } catch (err) {
1070
+ if (!res.headersSent) {
1071
+ res.statusCode = 500;
1072
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
1073
+ }
1074
+ }
1075
+ });
1076
+ nodeServer.listen(config.port, config.host);
1077
+ server = {
1078
+ port: config.port,
1079
+ stop: () => new Promise<void>((resolve) => nodeServer.close(() => resolve())),
1080
+ };
1081
+ }
1082
+
1083
+ function shutdown(): void {
1084
+ if (shutdownPromise) return;
1085
+ draining = true;
1086
+ chatGptTurnSessions.clear();
1087
+ flushResponseState();
1088
+ shutdownPromise = (async () => {
1089
+ const results = await Promise.allSettled([
1090
+ closeChatGptBrowserWorkers(),
1091
+ closeTurnBrokers(),
1092
+ ]);
1093
+ const failures = results
1094
+ .filter((result): result is PromiseRejectedResult => result.status === "rejected")
1095
+ .map(result => result.reason);
1096
+ if (failures.length > 0) {
1097
+ process.exitCode = 1;
1098
+ for (const failure of failures) {
1099
+ console.error(`[dsh-chatgpt-web] shutdown cleanup failed: ${failure instanceof Error ? failure.message : String(failure)}`);
1100
+ }
1101
+ }
1102
+ await server.stop(true);
1103
+ })().catch(error => {
1104
+ process.exitCode = 1;
1105
+ console.error(`[dsh-chatgpt-web] server shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
1106
+ });
1107
+ }
1108
+ process.once("SIGINT", shutdown);
1109
+ process.once("SIGTERM", shutdown);
1110
+ return server;
1111
+ }