@eddyskywalker/dsh-chatgpt-subscription 0.1.0-alpha.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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +673 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1753 -0
- package/lib/types/client/CodexSubscriptionSection.d.ts +15 -0
- package/lib/types/client/CodexSubscriptionSection.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +17 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +10 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +103 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/styles.d.ts +2 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/compat.d.ts +26 -0
- package/lib/types/compat.d.ts.map +1 -0
- package/lib/types/host/adapter.d.ts +14 -0
- package/lib/types/host/adapter.d.ts.map +1 -0
- package/lib/types/host/callback-server.d.ts +22 -0
- package/lib/types/host/callback-server.d.ts.map +1 -0
- package/lib/types/host/model-catalog.d.ts +6 -0
- package/lib/types/host/model-catalog.d.ts.map +1 -0
- package/lib/types/host/oauth-service.d.ts +69 -0
- package/lib/types/host/oauth-service.d.ts.map +1 -0
- package/lib/types/host/responses-client.d.ts +21 -0
- package/lib/types/host/responses-client.d.ts.map +1 -0
- package/lib/types/host/responses-mapper.d.ts +11 -0
- package/lib/types/host/responses-mapper.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +5 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/host/token-store-windows.d.ts +10 -0
- package/lib/types/host/token-store-windows.d.ts.map +1 -0
- package/lib/types/host/token-store.d.ts +23 -0
- package/lib/types/host/token-store.d.ts.map +1 -0
- package/lib/types/host/usage-service.d.ts +33 -0
- package/lib/types/host/usage-service.d.ts.map +1 -0
- package/lib/types/host/wire-auth.d.ts +5 -0
- package/lib/types/host/wire-auth.d.ts.map +1 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/shared/contracts.d.ts +77 -0
- package/lib/types/shared/contracts.d.ts.map +1 -0
- package/package.json +105 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1753 @@
|
|
|
1
|
+
import { CallId, LlmAdapter, LlmError, ProviderRequestId, ReasoningEffortId, attributionHeaders, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
//#region src/host/model-catalog.ts
|
|
8
|
+
const PROVIDER_ID = "codex-chatgpt";
|
|
9
|
+
const PROVIDER_NAME = "Codex(ChatGPT 订阅)";
|
|
10
|
+
const MODEL_IDS = [
|
|
11
|
+
"gpt-5.6-sol",
|
|
12
|
+
"gpt-5.6-terra",
|
|
13
|
+
"gpt-5.6-luna",
|
|
14
|
+
"gpt-5.5",
|
|
15
|
+
"gpt-5.4",
|
|
16
|
+
"gpt-5.4-mini",
|
|
17
|
+
"gpt-5.2"
|
|
18
|
+
];
|
|
19
|
+
const STANDARD_REASONING_EFFORTS = [
|
|
20
|
+
"none",
|
|
21
|
+
"low",
|
|
22
|
+
"medium",
|
|
23
|
+
"high",
|
|
24
|
+
"xhigh"
|
|
25
|
+
];
|
|
26
|
+
const GPT_56_REASONING_EFFORTS = [...STANDARD_REASONING_EFFORTS, "max"];
|
|
27
|
+
const MODEL_REASONING = {
|
|
28
|
+
"gpt-5.6-sol": {
|
|
29
|
+
efforts: GPT_56_REASONING_EFFORTS,
|
|
30
|
+
defaultEffort: "medium"
|
|
31
|
+
},
|
|
32
|
+
"gpt-5.6-terra": {
|
|
33
|
+
efforts: GPT_56_REASONING_EFFORTS,
|
|
34
|
+
defaultEffort: "medium"
|
|
35
|
+
},
|
|
36
|
+
"gpt-5.6-luna": {
|
|
37
|
+
efforts: GPT_56_REASONING_EFFORTS,
|
|
38
|
+
defaultEffort: "medium"
|
|
39
|
+
},
|
|
40
|
+
"gpt-5.5": {
|
|
41
|
+
efforts: STANDARD_REASONING_EFFORTS,
|
|
42
|
+
defaultEffort: "medium"
|
|
43
|
+
},
|
|
44
|
+
"gpt-5.4": {
|
|
45
|
+
efforts: STANDARD_REASONING_EFFORTS,
|
|
46
|
+
defaultEffort: "none"
|
|
47
|
+
},
|
|
48
|
+
"gpt-5.4-mini": {
|
|
49
|
+
efforts: STANDARD_REASONING_EFFORTS,
|
|
50
|
+
defaultEffort: "none"
|
|
51
|
+
},
|
|
52
|
+
"gpt-5.2": {
|
|
53
|
+
efforts: STANDARD_REASONING_EFFORTS,
|
|
54
|
+
defaultEffort: "none"
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
function listCodexModels() {
|
|
58
|
+
return MODEL_IDS.map((id) => ({
|
|
59
|
+
provider: PROVIDER_ID,
|
|
60
|
+
id,
|
|
61
|
+
name: id,
|
|
62
|
+
inputModalities: ["text", "image"]
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
function resolveCodexModel(model) {
|
|
66
|
+
const reasoning = MODEL_REASONING[model] ?? MODEL_REASONING["gpt-5.6-sol"];
|
|
67
|
+
return {
|
|
68
|
+
provider: PROVIDER_ID,
|
|
69
|
+
id: model,
|
|
70
|
+
name: model,
|
|
71
|
+
inputModalities: ["text", "image"],
|
|
72
|
+
context: { contextWindow: 272e3 },
|
|
73
|
+
defaultMaxTokens: 32768,
|
|
74
|
+
reasoning: {
|
|
75
|
+
efforts: reasoning.efforts.map((effort) => ({
|
|
76
|
+
id: ReasoningEffortId(effort),
|
|
77
|
+
name: effort
|
|
78
|
+
})),
|
|
79
|
+
defaultEffort: ReasoningEffortId(reasoning.defaultEffort)
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/host/adapter.ts
|
|
85
|
+
const RETRY_POLICY = resolveRetryPolicy({
|
|
86
|
+
mode: "normal",
|
|
87
|
+
maxRetries: 2,
|
|
88
|
+
retryableCodes: [
|
|
89
|
+
"RATE_LIMIT",
|
|
90
|
+
"SERVER_ERROR",
|
|
91
|
+
"NETWORK"
|
|
92
|
+
],
|
|
93
|
+
backoff: {
|
|
94
|
+
initialDelayMs: 1e3,
|
|
95
|
+
maxDelayMs: 1e4,
|
|
96
|
+
jitterRatio: .15
|
|
97
|
+
}
|
|
98
|
+
}, "dsh-chatgpt-subscription.retry");
|
|
99
|
+
var CodexChatGptAdapter = class extends LlmAdapter {
|
|
100
|
+
client;
|
|
101
|
+
constructor(client) {
|
|
102
|
+
super();
|
|
103
|
+
this.client = client;
|
|
104
|
+
}
|
|
105
|
+
providerInfo(provider) {
|
|
106
|
+
return {
|
|
107
|
+
id: provider,
|
|
108
|
+
name: PROVIDER_NAME
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
providerRetryPolicy() {
|
|
112
|
+
return RETRY_POLICY;
|
|
113
|
+
}
|
|
114
|
+
async listModels() {
|
|
115
|
+
return listCodexModels();
|
|
116
|
+
}
|
|
117
|
+
async resolveModel(_provider, model) {
|
|
118
|
+
return resolveCodexModel(model);
|
|
119
|
+
}
|
|
120
|
+
stream(options) {
|
|
121
|
+
return this.client.stream(options);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/compat.ts
|
|
126
|
+
/**
|
|
127
|
+
* Compatibility constants for the ChatGPT-backed Codex flow. The backend and
|
|
128
|
+
* OAuth parameters are not a public third-party API contract, so every such
|
|
129
|
+
* value is isolated here for review and rollback.
|
|
130
|
+
*/
|
|
131
|
+
const CHATGPT_OAUTH_ISSUER = "https://auth.openai.com";
|
|
132
|
+
const CHATGPT_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
133
|
+
const OAUTH_CALLBACK_HOST = "localhost";
|
|
134
|
+
const OAUTH_CALLBACK_PORT = 1455;
|
|
135
|
+
const OAUTH_REDIRECT_URI = `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}/auth/callback`;
|
|
136
|
+
const OAUTH_SCOPE = "openid profile email offline_access";
|
|
137
|
+
const OAUTH_ORIGINATOR = "opencode";
|
|
138
|
+
const ROUTE_PREFIX = "/api/dsh-chatgpt-subscription";
|
|
139
|
+
const PLUGIN_VERSION = "0.1.0-alpha.0";
|
|
140
|
+
const CODEX_RESPONSES_URL = `https://chatgpt.com/backend-api/codex/responses`;
|
|
141
|
+
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
142
|
+
const CODEX_ORIGINATOR = "opencode";
|
|
143
|
+
const QUOTA_MIN_UPSTREAM_INTERVAL_MS = 15e3;
|
|
144
|
+
const OAUTH_AUTHORIZE_URL = `${CHATGPT_OAUTH_ISSUER}/oauth/authorize`;
|
|
145
|
+
const OAUTH_TOKEN_URL = `${CHATGPT_OAUTH_ISSUER}/oauth/token`;
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/host/callback-server.ts
|
|
148
|
+
/** One-shot localhost OAuth callback listener. */
|
|
149
|
+
var OAuthCallbackServer = class {
|
|
150
|
+
options;
|
|
151
|
+
abortController = new AbortController();
|
|
152
|
+
server = null;
|
|
153
|
+
settled = false;
|
|
154
|
+
resolveCompletion;
|
|
155
|
+
rejectCompletion;
|
|
156
|
+
completion = new Promise((resolve, reject) => {
|
|
157
|
+
this.resolveCompletion = resolve;
|
|
158
|
+
this.rejectCompletion = reject;
|
|
159
|
+
});
|
|
160
|
+
constructor(options) {
|
|
161
|
+
this.options = options;
|
|
162
|
+
}
|
|
163
|
+
async listen() {
|
|
164
|
+
if (this.server !== null) throw new Error("OAuth callback server already started");
|
|
165
|
+
const server = http.createServer((request, response) => {
|
|
166
|
+
this.handle(request, response);
|
|
167
|
+
});
|
|
168
|
+
this.server = server;
|
|
169
|
+
await new Promise((resolve, reject) => {
|
|
170
|
+
const onError = (error) => reject(error);
|
|
171
|
+
server.once("error", onError);
|
|
172
|
+
server.listen(OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_HOST, () => {
|
|
173
|
+
server.off("error", onError);
|
|
174
|
+
resolve();
|
|
175
|
+
});
|
|
176
|
+
}).catch((error) => {
|
|
177
|
+
this.server = null;
|
|
178
|
+
server.close();
|
|
179
|
+
throw error;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
cancel(reason) {
|
|
183
|
+
this.finish(reason);
|
|
184
|
+
}
|
|
185
|
+
dispose() {
|
|
186
|
+
if (!this.settled) this.finish(/* @__PURE__ */ new Error("OAuth callback listener disposed"));
|
|
187
|
+
else this.close();
|
|
188
|
+
}
|
|
189
|
+
async handle(request, response) {
|
|
190
|
+
if (this.settled) {
|
|
191
|
+
await writeHtml(response, 410, "This sign-in attempt is no longer active.");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!isLoopback(request.socket.remoteAddress)) {
|
|
195
|
+
await writeHtml(response, 403, "OAuth callback rejected.");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const url = new URL(request.url ?? "/", `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}`);
|
|
199
|
+
if (url.pathname !== "/auth/callback") {
|
|
200
|
+
await writeHtml(response, 404, "Not found.");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const providerError = url.searchParams.get("error_description") ?? url.searchParams.get("error");
|
|
204
|
+
const code = url.searchParams.get("code");
|
|
205
|
+
const state = url.searchParams.get("state");
|
|
206
|
+
if (providerError !== null || code === null || code === "" || state !== this.options.expectedState) {
|
|
207
|
+
await writeHtml(response, 400, "ChatGPT returned an invalid OAuth callback.");
|
|
208
|
+
this.finish(/* @__PURE__ */ new Error(providerError === null ? "invalid OAuth callback" : "OAuth provider rejected sign-in"));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
await this.options.exchange(code, this.abortController.signal);
|
|
213
|
+
await writeHtml(response, 200, "ChatGPT sign-in completed. You can close this window.");
|
|
214
|
+
this.finish();
|
|
215
|
+
} catch (error) {
|
|
216
|
+
await writeHtml(response, 500, "ChatGPT sign-in could not be completed. Return to DSH for details.");
|
|
217
|
+
this.finish(error instanceof Error ? error : /* @__PURE__ */ new Error("OAuth token exchange failed"));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finish(error) {
|
|
221
|
+
if (this.settled) return;
|
|
222
|
+
this.settled = true;
|
|
223
|
+
this.abortController.abort();
|
|
224
|
+
this.close();
|
|
225
|
+
if (error === void 0) this.resolveCompletion();
|
|
226
|
+
else this.rejectCompletion(error);
|
|
227
|
+
}
|
|
228
|
+
close() {
|
|
229
|
+
const server = this.server;
|
|
230
|
+
this.server = null;
|
|
231
|
+
server?.close();
|
|
232
|
+
server?.closeAllConnections();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
function isLoopback(address) {
|
|
236
|
+
if (address === void 0) return false;
|
|
237
|
+
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
|
238
|
+
}
|
|
239
|
+
function writeHtml(response, status, message) {
|
|
240
|
+
const escaped = message.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
241
|
+
response.writeHead(status, {
|
|
242
|
+
"content-type": "text/html; charset=utf-8",
|
|
243
|
+
"cache-control": "no-store",
|
|
244
|
+
"x-content-type-options": "nosniff",
|
|
245
|
+
connection: "close"
|
|
246
|
+
});
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
let settled = false;
|
|
249
|
+
const done = () => {
|
|
250
|
+
if (settled) return;
|
|
251
|
+
settled = true;
|
|
252
|
+
resolve();
|
|
253
|
+
};
|
|
254
|
+
response.once("finish", done);
|
|
255
|
+
response.once("close", done);
|
|
256
|
+
response.end(`<!doctype html><meta charset="utf-8"><title>DSH Codex sign-in</title><h1>${escaped}</h1>`);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region src/host/oauth-service.ts
|
|
261
|
+
var OAuthServiceError = class extends Error {
|
|
262
|
+
code;
|
|
263
|
+
constructor(code, message) {
|
|
264
|
+
super(message);
|
|
265
|
+
this.code = code;
|
|
266
|
+
this.name = "OAuthServiceError";
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
var OAuthService = class {
|
|
270
|
+
store;
|
|
271
|
+
fetchFn;
|
|
272
|
+
now;
|
|
273
|
+
random;
|
|
274
|
+
logger;
|
|
275
|
+
loginTimeoutMs;
|
|
276
|
+
loginEvents = /* @__PURE__ */ new Map();
|
|
277
|
+
listeners = /* @__PURE__ */ new Map();
|
|
278
|
+
activeLogin = null;
|
|
279
|
+
refreshPromise = null;
|
|
280
|
+
lastLoginError;
|
|
281
|
+
disposed = false;
|
|
282
|
+
constructor(store, options = {}) {
|
|
283
|
+
this.store = store;
|
|
284
|
+
this.fetchFn = options.fetchFn ?? fetch;
|
|
285
|
+
this.now = options.now ?? Date.now;
|
|
286
|
+
this.random = options.random ?? randomBytes;
|
|
287
|
+
this.logger = options.logger ?? console;
|
|
288
|
+
this.loginTimeoutMs = options.loginTimeoutMs ?? 3e5;
|
|
289
|
+
}
|
|
290
|
+
async status() {
|
|
291
|
+
try {
|
|
292
|
+
const credentials = await this.store.load();
|
|
293
|
+
return this.statusFromCredentials(credentials);
|
|
294
|
+
} catch {
|
|
295
|
+
return {
|
|
296
|
+
...this.statusFromCredentials(null),
|
|
297
|
+
error: publicError(new OAuthServiceError("storage-failed", "Secure credential storage could not be read."))
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async startLogin() {
|
|
302
|
+
this.assertAvailable();
|
|
303
|
+
if (this.activeLogin !== null) throw new OAuthServiceError("login-active", "A ChatGPT sign-in is already in progress.");
|
|
304
|
+
this.lastLoginError = void 0;
|
|
305
|
+
const loginId = this.random(24).toString("base64url");
|
|
306
|
+
const verifier = this.random(48).toString("base64url");
|
|
307
|
+
const state = this.random(32).toString("base64url");
|
|
308
|
+
const expiresAt = this.now() + this.loginTimeoutMs;
|
|
309
|
+
const server = new OAuthCallbackServer({
|
|
310
|
+
expectedState: state,
|
|
311
|
+
exchange: async (code, signal) => this.exchangeCode(code, verifier, signal)
|
|
312
|
+
});
|
|
313
|
+
try {
|
|
314
|
+
await server.listen();
|
|
315
|
+
} catch {
|
|
316
|
+
server.completion.catch(() => void 0);
|
|
317
|
+
server.dispose();
|
|
318
|
+
throw new OAuthServiceError("internal", "The localhost OAuth callback listener could not start on port 1455.");
|
|
319
|
+
}
|
|
320
|
+
const timeout = setTimeout(() => {
|
|
321
|
+
this.cancelActive(new OAuthServiceError("login-expired", "ChatGPT sign-in timed out."), "failed");
|
|
322
|
+
}, this.loginTimeoutMs);
|
|
323
|
+
timeout.unref?.();
|
|
324
|
+
this.activeLogin = {
|
|
325
|
+
id: loginId,
|
|
326
|
+
expiresAt,
|
|
327
|
+
server,
|
|
328
|
+
timeout
|
|
329
|
+
};
|
|
330
|
+
this.publish({
|
|
331
|
+
type: "pending",
|
|
332
|
+
loginId
|
|
333
|
+
});
|
|
334
|
+
server.completion.then(() => {
|
|
335
|
+
this.completeLogin(loginId);
|
|
336
|
+
}).catch((error) => {
|
|
337
|
+
this.failLogin(loginId, error);
|
|
338
|
+
});
|
|
339
|
+
this.logger.info("[dsh-chatgpt-subscription] OAuth login started");
|
|
340
|
+
return {
|
|
341
|
+
loginId,
|
|
342
|
+
authUrl: buildAuthorizationUrl(verifier, state),
|
|
343
|
+
expiresAt
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
cancelLogin(loginId) {
|
|
347
|
+
if (this.activeLogin === null || this.activeLogin.id !== loginId) throw new OAuthServiceError("bad-request", "The requested sign-in is not active.");
|
|
348
|
+
this.cancelActive(new OAuthServiceError("login-cancelled", "ChatGPT sign-in was cancelled."), "cancelled");
|
|
349
|
+
}
|
|
350
|
+
subscribe(loginId, listener) {
|
|
351
|
+
const current = this.loginEvents.get(loginId);
|
|
352
|
+
if (current === void 0) return null;
|
|
353
|
+
let set = this.listeners.get(loginId);
|
|
354
|
+
if (set === void 0) {
|
|
355
|
+
set = /* @__PURE__ */ new Set();
|
|
356
|
+
this.listeners.set(loginId, set);
|
|
357
|
+
}
|
|
358
|
+
set.add(listener);
|
|
359
|
+
listener(current);
|
|
360
|
+
return () => {
|
|
361
|
+
set?.delete(listener);
|
|
362
|
+
if (set?.size === 0) this.listeners.delete(loginId);
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
async refresh() {
|
|
366
|
+
this.assertAvailable();
|
|
367
|
+
const stored = await this.loadAuthenticated();
|
|
368
|
+
await this.refreshCredentials(stored);
|
|
369
|
+
return this.status();
|
|
370
|
+
}
|
|
371
|
+
async logout() {
|
|
372
|
+
if (this.activeLogin !== null) this.cancelActive(new OAuthServiceError("login-cancelled", "ChatGPT sign-in was cancelled."), "cancelled");
|
|
373
|
+
await this.store.clear().catch(() => {
|
|
374
|
+
throw new OAuthServiceError("storage-failed", "Secure credentials could not be deleted.");
|
|
375
|
+
});
|
|
376
|
+
this.lastLoginError = void 0;
|
|
377
|
+
this.logger.info("[dsh-chatgpt-subscription] OAuth credentials cleared");
|
|
378
|
+
}
|
|
379
|
+
async credentials(forceRefresh = false) {
|
|
380
|
+
const stored = await this.loadAuthenticated();
|
|
381
|
+
if (forceRefresh || stored.expiresAt - this.now() <= 6e4) return this.refreshCredentials(stored);
|
|
382
|
+
return stored;
|
|
383
|
+
}
|
|
384
|
+
dispose() {
|
|
385
|
+
if (this.disposed) return;
|
|
386
|
+
this.disposed = true;
|
|
387
|
+
if (this.activeLogin !== null) this.cancelActive(new OAuthServiceError("login-cancelled", "ChatGPT sign-in was cancelled."), "cancelled");
|
|
388
|
+
this.listeners.clear();
|
|
389
|
+
this.loginEvents.clear();
|
|
390
|
+
}
|
|
391
|
+
async exchangeCode(code, verifier, signal) {
|
|
392
|
+
const response = await this.fetchFn(OAUTH_TOKEN_URL, {
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
395
|
+
body: new URLSearchParams({
|
|
396
|
+
grant_type: "authorization_code",
|
|
397
|
+
code,
|
|
398
|
+
redirect_uri: OAUTH_REDIRECT_URI,
|
|
399
|
+
client_id: CHATGPT_OAUTH_CLIENT_ID,
|
|
400
|
+
code_verifier: verifier
|
|
401
|
+
}).toString(),
|
|
402
|
+
signal
|
|
403
|
+
}).catch(() => {
|
|
404
|
+
throw new OAuthServiceError("oauth-token-exchange-failed", "ChatGPT token exchange could not be reached.");
|
|
405
|
+
});
|
|
406
|
+
if (!response.ok) {
|
|
407
|
+
const detail = await oauthErrorIdentifier(response);
|
|
408
|
+
throw new OAuthServiceError("oauth-token-exchange-failed", `ChatGPT token exchange failed (${response.status}${detail === null ? "" : `, ${detail}`}).`);
|
|
409
|
+
}
|
|
410
|
+
const credentials = credentialsFromTokenResponse(await response.json(), this.now());
|
|
411
|
+
await this.store.save(credentials).catch(() => {
|
|
412
|
+
throw new OAuthServiceError("storage-failed", "ChatGPT credentials could not be saved securely.");
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
refreshCredentials(stored) {
|
|
416
|
+
if (this.refreshPromise !== null) return this.refreshPromise;
|
|
417
|
+
this.refreshPromise = this.performRefresh(stored).finally(() => {
|
|
418
|
+
this.refreshPromise = null;
|
|
419
|
+
});
|
|
420
|
+
return this.refreshPromise;
|
|
421
|
+
}
|
|
422
|
+
async performRefresh(stored) {
|
|
423
|
+
const response = await this.fetchFn(OAUTH_TOKEN_URL, {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
426
|
+
body: new URLSearchParams({
|
|
427
|
+
grant_type: "refresh_token",
|
|
428
|
+
refresh_token: stored.refreshToken,
|
|
429
|
+
client_id: CHATGPT_OAUTH_CLIENT_ID
|
|
430
|
+
}).toString()
|
|
431
|
+
}).catch(() => {
|
|
432
|
+
throw new OAuthServiceError("refresh-failed", "ChatGPT token refresh could not be reached.");
|
|
433
|
+
});
|
|
434
|
+
if (!response.ok) {
|
|
435
|
+
const detail = await oauthErrorIdentifier(response);
|
|
436
|
+
if (response.status === 400 || response.status === 401) await this.store.clear().catch(() => {
|
|
437
|
+
throw new OAuthServiceError("storage-failed", "Expired ChatGPT credentials could not be deleted securely.");
|
|
438
|
+
});
|
|
439
|
+
throw new OAuthServiceError("refresh-failed", `ChatGPT token refresh failed (${response.status}${detail === null ? "" : `, ${detail}`}). Sign in again.`);
|
|
440
|
+
}
|
|
441
|
+
const fresh = credentialsFromTokenResponse(await response.json(), this.now(), stored);
|
|
442
|
+
await this.store.save(fresh).catch(() => {
|
|
443
|
+
throw new OAuthServiceError("storage-failed", "Refreshed credentials could not be saved securely.");
|
|
444
|
+
});
|
|
445
|
+
this.logger.info("[dsh-chatgpt-subscription] OAuth credentials refreshed");
|
|
446
|
+
return fresh;
|
|
447
|
+
}
|
|
448
|
+
async loadAuthenticated() {
|
|
449
|
+
const stored = await this.store.load().catch(() => {
|
|
450
|
+
throw new OAuthServiceError("storage-failed", "Secure credential storage could not be read.");
|
|
451
|
+
});
|
|
452
|
+
if (stored === null) throw new OAuthServiceError("not-authenticated", "Sign in with ChatGPT first.");
|
|
453
|
+
return stored;
|
|
454
|
+
}
|
|
455
|
+
statusFromCredentials(credentials) {
|
|
456
|
+
const active = this.activeLogin;
|
|
457
|
+
if (credentials === null) return {
|
|
458
|
+
authenticated: false,
|
|
459
|
+
account: null,
|
|
460
|
+
storage: {
|
|
461
|
+
kind: "windows-dpapi",
|
|
462
|
+
encrypted: true
|
|
463
|
+
},
|
|
464
|
+
login: {
|
|
465
|
+
active: active !== null,
|
|
466
|
+
loginId: active?.id ?? null,
|
|
467
|
+
expiresAt: active === null ? null : Math.floor(active.expiresAt / 1e3)
|
|
468
|
+
},
|
|
469
|
+
...this.lastLoginError === void 0 ? {} : { error: this.lastLoginError }
|
|
470
|
+
};
|
|
471
|
+
const identity = extractIdentity(credentials);
|
|
472
|
+
return {
|
|
473
|
+
authenticated: true,
|
|
474
|
+
account: {
|
|
475
|
+
email: maskEmail(credentials.email ?? identity.email),
|
|
476
|
+
planType: credentials.planType ?? identity.planType ?? null,
|
|
477
|
+
accountIdSuffix: maskAccountId(credentials.accountId ?? identity.accountId),
|
|
478
|
+
tokenExpiresAt: Math.floor(credentials.expiresAt / 1e3)
|
|
479
|
+
},
|
|
480
|
+
storage: {
|
|
481
|
+
kind: "windows-dpapi",
|
|
482
|
+
encrypted: true
|
|
483
|
+
},
|
|
484
|
+
login: {
|
|
485
|
+
active: active !== null,
|
|
486
|
+
loginId: active?.id ?? null,
|
|
487
|
+
expiresAt: active === null ? null : Math.floor(active.expiresAt / 1e3)
|
|
488
|
+
},
|
|
489
|
+
...this.lastLoginError === void 0 ? {} : { error: this.lastLoginError }
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
completeLogin(loginId) {
|
|
493
|
+
if (this.activeLogin?.id !== loginId) return;
|
|
494
|
+
clearTimeout(this.activeLogin.timeout);
|
|
495
|
+
this.activeLogin = null;
|
|
496
|
+
this.lastLoginError = void 0;
|
|
497
|
+
this.publish({
|
|
498
|
+
type: "completed",
|
|
499
|
+
loginId
|
|
500
|
+
});
|
|
501
|
+
this.logger.info("[dsh-chatgpt-subscription] OAuth login completed");
|
|
502
|
+
}
|
|
503
|
+
failLogin(loginId, error) {
|
|
504
|
+
if (this.activeLogin?.id !== loginId) return;
|
|
505
|
+
clearTimeout(this.activeLogin.timeout);
|
|
506
|
+
this.activeLogin = null;
|
|
507
|
+
const mapped = publicError(error, "oauth-callback-invalid");
|
|
508
|
+
this.lastLoginError = mapped;
|
|
509
|
+
this.publish({
|
|
510
|
+
type: "failed",
|
|
511
|
+
loginId,
|
|
512
|
+
error: mapped
|
|
513
|
+
});
|
|
514
|
+
this.logger.warn(`[dsh-chatgpt-subscription] OAuth login failed (${mapped.code}): ${mapped.message}`);
|
|
515
|
+
}
|
|
516
|
+
cancelActive(error, outcome) {
|
|
517
|
+
const active = this.activeLogin;
|
|
518
|
+
if (active === null) return;
|
|
519
|
+
clearTimeout(active.timeout);
|
|
520
|
+
this.activeLogin = null;
|
|
521
|
+
active.server.cancel(error);
|
|
522
|
+
this.publish(outcome === "cancelled" ? {
|
|
523
|
+
type: "cancelled",
|
|
524
|
+
loginId: active.id
|
|
525
|
+
} : {
|
|
526
|
+
type: "failed",
|
|
527
|
+
loginId: active.id,
|
|
528
|
+
error: publicError(error)
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
publish(event) {
|
|
532
|
+
this.loginEvents.set(event.loginId, event);
|
|
533
|
+
for (const listener of this.listeners.get(event.loginId) ?? []) listener(event);
|
|
534
|
+
}
|
|
535
|
+
assertAvailable() {
|
|
536
|
+
if (this.disposed) throw new OAuthServiceError("internal", "The OAuth service has been disposed.");
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
function buildAuthorizationUrl(verifier, state) {
|
|
540
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
541
|
+
return `${OAUTH_AUTHORIZE_URL}?${new URLSearchParams({
|
|
542
|
+
response_type: "code",
|
|
543
|
+
client_id: CHATGPT_OAUTH_CLIENT_ID,
|
|
544
|
+
redirect_uri: OAUTH_REDIRECT_URI,
|
|
545
|
+
scope: OAUTH_SCOPE,
|
|
546
|
+
code_challenge: challenge,
|
|
547
|
+
code_challenge_method: "S256",
|
|
548
|
+
id_token_add_organizations: "true",
|
|
549
|
+
codex_cli_simplified_flow: "true",
|
|
550
|
+
state,
|
|
551
|
+
originator: OAUTH_ORIGINATOR
|
|
552
|
+
}).toString()}`;
|
|
553
|
+
}
|
|
554
|
+
function parseJwtClaims(token) {
|
|
555
|
+
if (token === void 0) return void 0;
|
|
556
|
+
const parts = token.split(".");
|
|
557
|
+
if (parts.length !== 3) return void 0;
|
|
558
|
+
try {
|
|
559
|
+
const value = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
560
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
561
|
+
} catch {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function publicError(error, fallback = "internal") {
|
|
566
|
+
if (error instanceof OAuthServiceError) return {
|
|
567
|
+
code: error.code,
|
|
568
|
+
message: error.message
|
|
569
|
+
};
|
|
570
|
+
return {
|
|
571
|
+
code: fallback,
|
|
572
|
+
message: "The ChatGPT sign-in operation failed."
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function credentialsFromTokenResponse(response, now, previous) {
|
|
576
|
+
if (typeof response.access_token !== "string" || response.access_token === "") throw new OAuthServiceError("oauth-token-exchange-failed", "ChatGPT returned no access token.");
|
|
577
|
+
const refreshToken = typeof response.refresh_token === "string" && response.refresh_token !== "" ? response.refresh_token : previous?.refreshToken;
|
|
578
|
+
if (refreshToken === void 0) throw new OAuthServiceError("oauth-token-exchange-failed", "ChatGPT returned no refresh token.");
|
|
579
|
+
const seconds = Number(response.expires_in);
|
|
580
|
+
const expiresIn = Number.isFinite(seconds) && seconds > 0 ? seconds : 3600;
|
|
581
|
+
const base = {
|
|
582
|
+
accessToken: response.access_token,
|
|
583
|
+
refreshToken,
|
|
584
|
+
idToken: typeof response.id_token === "string" ? response.id_token : previous?.idToken,
|
|
585
|
+
expiresAt: now + expiresIn * 1e3
|
|
586
|
+
};
|
|
587
|
+
const identity = extractIdentity(base);
|
|
588
|
+
return {
|
|
589
|
+
...base,
|
|
590
|
+
accountId: identity.accountId ?? previous?.accountId,
|
|
591
|
+
email: identity.email ?? previous?.email,
|
|
592
|
+
planType: identity.planType ?? previous?.planType
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
function extractIdentity(credentials) {
|
|
596
|
+
const result = {};
|
|
597
|
+
for (const token of [credentials.idToken, credentials.accessToken]) {
|
|
598
|
+
const claims = parseJwtClaims(token);
|
|
599
|
+
if (claims === void 0) continue;
|
|
600
|
+
const nested = claims["https://api.openai.com/auth"];
|
|
601
|
+
result.email ??= stringClaim(claims.email);
|
|
602
|
+
result.planType ??= stringClaim(claims.chatgpt_plan_type) ?? stringClaim(nested?.chatgpt_plan_type);
|
|
603
|
+
result.accountId ??= stringClaim(claims.chatgpt_account_id) ?? stringClaim(nested?.chatgpt_account_id) ?? stringClaim(claims.organizations?.[0]?.id) ?? stringClaim(nested?.organizations?.[0]?.id);
|
|
604
|
+
}
|
|
605
|
+
return result;
|
|
606
|
+
}
|
|
607
|
+
function stringClaim(value) {
|
|
608
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
609
|
+
}
|
|
610
|
+
function maskEmail(email) {
|
|
611
|
+
if (email === void 0) return null;
|
|
612
|
+
const at = email.indexOf("@");
|
|
613
|
+
if (at <= 0 || at === email.length - 1) return "***";
|
|
614
|
+
return `${email.slice(0, 1)}***${email.slice(at)}`;
|
|
615
|
+
}
|
|
616
|
+
function maskAccountId(accountId) {
|
|
617
|
+
if (accountId === void 0) return null;
|
|
618
|
+
return `…${accountId.slice(-4)}`;
|
|
619
|
+
}
|
|
620
|
+
async function oauthErrorIdentifier(response) {
|
|
621
|
+
const payload = await response.json().catch(() => null);
|
|
622
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return null;
|
|
623
|
+
const error = payload.error;
|
|
624
|
+
const candidates = typeof error === "object" && error !== null && !Array.isArray(error) ? [error.code, error.type] : [error];
|
|
625
|
+
for (const candidate of candidates) if (typeof candidate === "string" && /^[a-z0-9_.-]{1,64}$/i.test(candidate)) return candidate;
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region src/host/responses-mapper.ts
|
|
630
|
+
function hiddenSandboxControlToolNames(options) {
|
|
631
|
+
const retryTools = recentSandboxRetryToolNames(options.messages);
|
|
632
|
+
return new Set(options.tools?.filter((tool) => hasSandboxControls(tool.parameters) && !retryTools.has(tool.name)).map((tool) => tool.name) ?? []);
|
|
633
|
+
}
|
|
634
|
+
async function buildResponsesPayload(options, attachments) {
|
|
635
|
+
const sandboxRetryTools = recentSandboxRetryToolNames(options.messages);
|
|
636
|
+
const instructions = [
|
|
637
|
+
options.system?.trim(),
|
|
638
|
+
...options.messages.filter((message) => message.role === "system").map((message) => blocksToText(message.content).trim()),
|
|
639
|
+
sandboxToolInstruction(options.tools, sandboxRetryTools),
|
|
640
|
+
runCodeInstruction(options.tools)
|
|
641
|
+
].filter((value) => Boolean(value));
|
|
642
|
+
const input = [];
|
|
643
|
+
const knownToolCalls = /* @__PURE__ */ new Map();
|
|
644
|
+
for (const message of options.messages) {
|
|
645
|
+
if (message.role === "system") continue;
|
|
646
|
+
const replayItems = replayOutputItems(message);
|
|
647
|
+
if (message.role === "assistant" && replayItems !== null) {
|
|
648
|
+
input.push(...replayItems);
|
|
649
|
+
for (const item of replayItems) if (item.type === "function_call" && typeof item.call_id === "string") knownToolCalls.set(item.call_id, typeof item.name === "string" ? item.name : void 0);
|
|
650
|
+
if (!replayItems.some((item) => item.type === "message")) {
|
|
651
|
+
const content = await mapContent(message, attachments, options.signal);
|
|
652
|
+
if (content.length > 0) input.push({
|
|
653
|
+
role: message.role,
|
|
654
|
+
content
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
appendMissingToolCalls(input, knownToolCalls, message);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const toolResult = message.content.find((block) => block.type === "tool-result");
|
|
661
|
+
if (toolResult?.type === "tool-result") {
|
|
662
|
+
const callId = String(toolResult.toolCallId);
|
|
663
|
+
const rawOutput = blocksToText(toolResult.content);
|
|
664
|
+
if (knownToolCalls.has(callId)) {
|
|
665
|
+
const output = toolResult.isError && knownToolCalls.get(callId) === "run_code" ? runCodeErrorOutput(rawOutput) : rawOutput;
|
|
666
|
+
input.push({
|
|
667
|
+
type: "function_call_output",
|
|
668
|
+
call_id: callId,
|
|
669
|
+
output
|
|
670
|
+
});
|
|
671
|
+
} else input.push({
|
|
672
|
+
role: "user",
|
|
673
|
+
content: [{
|
|
674
|
+
type: "input_text",
|
|
675
|
+
text: `Tool result for unavailable call ${callId}${toolResult.isError ? " (error)" : ""}:\n${rawOutput}`
|
|
676
|
+
}]
|
|
677
|
+
});
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
const content = await mapContent(message, attachments, options.signal);
|
|
681
|
+
if (content.length > 0) input.push({
|
|
682
|
+
role: message.role,
|
|
683
|
+
content
|
|
684
|
+
});
|
|
685
|
+
if (message.role === "assistant") appendMissingToolCalls(input, knownToolCalls, message);
|
|
686
|
+
}
|
|
687
|
+
const payload = {
|
|
688
|
+
model: options.model,
|
|
689
|
+
input,
|
|
690
|
+
stream: true,
|
|
691
|
+
store: false,
|
|
692
|
+
include: ["reasoning.encrypted_content"]
|
|
693
|
+
};
|
|
694
|
+
if (instructions.length > 0) payload.instructions = instructions.join("\n\n");
|
|
695
|
+
if (options.tools?.length) {
|
|
696
|
+
payload.tools = options.tools.map((tool) => ({
|
|
697
|
+
type: "function",
|
|
698
|
+
name: tool.name,
|
|
699
|
+
description: toolDescriptionForCodex(tool.name, tool.description),
|
|
700
|
+
parameters: toolParametersForCodex(tool.name, tool.parameters, sandboxRetryTools.has(tool.name))
|
|
701
|
+
}));
|
|
702
|
+
payload.tool_choice = "auto";
|
|
703
|
+
payload.parallel_tool_calls = true;
|
|
704
|
+
}
|
|
705
|
+
if (options.reasoningEffort !== void 0) payload.reasoning = {
|
|
706
|
+
effort: options.reasoningEffort,
|
|
707
|
+
summary: "auto"
|
|
708
|
+
};
|
|
709
|
+
return payload;
|
|
710
|
+
}
|
|
711
|
+
function runCodeInstruction(tools) {
|
|
712
|
+
if (!tools?.some((tool) => tool.name === "run_code")) return void 0;
|
|
713
|
+
return "run_code compatibility rule: its code is parsed as strict JavaScript/TypeScript before execution. On Windows, do not embed PowerShell containing $, ${...}, backslashes, or here-strings in JavaScript template literals; String.raw does not disable ${...} interpolation. Prefer arrays of ordinary quoted strings joined with \"\\n\", escaping backslashes, or use a file-write tool for large scripts and then invoke pwsh.";
|
|
714
|
+
}
|
|
715
|
+
function toolDescriptionForCodex(name, description) {
|
|
716
|
+
if (name !== "run_code") return description;
|
|
717
|
+
return `${description}\n\nCompatibility: code is strict JavaScript/TypeScript. When composing PowerShell, avoid JavaScript template literals containing $, \${...}, Windows backslashes, or PowerShell here-strings. Prefer ordinary quoted string arrays joined with "\\n", or write a script file with a dedicated file tool before invoking pwsh.`;
|
|
718
|
+
}
|
|
719
|
+
function sandboxToolInstruction(tools, sandboxRetryTools) {
|
|
720
|
+
if (!tools?.some((tool) => hasSandboxControls(tool.parameters))) return void 0;
|
|
721
|
+
if (sandboxRetryTools.size === 0) return "Tool sandbox rule: this is not a sandbox-escalation retry. Omit sandbox_permissions and justification from every tool call. First run the tool with the session's current access.";
|
|
722
|
+
return `Tool sandbox rule: sandbox_permissions and justification may only be used to retry the exact denied call for: ${[...sandboxRetryTools].join(", ")}. Omit both fields from every other tool call, and request a strictly wider mode with a non-empty justification sentence.`;
|
|
723
|
+
}
|
|
724
|
+
function toolParametersForCodex(toolName, parameters, allowSandboxRetry) {
|
|
725
|
+
const hideSandboxControls = !allowSandboxRetry && hasSandboxControls(parameters);
|
|
726
|
+
if (!hideSandboxControls && toolName !== "run_code") return parameters;
|
|
727
|
+
const cloned = structuredClone(parameters);
|
|
728
|
+
const properties = record$2(cloned.properties);
|
|
729
|
+
if (properties !== null && hideSandboxControls) {
|
|
730
|
+
delete properties.sandbox_permissions;
|
|
731
|
+
delete properties.justification;
|
|
732
|
+
}
|
|
733
|
+
if (hideSandboxControls && Array.isArray(cloned.required)) cloned.required = cloned.required.filter((name) => name !== "sandbox_permissions" && name !== "justification");
|
|
734
|
+
if (toolName === "run_code" && properties !== null) {
|
|
735
|
+
const code = record$2(properties.code);
|
|
736
|
+
if (code !== null) {
|
|
737
|
+
const current = typeof code.description === "string" ? code.description.trim() : "";
|
|
738
|
+
const compatibility = "Strict JavaScript/TypeScript source. For PowerShell on Windows, avoid JavaScript template literals containing $, ${...}, backslashes, or here-strings; String.raw still performs ${...} interpolation. Prefer ordinary quoted string arrays joined with \"\\n\" and escape backslashes, or write a script file with a dedicated file tool.";
|
|
739
|
+
code.description = current ? `${current}\n\n${compatibility}` : compatibility;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return cloned;
|
|
743
|
+
}
|
|
744
|
+
function hasSandboxControls(parameters) {
|
|
745
|
+
const properties = record$2(parameters.properties);
|
|
746
|
+
return properties !== null && ("sandbox_permissions" in properties || "justification" in properties);
|
|
747
|
+
}
|
|
748
|
+
function recentSandboxRetryToolNames(messages) {
|
|
749
|
+
const deniedCallIds = /* @__PURE__ */ new Set();
|
|
750
|
+
let assistant;
|
|
751
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
752
|
+
const message = messages[index];
|
|
753
|
+
if (message.role === "assistant") {
|
|
754
|
+
assistant = message;
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
for (const block of message.content) {
|
|
758
|
+
if (block.type !== "tool-result") continue;
|
|
759
|
+
if (isSandboxDenial(blocksToText(block.content))) deniedCallIds.add(String(block.toolCallId));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const result = /* @__PURE__ */ new Set();
|
|
763
|
+
if (assistant === void 0 || deniedCallIds.size === 0) return result;
|
|
764
|
+
for (const block of assistant.content) if (block.type === "tool-call" && deniedCallIds.has(String(block.id))) result.add(block.name);
|
|
765
|
+
return result;
|
|
766
|
+
}
|
|
767
|
+
function isSandboxDenial(output) {
|
|
768
|
+
return /\[sandbox:\s*file access denied\b/i.test(output) || /\bsandbox\b.*\b(?:access denied|denied access|EPERM)\b/i.test(output);
|
|
769
|
+
}
|
|
770
|
+
function appendMissingToolCalls(input, knownToolCalls, message) {
|
|
771
|
+
for (const block of message.content) {
|
|
772
|
+
if (block.type !== "tool-call") continue;
|
|
773
|
+
const callId = String(block.id);
|
|
774
|
+
if (knownToolCalls.has(callId)) continue;
|
|
775
|
+
input.push({
|
|
776
|
+
type: "function_call",
|
|
777
|
+
call_id: callId,
|
|
778
|
+
name: block.name,
|
|
779
|
+
arguments: block.arguments
|
|
780
|
+
});
|
|
781
|
+
knownToolCalls.set(callId, block.name);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function runCodeErrorOutput(output) {
|
|
785
|
+
if (!isRunCodeParserError(output)) return output;
|
|
786
|
+
return `${output}\n\nCompatibility hint: run_code failed while parsing strict JavaScript/TypeScript, before the nested tool ran. Avoid JavaScript template literals for PowerShell containing $, \${...}, Windows backslashes, or here-strings; String.raw does not prevent \${...} interpolation. Build the script from ordinary quoted strings joined with "\\n" (escaping backslashes), or write a script file with a dedicated file tool and then invoke pwsh.`;
|
|
787
|
+
}
|
|
788
|
+
function isRunCodeParserError(output) {
|
|
789
|
+
return /(?:Legacy octal escape is not permitted in strict mode|Unexpected token|Invalid or unexpected token|Unterminated template|Expected ['"]?\}['"]?)/i.test(output);
|
|
790
|
+
}
|
|
791
|
+
async function mapContent(message, attachments, signal) {
|
|
792
|
+
const result = [];
|
|
793
|
+
for (const block of message.content) if (block.type === "text") result.push({
|
|
794
|
+
type: message.role === "assistant" ? "output_text" : "input_text",
|
|
795
|
+
text: block.text
|
|
796
|
+
});
|
|
797
|
+
else if (block.type === "image") {
|
|
798
|
+
if (message.role !== "user") continue;
|
|
799
|
+
result.push({
|
|
800
|
+
type: "input_image",
|
|
801
|
+
image_url: await imageDataUrl(block.attachment, attachments, signal)
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
return result;
|
|
805
|
+
}
|
|
806
|
+
async function imageDataUrl(ref, attachments, signal) {
|
|
807
|
+
const stored = await attachments.readImage(ref, signal);
|
|
808
|
+
return `data:${stored.ref.mediaType};base64,${Buffer.from(stored.data).toString("base64")}`;
|
|
809
|
+
}
|
|
810
|
+
function blocksToText(blocks) {
|
|
811
|
+
return blocks.map((block) => {
|
|
812
|
+
if (block.type === "text" || block.type === "reasoning") return block.text;
|
|
813
|
+
if (block.type === "image") return `[image: ${block.attachment.name ?? block.attachment.attachmentId}]`;
|
|
814
|
+
if (block.type === "tool-result") return blocksToText(block.content);
|
|
815
|
+
return "";
|
|
816
|
+
}).filter(Boolean).join("\n");
|
|
817
|
+
}
|
|
818
|
+
function replayOutputItems(message) {
|
|
819
|
+
if (message.source.kind !== "model") return null;
|
|
820
|
+
const replay = message.source.replayState;
|
|
821
|
+
if (typeof replay !== "object" || replay === null || Array.isArray(replay)) return null;
|
|
822
|
+
const items = replay.outputItems;
|
|
823
|
+
if (!Array.isArray(items)) return null;
|
|
824
|
+
return structuredClone(items.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)));
|
|
825
|
+
}
|
|
826
|
+
function record$2(value) {
|
|
827
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
828
|
+
}
|
|
829
|
+
//#endregion
|
|
830
|
+
//#region src/host/wire-auth.ts
|
|
831
|
+
function codexHeaders(credentials, sessionId) {
|
|
832
|
+
const dshAgent = attributionHeaders()["user-agent"] ?? "dsh/unknown";
|
|
833
|
+
return {
|
|
834
|
+
authorization: `Bearer ${credentials.accessToken}`,
|
|
835
|
+
...credentials.accountId ? { "chatgpt-account-id": credentials.accountId } : {},
|
|
836
|
+
originator: CODEX_ORIGINATOR,
|
|
837
|
+
"user-agent": `dsh-chatgpt-subscription/${PLUGIN_VERSION} (${dshAgent})`,
|
|
838
|
+
...sessionId ? { "session-id": sessionId } : {}
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function stableSessionId(value) {
|
|
842
|
+
const source = value === void 0 || value === "" ? randomUUID() : value;
|
|
843
|
+
return `dsh-${createHash("sha256").update(source).digest("hex").slice(0, 32)}`;
|
|
844
|
+
}
|
|
845
|
+
function retryAfterMs(headers) {
|
|
846
|
+
const raw = headers.get("retry-after");
|
|
847
|
+
if (raw === null) return void 0;
|
|
848
|
+
const seconds = Number(raw);
|
|
849
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, 10 * 6e4);
|
|
850
|
+
const timestamp = Date.parse(raw);
|
|
851
|
+
if (!Number.isFinite(timestamp)) return void 0;
|
|
852
|
+
return Math.min(Math.max(0, timestamp - Date.now()), 10 * 6e4);
|
|
853
|
+
}
|
|
854
|
+
//#endregion
|
|
855
|
+
//#region src/host/responses-client.ts
|
|
856
|
+
var ResponsesClient = class {
|
|
857
|
+
oauth;
|
|
858
|
+
attachments;
|
|
859
|
+
fetchFn;
|
|
860
|
+
onGenerationFinished;
|
|
861
|
+
constructor(oauth, attachments, options = {}) {
|
|
862
|
+
this.oauth = oauth;
|
|
863
|
+
this.attachments = attachments;
|
|
864
|
+
this.fetchFn = options.fetchFn ?? fetch;
|
|
865
|
+
this.onGenerationFinished = options.onGenerationFinished ?? (() => void 0);
|
|
866
|
+
}
|
|
867
|
+
async *stream(options) {
|
|
868
|
+
const payload = await buildResponsesPayload(options, this.attachments);
|
|
869
|
+
const hiddenSandboxControls = hiddenSandboxControlToolNames(options);
|
|
870
|
+
const sessionId = stableSessionId(options.sessionId);
|
|
871
|
+
try {
|
|
872
|
+
yield* parseResponsesStream(await this.send(payload, sessionId, options.signal), options.signal, hiddenSandboxControls);
|
|
873
|
+
} finally {
|
|
874
|
+
this.onGenerationFinished();
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
async send(payload, sessionId, signal) {
|
|
878
|
+
let credentials = await this.oauth.credentials();
|
|
879
|
+
let response = await this.request(payload, credentials, sessionId, signal);
|
|
880
|
+
if (response.status === 401) {
|
|
881
|
+
await response.body?.cancel().catch(() => void 0);
|
|
882
|
+
credentials = await this.oauth.credentials(true);
|
|
883
|
+
response = await this.request(payload, credentials, sessionId, signal);
|
|
884
|
+
}
|
|
885
|
+
if (!response.ok) throw await responseError(response);
|
|
886
|
+
return response;
|
|
887
|
+
}
|
|
888
|
+
async request(payload, credentials, sessionId, signal) {
|
|
889
|
+
try {
|
|
890
|
+
return await this.fetchFn(CODEX_RESPONSES_URL, {
|
|
891
|
+
method: "POST",
|
|
892
|
+
headers: {
|
|
893
|
+
...codexHeaders(credentials, sessionId),
|
|
894
|
+
"content-type": "application/json",
|
|
895
|
+
accept: "text/event-stream"
|
|
896
|
+
},
|
|
897
|
+
body: JSON.stringify(payload),
|
|
898
|
+
signal
|
|
899
|
+
});
|
|
900
|
+
} catch (cause) {
|
|
901
|
+
if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
902
|
+
throw new LlmError("Codex could not be reached.", "NETWORK", { cause });
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
async function* parseResponsesStream(response, signal, hiddenSandboxControls = /* @__PURE__ */ new Set()) {
|
|
907
|
+
if (response.body === null) throw new LlmError("Codex returned no response stream.", "PROVIDER_ERROR");
|
|
908
|
+
const reader = response.body.getReader();
|
|
909
|
+
const abortReader = () => {
|
|
910
|
+
reader.cancel(signal?.reason).catch(() => void 0);
|
|
911
|
+
};
|
|
912
|
+
signal?.addEventListener("abort", abortReader, { once: true });
|
|
913
|
+
const decoder = new TextDecoder();
|
|
914
|
+
let buffer = "";
|
|
915
|
+
let nextIndex = 0;
|
|
916
|
+
let textIndex = null;
|
|
917
|
+
let reasoningIndex = null;
|
|
918
|
+
let text = "";
|
|
919
|
+
let reasoning = "";
|
|
920
|
+
let terminal = null;
|
|
921
|
+
let usage = null;
|
|
922
|
+
let replayOutput = [];
|
|
923
|
+
const tools = /* @__PURE__ */ new Map();
|
|
924
|
+
const toolFor = (event, item) => {
|
|
925
|
+
const itemId = string(event.item_id) ?? string(item?.id);
|
|
926
|
+
const outputIndex = number(event.output_index);
|
|
927
|
+
const key = itemId ?? (outputIndex === void 0 ? `tool-${tools.size}` : `index-${outputIndex}`);
|
|
928
|
+
let tool = tools.get(key);
|
|
929
|
+
if (tool === void 0) {
|
|
930
|
+
tool = {
|
|
931
|
+
index: nextIndex++,
|
|
932
|
+
id: string(item?.call_id) ?? string(event.call_id) ?? `call_${key}`,
|
|
933
|
+
itemId,
|
|
934
|
+
name: string(item?.name) ?? string(event.name) ?? "",
|
|
935
|
+
arguments: "",
|
|
936
|
+
started: false
|
|
937
|
+
};
|
|
938
|
+
tools.set(key, tool);
|
|
939
|
+
}
|
|
940
|
+
return tool;
|
|
941
|
+
};
|
|
942
|
+
const consume = async function* (event) {
|
|
943
|
+
const type = string(event.type);
|
|
944
|
+
if (type === "response.output_text.delta" || type === "response.refusal.delta") {
|
|
945
|
+
const delta = string(event.delta) ?? "";
|
|
946
|
+
if (textIndex === null) {
|
|
947
|
+
textIndex = nextIndex++;
|
|
948
|
+
yield {
|
|
949
|
+
type: "block-start",
|
|
950
|
+
index: textIndex,
|
|
951
|
+
blockType: "text"
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
text += delta;
|
|
955
|
+
if (delta) yield {
|
|
956
|
+
type: "text-delta",
|
|
957
|
+
index: textIndex,
|
|
958
|
+
text: delta
|
|
959
|
+
};
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (type === "response.reasoning_summary_text.delta") {
|
|
963
|
+
const delta = string(event.delta) ?? "";
|
|
964
|
+
if (reasoningIndex === null) {
|
|
965
|
+
reasoningIndex = nextIndex++;
|
|
966
|
+
yield {
|
|
967
|
+
type: "block-start",
|
|
968
|
+
index: reasoningIndex,
|
|
969
|
+
blockType: "reasoning"
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
reasoning += delta;
|
|
973
|
+
if (delta) yield {
|
|
974
|
+
type: "reasoning-delta",
|
|
975
|
+
index: reasoningIndex,
|
|
976
|
+
text: delta
|
|
977
|
+
};
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (type === "response.output_item.added" || type === "response.output_item.done") {
|
|
981
|
+
const item = record$1(event.item);
|
|
982
|
+
if (item !== null && type === "response.output_item.done") replayOutput.push(structuredClone(item));
|
|
983
|
+
if (string(item?.type) !== "function_call") return;
|
|
984
|
+
const tool = toolFor(event, item ?? void 0);
|
|
985
|
+
tool.id = string(item?.call_id) ?? tool.id;
|
|
986
|
+
tool.name = string(item?.name) ?? tool.name;
|
|
987
|
+
const initial = string(item?.arguments) ?? "";
|
|
988
|
+
if (!tool.started) {
|
|
989
|
+
tool.started = true;
|
|
990
|
+
yield {
|
|
991
|
+
type: "block-start",
|
|
992
|
+
index: tool.index,
|
|
993
|
+
blockType: "tool-call"
|
|
994
|
+
};
|
|
995
|
+
yield {
|
|
996
|
+
type: "tool-call-delta",
|
|
997
|
+
index: tool.index,
|
|
998
|
+
id: CallId(tool.id),
|
|
999
|
+
name: tool.name || void 0,
|
|
1000
|
+
argumentsDelta: initial
|
|
1001
|
+
};
|
|
1002
|
+
tool.arguments = initial;
|
|
1003
|
+
} else if (type === "response.output_item.done" && initial !== "") tool.arguments = initial;
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
if (type === "response.function_call_arguments.delta") {
|
|
1007
|
+
const tool = toolFor(event);
|
|
1008
|
+
const delta = string(event.delta) ?? "";
|
|
1009
|
+
if (!tool.started) {
|
|
1010
|
+
tool.started = true;
|
|
1011
|
+
yield {
|
|
1012
|
+
type: "block-start",
|
|
1013
|
+
index: tool.index,
|
|
1014
|
+
blockType: "tool-call"
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
tool.arguments += delta;
|
|
1018
|
+
yield {
|
|
1019
|
+
type: "tool-call-delta",
|
|
1020
|
+
index: tool.index,
|
|
1021
|
+
id: CallId(tool.id),
|
|
1022
|
+
name: tool.name || void 0,
|
|
1023
|
+
argumentsDelta: delta
|
|
1024
|
+
};
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (type === "response.function_call_arguments.done") {
|
|
1028
|
+
const tool = toolFor(event);
|
|
1029
|
+
const finalArguments = string(event.arguments);
|
|
1030
|
+
if (finalArguments !== void 0) tool.arguments = finalArguments;
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (type === "response.completed" || type === "response.incomplete") {
|
|
1034
|
+
const completed = record$1(event.response);
|
|
1035
|
+
usage = mapUsage(record$1(completed?.usage));
|
|
1036
|
+
const output = completed?.output;
|
|
1037
|
+
if (Array.isArray(output)) replayOutput = output.filter((item) => record$1(item) !== null).map((item) => structuredClone(item));
|
|
1038
|
+
terminal = type === "response.incomplete" ? { kind: "max-tokens" } : { kind: "stop" };
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (type === "response.failed" || type === "error") {
|
|
1042
|
+
const error = record$1(event.error) ?? record$1(record$1(event.response)?.error);
|
|
1043
|
+
throw new LlmError(string(error?.message) ?? "Codex generation failed.", string(error?.code) ?? "PROVIDER_ERROR");
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
try {
|
|
1047
|
+
while (true) {
|
|
1048
|
+
if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
1049
|
+
const { done, value } = await reader.read();
|
|
1050
|
+
if (done) break;
|
|
1051
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1052
|
+
const frames = buffer.split(/\r?\n\r?\n/);
|
|
1053
|
+
buffer = frames.pop() ?? "";
|
|
1054
|
+
for (const frame of frames) {
|
|
1055
|
+
const data = frame.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
1056
|
+
if (data === "" || data === "[DONE]") continue;
|
|
1057
|
+
let event;
|
|
1058
|
+
try {
|
|
1059
|
+
event = JSON.parse(data);
|
|
1060
|
+
} catch {
|
|
1061
|
+
throw new LlmError("Codex returned malformed streaming JSON.", "PROTOCOL_ERROR");
|
|
1062
|
+
}
|
|
1063
|
+
const valueRecord = record$1(event);
|
|
1064
|
+
if (valueRecord !== null) yield* consume(valueRecord);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
} finally {
|
|
1068
|
+
signal?.removeEventListener("abort", abortReader);
|
|
1069
|
+
reader.releaseLock();
|
|
1070
|
+
}
|
|
1071
|
+
if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
1072
|
+
if (terminal === null) throw new LlmError("Codex stream ended before a terminal event.", "PROTOCOL_ERROR");
|
|
1073
|
+
if (reasoningIndex !== null) yield {
|
|
1074
|
+
type: "block-end",
|
|
1075
|
+
index: reasoningIndex,
|
|
1076
|
+
block: {
|
|
1077
|
+
type: "reasoning",
|
|
1078
|
+
text: reasoning
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
if (textIndex !== null) yield {
|
|
1082
|
+
type: "block-end",
|
|
1083
|
+
index: textIndex,
|
|
1084
|
+
block: {
|
|
1085
|
+
type: "text",
|
|
1086
|
+
text
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
for (const item of replayOutput) if (item.type === "function_call" && typeof item.name === "string" && hiddenSandboxControls.has(item.name) && typeof item.arguments === "string") item.arguments = stripSandboxControls(item.arguments);
|
|
1090
|
+
let validToolCount = 0;
|
|
1091
|
+
const replayedToolCallIds = new Set(replayOutput.flatMap((item) => item.type === "function_call" && typeof item.call_id === "string" ? [item.call_id] : []));
|
|
1092
|
+
for (const tool of tools.values()) {
|
|
1093
|
+
if (!tool.started) continue;
|
|
1094
|
+
if (hiddenSandboxControls.has(tool.name)) tool.arguments = stripSandboxControls(tool.arguments);
|
|
1095
|
+
if (!isSafeJsonArguments(tool.arguments) || tool.name === "") throw new LlmError(`Codex returned invalid JSON arguments for tool ${tool.name || "(unnamed)"}.`, "INVALID_TOOL_ARGUMENTS");
|
|
1096
|
+
validToolCount++;
|
|
1097
|
+
if (!replayedToolCallIds.has(tool.id)) {
|
|
1098
|
+
replayOutput.push({
|
|
1099
|
+
type: "function_call",
|
|
1100
|
+
call_id: tool.id,
|
|
1101
|
+
name: tool.name,
|
|
1102
|
+
arguments: tool.arguments
|
|
1103
|
+
});
|
|
1104
|
+
replayedToolCallIds.add(tool.id);
|
|
1105
|
+
}
|
|
1106
|
+
yield {
|
|
1107
|
+
type: "block-end",
|
|
1108
|
+
index: tool.index,
|
|
1109
|
+
block: {
|
|
1110
|
+
type: "tool-call",
|
|
1111
|
+
id: CallId(tool.id),
|
|
1112
|
+
name: tool.name,
|
|
1113
|
+
arguments: tool.arguments
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
if (usage !== null) yield {
|
|
1118
|
+
type: "usage",
|
|
1119
|
+
usage
|
|
1120
|
+
};
|
|
1121
|
+
yield {
|
|
1122
|
+
type: "finish",
|
|
1123
|
+
reason: validToolCount > 0 ? { kind: "tool-calls" } : terminal,
|
|
1124
|
+
replayState: { outputItems: replayOutput }
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
function mapUsage(value) {
|
|
1128
|
+
if (value === null) return null;
|
|
1129
|
+
const totalInput = number(value.input_tokens) ?? 0;
|
|
1130
|
+
const outputTokens = number(value.output_tokens) ?? 0;
|
|
1131
|
+
const cached = number(record$1(value.input_tokens_details)?.cached_tokens) ?? 0;
|
|
1132
|
+
const reasoning = number(record$1(value.output_tokens_details)?.reasoning_tokens);
|
|
1133
|
+
return {
|
|
1134
|
+
inputTokens: Math.max(0, totalInput - cached),
|
|
1135
|
+
outputTokens,
|
|
1136
|
+
...cached > 0 ? { cacheReadTokens: cached } : {},
|
|
1137
|
+
...reasoning === void 0 ? {} : { reasoningTokens: reasoning }
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
function isSafeJsonArguments(value) {
|
|
1141
|
+
try {
|
|
1142
|
+
const parsed = JSON.parse(value);
|
|
1143
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed);
|
|
1144
|
+
} catch {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
function stripSandboxControls(value) {
|
|
1149
|
+
try {
|
|
1150
|
+
const parsedRecord = record$1(JSON.parse(value));
|
|
1151
|
+
if (parsedRecord === null) return value;
|
|
1152
|
+
let changed = false;
|
|
1153
|
+
for (const name of ["sandbox_permissions", "justification"]) if (name in parsedRecord) {
|
|
1154
|
+
delete parsedRecord[name];
|
|
1155
|
+
changed = true;
|
|
1156
|
+
}
|
|
1157
|
+
return changed ? JSON.stringify(parsedRecord) : value;
|
|
1158
|
+
} catch {
|
|
1159
|
+
return value;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
async function responseError(response) {
|
|
1163
|
+
const requestId = response.headers.get("x-request-id");
|
|
1164
|
+
const detail = (await response.text().catch(() => "")).slice(0, 500);
|
|
1165
|
+
const options = {
|
|
1166
|
+
status: response.status,
|
|
1167
|
+
...requestId ? { requestId: ProviderRequestId(requestId) } : {},
|
|
1168
|
+
...response.status === 429 ? { providerRetryAfterMs: retryAfterMs(response.headers) } : {}
|
|
1169
|
+
};
|
|
1170
|
+
if (response.status === 401) return new LlmError("ChatGPT sign-in has expired. Sign in again.", "AUTH", options);
|
|
1171
|
+
if (response.status === 429) return new LlmError("Codex rate limit reached.", "RATE_LIMIT", options);
|
|
1172
|
+
if (response.status >= 500) return new LlmError(`Codex service error (${response.status}).`, "SERVER_ERROR", options);
|
|
1173
|
+
return new LlmError(`Codex request failed (${response.status})${detail ? `: ${detail}` : "."}`, "PROVIDER_ERROR", options);
|
|
1174
|
+
}
|
|
1175
|
+
function record$1(value) {
|
|
1176
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
1177
|
+
}
|
|
1178
|
+
function string(value) {
|
|
1179
|
+
return typeof value === "string" ? value : void 0;
|
|
1180
|
+
}
|
|
1181
|
+
function number(value) {
|
|
1182
|
+
const parsed = Number(value);
|
|
1183
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
1184
|
+
}
|
|
1185
|
+
//#endregion
|
|
1186
|
+
//#region src/host/usage-service.ts
|
|
1187
|
+
var UsageService = class {
|
|
1188
|
+
oauth;
|
|
1189
|
+
fetchFn;
|
|
1190
|
+
now;
|
|
1191
|
+
cache = null;
|
|
1192
|
+
lastUpstreamAt = 0;
|
|
1193
|
+
blockedUntil = 0;
|
|
1194
|
+
invalidated = false;
|
|
1195
|
+
inFlight = null;
|
|
1196
|
+
constructor(oauth, options = {}) {
|
|
1197
|
+
this.oauth = oauth;
|
|
1198
|
+
this.fetchFn = options.fetchFn ?? fetch;
|
|
1199
|
+
this.now = options.now ?? Date.now;
|
|
1200
|
+
}
|
|
1201
|
+
async status(authenticated, force = false) {
|
|
1202
|
+
if (!authenticated) return {
|
|
1203
|
+
state: "signed-out",
|
|
1204
|
+
buckets: [],
|
|
1205
|
+
fetchedAt: null,
|
|
1206
|
+
stale: false
|
|
1207
|
+
};
|
|
1208
|
+
const now = this.now();
|
|
1209
|
+
let credentials;
|
|
1210
|
+
try {
|
|
1211
|
+
credentials = await this.oauth.credentials();
|
|
1212
|
+
} catch {
|
|
1213
|
+
return this.failure({
|
|
1214
|
+
code: "quota-failed",
|
|
1215
|
+
message: "ChatGPT credentials could not be refreshed."
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
const accountKey = identityKey(credentials);
|
|
1219
|
+
if (this.cache !== null && this.cache.accountKey !== accountKey) this.clear();
|
|
1220
|
+
if (!force && !this.invalidated && this.cache !== null && now - this.cache.fetchedAt < 6e4) return this.fromCache(false);
|
|
1221
|
+
if (this.cache !== null && now - this.lastUpstreamAt < 15e3) return this.fromCache(this.invalidated || now - this.cache.fetchedAt >= 6e4);
|
|
1222
|
+
if (now < this.blockedUntil) return this.failure({
|
|
1223
|
+
code: "rate-limited",
|
|
1224
|
+
message: "Quota refresh is temporarily rate limited."
|
|
1225
|
+
});
|
|
1226
|
+
if (this.inFlight !== null) return this.inFlight;
|
|
1227
|
+
this.inFlight = this.refreshUpstream(credentials, accountKey).finally(() => {
|
|
1228
|
+
this.inFlight = null;
|
|
1229
|
+
});
|
|
1230
|
+
return this.inFlight;
|
|
1231
|
+
}
|
|
1232
|
+
invalidate() {
|
|
1233
|
+
this.invalidated = true;
|
|
1234
|
+
}
|
|
1235
|
+
clear() {
|
|
1236
|
+
this.cache = null;
|
|
1237
|
+
this.blockedUntil = 0;
|
|
1238
|
+
this.invalidated = false;
|
|
1239
|
+
}
|
|
1240
|
+
async testConnection() {
|
|
1241
|
+
const started = this.now();
|
|
1242
|
+
const result = await this.status(true, true);
|
|
1243
|
+
if (result.state === "error" || result.error !== void 0) throw new UsageServiceError(result.error ?? {
|
|
1244
|
+
code: "connection-failed",
|
|
1245
|
+
message: "Codex connection test failed."
|
|
1246
|
+
});
|
|
1247
|
+
return {
|
|
1248
|
+
connected: true,
|
|
1249
|
+
latencyMs: Math.max(0, this.now() - started),
|
|
1250
|
+
checkedAt: Math.floor(this.now() / 1e3)
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
async refreshUpstream(initialCredentials, initialAccountKey) {
|
|
1254
|
+
this.lastUpstreamAt = this.now();
|
|
1255
|
+
try {
|
|
1256
|
+
let credentials = initialCredentials;
|
|
1257
|
+
let accountKey = initialAccountKey;
|
|
1258
|
+
let response = await this.fetch(credentials);
|
|
1259
|
+
if (response.status === 401) {
|
|
1260
|
+
await response.body?.cancel().catch(() => void 0);
|
|
1261
|
+
credentials = await this.oauth.credentials(true);
|
|
1262
|
+
accountKey = identityKey(credentials);
|
|
1263
|
+
response = await this.fetch(credentials);
|
|
1264
|
+
}
|
|
1265
|
+
if (response.status === 429) {
|
|
1266
|
+
const delay = retryAfterMs(response.headers) ?? 15e3;
|
|
1267
|
+
this.blockedUntil = this.now() + Math.max(QUOTA_MIN_UPSTREAM_INTERVAL_MS, delay);
|
|
1268
|
+
await response.body?.cancel().catch(() => void 0);
|
|
1269
|
+
return this.failure({
|
|
1270
|
+
code: "rate-limited",
|
|
1271
|
+
message: "Quota refresh was rate limited. Existing data was kept."
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
if (!response.ok) {
|
|
1275
|
+
await response.body?.cancel().catch(() => void 0);
|
|
1276
|
+
return this.failure({
|
|
1277
|
+
code: "quota-failed",
|
|
1278
|
+
message: `Quota request failed (${response.status}).`
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
const buckets = mapCodexUsage(await response.json());
|
|
1282
|
+
this.cache = {
|
|
1283
|
+
buckets,
|
|
1284
|
+
fetchedAt: this.now(),
|
|
1285
|
+
accountKey
|
|
1286
|
+
};
|
|
1287
|
+
this.invalidated = false;
|
|
1288
|
+
return this.fromCache(false);
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
const publicError = error instanceof UsageServiceError ? error.publicError : {
|
|
1291
|
+
code: "quota-failed",
|
|
1292
|
+
message: "Quota information could not be refreshed."
|
|
1293
|
+
};
|
|
1294
|
+
return this.failure(publicError);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
fetch(credentials) {
|
|
1298
|
+
return this.fetchFn(CODEX_USAGE_URL, { headers: {
|
|
1299
|
+
...codexHeaders(credentials),
|
|
1300
|
+
accept: "application/json"
|
|
1301
|
+
} });
|
|
1302
|
+
}
|
|
1303
|
+
fromCache(stale, error) {
|
|
1304
|
+
if (this.cache === null) return {
|
|
1305
|
+
state: error ? "error" : "empty",
|
|
1306
|
+
buckets: [],
|
|
1307
|
+
fetchedAt: null,
|
|
1308
|
+
stale,
|
|
1309
|
+
...error ? { error } : {}
|
|
1310
|
+
};
|
|
1311
|
+
return {
|
|
1312
|
+
state: error ? "stale" : this.cache.buckets.length > 0 ? "ready" : "empty",
|
|
1313
|
+
buckets: structuredClone(this.cache.buckets),
|
|
1314
|
+
fetchedAt: Math.floor(this.cache.fetchedAt / 1e3),
|
|
1315
|
+
stale,
|
|
1316
|
+
...error ? { error } : {}
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
failure(error) {
|
|
1320
|
+
return this.fromCache(this.cache !== null, error);
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
var UsageServiceError = class extends Error {
|
|
1324
|
+
publicError;
|
|
1325
|
+
constructor(publicError) {
|
|
1326
|
+
super(publicError.message);
|
|
1327
|
+
this.publicError = publicError;
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
function mapCodexUsage(value) {
|
|
1331
|
+
const data = record(value);
|
|
1332
|
+
if (data === null) return [];
|
|
1333
|
+
const planType = typeof data.plan_type === "string" ? data.plan_type : null;
|
|
1334
|
+
const result = [];
|
|
1335
|
+
addBucket(result, "codex", "Codex", planType, data.rate_limit);
|
|
1336
|
+
addBucket(result, "code-review", "Code review", planType, data.code_review_rate_limit);
|
|
1337
|
+
return result;
|
|
1338
|
+
}
|
|
1339
|
+
function addBucket(result, id, name, planType, value) {
|
|
1340
|
+
const source = record(value);
|
|
1341
|
+
if (source === null) return;
|
|
1342
|
+
const primary = mapWindow(source.primary_window);
|
|
1343
|
+
const secondary = mapWindow(source.secondary_window);
|
|
1344
|
+
if (primary === null && secondary === null) return;
|
|
1345
|
+
result.push({
|
|
1346
|
+
id,
|
|
1347
|
+
name,
|
|
1348
|
+
planType,
|
|
1349
|
+
primary,
|
|
1350
|
+
secondary
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
function mapWindow(value) {
|
|
1354
|
+
const data = record(value);
|
|
1355
|
+
if (data === null) return null;
|
|
1356
|
+
const used = numeric(data.used_percent);
|
|
1357
|
+
if (used === void 0) return null;
|
|
1358
|
+
const seconds = numeric(data.limit_window_seconds);
|
|
1359
|
+
const reset = numeric(data.reset_at);
|
|
1360
|
+
return {
|
|
1361
|
+
usedPercent: Math.min(100, Math.max(0, used)),
|
|
1362
|
+
windowDurationMins: seconds !== void 0 && seconds > 0 ? seconds / 60 : null,
|
|
1363
|
+
resetsAt: reset !== void 0 && reset > 0 ? reset : null
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
function record(value) {
|
|
1367
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
1368
|
+
}
|
|
1369
|
+
function numeric(value) {
|
|
1370
|
+
if (typeof value !== "number" && (typeof value !== "string" || value.trim() === "")) return void 0;
|
|
1371
|
+
const number = Number(value);
|
|
1372
|
+
return Number.isFinite(number) ? number : void 0;
|
|
1373
|
+
}
|
|
1374
|
+
function identityKey(credentials) {
|
|
1375
|
+
return credentials.accountId ?? credentials.email ?? credentials.planType ?? "signed-in";
|
|
1376
|
+
}
|
|
1377
|
+
//#endregion
|
|
1378
|
+
//#region src/host/routes.ts
|
|
1379
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
1380
|
+
function registerRoutes(ctx, oauth, usage) {
|
|
1381
|
+
const handler = async (request, response) => {
|
|
1382
|
+
const url = new URL(request.url ?? "/", "http://dsh.local");
|
|
1383
|
+
if (request.method === "GET" && url.pathname === `/api/dsh-chatgpt-subscription/status`) {
|
|
1384
|
+
const oauthStatus = await oauth.status();
|
|
1385
|
+
json(response, {
|
|
1386
|
+
ok: true,
|
|
1387
|
+
value: {
|
|
1388
|
+
...oauthStatus,
|
|
1389
|
+
quota: await usage.status(oauthStatus.authenticated)
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
if (request.method !== "POST") {
|
|
1395
|
+
jsonError(response, 405, {
|
|
1396
|
+
code: "bad-request",
|
|
1397
|
+
message: "Method not allowed."
|
|
1398
|
+
});
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
if (!isSameOriginMutation(request)) {
|
|
1402
|
+
jsonError(response, 403, {
|
|
1403
|
+
code: "csrf-rejected",
|
|
1404
|
+
message: "Cross-origin request rejected."
|
|
1405
|
+
});
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
const contentType = request.headers["content-type"];
|
|
1409
|
+
if (typeof contentType !== "string" || !contentType.toLowerCase().startsWith("application/json")) {
|
|
1410
|
+
jsonError(response, 415, {
|
|
1411
|
+
code: "bad-request",
|
|
1412
|
+
message: "A JSON request body is required."
|
|
1413
|
+
});
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const body = await readJson(request);
|
|
1417
|
+
if (body === null) {
|
|
1418
|
+
jsonError(response, 400, {
|
|
1419
|
+
code: "bad-request",
|
|
1420
|
+
message: "Malformed JSON request."
|
|
1421
|
+
});
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
try {
|
|
1425
|
+
switch (url.pathname) {
|
|
1426
|
+
case `${ROUTE_PREFIX}/login/start`:
|
|
1427
|
+
json(response, {
|
|
1428
|
+
ok: true,
|
|
1429
|
+
value: await oauth.startLogin()
|
|
1430
|
+
});
|
|
1431
|
+
return;
|
|
1432
|
+
case `${ROUTE_PREFIX}/login/cancel`: {
|
|
1433
|
+
const loginId = field(body, "loginId");
|
|
1434
|
+
if (loginId === null) throw new Error("missing loginId");
|
|
1435
|
+
oauth.cancelLogin(loginId);
|
|
1436
|
+
json(response, {
|
|
1437
|
+
ok: true,
|
|
1438
|
+
value: { cancelled: true }
|
|
1439
|
+
});
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
case `${ROUTE_PREFIX}/logout`:
|
|
1443
|
+
await oauth.logout();
|
|
1444
|
+
usage.clear();
|
|
1445
|
+
json(response, {
|
|
1446
|
+
ok: true,
|
|
1447
|
+
value: { authenticated: false }
|
|
1448
|
+
});
|
|
1449
|
+
return;
|
|
1450
|
+
case `${ROUTE_PREFIX}/token/refresh`: {
|
|
1451
|
+
const oauthStatus = await oauth.refresh();
|
|
1452
|
+
json(response, {
|
|
1453
|
+
ok: true,
|
|
1454
|
+
value: {
|
|
1455
|
+
...oauthStatus,
|
|
1456
|
+
quota: await usage.status(oauthStatus.authenticated)
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
case `${ROUTE_PREFIX}/quota/refresh`:
|
|
1462
|
+
if (!(await oauth.status()).authenticated) throw new Error("not authenticated");
|
|
1463
|
+
json(response, {
|
|
1464
|
+
ok: true,
|
|
1465
|
+
value: await usage.status(true, true)
|
|
1466
|
+
});
|
|
1467
|
+
return;
|
|
1468
|
+
case `${ROUTE_PREFIX}/connection/test`:
|
|
1469
|
+
json(response, {
|
|
1470
|
+
ok: true,
|
|
1471
|
+
value: await usage.testConnection()
|
|
1472
|
+
});
|
|
1473
|
+
return;
|
|
1474
|
+
default: jsonError(response, 404, {
|
|
1475
|
+
code: "bad-request",
|
|
1476
|
+
message: "Route not found."
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
const mapped = error instanceof UsageServiceError ? error.publicError : publicError(error, error instanceof Error && error.message === "missing loginId" ? "bad-request" : error instanceof Error && error.message === "not authenticated" ? "not-authenticated" : "internal");
|
|
1481
|
+
jsonError(response, statusFor(mapped), mapped);
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
const events = (request, response) => {
|
|
1485
|
+
if (request.method !== "GET") {
|
|
1486
|
+
response.writeHead(405);
|
|
1487
|
+
response.end();
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
const loginId = new URL(request.url ?? "/", "http://dsh.local").searchParams.get("loginId");
|
|
1491
|
+
if (loginId === null || loginId === "") {
|
|
1492
|
+
jsonError(response, 400, {
|
|
1493
|
+
code: "bad-request",
|
|
1494
|
+
message: "loginId is required."
|
|
1495
|
+
});
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
response.writeHead(200, {
|
|
1499
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
1500
|
+
"cache-control": "no-store",
|
|
1501
|
+
connection: "keep-alive",
|
|
1502
|
+
"x-content-type-options": "nosniff"
|
|
1503
|
+
});
|
|
1504
|
+
response.write("retry: 1000\n\n");
|
|
1505
|
+
let terminal = false;
|
|
1506
|
+
let heartbeat;
|
|
1507
|
+
let unsubscribe = null;
|
|
1508
|
+
const cleanup = () => {
|
|
1509
|
+
if (heartbeat !== void 0) clearInterval(heartbeat);
|
|
1510
|
+
unsubscribe?.();
|
|
1511
|
+
unsubscribe = null;
|
|
1512
|
+
};
|
|
1513
|
+
const send = (event) => {
|
|
1514
|
+
response.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
1515
|
+
if (event.type !== "pending") {
|
|
1516
|
+
terminal = true;
|
|
1517
|
+
queueMicrotask(() => {
|
|
1518
|
+
cleanup();
|
|
1519
|
+
response.end();
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
};
|
|
1523
|
+
unsubscribe = oauth.subscribe(loginId, send);
|
|
1524
|
+
if (unsubscribe === null) {
|
|
1525
|
+
response.end("event: failed\ndata: {\"type\":\"failed\",\"error\":{\"code\":\"bad-request\",\"message\":\"Unknown loginId.\"}}\n\n");
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (terminal) {
|
|
1529
|
+
unsubscribe();
|
|
1530
|
+
response.end();
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
heartbeat = setInterval(() => response.write(": ping\n\n"), 15e3);
|
|
1534
|
+
request.once("close", cleanup);
|
|
1535
|
+
};
|
|
1536
|
+
const disposers = [ctx.webServer.register({
|
|
1537
|
+
kind: "prefix",
|
|
1538
|
+
path: ROUTE_PREFIX,
|
|
1539
|
+
handler
|
|
1540
|
+
}), ctx.webServer.register({
|
|
1541
|
+
kind: "exact",
|
|
1542
|
+
path: `${ROUTE_PREFIX}/login/events`,
|
|
1543
|
+
handler: events
|
|
1544
|
+
})];
|
|
1545
|
+
return () => {
|
|
1546
|
+
for (const dispose of disposers) dispose();
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
function isSameOriginMutation(request) {
|
|
1550
|
+
const host = request.headers.host;
|
|
1551
|
+
const origin = request.headers.origin;
|
|
1552
|
+
if (typeof host !== "string" || host === "" || typeof origin !== "string" || origin === "") return false;
|
|
1553
|
+
try {
|
|
1554
|
+
const parsed = new URL(origin);
|
|
1555
|
+
return (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.host.toLowerCase() === host.toLowerCase();
|
|
1556
|
+
} catch {
|
|
1557
|
+
return false;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
async function readJson(request) {
|
|
1561
|
+
const chunks = [];
|
|
1562
|
+
let total = 0;
|
|
1563
|
+
for await (const chunk of request) {
|
|
1564
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1565
|
+
total += buffer.length;
|
|
1566
|
+
if (total > MAX_BODY_BYTES) return null;
|
|
1567
|
+
chunks.push(buffer);
|
|
1568
|
+
}
|
|
1569
|
+
try {
|
|
1570
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1571
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
1572
|
+
} catch {
|
|
1573
|
+
return null;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
function field(value, name) {
|
|
1577
|
+
const candidate = value[name];
|
|
1578
|
+
return typeof candidate === "string" && candidate !== "" ? candidate : null;
|
|
1579
|
+
}
|
|
1580
|
+
function json(response, envelope, status = 200) {
|
|
1581
|
+
response.writeHead(status, {
|
|
1582
|
+
"content-type": "application/json; charset=utf-8",
|
|
1583
|
+
"cache-control": "no-store",
|
|
1584
|
+
"x-content-type-options": "nosniff"
|
|
1585
|
+
});
|
|
1586
|
+
response.end(JSON.stringify(envelope));
|
|
1587
|
+
}
|
|
1588
|
+
function jsonError(response, status, error) {
|
|
1589
|
+
json(response, {
|
|
1590
|
+
ok: false,
|
|
1591
|
+
error
|
|
1592
|
+
}, status);
|
|
1593
|
+
}
|
|
1594
|
+
function statusFor(error) {
|
|
1595
|
+
if (error.code === "csrf-rejected") return 403;
|
|
1596
|
+
if (error.code === "not-authenticated") return 401;
|
|
1597
|
+
if (error.code === "rate-limited") return 429;
|
|
1598
|
+
if (error.code === "login-active") return 409;
|
|
1599
|
+
if (error.code === "bad-request") return 400;
|
|
1600
|
+
return 502;
|
|
1601
|
+
}
|
|
1602
|
+
//#endregion
|
|
1603
|
+
//#region src/host/token-store.ts
|
|
1604
|
+
function parseStoredCredentials(value) {
|
|
1605
|
+
if (typeof value !== "object" || value === null) throw new Error("credential bundle is not an object");
|
|
1606
|
+
const record = value;
|
|
1607
|
+
if (typeof record.accessToken !== "string" || record.accessToken === "") throw new Error("access token is missing");
|
|
1608
|
+
if (typeof record.refreshToken !== "string" || record.refreshToken === "") throw new Error("refresh token is missing");
|
|
1609
|
+
if (typeof record.expiresAt !== "number" || !Number.isFinite(record.expiresAt)) throw new Error("expiry is invalid");
|
|
1610
|
+
const optional = (key) => {
|
|
1611
|
+
const candidate = record[key];
|
|
1612
|
+
if (candidate === void 0) return void 0;
|
|
1613
|
+
if (typeof candidate !== "string") throw new Error(`${key} is invalid`);
|
|
1614
|
+
return candidate;
|
|
1615
|
+
};
|
|
1616
|
+
return {
|
|
1617
|
+
accessToken: record.accessToken,
|
|
1618
|
+
refreshToken: record.refreshToken,
|
|
1619
|
+
expiresAt: record.expiresAt,
|
|
1620
|
+
idToken: optional("idToken"),
|
|
1621
|
+
accountId: optional("accountId"),
|
|
1622
|
+
email: optional("email"),
|
|
1623
|
+
planType: optional("planType")
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
//#endregion
|
|
1627
|
+
//#region src/host/token-store-windows.ts
|
|
1628
|
+
const PROTECT_SCRIPT = String.raw`
|
|
1629
|
+
$ErrorActionPreference = 'Stop'
|
|
1630
|
+
Add-Type -AssemblyName System.Security
|
|
1631
|
+
$path = $env:DSH_CODEX_TOKEN_PATH
|
|
1632
|
+
$plain = [Console]::In.ReadToEnd()
|
|
1633
|
+
$bytes = [Text.Encoding]::UTF8.GetBytes($plain)
|
|
1634
|
+
$cipher = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
1635
|
+
$directory = [IO.Path]::GetDirectoryName($path)
|
|
1636
|
+
[IO.Directory]::CreateDirectory($directory) | Out-Null
|
|
1637
|
+
$temporary = $path + '.tmp-' + [Guid]::NewGuid().ToString('N')
|
|
1638
|
+
[IO.File]::WriteAllBytes($temporary, $cipher)
|
|
1639
|
+
if ([IO.File]::Exists($path)) { [IO.File]::Replace($temporary, $path, $null) } else { [IO.File]::Move($temporary, $path) }
|
|
1640
|
+
`;
|
|
1641
|
+
const UNPROTECT_SCRIPT = String.raw`
|
|
1642
|
+
$ErrorActionPreference = 'Stop'
|
|
1643
|
+
Add-Type -AssemblyName System.Security
|
|
1644
|
+
$path = $env:DSH_CODEX_TOKEN_PATH
|
|
1645
|
+
if (-not [IO.File]::Exists($path)) { exit 3 }
|
|
1646
|
+
$cipher = [IO.File]::ReadAllBytes($path)
|
|
1647
|
+
$bytes = [Security.Cryptography.ProtectedData]::Unprotect($cipher, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
1648
|
+
[Console]::Out.Write([Text.Encoding]::UTF8.GetString($bytes))
|
|
1649
|
+
`;
|
|
1650
|
+
const CLEAR_SCRIPT = String.raw`
|
|
1651
|
+
$ErrorActionPreference = 'Stop'
|
|
1652
|
+
$path = $env:DSH_CODEX_TOKEN_PATH
|
|
1653
|
+
if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) }
|
|
1654
|
+
`;
|
|
1655
|
+
function defaultDpapiCredentialPath() {
|
|
1656
|
+
return join(process.env.DSH_HOME?.trim() || join(homedir(), ".dsh"), "storages", "dsh-chatgpt-subscription", "oauth.dpapi");
|
|
1657
|
+
}
|
|
1658
|
+
var WindowsDpapiTokenStore = class {
|
|
1659
|
+
path;
|
|
1660
|
+
constructor(path = defaultDpapiCredentialPath()) {
|
|
1661
|
+
this.path = path;
|
|
1662
|
+
if (process.platform !== "win32") throw new Error("Windows DPAPI storage requires Windows");
|
|
1663
|
+
if (dirname(path) === path) throw new Error("invalid DPAPI credential path");
|
|
1664
|
+
}
|
|
1665
|
+
async load() {
|
|
1666
|
+
const result = await runPowerShell(UNPROTECT_SCRIPT, this.path, "");
|
|
1667
|
+
if (result.code === 3) return null;
|
|
1668
|
+
if (result.code !== 0) throw new Error("DPAPI credential read failed");
|
|
1669
|
+
try {
|
|
1670
|
+
return parseStoredCredentials(JSON.parse(result.stdout));
|
|
1671
|
+
} catch {
|
|
1672
|
+
throw new Error("DPAPI credential payload is invalid");
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
async save(value) {
|
|
1676
|
+
if ((await runPowerShell(PROTECT_SCRIPT, this.path, JSON.stringify(value))).code !== 0) throw new Error("DPAPI credential write failed");
|
|
1677
|
+
}
|
|
1678
|
+
async clear() {
|
|
1679
|
+
if ((await runPowerShell(CLEAR_SCRIPT, this.path, "")).code !== 0) throw new Error("DPAPI credential deletion failed");
|
|
1680
|
+
}
|
|
1681
|
+
};
|
|
1682
|
+
function runPowerShell(script, path, stdin) {
|
|
1683
|
+
return new Promise((resolve, reject) => {
|
|
1684
|
+
const child = spawn("powershell.exe", [
|
|
1685
|
+
"-NoLogo",
|
|
1686
|
+
"-NoProfile",
|
|
1687
|
+
"-NonInteractive",
|
|
1688
|
+
"-Command",
|
|
1689
|
+
script
|
|
1690
|
+
], {
|
|
1691
|
+
env: {
|
|
1692
|
+
...process.env,
|
|
1693
|
+
DSH_CODEX_TOKEN_PATH: path
|
|
1694
|
+
},
|
|
1695
|
+
stdio: [
|
|
1696
|
+
"pipe",
|
|
1697
|
+
"pipe",
|
|
1698
|
+
"pipe"
|
|
1699
|
+
],
|
|
1700
|
+
windowsHide: true
|
|
1701
|
+
});
|
|
1702
|
+
let stdout = "";
|
|
1703
|
+
let stderrLength = 0;
|
|
1704
|
+
const timer = setTimeout(() => {
|
|
1705
|
+
child.kill();
|
|
1706
|
+
reject(/* @__PURE__ */ new Error("DPAPI helper timed out"));
|
|
1707
|
+
}, 1e4);
|
|
1708
|
+
child.stdout.setEncoding("utf8");
|
|
1709
|
+
child.stdout.on("data", (chunk) => {
|
|
1710
|
+
stdout += chunk;
|
|
1711
|
+
if (stdout.length > 1 << 20) child.kill();
|
|
1712
|
+
});
|
|
1713
|
+
child.stderr.on("data", (chunk) => {
|
|
1714
|
+
stderrLength += chunk.length;
|
|
1715
|
+
if (stderrLength > 1 << 20) child.kill();
|
|
1716
|
+
});
|
|
1717
|
+
child.once("error", (error) => {
|
|
1718
|
+
clearTimeout(timer);
|
|
1719
|
+
reject(error);
|
|
1720
|
+
});
|
|
1721
|
+
child.once("close", (code) => {
|
|
1722
|
+
clearTimeout(timer);
|
|
1723
|
+
resolve({
|
|
1724
|
+
code: code ?? 1,
|
|
1725
|
+
stdout
|
|
1726
|
+
});
|
|
1727
|
+
});
|
|
1728
|
+
child.stdin.end(stdin);
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
//#endregion
|
|
1732
|
+
//#region src/index.ts
|
|
1733
|
+
const inject = [
|
|
1734
|
+
"webServer",
|
|
1735
|
+
"llm",
|
|
1736
|
+
"attachments"
|
|
1737
|
+
];
|
|
1738
|
+
function apply(ctx) {
|
|
1739
|
+
const oauth = new OAuthService(new WindowsDpapiTokenStore(), { logger: ctx.logger });
|
|
1740
|
+
const usage = new UsageService(oauth);
|
|
1741
|
+
const adapter = new CodexChatGptAdapter(new ResponsesClient(oauth, ctx.attachments, { onGenerationFinished: () => usage.invalidate() }));
|
|
1742
|
+
ctx.effect(() => {
|
|
1743
|
+
const disposeRoutes = registerRoutes(ctx, oauth, usage);
|
|
1744
|
+
const disposeAdapter = ctx.llm.registerAdapter([PROVIDER_ID], adapter);
|
|
1745
|
+
return () => {
|
|
1746
|
+
disposeAdapter();
|
|
1747
|
+
disposeRoutes();
|
|
1748
|
+
oauth.dispose();
|
|
1749
|
+
};
|
|
1750
|
+
}, "dsh-chatgpt-subscription: adapter, routes, and lifecycle");
|
|
1751
|
+
}
|
|
1752
|
+
//#endregion
|
|
1753
|
+
export { CodexChatGptAdapter, OAuthService, ResponsesClient, UsageService, apply, inject, mapCodexUsage, parseResponsesStream };
|