@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,240 @@
1
+ import type { Locator, Page } from "playwright-core";
2
+ import type { ChatGptWebAccountCapabilities } from "./chatgpt-web-models";
3
+
4
+ export const CHATGPT_TEMPORARY_CHAT_URL = "https://chatgpt.com/?temporary-chat=true";
5
+ export const CHATGPT_COMPOSER_SELECTOR = [
6
+ '[data-testid="prompt-textarea"]',
7
+ "#prompt-textarea",
8
+ '[contenteditable="true"][data-lexical-editor="true"]',
9
+ ].join(", ");
10
+ export const CHATGPT_EFFORT_CONTROL_SELECTOR = [
11
+ 'button[aria-haspopup="menu"][data-tone="neutral"]',
12
+ 'button[data-testid="model-switcher-dropdown-button"][aria-haspopup="menu"]',
13
+ ].join(", ");
14
+ export const CHATGPT_EFFORT_MENU_SELECTOR = [
15
+ '[data-testid="composer-intelligence-picker-content"]:has([role="menuitemradio"], [data-model-reasoning-effort-slider])',
16
+ '[role="menu"]:has([role="menuitemradio"], [data-model-reasoning-effort-slider])',
17
+ '[role="group"]:has([role="menuitemradio"], [data-model-reasoning-effort-slider])',
18
+ ].join(", ");
19
+ export const CHATGPT_EFFORT_ITEM_SELECTOR = '[role="menuitemradio"]';
20
+ export const CHATGPT_EFFORT_SLIDER_SELECTOR = '[data-model-reasoning-effort-slider] [role="slider"]';
21
+ export const CHATGPT_EFFORT_SLIDER_MAX_OPTIONS = 5;
22
+ export const CHATGPT_STOP_BUTTON_SELECTOR = '[data-testid="stop-button"]';
23
+ export const CHATGPT_COMPLETION_ACTION_SELECTOR = 'button[data-testid="copy-turn-action-button"]';
24
+ export const CHATGPT_ASSISTANT_TURN_SELECTOR = [
25
+ '[data-testid^="conversation-turn-"][data-turn="assistant"]',
26
+ '[data-testid^="conversation-turn-"][data-message-author-role="assistant"]',
27
+ '[data-testid^="conversation-turn-"]:has([data-message-author-role="assistant"])',
28
+ ].join(", ");
29
+ export const CHATGPT_USER_TURN_SELECTOR = [
30
+ '[data-testid^="conversation-turn-"][data-turn="user"]',
31
+ '[data-testid^="conversation-turn-"][data-message-author-role="user"]',
32
+ '[data-testid^="conversation-turn-"]:has([data-message-author-role="user"])',
33
+ ].join(", ");
34
+
35
+ export interface ChatGptEffortSliderState {
36
+ min: number;
37
+ max: number;
38
+ value: number;
39
+ }
40
+
41
+ export interface ChatGptEffortActivation {
42
+ method: "already-open" | "click" | "pointerdown";
43
+ menu: Locator;
44
+ slider: Locator;
45
+ }
46
+
47
+ function effortMenuSelectorForId(menuId: string): string {
48
+ return `[id=${JSON.stringify(menuId)}]`;
49
+ }
50
+
51
+ export async function chatGptEffortMenuForControl(page: Page, control: Locator): Promise<Locator> {
52
+ const menuId = await control.getAttribute("aria-controls").catch(() => null);
53
+ if (menuId) return page.locator(effortMenuSelectorForId(menuId));
54
+ return page.locator(CHATGPT_EFFORT_MENU_SELECTOR).filter({ visible: true }).last();
55
+ }
56
+
57
+ async function visibleEffortSurface(
58
+ page: Page,
59
+ control: Locator,
60
+ ): Promise<{ menu: Locator; slider: Locator } | undefined> {
61
+ const menu = await chatGptEffortMenuForControl(page, control);
62
+ const slider = page.locator(CHATGPT_EFFORT_SLIDER_SELECTOR).filter({ visible: true }).last();
63
+ if (await menu.isVisible().catch(() => false) || await slider.isVisible().catch(() => false)) {
64
+ return { menu, slider };
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ async function waitForEffortSurface(
70
+ page: Page,
71
+ control: Locator,
72
+ timeoutMs: number,
73
+ ): Promise<{ menu: Locator; slider: Locator } | undefined> {
74
+ const deadline = Date.now() + timeoutMs;
75
+ do {
76
+ const surface = await visibleEffortSurface(page, control);
77
+ if (surface) return surface;
78
+ if (Date.now() >= deadline) return undefined;
79
+ await new Promise(resolveSleep => setTimeout(resolveSleep, 50));
80
+ } while (true);
81
+ }
82
+
83
+ async function clearGhostEffortState(page: Page, control: Locator): Promise<void> {
84
+ const expanded = await control.getAttribute("aria-expanded").catch(() => null);
85
+ const state = await control.getAttribute("data-state").catch(() => null);
86
+ if (expanded === "true" || state === "open") {
87
+ await page.keyboard.press("Escape").catch(() => {});
88
+ }
89
+ }
90
+
91
+ export async function activateChatGptEffortMenu(
92
+ page: Page,
93
+ control: Locator,
94
+ options: { settleMs?: number } = {},
95
+ ): Promise<ChatGptEffortActivation> {
96
+ const openSurface = await visibleEffortSurface(page, control);
97
+ if (openSurface) return { method: "already-open", ...openSurface };
98
+
99
+ const settleMs = options.settleMs ?? 3_000;
100
+ await clearGhostEffortState(page, control);
101
+ await control.click({ force: true, timeout: Math.max(1, settleMs) });
102
+ const clickedSurface = await waitForEffortSurface(page, control, settleMs);
103
+ if (clickedSurface) return { method: "click", ...clickedSurface };
104
+
105
+ await clearGhostEffortState(page, control);
106
+ await control.dispatchEvent("pointerdown", {
107
+ button: 0,
108
+ buttons: 1,
109
+ pointerType: "mouse",
110
+ isPrimary: true,
111
+ });
112
+ const pointerSurface = await waitForEffortSurface(page, control, settleMs);
113
+ if (pointerSurface) return { method: "pointerdown", ...pointerSurface };
114
+ throw new Error(
115
+ "ChatGPT effort control did not expose its owned menu or structural slider after click and primary pointerdown",
116
+ );
117
+ }
118
+
119
+ function safeIntegerAttribute(value: string | null): number | undefined {
120
+ if (value === null || !/^-?\d+$/.test(value)) return undefined;
121
+ const parsed = Number(value);
122
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
123
+ }
124
+
125
+ export function parseChatGptEffortSliderState(
126
+ rawMin: string | null,
127
+ rawMax: string | null,
128
+ rawValue: string | null,
129
+ ): ChatGptEffortSliderState | undefined {
130
+ const min = safeIntegerAttribute(rawMin);
131
+ const max = safeIntegerAttribute(rawMax);
132
+ const value = safeIntegerAttribute(rawValue);
133
+ if (min === undefined || max === undefined || value === undefined) return undefined;
134
+ const optionCount = max - min + 1;
135
+ if (optionCount < 1 || optionCount > CHATGPT_EFFORT_SLIDER_MAX_OPTIONS) return undefined;
136
+ if (value < min || value > max) return undefined;
137
+ return { min, max, value };
138
+ }
139
+
140
+ async function anyVisible(locator: Locator): Promise<boolean> {
141
+ const count = await locator.count();
142
+ for (let index = 0; index < count; index += 1) {
143
+ if (await locator.nth(index).isVisible().catch(() => false)) return true;
144
+ }
145
+ return false;
146
+ }
147
+
148
+ export async function assertAuthenticatedChatGptPage(page: Page): Promise<void> {
149
+ const composer = page.locator(
150
+ CHATGPT_COMPOSER_SELECTOR,
151
+ );
152
+ if (!await anyVisible(composer)) {
153
+ throw new Error("ChatGPT authentication could not be verified: no visible composer is present");
154
+ }
155
+ }
156
+
157
+ export async function assertTemporaryChatPage(page: Page): Promise<void> {
158
+ const url = new URL(page.url());
159
+ const expected = new URL(CHATGPT_TEMPORARY_CHAT_URL);
160
+ if (url.origin !== expected.origin || url.pathname !== expected.pathname || url.searchParams.get("temporary-chat") !== "true") {
161
+ throw new Error(`ChatGPT left the isolated Temporary Chat surface (${page.url()})`);
162
+ }
163
+ }
164
+
165
+ export async function detectChatGptAccountCapabilities(
166
+ page: Page,
167
+ options: { selectorTimeoutMs?: number; stableAbsenceMs?: number } = {},
168
+ ): Promise<ChatGptWebAccountCapabilities> {
169
+ const composers = page.locator(CHATGPT_COMPOSER_SELECTOR).filter({ visible: true });
170
+ const composer = composers.last();
171
+ const composerForm = composer.locator("xpath=ancestor::form[1]");
172
+ const effortButton = composerForm.locator(CHATGPT_EFFORT_CONTROL_SELECTOR).last();
173
+ const deadline = Date.now() + (options.selectorTimeoutMs ?? 30_000);
174
+ const stableAbsenceMs = options.stableAbsenceMs ?? 3_000;
175
+ let absenceSince: number | undefined;
176
+ let presenceObservations = 0;
177
+ while (true) {
178
+ const effortVisible = await effortButton.isVisible().catch(() => false);
179
+ if (effortVisible) {
180
+ presenceObservations += 1;
181
+ absenceSince = undefined;
182
+ if (presenceObservations >= 2) break;
183
+ await new Promise(resolveSleep => setTimeout(resolveSleep, 100));
184
+ continue;
185
+ }
186
+ presenceObservations = 0;
187
+ const composerReady = await composers.count().then(count => count === 1).catch(() => false);
188
+ const formReady = await composerForm.count().then(count => count === 1).catch(() => false);
189
+ const documentReady = await page.evaluate(() => document.readyState === "complete").catch(() => false);
190
+ if (composerReady && formReady && documentReady) {
191
+ absenceSince ??= Date.now();
192
+ if (Date.now() - absenceSince >= stableAbsenceMs) {
193
+ return { solAvailable: false, proAvailable: false };
194
+ }
195
+ } else {
196
+ absenceSince = undefined;
197
+ }
198
+ if (Date.now() >= deadline) {
199
+ throw new Error("ChatGPT account capability probe did not reach a stable composer state");
200
+ }
201
+ await new Promise(resolveSleep => setTimeout(resolveSleep, 100));
202
+ }
203
+ const menu = page.locator(CHATGPT_EFFORT_MENU_SELECTOR).last();
204
+ const menuVisible = await menu.isVisible().catch(() => false);
205
+ const menuExpanded = await effortButton.getAttribute("aria-expanded").catch(() => null);
206
+ if (!menuVisible && menuExpanded !== "true") await effortButton.press("Enter");
207
+ try {
208
+ const efforts = menu.locator(CHATGPT_EFFORT_ITEM_SELECTOR);
209
+ const slider = page.locator(CHATGPT_EFFORT_SLIDER_SELECTOR).filter({ visible: true }).last();
210
+ const waitAbort = new AbortController();
211
+ try {
212
+ const ready = await Promise.race([
213
+ efforts.first().waitFor({ state: "visible", timeout: 70_000, signal: waitAbort.signal })
214
+ .then(() => "items" as const),
215
+ slider.waitFor({ state: "visible", timeout: 70_000, signal: waitAbort.signal })
216
+ .then(() => "slider" as const),
217
+ ]);
218
+ const sliderVisible = ready === "slider" || await slider.isVisible().catch(() => false);
219
+ if (!sliderVisible) {
220
+ return { solAvailable: true, proAvailable: await efforts.count() >= 5 };
221
+ }
222
+ const state = parseChatGptEffortSliderState(
223
+ await slider.getAttribute("aria-valuemin"),
224
+ await slider.getAttribute("aria-valuemax"),
225
+ await slider.getAttribute("aria-valuenow"),
226
+ );
227
+ if (!state) {
228
+ throw new Error(
229
+ "ChatGPT model controls are unavailable. Reload ChatGPT and run Repair again.",
230
+ { cause: new Error("ChatGPT effort slider exposed an invalid ARIA range") },
231
+ );
232
+ }
233
+ return { solAvailable: true, proAvailable: state.max - state.min + 1 >= 5 };
234
+ } finally {
235
+ waitAbort.abort();
236
+ }
237
+ } finally {
238
+ await page.keyboard.press("Escape").catch(() => {});
239
+ }
240
+ }
@@ -0,0 +1,400 @@
1
+ export const CHATGPT_WEB_MODEL_PREFIX = "chatgpt-web/";
2
+ export const CHATGPT_WEB_BACKEND_MODEL = "gpt-5.6-sol";
3
+ export const CHATGPT_WEB_LUNA_BACKEND_MODEL = "gpt-5.6-luna";
4
+ /** Internal adapter identity for a turn whose ChatGPT model is selected by the user in the launcher. */
5
+ export const CHATGPT_WEB_ZERO_RISK_BACKEND_MODEL = "chatgpt-web-zero-risk";
6
+ /** Internal adapter identity for the explicitly enabled, Pro-sized Zero Risk context profile. */
7
+ export const CHATGPT_WEB_ZERO_RISK_PRO_BACKEND_MODEL = "chatgpt-web-zero-risk-pro";
8
+
9
+ export type ChatGptWebAutomaticBackendModel =
10
+ | typeof CHATGPT_WEB_BACKEND_MODEL
11
+ | typeof CHATGPT_WEB_LUNA_BACKEND_MODEL;
12
+ export type ChatGptWebBackendModel =
13
+ | ChatGptWebAutomaticBackendModel
14
+ | ChatGptWebZeroRiskBackendModel;
15
+ export type ChatGptWebZeroRiskBackendModel =
16
+ | typeof CHATGPT_WEB_ZERO_RISK_BACKEND_MODEL
17
+ | typeof CHATGPT_WEB_ZERO_RISK_PRO_BACKEND_MODEL;
18
+
19
+ export type ChatGptWebCodexEffort = "low" | "medium" | "high" | "xhigh" | "ultra";
20
+ export type ChatGptWebAdapterEffort = "low" | "medium" | "high" | "xhigh" | "max";
21
+
22
+ /**
23
+ * Measured Plus browser transport windows, including the fixed hidden ChatGPT platform reserve.
24
+ * Codex compacts the visible task at the lower explicit threshold before the next browser turn is
25
+ * compiled. The remaining headroom is owned by ChatGPT's product prompt and Codex Native schemas.
26
+ */
27
+ export const CHATGPT_WEB_INSTANT_CONTEXT_WINDOW = 41_000;
28
+ export const CHATGPT_WEB_INSTANT_AUTO_COMPACT_TOKEN_LIMIT = 32_000;
29
+ /**
30
+ * Zero Risk keeps one visible ChatGPT conversation across sequential Codex turns. Its fixed route
31
+ * therefore uses the requested three-turn compaction interval without enabling Bigger Context's
32
+ * automatic multipart transport; the user still pastes exactly one incremental prompt per turn.
33
+ */
34
+ export const CHATGPT_WEB_ZERO_RISK_CONTEXT_WINDOW = CHATGPT_WEB_INSTANT_CONTEXT_WINDOW * 3;
35
+ export const CHATGPT_WEB_ZERO_RISK_AUTO_COMPACT_TOKEN_LIMIT = CHATGPT_WEB_INSTANT_AUTO_COMPACT_TOKEN_LIMIT * 3;
36
+ export const CHATGPT_WEB_MEDIUM_HIGH_CONTEXT_WINDOW = 90_000;
37
+ export const CHATGPT_WEB_MEDIUM_HIGH_AUTO_COMPACT_TOKEN_LIMIT = 80_000;
38
+ export const CHATGPT_WEB_INSTANT_COMPOSER_CHAR_LIMIT = 211_256;
39
+ export const CHATGPT_WEB_MEDIUM_HIGH_COMPOSER_CHAR_LIMIT = 1_048_572;
40
+ /** Hidden ChatGPT product prompt and Codex Native schema reserve included in usage estimates. */
41
+ export const CHATGPT_WEB_PLATFORM_RESERVE_TOKENS = 8_192;
42
+ /** Pro-account usable browser windows and separately measured one-message boundaries. */
43
+ export const CHATGPT_WEB_PRO_AUTO_COMPACT_TOKEN_LIMIT = 95_000;
44
+ export const CHATGPT_WEB_PRO_STANDARD_MESSAGE_TOKEN_LIMIT = 103_000;
45
+ export const CHATGPT_WEB_PRO_MODEL_MESSAGE_TOKEN_LIMIT = 104_000;
46
+ // Browser message maxima are inclusive, while the context preflight treats its ceiling as an
47
+ // exclusive upper bound. The extra token preserves the last accepted payload exactly.
48
+ export const CHATGPT_WEB_PRO_STANDARD_CONTEXT_WINDOW =
49
+ CHATGPT_WEB_PRO_STANDARD_MESSAGE_TOKEN_LIMIT + CHATGPT_WEB_PLATFORM_RESERVE_TOKENS + 1;
50
+ export const CHATGPT_WEB_PRO_MODEL_CONTEXT_WINDOW =
51
+ CHATGPT_WEB_PRO_MODEL_MESSAGE_TOKEN_LIMIT + CHATGPT_WEB_PLATFORM_RESERVE_TOKENS + 1;
52
+ /**
53
+ * Zero Risk Pro keeps the same three-turn manual conversation budget as the default profile, but
54
+ * sizes each turn from the measured ChatGPT Pro boundary. The launcher cannot verify that the user
55
+ * actually selected Pro, so this profile is exposed only through an explicit user setting.
56
+ */
57
+ export const CHATGPT_WEB_ZERO_RISK_PRO_CONTEXT_WINDOW =
58
+ CHATGPT_WEB_PRO_MODEL_CONTEXT_WINDOW * 3;
59
+ export const CHATGPT_WEB_ZERO_RISK_PRO_AUTO_COMPACT_TOKEN_LIMIT =
60
+ CHATGPT_WEB_PRO_AUTO_COMPACT_TOKEN_LIMIT * 3;
61
+ export const CHATGPT_WEB_PRO_INSTANT_COMPOSER_CHAR_LIMIT = 545_000;
62
+ export const CHATGPT_WEB_PRO_REASONING_COMPOSER_CHAR_LIMIT = 1_045_000;
63
+ export const CHATGPT_WEB_PRO_MODEL_COMPOSER_CHAR_LIMIT = 1_635_000;
64
+ /**
65
+ * The underlying Luna model owns this context window. ChatGPT Free's much smaller browser request
66
+ * envelope is enforced separately at the browser boundary; rolling checkpoints keep completed
67
+ * history out of later browser requests without asking Codex to compact its canonical history.
68
+ */
69
+ export const CHATGPT_WEB_LUNA_CONTEXT_WINDOW = 1_050_000;
70
+ export const CHATGPT_WEB_BIGGER_CONTEXT_MULTIPLIER = 3;
71
+
72
+ export interface ChatGptWebContextLimits {
73
+ contextWindow: number;
74
+ effectiveContextWindowPercent: number;
75
+ autoCompactTokenLimit: number;
76
+ }
77
+
78
+ export interface ChatGptWebTransportLimits {
79
+ browserMessageTokenLimit?: number;
80
+ browserComposerCharLimit?: number;
81
+ }
82
+
83
+ export function isChatGptWebZeroRiskBackendModel(
84
+ model: string,
85
+ ): model is ChatGptWebZeroRiskBackendModel {
86
+ return model === CHATGPT_WEB_ZERO_RISK_BACKEND_MODEL
87
+ || model === CHATGPT_WEB_ZERO_RISK_PRO_BACKEND_MODEL;
88
+ }
89
+
90
+ function contextLimits(
91
+ contextWindow: number,
92
+ autoCompactTokenLimit: number,
93
+ ): ChatGptWebContextLimits {
94
+ return {
95
+ contextWindow,
96
+ // Codex reports this effective window in its context indicator. Align it with the practical
97
+ // pre-compaction budget instead of exposing an unreachable underlying model window.
98
+ effectiveContextWindowPercent: Math.round((autoCompactTokenLimit / contextWindow) * 100),
99
+ autoCompactTokenLimit,
100
+ };
101
+ }
102
+
103
+ /** Resolve the product limit for the selected visible ChatGPT mode. */
104
+ export function resolveChatGptWebContextLimits(
105
+ backendModel: ChatGptWebBackendModel,
106
+ effort: ChatGptWebAdapterEffort,
107
+ capabilities: ChatGptWebAccountCapabilities,
108
+ ): ChatGptWebContextLimits {
109
+ if (isChatGptWebZeroRiskBackendModel(backendModel)) {
110
+ if (capabilities.experimentalBiggerContext) {
111
+ throw new Error("Zero Risk does not support Bigger Context");
112
+ }
113
+ if (backendModel === CHATGPT_WEB_ZERO_RISK_PRO_BACKEND_MODEL) {
114
+ return contextLimits(
115
+ CHATGPT_WEB_ZERO_RISK_PRO_CONTEXT_WINDOW,
116
+ CHATGPT_WEB_ZERO_RISK_PRO_AUTO_COMPACT_TOKEN_LIMIT,
117
+ );
118
+ }
119
+ return contextLimits(
120
+ CHATGPT_WEB_ZERO_RISK_CONTEXT_WINDOW,
121
+ CHATGPT_WEB_ZERO_RISK_AUTO_COMPACT_TOKEN_LIMIT,
122
+ );
123
+ }
124
+ if (backendModel === CHATGPT_WEB_LUNA_BACKEND_MODEL) {
125
+ // Luna carries continuity through a private checkpoint on every completed browser turn. Codex
126
+ // internally clamps this field to 90% of the model window, but the reported active usage is the
127
+ // bounded payload actually sent to ChatGPT and therefore stays far below that threshold.
128
+ return contextLimits(CHATGPT_WEB_LUNA_CONTEXT_WINDOW, CHATGPT_WEB_LUNA_CONTEXT_WINDOW);
129
+ }
130
+
131
+ let limits: ChatGptWebContextLimits;
132
+ if (capabilities.proAvailable) {
133
+ const contextWindow = effort === "low"
134
+ ? CHATGPT_WEB_PRO_STANDARD_CONTEXT_WINDOW
135
+ : effort === "max"
136
+ ? CHATGPT_WEB_PRO_MODEL_CONTEXT_WINDOW
137
+ : CHATGPT_WEB_PRO_STANDARD_CONTEXT_WINDOW;
138
+ limits = contextLimits(contextWindow, CHATGPT_WEB_PRO_AUTO_COMPACT_TOKEN_LIMIT);
139
+ } else if (effort === "low") {
140
+ limits = contextLimits(
141
+ CHATGPT_WEB_INSTANT_CONTEXT_WINDOW,
142
+ CHATGPT_WEB_INSTANT_AUTO_COMPACT_TOKEN_LIMIT,
143
+ );
144
+ } else if (effort === "medium" || effort === "high") {
145
+ limits = contextLimits(
146
+ CHATGPT_WEB_MEDIUM_HIGH_CONTEXT_WINDOW,
147
+ CHATGPT_WEB_MEDIUM_HIGH_AUTO_COMPACT_TOKEN_LIMIT,
148
+ );
149
+ } else {
150
+ throw new Error(`ChatGPT Plus context limit is not defined for unavailable effort: ${effort}`);
151
+ }
152
+ if (!capabilities.experimentalBiggerContext) return limits;
153
+ return contextLimits(
154
+ limits.contextWindow * CHATGPT_WEB_BIGGER_CONTEXT_MULTIPLIER,
155
+ limits.autoCompactTokenLimit * CHATGPT_WEB_BIGGER_CONTEXT_MULTIPLIER,
156
+ );
157
+ }
158
+
159
+ /** Resolve limits of one visible ChatGPT composer message, independently of model context. */
160
+ export function resolveChatGptWebTransportLimits(
161
+ backendModel: ChatGptWebBackendModel,
162
+ effort: ChatGptWebAdapterEffort,
163
+ capabilities: ChatGptWebAccountCapabilities,
164
+ ): ChatGptWebTransportLimits {
165
+ if (isChatGptWebZeroRiskBackendModel(backendModel)) return {};
166
+ if (backendModel === CHATGPT_WEB_LUNA_BACKEND_MODEL) return {};
167
+ if (!capabilities.proAvailable) {
168
+ if (effort === "low") {
169
+ return { browserComposerCharLimit: CHATGPT_WEB_INSTANT_COMPOSER_CHAR_LIMIT };
170
+ }
171
+ if (effort === "medium" || effort === "high") {
172
+ return { browserComposerCharLimit: CHATGPT_WEB_MEDIUM_HIGH_COMPOSER_CHAR_LIMIT };
173
+ }
174
+ throw new Error(`ChatGPT Plus transport limit is not defined for unavailable effort: ${effort}`);
175
+ }
176
+ if (effort === "low") {
177
+ return {
178
+ browserMessageTokenLimit: CHATGPT_WEB_PRO_STANDARD_MESSAGE_TOKEN_LIMIT,
179
+ browserComposerCharLimit: CHATGPT_WEB_PRO_INSTANT_COMPOSER_CHAR_LIMIT,
180
+ };
181
+ }
182
+ if (effort === "max") {
183
+ return {
184
+ browserMessageTokenLimit: CHATGPT_WEB_PRO_MODEL_MESSAGE_TOKEN_LIMIT,
185
+ browserComposerCharLimit: CHATGPT_WEB_PRO_MODEL_COMPOSER_CHAR_LIMIT,
186
+ };
187
+ }
188
+ return {
189
+ browserMessageTokenLimit: CHATGPT_WEB_PRO_STANDARD_MESSAGE_TOKEN_LIMIT,
190
+ browserComposerCharLimit: CHATGPT_WEB_PRO_REASONING_COMPOSER_CHAR_LIMIT,
191
+ };
192
+ }
193
+
194
+ interface ChatGptWebModelRouteBase {
195
+ slug: string;
196
+ displayName: string;
197
+ description: string;
198
+ codexEffort: ChatGptWebCodexEffort;
199
+ requiresPro: boolean;
200
+ }
201
+
202
+ export interface ChatGptWebAutomaticModelRoute extends ChatGptWebModelRouteBase {
203
+ interactionMode: "automatic";
204
+ backendModel: ChatGptWebAutomaticBackendModel;
205
+ adapterEffort: ChatGptWebAdapterEffort;
206
+ }
207
+
208
+ export interface ChatGptWebZeroRiskModelRoute extends ChatGptWebModelRouteBase {
209
+ interactionMode: "manual";
210
+ backendModel: ChatGptWebZeroRiskBackendModel;
211
+ /** Technical protocol value only; Zero Risk must not use it to choose the ChatGPT model. */
212
+ adapterEffort: "low";
213
+ }
214
+
215
+ export type ChatGptWebModelRoute = ChatGptWebAutomaticModelRoute | ChatGptWebZeroRiskModelRoute;
216
+
217
+ export interface ChatGptWebAccountCapabilities {
218
+ solAvailable: boolean;
219
+ proAvailable: boolean;
220
+ experimentalBiggerContext?: boolean;
221
+ browserInteractionMode?: "automatic" | "manual";
222
+ zeroRiskProEnabled?: boolean;
223
+ }
224
+
225
+ export const CHATGPT_WEB_ZERO_RISK_MODEL_ROUTE: ChatGptWebZeroRiskModelRoute = {
226
+ slug: "chatgpt-web/zero-risk",
227
+ displayName: "ChatGPT Web — Zero Risk",
228
+ description: "Zero Risk keeps model selection and prompt submission under your control while preserving the native Codex harness.",
229
+ interactionMode: "manual",
230
+ backendModel: CHATGPT_WEB_ZERO_RISK_BACKEND_MODEL,
231
+ codexEffort: "low",
232
+ adapterEffort: "low",
233
+ requiresPro: false,
234
+ };
235
+
236
+ export const CHATGPT_WEB_ZERO_RISK_PRO_MODEL_ROUTE: ChatGptWebZeroRiskModelRoute = {
237
+ slug: "chatgpt-web/zero-risk-pro",
238
+ displayName: "ChatGPT Web — Zero Risk Pro",
239
+ description: "Explicit Pro-sized Zero Risk context; select ChatGPT Pro manually for every turn.",
240
+ interactionMode: "manual",
241
+ backendModel: CHATGPT_WEB_ZERO_RISK_PRO_BACKEND_MODEL,
242
+ codexEffort: "low",
243
+ adapterEffort: "low",
244
+ requiresPro: true,
245
+ };
246
+
247
+ export const CHATGPT_WEB_LUNA_MODEL_ROUTE: ChatGptWebAutomaticModelRoute = {
248
+ slug: "chatgpt-web/luna",
249
+ displayName: "ChatGPT Web — Luna",
250
+ description: "ChatGPT Web Luna for accounts without the Sol model selector.",
251
+ interactionMode: "automatic",
252
+ backendModel: CHATGPT_WEB_LUNA_BACKEND_MODEL,
253
+ codexEffort: "low",
254
+ adapterEffort: "low",
255
+ requiresPro: false,
256
+ };
257
+
258
+ export const CHATGPT_WEB_LUNA_THINK_MODEL_ROUTE: ChatGptWebModelRoute = {
259
+ slug: "chatgpt-web/think",
260
+ displayName: "ChatGPT Web — Think",
261
+ description: "ChatGPT Web Think for Luna-only accounts.",
262
+ interactionMode: "automatic",
263
+ backendModel: CHATGPT_WEB_LUNA_BACKEND_MODEL,
264
+ codexEffort: "low",
265
+ // The backend model remains Luna. This internal adapter effort distinguishes the explicit
266
+ // Think route after Codex has selected its separate catalog row.
267
+ adapterEffort: "medium",
268
+ requiresPro: false,
269
+ };
270
+
271
+ export const CHATGPT_WEB_LUNA_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [
272
+ CHATGPT_WEB_LUNA_MODEL_ROUTE,
273
+ CHATGPT_WEB_LUNA_THINK_MODEL_ROUTE,
274
+ ];
275
+
276
+ /**
277
+ * The selected Codex model is the authoritative ChatGPT browser mode. Codex's signed desktop UI
278
+ * always renders an Effort row, so every routed model advertises exactly one immutable protocol
279
+ * effort. Pro uses Codex's `ultra` protocol value but binds explicitly to ChatGPT Pro (`max`) at
280
+ * the adapter boundary.
281
+ */
282
+ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebAutomaticModelRoute[] = [
283
+ {
284
+ slug: "chatgpt-web/light",
285
+ displayName: "ChatGPT Web — Instant",
286
+ description: "ChatGPT Web Instant through the native Codex harness.",
287
+ interactionMode: "automatic",
288
+ backendModel: CHATGPT_WEB_BACKEND_MODEL,
289
+ codexEffort: "low",
290
+ adapterEffort: "low",
291
+ requiresPro: false,
292
+ },
293
+ {
294
+ slug: "chatgpt-web/medium",
295
+ displayName: "ChatGPT Web — Medium",
296
+ description: "ChatGPT Web Medium through the native Codex harness.",
297
+ interactionMode: "automatic",
298
+ backendModel: CHATGPT_WEB_BACKEND_MODEL,
299
+ codexEffort: "medium",
300
+ adapterEffort: "medium",
301
+ requiresPro: false,
302
+ },
303
+ {
304
+ slug: "chatgpt-web/high",
305
+ displayName: "ChatGPT Web — High",
306
+ description: "ChatGPT Web High through the native Codex harness.",
307
+ interactionMode: "automatic",
308
+ backendModel: CHATGPT_WEB_BACKEND_MODEL,
309
+ codexEffort: "high",
310
+ adapterEffort: "high",
311
+ requiresPro: false,
312
+ },
313
+ {
314
+ slug: "chatgpt-web/extra-high",
315
+ displayName: "ChatGPT Web — Extra High",
316
+ description: "Account-gated ChatGPT Web Extra High through the native Codex harness.",
317
+ interactionMode: "automatic",
318
+ backendModel: CHATGPT_WEB_BACKEND_MODEL,
319
+ codexEffort: "xhigh",
320
+ adapterEffort: "xhigh",
321
+ requiresPro: true,
322
+ },
323
+ {
324
+ slug: "chatgpt-web/pro",
325
+ displayName: "ChatGPT Web — Pro",
326
+ description: "Account-gated ChatGPT Pro through the native Codex harness.",
327
+ interactionMode: "automatic",
328
+ backendModel: CHATGPT_WEB_BACKEND_MODEL,
329
+ codexEffort: "ultra",
330
+ adapterEffort: "max",
331
+ requiresPro: true,
332
+ },
333
+ ];
334
+
335
+ const routesBySlug = new Map(
336
+ [
337
+ CHATGPT_WEB_ZERO_RISK_MODEL_ROUTE,
338
+ CHATGPT_WEB_ZERO_RISK_PRO_MODEL_ROUTE,
339
+ ...CHATGPT_WEB_LUNA_MODEL_ROUTES,
340
+ ...CHATGPT_WEB_MODEL_ROUTES,
341
+ ]
342
+ .map(route => [route.slug, route]),
343
+ );
344
+
345
+ export function isChatGptWebModelSlug(modelId: string): boolean {
346
+ return modelId.startsWith(CHATGPT_WEB_MODEL_PREFIX);
347
+ }
348
+
349
+ export function availableChatGptWebModelRoutes(
350
+ capabilities: ChatGptWebAccountCapabilities,
351
+ ): readonly ChatGptWebModelRoute[] {
352
+ if (capabilities.browserInteractionMode === "manual") {
353
+ if (capabilities.experimentalBiggerContext) {
354
+ throw new Error("Zero Risk does not support Bigger Context");
355
+ }
356
+ return capabilities.zeroRiskProEnabled
357
+ ? [CHATGPT_WEB_ZERO_RISK_MODEL_ROUTE, CHATGPT_WEB_ZERO_RISK_PRO_MODEL_ROUTE]
358
+ : [CHATGPT_WEB_ZERO_RISK_MODEL_ROUTE];
359
+ }
360
+ if (!capabilities.solAvailable) return CHATGPT_WEB_LUNA_MODEL_ROUTES;
361
+ return capabilities.proAvailable
362
+ ? CHATGPT_WEB_MODEL_ROUTES
363
+ : CHATGPT_WEB_MODEL_ROUTES.filter(route => !route.requiresPro);
364
+ }
365
+
366
+ export function requireChatGptWebModelRoute(
367
+ modelId: string,
368
+ capabilities: ChatGptWebAccountCapabilities,
369
+ ): ChatGptWebModelRoute {
370
+ if (capabilities.browserInteractionMode === "manual" && capabilities.experimentalBiggerContext) {
371
+ throw new Error("Zero Risk does not support Bigger Context");
372
+ }
373
+ const route = routesBySlug.get(modelId);
374
+ if (!route) throw new Error(`ChatGPT web model is not enabled: ${modelId}`);
375
+ if (capabilities.browserInteractionMode === "manual") {
376
+ if (route.interactionMode !== "manual") {
377
+ throw new Error(`${route.displayName} is not available while Zero Risk is enabled`);
378
+ }
379
+ if (route === CHATGPT_WEB_ZERO_RISK_PRO_MODEL_ROUTE && !capabilities.zeroRiskProEnabled) {
380
+ throw new Error(`${route.displayName} is not enabled in Zero Risk model settings`);
381
+ }
382
+ return route;
383
+ }
384
+ if (route.interactionMode === "manual") {
385
+ throw new Error(`${route.displayName} is only available while Zero Risk is enabled`);
386
+ }
387
+ if (route.backendModel === CHATGPT_WEB_LUNA_BACKEND_MODEL) {
388
+ if (capabilities.solAvailable) {
389
+ throw new Error(`${route.displayName} is only available for Luna-only accounts`);
390
+ }
391
+ return route;
392
+ }
393
+ if (!capabilities.solAvailable) {
394
+ throw new Error(`${route.displayName} is not available for this Luna-only account`);
395
+ }
396
+ if (route.requiresPro && !capabilities.proAvailable) {
397
+ throw new Error(`${route.displayName} is not available for this account`);
398
+ }
399
+ return route;
400
+ }