dsh-coding-subscription-oauth 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +258 -250
  2. package/CONTRIBUTING.md +129 -129
  3. package/INSTALL.md +256 -255
  4. package/LICENSE +19 -19
  5. package/NOTICE +11 -11
  6. package/README.de.md +303 -303
  7. package/README.es.md +304 -304
  8. package/README.fr.md +304 -304
  9. package/README.ja.md +304 -304
  10. package/README.ko.md +304 -304
  11. package/README.md +320 -320
  12. package/README.pt-BR.md +304 -304
  13. package/README.ru.md +304 -304
  14. package/README.zh-CN.md +318 -318
  15. package/compatibility/dsh-bom.json +30 -30
  16. package/cordis.patch.yml +13 -13
  17. package/docs/00-project-rules.md +212 -212
  18. package/docs/02-architecture.md +138 -138
  19. package/docs/02-architecture.zh-CN.md +138 -138
  20. package/lib/bin.js +1333 -569
  21. package/lib/bin.js.map +4 -4
  22. package/lib/client.js +1 -1
  23. package/lib/client.js.map +2 -2
  24. package/lib/index.js +689 -25492
  25. package/lib/index.js.map +4 -4
  26. package/lib/invariant.js.map +1 -1
  27. package/media/en/settings_accounts.png +0 -0
  28. package/media/en/settings_capabilities.png +0 -0
  29. package/media/en/settings_gateway.png +0 -0
  30. package/media/settings_accounts.png +0 -0
  31. package/media/settings_capabilities.png +0 -0
  32. package/media/settings_gateway.png +0 -0
  33. package/media/settings_overview.png +0 -0
  34. package/media/zh-CN/settings_accounts.png +0 -0
  35. package/media/zh-CN/settings_capabilities.png +0 -0
  36. package/media/zh-CN/settings_gateway.png +0 -0
  37. package/package.json +223 -223
  38. package/patches/dsh-agy@0.1.2.patch +25 -25
  39. package/scripts/release.mjs +186 -186
  40. package/scripts/smoke-deployed-routes.mjs +146 -146
  41. package/scripts/verify-deployed-catalog.mjs +87 -87
  42. package/src/adapter.ts +348 -348
  43. package/src/alias-adapter.ts +147 -147
  44. package/src/auth-routes.ts +921 -921
  45. package/src/auth.ts +67 -67
  46. package/src/bin.ts +350 -350
  47. package/src/capability-routes.ts +279 -279
  48. package/src/capability-runtime.ts +314 -314
  49. package/src/capability-settings.ts +671 -671
  50. package/src/capability-tools.ts +685 -685
  51. package/src/catalog.ts +271 -271
  52. package/src/client/GrokBuildSettings.tsx +771 -771
  53. package/src/client/api.ts +88 -88
  54. package/src/client/components/AboutTab.tsx +30 -30
  55. package/src/client/components/AccountsTab.tsx +241 -241
  56. package/src/client/components/Badge.tsx +33 -33
  57. package/src/client/components/CapabilitiesTab.tsx +265 -265
  58. package/src/client/components/CliPullPreview.tsx +116 -116
  59. package/src/client/components/CopyButton.tsx +57 -57
  60. package/src/client/components/GatewayTab.tsx +469 -469
  61. package/src/client/components/NoticeBanner.tsx +46 -46
  62. package/src/client/components/ProgressBar.tsx +53 -53
  63. package/src/client/components/ProviderCard.tsx +606 -606
  64. package/src/client/components/SettingsTabs.tsx +75 -75
  65. package/src/client/components/ToggleSwitch.tsx +71 -71
  66. package/src/client/constants.ts +230 -230
  67. package/src/client/display.ts +61 -61
  68. package/src/client/dshClientAdapter.ts +127 -127
  69. package/src/client/gatewaySnippets.ts +37 -37
  70. package/src/client/index.tsx +156 -156
  71. package/src/client/locales.ts +540 -540
  72. package/src/client/microStyles.ts +52 -52
  73. package/src/client/parsers.ts +398 -398
  74. package/src/client/styles.ts +325 -325
  75. package/src/client/types.ts +199 -199
  76. package/src/codex-http.ts +447 -447
  77. package/src/codex-images.ts +503 -503
  78. package/src/codex-model-capabilities.ts +320 -320
  79. package/src/codex-search.ts +245 -245
  80. package/src/codex-usage.ts +263 -263
  81. package/src/compatibility.ts +55 -55
  82. package/src/dsh-host-adapter.ts +173 -173
  83. package/src/gateway-anthropic-messages.ts +84 -84
  84. package/src/gateway-auth.ts +102 -102
  85. package/src/gateway-backend.ts +274 -274
  86. package/src/gateway-body.ts +49 -49
  87. package/src/gateway-config.ts +76 -76
  88. package/src/gateway-http.ts +104 -104
  89. package/src/gateway-openai-chat.ts +124 -124
  90. package/src/gateway-openai-responses.ts +53 -53
  91. package/src/gateway-parse.ts +224 -224
  92. package/src/gateway-protocol.ts +52 -52
  93. package/src/gateway-routes.ts +158 -158
  94. package/src/gateway.ts +258 -258
  95. package/src/grok-errors.ts +24 -24
  96. package/src/grok-imagine.ts +1627 -1627
  97. package/src/grok-import.ts +151 -151
  98. package/src/http-json.ts +82 -82
  99. package/src/ids.ts +59 -59
  100. package/src/imagine-routes.ts +463 -463
  101. package/src/index.ts +735 -735
  102. package/src/invariant.ts +17 -17
  103. package/src/kimi-errors.ts +26 -26
  104. package/src/media-store.ts +927 -927
  105. package/src/oauth-import-routes.ts +324 -324
  106. package/src/oauth-providers.ts +152 -152
  107. package/src/oauth-session.ts +183 -183
  108. package/src/oauth-sources.ts +1104 -1104
  109. package/src/oauth.ts +620 -620
  110. package/src/provider.ts +128 -128
  111. package/src/proxy.ts +11 -11
  112. package/src/redact.ts +72 -72
  113. package/src/session.ts +218 -218
  114. package/src/store.ts +217 -217
  115. package/src/web-origin.ts +296 -296
  116. package/src/web-routes.ts +38 -38
package/src/codex-http.ts CHANGED
@@ -1,447 +1,447 @@
1
- /**
2
- * Shared ChatGPT Codex backend HTTP client.
3
- *
4
- * Token and account identity are injected by the plugin-owned OAuth resolver.
5
- * The JWT `chatgpt_account_id` claim is used in full (never truncated).
6
- * Status policy: 401 invalidate+refresh once; 403 entitlement; 429 rate;
7
- * limited 5xx/transport retries. Only private chatgpt.com backend-api URLs.
8
- *
9
- * @module dsh-coding-subscription-oauth/codex-http
10
- */
11
-
12
- import { LlmError } from "@deepseek-ai/dsh-llm";
13
- import { safeMessage } from "./redact.ts";
14
-
15
- /** First-party ChatGPT host for every Codex optional-capability request. */
16
- export const CODEX_CHATGPT_ORIGIN = "https://chatgpt.com";
17
-
18
- /** Path prefix that every Codex backend URL must stay under. */
19
- export const CODEX_BACKEND_API_PREFIX = "/backend-api/";
20
-
21
- const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
22
- const DEFAULT_MAX_SERVER_RETRIES = 2;
23
- const DEFAULT_JSON_MAX_BYTES = 1_048_576;
24
- export const DEFAULT_CODEX_REQUEST_TIMEOUT_MS = 60_000;
25
- const DEFAULT_ORIGINATOR = "dsh-coding-subscription-oauth";
26
- const DEFAULT_USER_AGENT = "dsh-coding-subscription-oauth";
27
- const FORBIDDEN_CALLER_HEADERS = new Set(["authorization", "chatgpt-account-id", "accept"]);
28
-
29
- export type CodexHttpMethod = "GET" | "POST";
30
-
31
- /** One resolved Codex access token plus the full ChatGPT account id. */
32
- export interface CodexAccess {
33
- readonly accessToken: string;
34
- readonly accountId: string;
35
- }
36
-
37
- /**
38
- * Plugin-owned OAuth seam. Parent typically wraps `OAuthProviderSession`:
39
- * `resolve` calls `resolveAccessToken()` (which refreshes under the store lock);
40
- * `invalidate` calls `invalidateAccessToken()` after an upstream 401.
41
- */
42
- export interface CodexAuthSession {
43
- resolve(): Promise<{ accessToken: string; accountId?: string } | undefined>;
44
- invalidate(): Promise<void>;
45
- }
46
-
47
- export interface CodexHttpRequest {
48
- readonly url: string;
49
- readonly method?: CodexHttpMethod;
50
- readonly body?: unknown;
51
- readonly headers?: Readonly<Record<string, string>>;
52
- readonly signal?: AbortSignal;
53
- readonly maxBytes?: number;
54
- /** Skip the one-shot 401 invalidate/refresh (used by the retry itself). */
55
- readonly skipAuthRetry?: boolean;
56
- }
57
-
58
- /** Injected fetch. Tests must pass a mock; production defaults to global fetch. */
59
- export type CodexFetch = (input: string, init?: RequestInit) => Promise<Response>;
60
-
61
- export interface CodexHttpClientOptions {
62
- readonly auth: CodexAuthSession;
63
- readonly fetchImpl?: CodexFetch;
64
- readonly originator?: string;
65
- readonly userAgent?: string;
66
- readonly now?: () => number;
67
- readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
68
- readonly maxServerRetries?: number;
69
- /** Per-attempt wall-clock ceiling, including response-body streaming. */
70
- readonly requestTimeoutMs?: number;
71
- }
72
-
73
- export interface CodexHttpClient {
74
- requestJson(request: CodexHttpRequest): Promise<unknown>;
75
- resolveAccess(): Promise<CodexAccess>;
76
- }
77
-
78
- /** Adapt an `OAuthProviderSession`-shaped object without importing that class. */
79
- export function codexAuthFromSession(session: {
80
- resolveAccessToken(): Promise<string | undefined>;
81
- invalidateAccessToken(): Promise<void>;
82
- }): CodexAuthSession {
83
- return {
84
- resolve: async () => {
85
- const accessToken = await session.resolveAccessToken();
86
- return accessToken === undefined || accessToken.length === 0 ? undefined : { accessToken };
87
- },
88
- invalidate: () => session.invalidateAccessToken(),
89
- };
90
- }
91
-
92
- export function isRecord(value: unknown): value is Record<string, unknown> {
93
- return typeof value === "object" && value !== null && !Array.isArray(value);
94
- }
95
-
96
- export function optionalNonEmptyString(value: unknown): string | undefined {
97
- return typeof value === "string" && value.length > 0 ? value : undefined;
98
- }
99
-
100
- /** Decode the full ChatGPT account id from a Codex access JWT. Never truncates. */
101
- export function chatgptAccountIdFromAccessToken(accessToken: string): string | undefined {
102
- const parts = accessToken.split(".");
103
- if (parts.length !== 3 || parts[1] === undefined) return undefined;
104
- try {
105
- const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as unknown;
106
- if (!isRecord(payload)) return undefined;
107
- const auth = payload[OPENAI_AUTH_CLAIM];
108
- if (!isRecord(auth)) return undefined;
109
- return optionalNonEmptyString(auth["chatgpt_account_id"]);
110
- } catch {
111
- return undefined;
112
- }
113
- }
114
-
115
- /** Reject anything that is not a first-party ChatGPT backend-api URL. */
116
- export function assertCodexBackendUrl(url: string): URL {
117
- let parsed: URL;
118
- try {
119
- parsed = new URL(url);
120
- } catch {
121
- throw new LlmError("Codex backend URL is invalid", "INVALID_ARGS");
122
- }
123
- const port = parsed.port;
124
- if (
125
- parsed.protocol !== "https:" ||
126
- parsed.hostname !== "chatgpt.com" ||
127
- parsed.username !== "" ||
128
- parsed.password !== "" ||
129
- (port !== "" && port !== "443")
130
- ) {
131
- throw new LlmError("Codex optional capabilities may only call the private ChatGPT backend", "INVALID_ARGS");
132
- }
133
- if (!parsed.pathname.startsWith(CODEX_BACKEND_API_PREFIX)) {
134
- throw new LlmError("Codex optional capabilities may only call /backend-api paths", "INVALID_ARGS");
135
- }
136
- return parsed;
137
- }
138
-
139
- export function parseRetryAfterMs(value: string | null, now: () => number): number | undefined {
140
- if (value === null || value.length === 0) return undefined;
141
- if (/^\d+$/u.test(value)) {
142
- const seconds = Number(value);
143
- return Number.isSafeInteger(seconds) && seconds > 0 ? seconds * 1000 : undefined;
144
- }
145
- const at = Date.parse(value);
146
- if (!Number.isFinite(at)) return undefined;
147
- const delta = at - now();
148
- return delta > 0 ? delta : undefined;
149
- }
150
-
151
- /** Redact every provider error body through {@link safeMessage}. */
152
- export function providerDetail(value: unknown): string | undefined {
153
- if (typeof value === "string") {
154
- const trimmed = value.trim();
155
- return trimmed.length === 0 ? undefined : safeMessage(trimmed);
156
- }
157
- if (!isRecord(value)) return undefined;
158
- const error = value["error"];
159
- const raw =
160
- typeof error === "string"
161
- ? error
162
- : isRecord(error) && typeof error["message"] === "string"
163
- ? error["message"]
164
- : typeof value["message"] === "string"
165
- ? value["message"]
166
- : undefined;
167
- return raw === undefined ? undefined : safeMessage(raw);
168
- }
169
-
170
- function callerHeaders(headers: Readonly<Record<string, string>> | undefined): Record<string, string> {
171
- if (headers === undefined) return {};
172
- const next: Record<string, string> = {};
173
- for (const [key, value] of Object.entries(headers)) {
174
- if (FORBIDDEN_CALLER_HEADERS.has(key.toLowerCase())) continue;
175
- next[key] = value;
176
- }
177
- return next;
178
- }
179
-
180
- function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
181
- if (ms <= 0) return Promise.resolve();
182
- if (signal?.aborted === true) return Promise.reject(abortError(signal));
183
- return new Promise((resolve, reject) => {
184
- const timer = setTimeout(() => {
185
- signal?.removeEventListener("abort", onAbort);
186
- resolve();
187
- }, ms);
188
- const onAbort = (): void => {
189
- clearTimeout(timer);
190
- reject(abortError(signal));
191
- };
192
- signal?.addEventListener("abort", onAbort, { once: true });
193
- });
194
- }
195
-
196
- function abortError(signal?: AbortSignal): LlmError {
197
- return new LlmError("Codex backend request aborted", "TIMEOUT", {
198
- cause: signal?.reason,
199
- });
200
- }
201
-
202
- function isAbortError(error: unknown): boolean {
203
- return (
204
- (error instanceof DOMException && error.name === "AbortError") ||
205
- (error instanceof Error && error.name === "AbortError")
206
- );
207
- }
208
-
209
- function throwIfAborted(signal?: AbortSignal): void {
210
- if (signal?.aborted === true) throw abortError(signal);
211
- }
212
-
213
- async function resolveAccess(auth: CodexAuthSession): Promise<CodexAccess> {
214
- let resolved: { accessToken: string; accountId?: string } | undefined;
215
- try {
216
- resolved = await auth.resolve();
217
- } catch (error) {
218
- throw new LlmError(
219
- `Codex could not refresh its sign-in (${safeMessage(error)}). Open Settings → Coding OAuth and sign in again.`,
220
- "MISSING_CREDENTIAL",
221
- );
222
- }
223
- const accessToken = resolved?.accessToken.trim();
224
- if (accessToken === undefined || accessToken.length === 0) {
225
- throw new LlmError(
226
- "Codex is not signed in. Open Settings → Coding OAuth and sign in with your ChatGPT subscription.",
227
- "MISSING_CREDENTIAL",
228
- );
229
- }
230
- const accountId = chatgptAccountIdFromAccessToken(accessToken) ?? optionalNonEmptyString(resolved?.accountId);
231
- if (accountId === undefined) {
232
- throw new LlmError(
233
- "Codex access token has no usable chatgpt_account_id claim. Open Settings → Coding OAuth and sign in again.",
234
- "INVALID_CREDENTIAL",
235
- );
236
- }
237
- return { accessToken, accountId };
238
- }
239
-
240
- async function cancelBody(response: Response): Promise<void> {
241
- try {
242
- await response.body?.cancel();
243
- } catch {
244
- // Best-effort: the size cap already rejected the payload.
245
- }
246
- }
247
-
248
- /**
249
- * Stream the response with a running byte cap. Never buffers via `arrayBuffer()`.
250
- * A declared Content-Length above `maxBytes` fails closed before any read.
251
- */
252
- async function readLimitedResponseBody(
253
- response: Response,
254
- maxBytes: number,
255
- signal?: AbortSignal,
256
- ): Promise<Uint8Array> {
257
- const declared = Number(response.headers.get("content-length"));
258
- if (Number.isFinite(declared) && declared > maxBytes) {
259
- await cancelBody(response);
260
- throw new LlmError("Codex backend response exceeded the encoded size limit", "SERVER", {
261
- status: response.status,
262
- });
263
- }
264
- if (response.body === null) return new Uint8Array(0);
265
- const reader = response.body.getReader();
266
- const chunks: Uint8Array[] = [];
267
- let size = 0;
268
- const onAbort = (): void => {
269
- void reader.cancel(signal?.reason).catch(() => undefined);
270
- };
271
- if (signal?.aborted === true) onAbort();
272
- else signal?.addEventListener("abort", onAbort, { once: true });
273
- try {
274
- for (;;) {
275
- throwIfAborted(signal);
276
- const { done, value } = await reader.read();
277
- throwIfAborted(signal);
278
- if (done) break;
279
- if (value === undefined) continue;
280
- size += value.byteLength;
281
- if (size > maxBytes) {
282
- await reader.cancel().catch(() => undefined);
283
- throw new LlmError("Codex backend response exceeded the encoded size limit", "SERVER", {
284
- status: response.status,
285
- });
286
- }
287
- chunks.push(value);
288
- }
289
- } catch (error) {
290
- if (error instanceof LlmError) throw error;
291
- throwIfAborted(signal);
292
- if (isAbortError(error)) throw abortError(signal);
293
- throw new LlmError(`Codex backend response could not be read (${safeMessage(error)})`, "TRANSPORT", {
294
- cause: error,
295
- });
296
- } finally {
297
- signal?.removeEventListener("abort", onAbort);
298
- }
299
- const out = new Uint8Array(size);
300
- let offset = 0;
301
- for (const chunk of chunks) {
302
- out.set(chunk, offset);
303
- offset += chunk.byteLength;
304
- }
305
- return out;
306
- }
307
-
308
- async function readJsonBody(response: Response, maxBytes: number, signal?: AbortSignal): Promise<unknown> {
309
- throwIfAborted(signal);
310
- const bytes = await readLimitedResponseBody(response, maxBytes, signal);
311
- const text = new TextDecoder().decode(bytes).trim();
312
- if (text.length === 0) return {};
313
- try {
314
- return JSON.parse(text) as unknown;
315
- } catch (error) {
316
- throw new LlmError(
317
- `Codex backend returned an unprocessable JSON response (HTTP ${String(response.status)})`,
318
- "SERVER",
319
- { cause: error, status: response.status },
320
- );
321
- }
322
- }
323
-
324
- function statusError(status: number, payload: unknown, retryAfterMs?: number): LlmError {
325
- const detail = providerDetail(payload);
326
- const suffix = detail === undefined ? "" : `: ${detail}`;
327
- if (status === 401) {
328
- return new LlmError(`Codex authorization was rejected (HTTP 401)${suffix}. Sign in again.`, "AUTH", { status });
329
- }
330
- if (status === 403) {
331
- return new LlmError(`Current ChatGPT subscription cannot use this Codex capability (HTTP 403)${suffix}`, "QUOTA", {
332
- status,
333
- });
334
- }
335
- if (status === 429) {
336
- return new LlmError(`Codex rate limit reached (HTTP 429)${suffix}`, "RATE_LIMIT", {
337
- status,
338
- ...(retryAfterMs === undefined ? {} : { providerRetryAfterMs: retryAfterMs }),
339
- });
340
- }
341
- if (status >= 500) {
342
- return new LlmError(`Codex backend failed (HTTP ${String(status)})${suffix}`, "SERVER", { status });
343
- }
344
- if (status === 400 || status === 404 || status === 409 || status === 422) {
345
- return new LlmError(`Codex backend rejected the request (HTTP ${String(status)})${suffix}`, "INVALID_ARGS", {
346
- status,
347
- });
348
- }
349
- return new LlmError(`Codex backend request failed (HTTP ${String(status)})${suffix}`, "SERVER", { status });
350
- }
351
-
352
- /** Create a ChatGPT-backend-only client with injected OAuth resolve/invalidate. */
353
- export function createCodexHttpClient(options: CodexHttpClientOptions): CodexHttpClient {
354
- const fetchImpl = options.fetchImpl ?? fetch;
355
- const originator = options.originator ?? DEFAULT_ORIGINATOR;
356
- const userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
357
- const now = options.now ?? Date.now;
358
- const sleep = options.sleep ?? defaultSleep;
359
- const maxServerRetries = options.maxServerRetries ?? DEFAULT_MAX_SERVER_RETRIES;
360
- const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_CODEX_REQUEST_TIMEOUT_MS;
361
- if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs <= 0) {
362
- throw new TypeError("Codex requestTimeoutMs must be a positive safe integer");
363
- }
364
-
365
- const requestOnce = async (
366
- request: CodexHttpRequest,
367
- access: CodexAccess,
368
- ): Promise<{ response: Response; payload: unknown }> => {
369
- const url = assertCodexBackendUrl(request.url);
370
- throwIfAborted(request.signal);
371
- const headers: Record<string, string> = {
372
- ...callerHeaders(request.headers),
373
- authorization: `Bearer ${access.accessToken}`,
374
- "chatgpt-account-id": access.accountId,
375
- accept: "application/json",
376
- originator,
377
- "user-agent": userAgent,
378
- };
379
- const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
380
- const signal = request.signal === undefined ? timeoutSignal : AbortSignal.any([request.signal, timeoutSignal]);
381
- const init: RequestInit = {
382
- method: request.method ?? (request.body === undefined ? "GET" : "POST"),
383
- redirect: "error",
384
- headers,
385
- signal,
386
- };
387
- if (request.body !== undefined) {
388
- headers["content-type"] = headers["content-type"] ?? "application/json";
389
- init.body = JSON.stringify(request.body);
390
- }
391
- let response: Response;
392
- try {
393
- response = await fetchImpl(url.toString(), init);
394
- } catch (error) {
395
- throwIfAborted(request.signal);
396
- const name = error instanceof Error ? error.name : "";
397
- if (timeoutSignal.aborted || isAbortError(error) || name === "TimeoutError") {
398
- throw abortError(signal);
399
- }
400
- throw new LlmError(`Codex backend request failed (${safeMessage(error)})`, "TRANSPORT", { cause: error });
401
- }
402
- const payload = await readJsonBody(response, request.maxBytes ?? DEFAULT_JSON_MAX_BYTES, signal);
403
- return { response, payload };
404
- };
405
-
406
- const requestJson = async (request: CodexHttpRequest): Promise<unknown> => {
407
- let access = await resolveAccess(options.auth);
408
- let current: CodexHttpRequest = request;
409
- let serverAttempt = 0;
410
- for (;;) {
411
- let result: { response: Response; payload: unknown };
412
- try {
413
- result = await requestOnce(current, access);
414
- } catch (error) {
415
- if (error instanceof LlmError && error.code === "TRANSPORT" && serverAttempt < maxServerRetries) {
416
- serverAttempt += 1;
417
- await sleep(250 * serverAttempt, current.signal);
418
- continue;
419
- }
420
- throw error;
421
- }
422
- const { response, payload } = result;
423
- if (response.ok) return payload;
424
- if (response.status === 401 && current.skipAuthRetry !== true) {
425
- try {
426
- await options.auth.invalidate();
427
- } catch {
428
- // Still attempt one refresh so a failed backdate cannot skip re-login.
429
- }
430
- access = await resolveAccess(options.auth);
431
- current = { ...current, skipAuthRetry: true };
432
- continue;
433
- }
434
- if (response.status >= 500 && serverAttempt < maxServerRetries) {
435
- serverAttempt += 1;
436
- await sleep(250 * serverAttempt, current.signal);
437
- continue;
438
- }
439
- throw statusError(response.status, payload, parseRetryAfterMs(response.headers.get("retry-after"), now));
440
- }
441
- };
442
-
443
- return {
444
- requestJson,
445
- resolveAccess: () => resolveAccess(options.auth),
446
- };
447
- }
1
+ /**
2
+ * Shared ChatGPT Codex backend HTTP client.
3
+ *
4
+ * Token and account identity are injected by the plugin-owned OAuth resolver.
5
+ * The JWT `chatgpt_account_id` claim is used in full (never truncated).
6
+ * Status policy: 401 invalidate+refresh once; 403 entitlement; 429 rate;
7
+ * limited 5xx/transport retries. Only private chatgpt.com backend-api URLs.
8
+ *
9
+ * @module dsh-coding-subscription-oauth/codex-http
10
+ */
11
+
12
+ import { LlmError } from "@deepseek-ai/dsh-llm";
13
+ import { safeMessage } from "./redact.ts";
14
+
15
+ /** First-party ChatGPT host for every Codex optional-capability request. */
16
+ export const CODEX_CHATGPT_ORIGIN = "https://chatgpt.com";
17
+
18
+ /** Path prefix that every Codex backend URL must stay under. */
19
+ export const CODEX_BACKEND_API_PREFIX = "/backend-api/";
20
+
21
+ const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
22
+ const DEFAULT_MAX_SERVER_RETRIES = 2;
23
+ const DEFAULT_JSON_MAX_BYTES = 1_048_576;
24
+ export const DEFAULT_CODEX_REQUEST_TIMEOUT_MS = 60_000;
25
+ const DEFAULT_ORIGINATOR = "dsh-coding-subscription-oauth";
26
+ const DEFAULT_USER_AGENT = "dsh-coding-subscription-oauth";
27
+ const FORBIDDEN_CALLER_HEADERS = new Set(["authorization", "chatgpt-account-id", "accept"]);
28
+
29
+ export type CodexHttpMethod = "GET" | "POST";
30
+
31
+ /** One resolved Codex access token plus the full ChatGPT account id. */
32
+ export interface CodexAccess {
33
+ readonly accessToken: string;
34
+ readonly accountId: string;
35
+ }
36
+
37
+ /**
38
+ * Plugin-owned OAuth seam. Parent typically wraps `OAuthProviderSession`:
39
+ * `resolve` calls `resolveAccessToken()` (which refreshes under the store lock);
40
+ * `invalidate` calls `invalidateAccessToken()` after an upstream 401.
41
+ */
42
+ export interface CodexAuthSession {
43
+ resolve(): Promise<{ accessToken: string; accountId?: string } | undefined>;
44
+ invalidate(): Promise<void>;
45
+ }
46
+
47
+ export interface CodexHttpRequest {
48
+ readonly url: string;
49
+ readonly method?: CodexHttpMethod;
50
+ readonly body?: unknown;
51
+ readonly headers?: Readonly<Record<string, string>>;
52
+ readonly signal?: AbortSignal;
53
+ readonly maxBytes?: number;
54
+ /** Skip the one-shot 401 invalidate/refresh (used by the retry itself). */
55
+ readonly skipAuthRetry?: boolean;
56
+ }
57
+
58
+ /** Injected fetch. Tests must pass a mock; production defaults to global fetch. */
59
+ export type CodexFetch = (input: string, init?: RequestInit) => Promise<Response>;
60
+
61
+ export interface CodexHttpClientOptions {
62
+ readonly auth: CodexAuthSession;
63
+ readonly fetchImpl?: CodexFetch;
64
+ readonly originator?: string;
65
+ readonly userAgent?: string;
66
+ readonly now?: () => number;
67
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
68
+ readonly maxServerRetries?: number;
69
+ /** Per-attempt wall-clock ceiling, including response-body streaming. */
70
+ readonly requestTimeoutMs?: number;
71
+ }
72
+
73
+ export interface CodexHttpClient {
74
+ requestJson(request: CodexHttpRequest): Promise<unknown>;
75
+ resolveAccess(): Promise<CodexAccess>;
76
+ }
77
+
78
+ /** Adapt an `OAuthProviderSession`-shaped object without importing that class. */
79
+ export function codexAuthFromSession(session: {
80
+ resolveAccessToken(): Promise<string | undefined>;
81
+ invalidateAccessToken(): Promise<void>;
82
+ }): CodexAuthSession {
83
+ return {
84
+ resolve: async () => {
85
+ const accessToken = await session.resolveAccessToken();
86
+ return accessToken === undefined || accessToken.length === 0 ? undefined : { accessToken };
87
+ },
88
+ invalidate: () => session.invalidateAccessToken(),
89
+ };
90
+ }
91
+
92
+ export function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
95
+
96
+ export function optionalNonEmptyString(value: unknown): string | undefined {
97
+ return typeof value === "string" && value.length > 0 ? value : undefined;
98
+ }
99
+
100
+ /** Decode the full ChatGPT account id from a Codex access JWT. Never truncates. */
101
+ export function chatgptAccountIdFromAccessToken(accessToken: string): string | undefined {
102
+ const parts = accessToken.split(".");
103
+ if (parts.length !== 3 || parts[1] === undefined) return undefined;
104
+ try {
105
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as unknown;
106
+ if (!isRecord(payload)) return undefined;
107
+ const auth = payload[OPENAI_AUTH_CLAIM];
108
+ if (!isRecord(auth)) return undefined;
109
+ return optionalNonEmptyString(auth["chatgpt_account_id"]);
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ /** Reject anything that is not a first-party ChatGPT backend-api URL. */
116
+ export function assertCodexBackendUrl(url: string): URL {
117
+ let parsed: URL;
118
+ try {
119
+ parsed = new URL(url);
120
+ } catch {
121
+ throw new LlmError("Codex backend URL is invalid", "INVALID_ARGS");
122
+ }
123
+ const port = parsed.port;
124
+ if (
125
+ parsed.protocol !== "https:" ||
126
+ parsed.hostname !== "chatgpt.com" ||
127
+ parsed.username !== "" ||
128
+ parsed.password !== "" ||
129
+ (port !== "" && port !== "443")
130
+ ) {
131
+ throw new LlmError("Codex optional capabilities may only call the private ChatGPT backend", "INVALID_ARGS");
132
+ }
133
+ if (!parsed.pathname.startsWith(CODEX_BACKEND_API_PREFIX)) {
134
+ throw new LlmError("Codex optional capabilities may only call /backend-api paths", "INVALID_ARGS");
135
+ }
136
+ return parsed;
137
+ }
138
+
139
+ export function parseRetryAfterMs(value: string | null, now: () => number): number | undefined {
140
+ if (value === null || value.length === 0) return undefined;
141
+ if (/^\d+$/u.test(value)) {
142
+ const seconds = Number(value);
143
+ return Number.isSafeInteger(seconds) && seconds > 0 ? seconds * 1000 : undefined;
144
+ }
145
+ const at = Date.parse(value);
146
+ if (!Number.isFinite(at)) return undefined;
147
+ const delta = at - now();
148
+ return delta > 0 ? delta : undefined;
149
+ }
150
+
151
+ /** Redact every provider error body through {@link safeMessage}. */
152
+ export function providerDetail(value: unknown): string | undefined {
153
+ if (typeof value === "string") {
154
+ const trimmed = value.trim();
155
+ return trimmed.length === 0 ? undefined : safeMessage(trimmed);
156
+ }
157
+ if (!isRecord(value)) return undefined;
158
+ const error = value["error"];
159
+ const raw =
160
+ typeof error === "string"
161
+ ? error
162
+ : isRecord(error) && typeof error["message"] === "string"
163
+ ? error["message"]
164
+ : typeof value["message"] === "string"
165
+ ? value["message"]
166
+ : undefined;
167
+ return raw === undefined ? undefined : safeMessage(raw);
168
+ }
169
+
170
+ function callerHeaders(headers: Readonly<Record<string, string>> | undefined): Record<string, string> {
171
+ if (headers === undefined) return {};
172
+ const next: Record<string, string> = {};
173
+ for (const [key, value] of Object.entries(headers)) {
174
+ if (FORBIDDEN_CALLER_HEADERS.has(key.toLowerCase())) continue;
175
+ next[key] = value;
176
+ }
177
+ return next;
178
+ }
179
+
180
+ function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
181
+ if (ms <= 0) return Promise.resolve();
182
+ if (signal?.aborted === true) return Promise.reject(abortError(signal));
183
+ return new Promise((resolve, reject) => {
184
+ const timer = setTimeout(() => {
185
+ signal?.removeEventListener("abort", onAbort);
186
+ resolve();
187
+ }, ms);
188
+ const onAbort = (): void => {
189
+ clearTimeout(timer);
190
+ reject(abortError(signal));
191
+ };
192
+ signal?.addEventListener("abort", onAbort, { once: true });
193
+ });
194
+ }
195
+
196
+ function abortError(signal?: AbortSignal): LlmError {
197
+ return new LlmError("Codex backend request aborted", "TIMEOUT", {
198
+ cause: signal?.reason,
199
+ });
200
+ }
201
+
202
+ function isAbortError(error: unknown): boolean {
203
+ return (
204
+ (error instanceof DOMException && error.name === "AbortError") ||
205
+ (error instanceof Error && error.name === "AbortError")
206
+ );
207
+ }
208
+
209
+ function throwIfAborted(signal?: AbortSignal): void {
210
+ if (signal?.aborted === true) throw abortError(signal);
211
+ }
212
+
213
+ async function resolveAccess(auth: CodexAuthSession): Promise<CodexAccess> {
214
+ let resolved: { accessToken: string; accountId?: string } | undefined;
215
+ try {
216
+ resolved = await auth.resolve();
217
+ } catch (error) {
218
+ throw new LlmError(
219
+ `Codex could not refresh its sign-in (${safeMessage(error)}). Open Settings → Coding OAuth and sign in again.`,
220
+ "MISSING_CREDENTIAL",
221
+ );
222
+ }
223
+ const accessToken = resolved?.accessToken.trim();
224
+ if (accessToken === undefined || accessToken.length === 0) {
225
+ throw new LlmError(
226
+ "Codex is not signed in. Open Settings → Coding OAuth and sign in with your ChatGPT subscription.",
227
+ "MISSING_CREDENTIAL",
228
+ );
229
+ }
230
+ const accountId = chatgptAccountIdFromAccessToken(accessToken) ?? optionalNonEmptyString(resolved?.accountId);
231
+ if (accountId === undefined) {
232
+ throw new LlmError(
233
+ "Codex access token has no usable chatgpt_account_id claim. Open Settings → Coding OAuth and sign in again.",
234
+ "INVALID_CREDENTIAL",
235
+ );
236
+ }
237
+ return { accessToken, accountId };
238
+ }
239
+
240
+ async function cancelBody(response: Response): Promise<void> {
241
+ try {
242
+ await response.body?.cancel();
243
+ } catch {
244
+ // Best-effort: the size cap already rejected the payload.
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Stream the response with a running byte cap. Never buffers via `arrayBuffer()`.
250
+ * A declared Content-Length above `maxBytes` fails closed before any read.
251
+ */
252
+ async function readLimitedResponseBody(
253
+ response: Response,
254
+ maxBytes: number,
255
+ signal?: AbortSignal,
256
+ ): Promise<Uint8Array> {
257
+ const declared = Number(response.headers.get("content-length"));
258
+ if (Number.isFinite(declared) && declared > maxBytes) {
259
+ await cancelBody(response);
260
+ throw new LlmError("Codex backend response exceeded the encoded size limit", "SERVER", {
261
+ status: response.status,
262
+ });
263
+ }
264
+ if (response.body === null) return new Uint8Array(0);
265
+ const reader = response.body.getReader();
266
+ const chunks: Uint8Array[] = [];
267
+ let size = 0;
268
+ const onAbort = (): void => {
269
+ void reader.cancel(signal?.reason).catch(() => undefined);
270
+ };
271
+ if (signal?.aborted === true) onAbort();
272
+ else signal?.addEventListener("abort", onAbort, { once: true });
273
+ try {
274
+ for (;;) {
275
+ throwIfAborted(signal);
276
+ const { done, value } = await reader.read();
277
+ throwIfAborted(signal);
278
+ if (done) break;
279
+ if (value === undefined) continue;
280
+ size += value.byteLength;
281
+ if (size > maxBytes) {
282
+ await reader.cancel().catch(() => undefined);
283
+ throw new LlmError("Codex backend response exceeded the encoded size limit", "SERVER", {
284
+ status: response.status,
285
+ });
286
+ }
287
+ chunks.push(value);
288
+ }
289
+ } catch (error) {
290
+ if (error instanceof LlmError) throw error;
291
+ throwIfAborted(signal);
292
+ if (isAbortError(error)) throw abortError(signal);
293
+ throw new LlmError(`Codex backend response could not be read (${safeMessage(error)})`, "TRANSPORT", {
294
+ cause: error,
295
+ });
296
+ } finally {
297
+ signal?.removeEventListener("abort", onAbort);
298
+ }
299
+ const out = new Uint8Array(size);
300
+ let offset = 0;
301
+ for (const chunk of chunks) {
302
+ out.set(chunk, offset);
303
+ offset += chunk.byteLength;
304
+ }
305
+ return out;
306
+ }
307
+
308
+ async function readJsonBody(response: Response, maxBytes: number, signal?: AbortSignal): Promise<unknown> {
309
+ throwIfAborted(signal);
310
+ const bytes = await readLimitedResponseBody(response, maxBytes, signal);
311
+ const text = new TextDecoder().decode(bytes).trim();
312
+ if (text.length === 0) return {};
313
+ try {
314
+ return JSON.parse(text) as unknown;
315
+ } catch (error) {
316
+ throw new LlmError(
317
+ `Codex backend returned an unprocessable JSON response (HTTP ${String(response.status)})`,
318
+ "SERVER",
319
+ { cause: error, status: response.status },
320
+ );
321
+ }
322
+ }
323
+
324
+ function statusError(status: number, payload: unknown, retryAfterMs?: number): LlmError {
325
+ const detail = providerDetail(payload);
326
+ const suffix = detail === undefined ? "" : `: ${detail}`;
327
+ if (status === 401) {
328
+ return new LlmError(`Codex authorization was rejected (HTTP 401)${suffix}. Sign in again.`, "AUTH", { status });
329
+ }
330
+ if (status === 403) {
331
+ return new LlmError(`Current ChatGPT subscription cannot use this Codex capability (HTTP 403)${suffix}`, "QUOTA", {
332
+ status,
333
+ });
334
+ }
335
+ if (status === 429) {
336
+ return new LlmError(`Codex rate limit reached (HTTP 429)${suffix}`, "RATE_LIMIT", {
337
+ status,
338
+ ...(retryAfterMs === undefined ? {} : { providerRetryAfterMs: retryAfterMs }),
339
+ });
340
+ }
341
+ if (status >= 500) {
342
+ return new LlmError(`Codex backend failed (HTTP ${String(status)})${suffix}`, "SERVER", { status });
343
+ }
344
+ if (status === 400 || status === 404 || status === 409 || status === 422) {
345
+ return new LlmError(`Codex backend rejected the request (HTTP ${String(status)})${suffix}`, "INVALID_ARGS", {
346
+ status,
347
+ });
348
+ }
349
+ return new LlmError(`Codex backend request failed (HTTP ${String(status)})${suffix}`, "SERVER", { status });
350
+ }
351
+
352
+ /** Create a ChatGPT-backend-only client with injected OAuth resolve/invalidate. */
353
+ export function createCodexHttpClient(options: CodexHttpClientOptions): CodexHttpClient {
354
+ const fetchImpl = options.fetchImpl ?? fetch;
355
+ const originator = options.originator ?? DEFAULT_ORIGINATOR;
356
+ const userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
357
+ const now = options.now ?? Date.now;
358
+ const sleep = options.sleep ?? defaultSleep;
359
+ const maxServerRetries = options.maxServerRetries ?? DEFAULT_MAX_SERVER_RETRIES;
360
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_CODEX_REQUEST_TIMEOUT_MS;
361
+ if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs <= 0) {
362
+ throw new TypeError("Codex requestTimeoutMs must be a positive safe integer");
363
+ }
364
+
365
+ const requestOnce = async (
366
+ request: CodexHttpRequest,
367
+ access: CodexAccess,
368
+ ): Promise<{ response: Response; payload: unknown }> => {
369
+ const url = assertCodexBackendUrl(request.url);
370
+ throwIfAborted(request.signal);
371
+ const headers: Record<string, string> = {
372
+ ...callerHeaders(request.headers),
373
+ authorization: `Bearer ${access.accessToken}`,
374
+ "chatgpt-account-id": access.accountId,
375
+ accept: "application/json",
376
+ originator,
377
+ "user-agent": userAgent,
378
+ };
379
+ const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
380
+ const signal = request.signal === undefined ? timeoutSignal : AbortSignal.any([request.signal, timeoutSignal]);
381
+ const init: RequestInit = {
382
+ method: request.method ?? (request.body === undefined ? "GET" : "POST"),
383
+ redirect: "error",
384
+ headers,
385
+ signal,
386
+ };
387
+ if (request.body !== undefined) {
388
+ headers["content-type"] = headers["content-type"] ?? "application/json";
389
+ init.body = JSON.stringify(request.body);
390
+ }
391
+ let response: Response;
392
+ try {
393
+ response = await fetchImpl(url.toString(), init);
394
+ } catch (error) {
395
+ throwIfAborted(request.signal);
396
+ const name = error instanceof Error ? error.name : "";
397
+ if (timeoutSignal.aborted || isAbortError(error) || name === "TimeoutError") {
398
+ throw abortError(signal);
399
+ }
400
+ throw new LlmError(`Codex backend request failed (${safeMessage(error)})`, "TRANSPORT", { cause: error });
401
+ }
402
+ const payload = await readJsonBody(response, request.maxBytes ?? DEFAULT_JSON_MAX_BYTES, signal);
403
+ return { response, payload };
404
+ };
405
+
406
+ const requestJson = async (request: CodexHttpRequest): Promise<unknown> => {
407
+ let access = await resolveAccess(options.auth);
408
+ let current: CodexHttpRequest = request;
409
+ let serverAttempt = 0;
410
+ for (;;) {
411
+ let result: { response: Response; payload: unknown };
412
+ try {
413
+ result = await requestOnce(current, access);
414
+ } catch (error) {
415
+ if (error instanceof LlmError && error.code === "TRANSPORT" && serverAttempt < maxServerRetries) {
416
+ serverAttempt += 1;
417
+ await sleep(250 * serverAttempt, current.signal);
418
+ continue;
419
+ }
420
+ throw error;
421
+ }
422
+ const { response, payload } = result;
423
+ if (response.ok) return payload;
424
+ if (response.status === 401 && current.skipAuthRetry !== true) {
425
+ try {
426
+ await options.auth.invalidate();
427
+ } catch {
428
+ // Still attempt one refresh so a failed backdate cannot skip re-login.
429
+ }
430
+ access = await resolveAccess(options.auth);
431
+ current = { ...current, skipAuthRetry: true };
432
+ continue;
433
+ }
434
+ if (response.status >= 500 && serverAttempt < maxServerRetries) {
435
+ serverAttempt += 1;
436
+ await sleep(250 * serverAttempt, current.signal);
437
+ continue;
438
+ }
439
+ throw statusError(response.status, payload, parseRetryAfterMs(response.headers.get("retry-after"), now));
440
+ }
441
+ };
442
+
443
+ return {
444
+ requestJson,
445
+ resolveAccess: () => resolveAccess(options.auth),
446
+ };
447
+ }