@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
@@ -0,0 +1,281 @@
1
+ export interface CodexErrorPayload {
2
+ message: string;
3
+ type: string;
4
+ code: string | null;
5
+ }
6
+
7
+ function isSubscriptionGateMessage(text: string): boolean {
8
+ return (
9
+ text.includes("requires a subscription") ||
10
+ text.includes("requires subscription") ||
11
+ text.includes("subscription required") ||
12
+ text.includes("upgrade for access") ||
13
+ text.includes("upgrade to pro") ||
14
+ text.includes("pro subscription") ||
15
+ (text.includes("upgrade") && text.includes("subscription"))
16
+ );
17
+ }
18
+
19
+ function isAuthenticationMessage(text: string): boolean {
20
+ const accessDeniedWithCredentialCue = (
21
+ text.includes("access denied") ||
22
+ text.includes("accessdeniedexception")
23
+ ) && (
24
+ text.includes("authentication") ||
25
+ text.includes("credential") ||
26
+ text.includes("api key") ||
27
+ text.includes("token") ||
28
+ text.includes("signature")
29
+ );
30
+ return (
31
+ text.includes("authentication failed") ||
32
+ text.includes("authentication") ||
33
+ text.includes("invalid_api_key") ||
34
+ text.includes("invalid api key") ||
35
+ text.includes("invalid token") ||
36
+ text.includes("unauthorizedexception") ||
37
+ text.includes("unrecognizedclientexception") ||
38
+ text.includes("unrecognizedclient") ||
39
+ text.includes("expired token") ||
40
+ text.includes("expiredtoken") ||
41
+ text.includes("unauthenticated") ||
42
+ text.includes("unauthorized") ||
43
+ accessDeniedWithCredentialCue
44
+ );
45
+ }
46
+
47
+ function isPermissionMessage(text: string): boolean {
48
+ return (
49
+ text.includes("permission_denied") ||
50
+ text.includes("permission denied") ||
51
+ text.includes("forbidden") ||
52
+ text.includes("access denied") ||
53
+ text.includes("accessdeniedexception") ||
54
+ text.includes("not allowed to use") ||
55
+ text.includes("model access")
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Client cancelled / closed the turn. Matches only explicit client-abort phrases
61
+ * produced by request handlers and adapters. Deliberately narrow: bare "client closed"
62
+ * would also swallow legitimate upstream failures like "upstream HTTP client
63
+ * closed idle connection" and turn a real 502 into a 499.
64
+ */
65
+ export function isClientClosedMessage(text: string): boolean {
66
+ const lower = text.toLowerCase();
67
+ return (
68
+ lower.includes("client closed request") ||
69
+ lower.includes("client cancelled request") ||
70
+ lower.includes("client canceled request") ||
71
+ lower.includes("request canceled by client") ||
72
+ lower.includes("request cancelled by client")
73
+ );
74
+ }
75
+
76
+ export function classifyError(status: number, type: string, message: string): CodexErrorPayload {
77
+ const text = message.toLowerCase();
78
+ // Preserve explicit cancel types; unify message-inferred client closes onto
79
+ // client_closed_request for /api/logs.
80
+ if (type === "client_cancelled") {
81
+ return { message, type: "client_cancelled", code: "client_cancelled" };
82
+ }
83
+ if (
84
+ status === 499 ||
85
+ type === "client_closed_request" ||
86
+ isClientClosedMessage(text)
87
+ ) {
88
+ return { message, type: "invalid_request_error", code: "client_closed_request" };
89
+ }
90
+ if (
91
+ text.includes("context_length_exceeded") ||
92
+ text.includes("context window") ||
93
+ text.includes("context length") ||
94
+ text.includes("maximum context") ||
95
+ text.includes("too many tokens")
96
+ ) {
97
+ return { message, type: "invalid_request_error", code: "context_length_exceeded" };
98
+ }
99
+ if (
100
+ text.includes("insufficient_quota") ||
101
+ text.includes("exceeded your current quota") ||
102
+ text.includes("quota exhausted") ||
103
+ text.includes("account quota exceeded") ||
104
+ text.includes("monthly quota exceeded") ||
105
+ text.includes("daily quota exceeded")
106
+ ) {
107
+ return { message, type: "insufficient_quota", code: "insufficient_quota" };
108
+ }
109
+ if (
110
+ status === 429 ||
111
+ text.includes("rate limit") ||
112
+ text.includes("rate limited") ||
113
+ text.includes("too many requests") ||
114
+ text.includes("resource_exhausted") ||
115
+ text.includes("resource exhausted") ||
116
+ text.includes("throttlingexception") ||
117
+ text.includes("throttling")
118
+ ) {
119
+ return { message, type: "rate_limit_error", code: "rate_limit_exceeded" };
120
+ }
121
+ if (type === "origin_rejected") {
122
+ return { message, type: "invalid_request_error", code: "origin_rejected" };
123
+ }
124
+ // HTTP 401 and explicit auth failures are authoritative even when provider text
125
+ // also advertises an upgrade or subscription.
126
+ if (
127
+ status === 401 ||
128
+ type === "authentication_error" ||
129
+ isAuthenticationMessage(text)
130
+ ) {
131
+ return { message, type: "authentication_error", code: "invalid_api_key" };
132
+ }
133
+ // Subscription labels are valid only in a known permission context.
134
+ if (
135
+ (status === 403 || type === "permission_error") &&
136
+ isSubscriptionGateMessage(text)
137
+ ) {
138
+ return { message, type: "permission_error", code: "subscription_required" };
139
+ }
140
+ if (
141
+ status === 403 ||
142
+ type === "permission_error" ||
143
+ isPermissionMessage(text)
144
+ ) {
145
+ return { message, type: "permission_error", code: "permission_denied" };
146
+ }
147
+ if (
148
+ status === 503 ||
149
+ text.includes("overloaded") ||
150
+ text.includes("server is busy") ||
151
+ text.includes("temporarily unavailable")
152
+ ) {
153
+ // Codex recognizes "server_is_overloaded" and applies retry-after backoff
154
+ // (responses.rs is_server_overloaded_error); generic "upstream_server_error" is not recognized.
155
+ return { message, type: "server_error", code: "server_is_overloaded" };
156
+ }
157
+ if (
158
+ text.includes("validationexception") ||
159
+ text.includes("invalid request") ||
160
+ text.includes("model unavailable") ||
161
+ text.includes("model not found") ||
162
+ text.includes("unsupported model")
163
+ ) {
164
+ return { message, type: "invalid_request_error", code: "invalid_request_error" };
165
+ }
166
+ if (status >= 500) {
167
+ return { message, type: "server_error", code: "upstream_server_error" };
168
+ }
169
+ if (status === 400 || type === "invalid_request_error") {
170
+ return { message, type: "invalid_request_error", code: "invalid_request_error" };
171
+ }
172
+ return { message, type, code: type || null };
173
+ }
174
+
175
+ /** Best-effort parse of a retry delay embedded in an upstream error message. */
176
+ export function parseRetryAfterFromMessage(message: string): number | undefined {
177
+ const patterns = [
178
+ /try again in (\d+(?:\.\d+)?)\s*s(?:ec(?:ond)?s?)?/i,
179
+ /retry after (\d+(?:\.\d+)?)\s*s(?:ec(?:ond)?s?)?/i,
180
+ /retry[- ]after[:\s]+(\d+)/i,
181
+ ];
182
+ for (const pattern of patterns) {
183
+ const match = message.match(pattern);
184
+ if (!match?.[1]) continue;
185
+ const seconds = Number.parseFloat(match[1]);
186
+ if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds);
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ /** Infer HTTP status from adapter terminal error text (provider-agnostic keyword matching). */
192
+ export function inferHttpStatusFromAdapterMessage(message: string): number {
193
+ const lower = message.toLowerCase();
194
+ // Client aborts must not look like upstream 502s in /api/logs.
195
+ if (isClientClosedMessage(lower)) return 499;
196
+ if (
197
+ lower.includes("resource_exhausted") ||
198
+ lower.includes("resource exhausted") ||
199
+ lower.includes("rate limit") ||
200
+ lower.includes("too many requests") ||
201
+ lower.includes("throttling")
202
+ ) return 429;
203
+ // Strong authentication signals win when a message contains mixed auth and
204
+ // subscription/permission wording.
205
+ if (isAuthenticationMessage(lower)) return 401;
206
+ if (isSubscriptionGateMessage(lower) || isPermissionMessage(lower)) return 403;
207
+ if (
208
+ lower.includes("unavailable") ||
209
+ lower.includes("overloaded") ||
210
+ lower.includes("temporarily") ||
211
+ lower.includes("server is busy")
212
+ ) return 503;
213
+ if (
214
+ lower.includes("invalid") ||
215
+ lower.includes("not found") ||
216
+ lower.includes("unsupported") ||
217
+ lower.includes("malformed") ||
218
+ lower.includes("unimplemented")
219
+ ) return 400;
220
+ if (
221
+ lower.includes("timed out") ||
222
+ lower.includes("timeout") ||
223
+ lower.includes("etimedout") ||
224
+ lower.includes("deadline")
225
+ ) return 504;
226
+ return 502;
227
+ }
228
+
229
+ /** Map an adapter terminal error message to HTTP status + classified Codex error payload. */
230
+ export function adapterFailureFromMessage(message: string): { httpStatus: number; error: CodexErrorPayload } {
231
+ const httpStatus = inferHttpStatusFromAdapterMessage(message);
232
+ let finalMessage = message;
233
+ const retryAfterSeconds = parseRetryAfterFromMessage(message);
234
+ if (retryAfterSeconds && !/please try again in /i.test(message)) {
235
+ finalMessage = `${message} Please try again in ${retryAfterSeconds}s.`;
236
+ }
237
+ const errorType = httpStatus === 499
238
+ ? "client_closed_request"
239
+ : httpStatus === 429
240
+ ? "rate_limit_error"
241
+ : httpStatus === 401
242
+ ? "authentication_error"
243
+ : httpStatus === 403
244
+ ? "permission_error"
245
+ : httpStatus === 503 || httpStatus === 504
246
+ ? "server_error"
247
+ : httpStatus === 400
248
+ ? "invalid_request_error"
249
+ : "upstream_error";
250
+ return {
251
+ httpStatus,
252
+ error: classifyError(httpStatus, errorType, finalMessage),
253
+ };
254
+ }
255
+
256
+ /** Map a terminal Responses error object to the HTTP status we record in /api/logs. */
257
+ export function httpStatusFromTerminalError(error: {
258
+ type?: string;
259
+ code?: string | null;
260
+ message?: string;
261
+ } | undefined): number {
262
+ if (!error) return 502;
263
+ if (error.code === "client_closed_request" || error.code === "client_cancelled") return 499;
264
+ if (error.type === "rate_limit_error" || error.code === "rate_limit_exceeded") return 429;
265
+ if (error.type === "authentication_error" || error.code === "invalid_api_key") return 401;
266
+ if (
267
+ error.type === "permission_error" ||
268
+ error.code === "permission_denied" ||
269
+ error.code === "subscription_required"
270
+ ) return 403;
271
+ if (error.type === "insufficient_quota" || error.code === "insufficient_quota") return 429;
272
+ if (error.type === "server_error" && error.code === "server_is_overloaded") return 503;
273
+ // Client-closed messages often arrive as invalid_request_error after classifyError; check message
274
+ // before treating every invalid_request_error as HTTP 400.
275
+ const message = error.message ?? "";
276
+ if (message && isClientClosedMessage(message)) return 499;
277
+ if (error.type === "invalid_request_error") return 400;
278
+ if (error.type === "proxy_error") return 500;
279
+ if (message) return inferHttpStatusFromAdapterMessage(message);
280
+ return 502;
281
+ }
@@ -0,0 +1,42 @@
1
+ import { get_encoding, type Tiktoken } from "tiktoken";
2
+
3
+ /**
4
+ * Token accounting for ChatGPT Web prompts.
5
+ *
6
+ * A character ratio is not safe here: dense JSON/base64 can contain far more tokens than prose
7
+ * of the same length. Count with the tokenizer used by the GPT-5 generation instead.
8
+ */
9
+
10
+ const TOKENIZER_CHUNK_CHARS = 4_096;
11
+ let tokenizer: Tiktoken | undefined;
12
+
13
+ function chatGptTokenizer(): Tiktoken {
14
+ tokenizer ??= get_encoding("o200k_base");
15
+ return tokenizer;
16
+ }
17
+
18
+ /**
19
+ * Count ordinary text conservatively without handing pathological multi-megabyte runs to one
20
+ * tokenizer call. Independent chunks can only lose cross-boundary merges, so their sum may
21
+ * over-count slightly but cannot under-count because of a missed boundary token.
22
+ */
23
+ export function estimateTokens(text: string, modelId?: string): number {
24
+ void modelId;
25
+ if (!text) return 0;
26
+
27
+ const encoding = chatGptTokenizer();
28
+ let count = 0;
29
+ for (let start = 0; start < text.length;) {
30
+ let end = Math.min(start + TOKENIZER_CHUNK_CHARS, text.length);
31
+ if (end < text.length) {
32
+ const previous = text.charCodeAt(end - 1);
33
+ const next = text.charCodeAt(end);
34
+ if (previous >= 0xD800 && previous <= 0xDBFF && next >= 0xDC00 && next <= 0xDFFF) {
35
+ end -= 1;
36
+ }
37
+ }
38
+ count += encoding.encode_ordinary(text.slice(start, end)).length;
39
+ start = end;
40
+ }
41
+ return count;
42
+ }
@@ -0,0 +1,140 @@
1
+ const { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } = require("node:fs");
2
+ const { tmpdir } = require("node:os");
3
+ const { dirname, join } = require("node:path");
4
+ const { chromium } = require("playwright-core");
5
+
6
+ const CHATGPT_TEMPORARY_CHAT_URL = "https://chatgpt.com/?temporary-chat=true";
7
+ const LOGIN_STORAGE_ROOT_DOMAINS = ["chatgpt.com", "openai.com"];
8
+
9
+ function allowedLoginStorageHost(rawHostname) {
10
+ const hostname = rawHostname.toLowerCase();
11
+ if (!/^[a-z0-9.-]+$/.test(hostname) || hostname.startsWith(".") || hostname.endsWith(".") || hostname.includes("..")) return false;
12
+ return LOGIN_STORAGE_ROOT_DOMAINS.some(root => hostname === root || hostname.endsWith(`.${root}`));
13
+ }
14
+
15
+ function sanitizeBrowserLoginStorageState(storageState) {
16
+ return {
17
+ cookies: (storageState.cookies || [])
18
+ .filter(cookie => !Object.prototype.hasOwnProperty.call(cookie, "partitionKey")
19
+ && allowedLoginStorageHost((cookie.domain || "").replace(/^\.+/, "")))
20
+ .map(cookie => ({ ...cookie })),
21
+ origins: (storageState.origins || [])
22
+ .filter(origin => origin.origin === "https://chatgpt.com")
23
+ .map(origin => ({
24
+ origin: origin.origin,
25
+ localStorage: (origin.localStorage || []).map(item => ({ ...item })),
26
+ })),
27
+ };
28
+ }
29
+
30
+ async function isUserAuthenticated(context, page) {
31
+ try {
32
+ const cookies = await context.cookies();
33
+ const hasAuthCookie = cookies.some(c =>
34
+ c.name.includes("session-token") ||
35
+ c.name.includes("__Secure-") ||
36
+ (c.domain.includes("chatgpt.com") && c.name.includes("token"))
37
+ );
38
+
39
+ const hasLoginButton = await page.locator(
40
+ 'button:has-text("Log in"), a:has-text("Log in"), [data-testid="login-button"], button:has-text("Sign in")'
41
+ ).first().isVisible().catch(() => false);
42
+
43
+ const hasProfile = await page.locator(
44
+ '[data-testid="profile-button"], [data-testid="accounts-profile-button"], [data-testid="user-menu"], button[aria-label*="Account"], button[aria-label*="profile" i]'
45
+ ).first().isVisible().catch(() => false);
46
+
47
+ const hasComposer = await page.locator(
48
+ '[data-testid="prompt-textarea"], #prompt-textarea, [contenteditable="true"][data-lexical-editor="true"]'
49
+ ).first().isVisible().catch(() => false);
50
+
51
+ if ((hasAuthCookie || hasProfile) && !hasLoginButton && hasComposer) {
52
+ return true;
53
+ }
54
+ } catch {}
55
+ return false;
56
+ }
57
+
58
+ async function runLogin(config, options = {}) {
59
+ if (!existsSync(config.chromeExecutablePath)) {
60
+ throw new Error(`Google Chrome was not found at ${config.chromeExecutablePath}`);
61
+ }
62
+ const profileDir = mkdtempSync(join(tmpdir(), "dsh-login-profile-"));
63
+
64
+ const context = await chromium.launchPersistentContext(profileDir, {
65
+ executablePath: config.chromeExecutablePath,
66
+ headless: false,
67
+ ignoreDefaultArgs: ["--enable-automation", "--password-store=basic", "--use-mock-keychain"],
68
+ args: ["--disable-blink-features=AutomationControlled", "--no-first-run", "--no-default-browser-check"],
69
+ });
70
+
71
+ try {
72
+ const page = context.pages()[0] || await context.newPage();
73
+ try {
74
+ await page.goto(CHATGPT_TEMPORARY_CHAT_URL, { waitUntil: "domcontentloaded", timeout: 60000 });
75
+ } catch {
76
+ // In case navigation redirects immediately (e.g. to login)
77
+ }
78
+
79
+ const deadline = Date.now() + (options.timeoutMs || 300000);
80
+ let authenticatedPage;
81
+
82
+ while (Date.now() < deadline) {
83
+ for (const p of context.pages()) {
84
+ if (await isUserAuthenticated(context, p)) {
85
+ authenticatedPage = p;
86
+ break;
87
+ }
88
+ }
89
+ if (authenticatedPage) break;
90
+ await new Promise(r => setTimeout(r, 1000));
91
+ }
92
+
93
+ if (!authenticatedPage) {
94
+ throw new Error("ChatGPT login timed out after 5 minutes");
95
+ }
96
+
97
+ const rawState = await context.storageState();
98
+ const sanitized = sanitizeBrowserLoginStorageState(rawState);
99
+
100
+ const marker = {
101
+ version: 1,
102
+ authenticated: true,
103
+ verifiedAt: new Date().toISOString(),
104
+ solAvailable: false,
105
+ proAvailable: false,
106
+ };
107
+
108
+ const markerPath = `${config.storageStatePath}.verified.json`;
109
+ mkdirSync(dirname(config.storageStatePath), { recursive: true });
110
+ writeFileSync(config.storageStatePath, `${JSON.stringify(sanitized, null, 2)}\n`, "utf8");
111
+ writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf8");
112
+
113
+ return {
114
+ storageStatePath: config.storageStatePath,
115
+ accountSurfaceUrl: authenticatedPage.url(),
116
+ solAvailable: false,
117
+ proAvailable: false,
118
+ };
119
+ } finally {
120
+ await context.close().catch(() => {});
121
+ try {
122
+ rmSync(profileDir, { recursive: true, force: true });
123
+ } catch {}
124
+ }
125
+ }
126
+
127
+ async function main() {
128
+ const payloadRaw = process.argv[2];
129
+ if (!payloadRaw) {
130
+ throw new Error("Missing config payload");
131
+ }
132
+ const payload = JSON.parse(payloadRaw);
133
+ const result = await runLogin(payload.config, payload.options);
134
+ process.stdout.write(`__RESULT__:${JSON.stringify(result)}\n`);
135
+ }
136
+
137
+ main().catch(err => {
138
+ process.stderr.write(`login-helper: ${err.message}\n`);
139
+ process.exit(1);
140
+ });
@@ -0,0 +1,197 @@
1
+ import type { AppConfig } from "./config";
2
+ import type { CodexModelContextOverride } from "./codex-integration";
3
+ import {
4
+ availableChatGptWebModelRoutes,
5
+ CHATGPT_WEB_MODEL_PREFIX,
6
+ resolveChatGptWebContextLimits,
7
+ type ChatGptWebModelRoute,
8
+ } from "./chatgpt-web-models";
9
+
10
+ type JsonObject = Record<string, unknown>;
11
+
12
+ function object(value: unknown, label: string): JsonObject {
13
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
14
+ throw new Error(`${label} must be a JSON object`);
15
+ }
16
+ return value as JsonObject;
17
+ }
18
+
19
+ function slug(value: unknown): string | undefined {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
21
+ const candidate = (value as JsonObject).slug;
22
+ return typeof candidate === "string" ? candidate : undefined;
23
+ }
24
+
25
+ function reasoningLevel(template: JsonObject, effort: string, description: string): JsonObject {
26
+ const levels = Array.isArray(template.supported_reasoning_levels)
27
+ ? template.supported_reasoning_levels.filter(level => level && typeof level === "object" && !Array.isArray(level)) as JsonObject[]
28
+ : [];
29
+ const source = levels.find(level => level.effort === effort);
30
+ return { ...(source ? structuredClone(source) : {}), effort, description };
31
+ }
32
+
33
+ function modelPriority(template: JsonObject): number | undefined {
34
+ const value = template.priority;
35
+ if (value === undefined) return undefined;
36
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) {
37
+ throw new Error("Native Codex model template priority must be an integer");
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function routedModelPriority(
43
+ template: JsonObject,
44
+ route: ChatGptWebModelRoute,
45
+ config: AppConfig,
46
+ ): number | undefined {
47
+ const priority = modelPriority(template);
48
+ if (priority === undefined
49
+ || config.subagentProtocol !== "compatibility-v1"
50
+ || route.slug !== "chatgpt-web/light") return priority;
51
+ if (priority === Number.MAX_SAFE_INTEGER) {
52
+ throw new Error("Native Codex model template priority cannot reserve the Compatibility V1 roster");
53
+ }
54
+ // Codex V1 exposes at most five model overrides. Keep the native Sol row plus the four useful
55
+ // delegated Web efforts (Medium, High, Extra High, Pro); Instant remains a selectable root model
56
+ // but does not displace Pro from spawn_agent's bounded registry.
57
+ return priority + 1;
58
+ }
59
+
60
+ function nativeTemplateCandidate(value: unknown, requireTools: boolean): value is JsonObject {
61
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
62
+ const model = value as JsonObject;
63
+ const modelSlug = slug(model);
64
+ if (!modelSlug || modelSlug.startsWith(CHATGPT_WEB_MODEL_PREFIX)) return false;
65
+ // This route forwards ChatGPT authentication. Codex's own model manager keeps every list-visible
66
+ // model in ChatGPT mode even when `supported_in_api` is false; that flag gates API-key mode, not
67
+ // whether the backend row is a valid catalog template. The routed Web row overrides the flag to
68
+ // true because this local Responses endpoint implements it.
69
+ if (model.visibility !== "list") return false;
70
+ if (!Array.isArray(model.supported_reasoning_levels)) return false;
71
+ return !requireTools || (typeof model.tool_mode === "string" && model.tool_mode.length > 0);
72
+ }
73
+
74
+ function selectNativeTemplate(models: unknown[], config: AppConfig): JsonObject {
75
+ const requireTools = config.mode === "full";
76
+ const candidates = models.filter(model => nativeTemplateCandidate(model, requireTools)) as JsonObject[];
77
+ const template = candidates[0];
78
+ if (template) return template;
79
+ throw new Error(
80
+ requireTools
81
+ ? "Native Codex models response has no list-visible, tool-capable model with reasoning metadata"
82
+ : "Native Codex models response has no list-visible model with reasoning metadata",
83
+ );
84
+ }
85
+
86
+ function useCompatibilityV1SubagentSurface(model: JsonObject): void {
87
+ // Compatibility V1 is an explicit whole-task protocol mode. Preserve an explicit disabled
88
+ // capability instead of advertising support that the native model denied.
89
+ if (model.multi_agent_version !== "disabled") model.multi_agent_version = "v1";
90
+ }
91
+
92
+ function routedSubagentVersion(template: JsonObject, config: AppConfig): string | undefined {
93
+ if (config.subagentProtocol === "compatibility-v1") return "v1";
94
+ return typeof template.multi_agent_version === "string" ? template.multi_agent_version : undefined;
95
+ }
96
+
97
+ export function buildChatGptWebModel(
98
+ templateValue: unknown,
99
+ route: ChatGptWebModelRoute,
100
+ config: AppConfig,
101
+ ): JsonObject {
102
+ const template = object(templateValue, "native Codex model template");
103
+ const templateSlug = slug(template);
104
+ if (!templateSlug || templateSlug.startsWith(CHATGPT_WEB_MODEL_PREFIX)) {
105
+ throw new Error("ChatGPT Web model template must be a native Codex model");
106
+ }
107
+ const limits = resolveChatGptWebContextLimits(route.backendModel, route.adapterEffort, config);
108
+ const multiAgentVersion = routedSubagentVersion(template, config);
109
+ const priority = routedModelPriority(template, route, config);
110
+ const model: JsonObject = {
111
+ ...structuredClone(template),
112
+ slug: route.slug,
113
+ display_name: route.displayName,
114
+ description: route.description,
115
+ input_modalities: route.interactionMode === "manual" ? ["text"] : ["text", "image"],
116
+ visibility: "list",
117
+ // These slugs are implemented by this local Responses-compatible bridge. Marking them false
118
+ // makes Codex drop them from spawn_agent whenever openai_base_url points at the bridge.
119
+ supported_in_api: true,
120
+ // Follow the official template's ordering without outranking it. Codex advertises at most five
121
+ // spawn-agent overrides; forcing every routed row to priority 0 displaced gpt-5.6-sol from that
122
+ // registry and made an explicit native child model fail validation.
123
+ ...(priority === undefined ? {} : { priority }),
124
+ // In native mode the routed row follows the official template's protocol surface. Web-origin
125
+ // V2 collaboration calls carry the protocol's explicit plaintext marker; Compatibility V1
126
+ // instead pins the entire catalog and Codex feature override to V1.
127
+ ...(multiAgentVersion === undefined
128
+ ? {}
129
+ : { multi_agent_version: multiAgentVersion }),
130
+ // Code mode collapses the outer registry into an exec gateway; routed models need the regular
131
+ // Responses tool surface so MCP namespaces, deferred tool_search, and custom tools reach us.
132
+ tool_mode: null,
133
+ upgrade: null,
134
+ default_reasoning_level: route.codexEffort,
135
+ supported_reasoning_levels: [reasoningLevel(template, route.codexEffort, route.displayName)],
136
+ context_window: limits.contextWindow,
137
+ max_context_window: limits.contextWindow,
138
+ effective_context_window_percent: limits.effectiveContextWindowPercent,
139
+ auto_compact_token_limit: limits.autoCompactTokenLimit,
140
+ // ChatGPT Web has no Codex service tier. Never inherit the native template's Fast tiers.
141
+ additional_speed_tiers: [],
142
+ service_tiers: [],
143
+ default_service_tier: null,
144
+ };
145
+ // A native template's compaction hash describes OpenAI's native model contract, not this routed
146
+ // browser model. The explicit Web window above is owned by this adapter and never copied back to
147
+ // native models or the user's top-level model_context_window setting.
148
+ delete model.comp_hash;
149
+ delete model.availability_nux;
150
+ return model;
151
+ }
152
+
153
+ export function augmentNativeModelCatalog(
154
+ value: unknown,
155
+ config: AppConfig,
156
+ contextOverride?: CodexModelContextOverride,
157
+ ): JsonObject {
158
+ const catalog = object(value, "native Codex models response");
159
+ if (!Array.isArray(catalog.models)) {
160
+ throw new Error("Native Codex models response is missing a models array");
161
+ }
162
+ const nativeModels = structuredClone(
163
+ catalog.models.filter(model => !slug(model)?.startsWith(CHATGPT_WEB_MODEL_PREFIX)),
164
+ );
165
+ if (config.subagentProtocol === "compatibility-v1") {
166
+ for (const candidate of nativeModels) {
167
+ if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
168
+ useCompatibilityV1SubagentSurface(candidate as JsonObject);
169
+ }
170
+ }
171
+ }
172
+ const template = selectNativeTemplate(nativeModels, config);
173
+ if (contextOverride) {
174
+ // model_context_window is a single top-level Codex setting, not a per-model one. Apply its
175
+ // advertised maximum to every native row so switching native models cannot silently clamp the
176
+ // effective override. Codex itself applies context_window and auto-compaction configuration.
177
+ for (const candidate of nativeModels) {
178
+ const modelSlug = slug(candidate);
179
+ if (!modelSlug) continue;
180
+ const model = object(candidate, `native ${modelSlug} model`);
181
+ const current = model.max_context_window;
182
+ if (current !== undefined && current !== null
183
+ && (typeof current !== "number" || !Number.isSafeInteger(current) || current <= 0)) {
184
+ throw new Error(`Native ${modelSlug} max_context_window must be a positive integer`);
185
+ }
186
+ if (current === undefined || current === null || current < contextOverride.contextWindow) {
187
+ model.max_context_window = contextOverride.contextWindow;
188
+ }
189
+ }
190
+ }
191
+ const webModels = availableChatGptWebModelRoutes(config)
192
+ .map(route => buildChatGptWebModel(template, route, config));
193
+ return {
194
+ ...structuredClone(catalog),
195
+ models: [...nativeModels, ...webModels],
196
+ };
197
+ }