dsh-coding-subscription-oauth 0.5.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 +136 -0
- package/CONTRIBUTING.md +109 -0
- package/INSTALL.md +220 -0
- package/LICENSE +19 -0
- package/NOTICE +11 -0
- package/README.de.md +254 -0
- package/README.es.md +254 -0
- package/README.fr.md +254 -0
- package/README.ja.md +254 -0
- package/README.ko.md +254 -0
- package/README.md +286 -0
- package/README.pt-BR.md +254 -0
- package/README.ru.md +254 -0
- package/README.zh-CN.md +271 -0
- package/cordis.patch.yml +13 -0
- package/docs/00-project-rules.md +195 -0
- package/docs/02-architecture.md +132 -0
- package/docs/02-architecture.zh-CN.md +128 -0
- package/lib/adapter.d.ts +24 -0
- package/lib/adapter.d.ts.map +1 -0
- package/lib/alias-adapter.d.ts +43 -0
- package/lib/alias-adapter.d.ts.map +1 -0
- package/lib/auth-routes.d.ts +155 -0
- package/lib/auth-routes.d.ts.map +1 -0
- package/lib/auth.d.ts +29 -0
- package/lib/auth.d.ts.map +1 -0
- package/lib/bin.d.ts +6 -0
- package/lib/bin.d.ts.map +1 -0
- package/lib/bin.js +27591 -0
- package/lib/bin.js.map +7 -0
- package/lib/capability-routes.d.ts +43 -0
- package/lib/capability-routes.d.ts.map +1 -0
- package/lib/capability-runtime.d.ts +56 -0
- package/lib/capability-runtime.d.ts.map +1 -0
- package/lib/capability-settings.d.ts +263 -0
- package/lib/capability-settings.d.ts.map +1 -0
- package/lib/capability-tools.d.ts +50 -0
- package/lib/capability-tools.d.ts.map +1 -0
- package/lib/catalog.d.ts +53 -0
- package/lib/catalog.d.ts.map +1 -0
- package/lib/client.js +3 -0
- package/lib/client.js.map +7 -0
- package/lib/codex-http.d.ts +77 -0
- package/lib/codex-http.d.ts.map +1 -0
- package/lib/codex-images.d.ts +109 -0
- package/lib/codex-images.d.ts.map +1 -0
- package/lib/codex-model-capabilities.d.ts +112 -0
- package/lib/codex-model-capabilities.d.ts.map +1 -0
- package/lib/codex-search.d.ts +96 -0
- package/lib/codex-search.d.ts.map +1 -0
- package/lib/codex-usage.d.ts +79 -0
- package/lib/codex-usage.d.ts.map +1 -0
- package/lib/gateway-anthropic-messages.d.ts +8 -0
- package/lib/gateway-anthropic-messages.d.ts.map +1 -0
- package/lib/gateway-auth.d.ts +22 -0
- package/lib/gateway-auth.d.ts.map +1 -0
- package/lib/gateway-backend.d.ts +62 -0
- package/lib/gateway-backend.d.ts.map +1 -0
- package/lib/gateway-body.d.ts +9 -0
- package/lib/gateway-body.d.ts.map +1 -0
- package/lib/gateway-config.d.ts +24 -0
- package/lib/gateway-config.d.ts.map +1 -0
- package/lib/gateway-http.d.ts +16 -0
- package/lib/gateway-http.d.ts.map +1 -0
- package/lib/gateway-openai-chat.d.ts +8 -0
- package/lib/gateway-openai-chat.d.ts.map +1 -0
- package/lib/gateway-openai-responses.d.ts +8 -0
- package/lib/gateway-openai-responses.d.ts.map +1 -0
- package/lib/gateway-parse.d.ts +10 -0
- package/lib/gateway-parse.d.ts.map +1 -0
- package/lib/gateway-protocol.d.ts +47 -0
- package/lib/gateway-protocol.d.ts.map +1 -0
- package/lib/gateway-routes.d.ts +21 -0
- package/lib/gateway-routes.d.ts.map +1 -0
- package/lib/gateway.d.ts +48 -0
- package/lib/gateway.d.ts.map +1 -0
- package/lib/grok-imagine.d.ts +271 -0
- package/lib/grok-imagine.d.ts.map +1 -0
- package/lib/grok-import.d.ts +21 -0
- package/lib/grok-import.d.ts.map +1 -0
- package/lib/http-json.d.ts +10 -0
- package/lib/http-json.d.ts.map +1 -0
- package/lib/ids.d.ts +33 -0
- package/lib/ids.d.ts.map +1 -0
- package/lib/imagine-routes.d.ts +59 -0
- package/lib/imagine-routes.d.ts.map +1 -0
- package/lib/index.d.ts +69 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +35355 -0
- package/lib/index.js.map +7 -0
- package/lib/invariant.d.ts +9 -0
- package/lib/invariant.d.ts.map +1 -0
- package/lib/invariant.js +14 -0
- package/lib/invariant.js.map +7 -0
- package/lib/kimi-errors.d.ts +13 -0
- package/lib/kimi-errors.d.ts.map +1 -0
- package/lib/media-store.d.ts +130 -0
- package/lib/media-store.d.ts.map +1 -0
- package/lib/oauth-import-routes.d.ts +52 -0
- package/lib/oauth-import-routes.d.ts.map +1 -0
- package/lib/oauth-providers.d.ts +26 -0
- package/lib/oauth-providers.d.ts.map +1 -0
- package/lib/oauth-session.d.ts +40 -0
- package/lib/oauth-session.d.ts.map +1 -0
- package/lib/oauth-sources.d.ts +205 -0
- package/lib/oauth-sources.d.ts.map +1 -0
- package/lib/oauth.d.ts +79 -0
- package/lib/oauth.d.ts.map +1 -0
- package/lib/provider.d.ts +38 -0
- package/lib/provider.d.ts.map +1 -0
- package/lib/proxy.d.ts +17 -0
- package/lib/proxy.d.ts.map +1 -0
- package/lib/redact.d.ts +5 -0
- package/lib/redact.d.ts.map +1 -0
- package/lib/session.d.ts +40 -0
- package/lib/session.d.ts.map +1 -0
- package/lib/store.d.ts +46 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/web-origin.d.ts +10 -0
- package/lib/web-origin.d.ts.map +1 -0
- package/lib/web-routes.d.ts +20 -0
- package/lib/web-routes.d.ts.map +1 -0
- package/package.json +185 -0
- package/patches/dsh-agy@0.1.2.patch +25 -0
- package/scripts/release.mjs +166 -0
- package/scripts/smoke-deployed-routes.mjs +146 -0
- package/scripts/verify-deployed-catalog.mjs +87 -0
- package/src/adapter.ts +282 -0
- package/src/alias-adapter.ts +152 -0
- package/src/auth-routes.ts +871 -0
- package/src/auth.ts +67 -0
- package/src/bin.ts +350 -0
- package/src/capability-routes.ts +275 -0
- package/src/capability-runtime.ts +313 -0
- package/src/capability-settings.ts +657 -0
- package/src/capability-tools.ts +666 -0
- package/src/catalog.ts +271 -0
- package/src/client/GrokBuildSettings.tsx +2221 -0
- package/src/client/index.tsx +37 -0
- package/src/client/locales.ts +421 -0
- package/src/codex-http.ts +447 -0
- package/src/codex-images.ts +485 -0
- package/src/codex-model-capabilities.ts +320 -0
- package/src/codex-search.ts +245 -0
- package/src/codex-usage.ts +263 -0
- package/src/gateway-anthropic-messages.ts +84 -0
- package/src/gateway-auth.ts +100 -0
- package/src/gateway-backend.ts +274 -0
- package/src/gateway-body.ts +49 -0
- package/src/gateway-config.ts +76 -0
- package/src/gateway-http.ts +104 -0
- package/src/gateway-openai-chat.ts +124 -0
- package/src/gateway-openai-responses.ts +53 -0
- package/src/gateway-parse.ts +224 -0
- package/src/gateway-protocol.ts +52 -0
- package/src/gateway-routes.ts +152 -0
- package/src/gateway.ts +242 -0
- package/src/grok-imagine.ts +1627 -0
- package/src/grok-import.ts +151 -0
- package/src/http-json.ts +82 -0
- package/src/ids.ts +45 -0
- package/src/imagine-routes.ts +461 -0
- package/src/index.ts +598 -0
- package/src/invariant.ts +17 -0
- package/src/kimi-errors.ts +26 -0
- package/src/media-store.ts +927 -0
- package/src/oauth-import-routes.ts +314 -0
- package/src/oauth-providers.ts +152 -0
- package/src/oauth-session.ts +183 -0
- package/src/oauth-sources.ts +1104 -0
- package/src/oauth.ts +620 -0
- package/src/provider.ts +128 -0
- package/src/proxy.ts +99 -0
- package/src/redact.ts +72 -0
- package/src/session.ts +218 -0
- package/src/store.ts +217 -0
- package/src/web-origin.ts +60 -0
- package/src/web-routes.ts +75 -0
|
@@ -0,0 +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
|
+
}
|