@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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/assets/demo.gif +0 -0
- package/assets/hero-demo.png +0 -0
- package/assets/promo-dshmarket-official.png +0 -0
- package/cordis.patch.yml +4 -0
- package/lib/cli.js +239642 -0
- package/lib/plugin.js +195 -0
- package/package.json +88 -0
- package/screenshots.json +5 -0
- package/src/adapters/base.ts +16 -0
- package/src/adapters/chatgpt-web/adapter-error.ts +59 -0
- package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -0
- package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -0
- package/src/adapters/chatgpt-web/browser-worker.ts +4944 -0
- package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -0
- package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -0
- package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -0
- package/src/adapters/chatgpt-web/concurrency.ts +6 -0
- package/src/adapters/chatgpt-web/conversation-key.ts +58 -0
- package/src/adapters/chatgpt-web/environment.ts +669 -0
- package/src/adapters/chatgpt-web/index.ts +1544 -0
- package/src/adapters/chatgpt-web/input-tokens.ts +74 -0
- package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -0
- package/src/adapters/chatgpt-web/markdown.ts +418 -0
- package/src/adapters/chatgpt-web/mcp-main.ts +25 -0
- package/src/adapters/chatgpt-web/mcp-server.ts +933 -0
- package/src/adapters/chatgpt-web/model.ts +70 -0
- package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -0
- package/src/adapters/chatgpt-web/output-validation.ts +62 -0
- package/src/adapters/chatgpt-web/process-line-writer.ts +46 -0
- package/src/adapters/chatgpt-web/prompt.ts +702 -0
- package/src/adapters/chatgpt-web/retry-policy.ts +73 -0
- package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -0
- package/src/adapters/chatgpt-web/thread-environment.ts +238 -0
- package/src/adapters/chatgpt-web/tool-stream-parser.ts +601 -0
- package/src/adapters/chatgpt-web/turn-broker.ts +1481 -0
- package/src/adapters/chatgpt-web/turn-execution.ts +816 -0
- package/src/adapters/chatgpt-web/turn-progress.ts +292 -0
- package/src/adapters/chatgpt-web/usage.ts +121 -0
- package/src/adapters/image.ts +9 -0
- package/src/bridge.ts +1083 -0
- package/src/browser-login.ts +521 -0
- package/src/chatgpt-session.ts +240 -0
- package/src/chatgpt-web-models.ts +400 -0
- package/src/cli.ts +568 -0
- package/src/codex-integration-document.ts +824 -0
- package/src/codex-integration-journal.ts +212 -0
- package/src/codex-integration-route.ts +515 -0
- package/src/codex-integration-shared.ts +332 -0
- package/src/codex-integration.ts +529 -0
- package/src/codex-interrupt-hook.ts +158 -0
- package/src/config.ts +616 -0
- package/src/dev-chat/cli.ts +432 -0
- package/src/dev-chat/constants.ts +3 -0
- package/src/dev-chat/driver.ts +655 -0
- package/src/dev-chat/profile.ts +223 -0
- package/src/dev-chat/session.ts +287 -0
- package/src/dev-chat/transport.ts +54 -0
- package/src/doctor.ts +237 -0
- package/src/event-queue.ts +45 -0
- package/src/http-body.ts +30 -0
- package/src/launcher-browser-host.ts +695 -0
- package/src/lib/errors.ts +281 -0
- package/src/lib/token-estimate.ts +42 -0
- package/src/login-helper.cjs +140 -0
- package/src/model-catalog.ts +197 -0
- package/src/native-passthrough.ts +261 -0
- package/src/plugin.ts +191 -0
- package/src/process.ts +45 -0
- package/src/responses/compaction.ts +199 -0
- package/src/responses/parser.ts +633 -0
- package/src/responses/reasoning-envelope.ts +49 -0
- package/src/responses/schema.ts +172 -0
- package/src/responses/state.ts +230 -0
- package/src/server.ts +1111 -0
- package/src/service.ts +315 -0
- package/src/setup.ts +671 -0
- package/src/stall-timeout.ts +23 -0
- package/src/tunnel-service.ts +160 -0
- package/src/tunnel.ts +417 -0
- package/src/turndown-plugin-gfm.d.ts +5 -0
- package/src/types.ts +307 -0
- package/src/usage/totals.ts +12 -0
- 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
|
+
}
|