@moxxy/plugin-stt-whisper-codex 0.27.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/src/index.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { definePlugin, defineTranscriber, type LifecycleHooks, type Plugin } from '@moxxy/sdk';
2
+ import {
3
+ CodexOAuthTranscriber,
4
+ OPENAI_CODEX_TRANSCRIBER_NAME,
5
+ type CodexOAuthTranscriberOptions,
6
+ type CodexOAuthVault,
7
+ } from './transcriber.js';
8
+
9
+ export {
10
+ CodexOAuthTranscriber,
11
+ DEFAULT_CODEX_TRANSCRIBE_BASE_URL,
12
+ MOXXY_PCM16_24KHZ_MIME,
13
+ OPENAI_CODEX_TRANSCRIBER_NAME,
14
+ buildCodexTranscribeUrl,
15
+ type CodexOAuthTranscriberOptions,
16
+ type CodexOAuthVault,
17
+ } from './transcriber.js';
18
+ // pcm16MonoToWav was moved into @moxxy/plugin-stt-whisper alongside the
19
+ // rest of the shared audio helpers; re-export it here so older callers
20
+ // that import it from this package keep compiling.
21
+ export { pcm16MonoToWav } from '@moxxy/plugin-stt-whisper';
22
+
23
+ export interface BuildWhisperCodexPluginOptions {
24
+ readonly vault: CodexOAuthVault;
25
+ readonly baseUrl?: string;
26
+ readonly fetch?: typeof fetch;
27
+ readonly sessionIdProvider?: () => string;
28
+ /** Whole-request deadline in ms; defaults to 60s. `<= 0` disables it. */
29
+ readonly requestTimeoutMs?: number;
30
+ }
31
+
32
+ export function buildWhisperCodexPlugin(
33
+ opts: BuildWhisperCodexPluginOptions,
34
+ ): Plugin {
35
+ const { vault, ...rest } = opts;
36
+ return makeWhisperCodexPlugin(() => vault, rest);
37
+ }
38
+
39
+ /**
40
+ * Discovery-loadable default export: resolves the vault from the inter-plugin
41
+ * service registry in `onInit`. Requires `@moxxy/plugin-vault` to load first
42
+ * (declared in `package.json` `moxxy.requirements`). `baseUrl`/`fetch`/
43
+ * `sessionIdProvider`/`requestTimeoutMs` fall back to their `createClient`
44
+ * config/defaults when the host doesn't inject them.
45
+ */
46
+ export const whisperCodexPlugin: Plugin = (() => {
47
+ let resolved: CodexOAuthVault | null = null;
48
+ const getVault = (): CodexOAuthVault => {
49
+ if (!resolved) {
50
+ throw new Error(
51
+ '@moxxy/plugin-stt-whisper-codex: the "vault" service is unavailable — @moxxy/plugin-vault must load first',
52
+ );
53
+ }
54
+ return resolved;
55
+ };
56
+ const hooks: LifecycleHooks = {
57
+ onInit: (ctx) => {
58
+ resolved = ctx.services.require<CodexOAuthVault>('vault');
59
+ },
60
+ };
61
+ return makeWhisperCodexPlugin(getVault, {}, hooks);
62
+ })();
63
+
64
+ function makeWhisperCodexPlugin(
65
+ getVault: () => CodexOAuthVault,
66
+ opts: Omit<BuildWhisperCodexPluginOptions, 'vault'>,
67
+ hooks?: LifecycleHooks,
68
+ ): Plugin {
69
+ return definePlugin({
70
+ name: '@moxxy/plugin-stt-whisper-codex',
71
+ version: '0.0.0',
72
+ ...(hooks ? { hooks } : {}),
73
+ transcribers: [
74
+ defineTranscriber({
75
+ name: OPENAI_CODEX_TRANSCRIBER_NAME,
76
+ displayName: 'OpenAI Codex transcription (OAuth)',
77
+ createClient: (config) => {
78
+ // Only `baseUrl`/`sessionIdProvider` are caller-overridable; the
79
+ // host-wired `vault`/`fetch` stay authoritative so a stray config
80
+ // key cannot shadow a required dependency. Read narrow, typed keys
81
+ // off the untrusted `config` rather than spreading it wholesale.
82
+ const cfg = (config ?? {}) as {
83
+ baseUrl?: unknown;
84
+ sessionIdProvider?: unknown;
85
+ };
86
+ const configBaseUrl =
87
+ typeof cfg.baseUrl === 'string' ? cfg.baseUrl : undefined;
88
+ const configSessionIdProvider =
89
+ typeof cfg.sessionIdProvider === 'function'
90
+ ? (cfg.sessionIdProvider as () => string)
91
+ : undefined;
92
+ const baseUrl = configBaseUrl ?? opts.baseUrl;
93
+ const sessionIdProvider =
94
+ configSessionIdProvider ?? opts.sessionIdProvider;
95
+ const merged: CodexOAuthTranscriberOptions = {
96
+ vault: getVault(),
97
+ ...(baseUrl ? { baseUrl } : {}),
98
+ ...(opts.fetch ? { fetch: opts.fetch } : {}),
99
+ ...(sessionIdProvider ? { sessionIdProvider } : {}),
100
+ ...(opts.requestTimeoutMs !== undefined
101
+ ? { requestTimeoutMs: opts.requestTimeoutMs }
102
+ : {}),
103
+ };
104
+ return new CodexOAuthTranscriber(merged);
105
+ },
106
+ }),
107
+ ],
108
+ });
109
+ }
110
+
111
+ // Discovery entry: `createPluginLoader` requires a default Plugin export.
112
+ export default whisperCodexPlugin;
@@ -0,0 +1,468 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ CODEX_PROVIDER_ID,
4
+ ensureFreshCodexTokens,
5
+ type CodexTokens,
6
+ } from '@moxxy/plugin-provider-openai-codex';
7
+ import {
8
+ MOXXY_PCM16_24KHZ_MIME,
9
+ normalizeWhisperUpload,
10
+ } from '@moxxy/plugin-stt-whisper';
11
+ import {
12
+ classifyHttpStatus,
13
+ classifyNetworkError,
14
+ MoxxyError,
15
+ type Transcriber,
16
+ type TranscribeOptions,
17
+ type TranscriptionResult,
18
+ } from '@moxxy/sdk';
19
+
20
+ export const OPENAI_CODEX_TRANSCRIBER_NAME = 'openai-codex-transcribe';
21
+ export const DEFAULT_CODEX_TRANSCRIBE_BASE_URL = 'https://chatgpt.com';
22
+ export { MOXXY_PCM16_24KHZ_MIME };
23
+ const CODEX_TRANSCRIBE_ORIGINATOR = 'Codex Desktop';
24
+ const CODEX_TRANSCRIBE_USER_AGENT =
25
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36';
26
+ // Deadline for the whole transcribe round-trip. Several callers pass no
27
+ // AbortSignal at all, so without this a half-open proxy / slow-loris peer that
28
+ // accepts the TCP connection but never responds would leave the promise (and
29
+ // the in-flight audio buffer) pending forever.
30
+ const DEFAULT_CODEX_TRANSCRIBE_TIMEOUT_MS = 60_000;
31
+ // The success body is a tiny `{ text }` JSON; error bodies (HTML pages) are the
32
+ // only large ones. Cap the buffered read so a hostile/broken backend can't
33
+ // balloon memory by streaming a multi-GB body before we even inspect it.
34
+ const MAX_CODEX_TRANSCRIBE_BODY_BYTES = 4 * 1024 * 1024;
35
+ // Upper bound on any raw upstream body we embed in a thrown error's cause, so
36
+ // an unmapped status can't smuggle an arbitrarily long (or PII-bearing) body
37
+ // into stack traces / debug logs.
38
+ const MAX_CODEX_ERROR_CAUSE_CHARS = 500;
39
+
40
+ interface TimeoutHandle {
41
+ readonly signal: AbortSignal;
42
+ readonly timedOut: () => boolean;
43
+ readonly clear: () => void;
44
+ }
45
+
46
+ /** Merge an optional caller signal with an optional internal one. */
47
+ function combineSignals(
48
+ a: AbortSignal | undefined,
49
+ b: AbortSignal | undefined,
50
+ ): AbortSignal | undefined {
51
+ if (a && b) return AbortSignal.any([a, b]);
52
+ return a ?? b;
53
+ }
54
+
55
+ export interface CodexOAuthVault {
56
+ get(key: string): Promise<string | null>;
57
+ set(key: string, value: string, tags?: ReadonlyArray<string>): Promise<void>;
58
+ delete?(key: string): Promise<boolean>;
59
+ }
60
+
61
+ export interface CodexOAuthTranscriberOptions {
62
+ readonly vault: CodexOAuthVault;
63
+ readonly baseUrl?: string;
64
+ readonly fetch?: typeof fetch;
65
+ readonly sessionIdProvider?: () => string;
66
+ /** Whole-request deadline in ms; defaults to 60s. `<= 0` disables it. */
67
+ readonly requestTimeoutMs?: number;
68
+ }
69
+
70
+ export class CodexOAuthTranscriber implements Transcriber {
71
+ readonly name = OPENAI_CODEX_TRANSCRIBER_NAME;
72
+ private readonly vault: CodexOAuthVault;
73
+ private readonly endpoint: string;
74
+ private readonly fetchImpl: typeof fetch;
75
+ private readonly sessionIdProvider: () => string;
76
+ private readonly requestTimeoutMs: number;
77
+
78
+ constructor(opts: CodexOAuthTranscriberOptions) {
79
+ this.vault = opts.vault;
80
+ this.endpoint = buildCodexTranscribeUrl(opts.baseUrl);
81
+ this.fetchImpl = opts.fetch ?? fetch;
82
+ this.sessionIdProvider = opts.sessionIdProvider ?? randomUUID;
83
+ this.requestTimeoutMs =
84
+ opts.requestTimeoutMs ?? DEFAULT_CODEX_TRANSCRIBE_TIMEOUT_MS;
85
+ }
86
+
87
+ async transcribe(
88
+ audio: Uint8Array | ArrayBuffer,
89
+ opts: TranscribeOptions = {},
90
+ ): Promise<TranscriptionResult> {
91
+ // Empty audio can never yield a transcript: fast-fail before loading tokens
92
+ // (which may trigger an OAuth refresh) or hitting the network.
93
+ if (audio.byteLength === 0) return { text: '' };
94
+
95
+ const tokens = await this.loadTokens();
96
+ const sessionId = this.sessionIdProvider();
97
+ // NB: `opts.language` / `opts.prompt` are intentionally NOT forwarded.
98
+ // This hits the undocumented, reverse-engineered ChatGPT `backend-api/
99
+ // transcribe` endpoint (mimicking Codex Desktop), which only accepts the
100
+ // audio file part — sending extra multipart fields risks a rejection or
101
+ // silent behavior change. The OpenAI Whisper sibling (which does support
102
+ // language/prompt) is the path for callers that need those hints.
103
+ const upload = normalizeWhisperUpload(audio, opts.mimeType, 'moxxy');
104
+ const form = new FormData();
105
+ form.append('file', new File([upload.bytes], upload.filename, { type: upload.mimeType }));
106
+
107
+ // Always race the request against an internal deadline (combined with any
108
+ // caller signal) so a connection the peer accepts but never answers still
109
+ // rejects instead of hanging forever. We own the timeout controller so we
110
+ // can tell our deadline apart from a caller abort and surface NETWORK_TIMEOUT
111
+ // explicitly (classifyNetworkError keys off `AbortError`, not the
112
+ // `TimeoutError` an AbortSignal.timeout would raise).
113
+ //
114
+ // The single deadline spans BOTH the fetch AND the subsequent body read: a
115
+ // slow-loris peer can send response *headers* promptly (resolving the fetch
116
+ // promise) and then dribble the *body* one byte at a time, so capping only
117
+ // the body SIZE isn't enough — the body read must also abort on the same
118
+ // deadline. We therefore keep the timer armed through the body read and only
119
+ // clear it in the outer `finally`.
120
+ const timeout = this.armTimeout();
121
+ const signal = combineSignals(opts.signal, timeout?.signal);
122
+
123
+ const timedOutError = (err: unknown): MoxxyError =>
124
+ new MoxxyError({
125
+ code: 'NETWORK_TIMEOUT',
126
+ message: `Codex transcription timed out after ${this.requestTimeoutMs}ms.`,
127
+ context: { provider: CODEX_PROVIDER_ID, url: this.endpoint },
128
+ cause: err,
129
+ });
130
+
131
+ try {
132
+ let response: Response;
133
+ try {
134
+ response = await this.fetchImpl(this.endpoint, {
135
+ method: 'POST',
136
+ headers: buildCodexTranscribeHeaders(tokens, sessionId),
137
+ body: form,
138
+ ...(signal ? { signal } : {}),
139
+ });
140
+ } catch (err) {
141
+ if (timeout?.timedOut() && !opts.signal?.aborted) throw timedOutError(err);
142
+ const network = classifyNetworkError(err, { url: this.endpoint, provider: CODEX_PROVIDER_ID });
143
+ if (network) throw network;
144
+ throw err;
145
+ }
146
+
147
+ const raw = await readCappedBody(response, MAX_CODEX_TRANSCRIBE_BODY_BYTES, signal);
148
+ // A body read that stalled past the deadline aborts via `signal`; surface
149
+ // it as a timeout (our deadline) or a NETWORK_ABORTED (caller cancelled)
150
+ // the same way a hung fetch is, instead of silently parsing a truncated body.
151
+ if (timeout?.timedOut() && !opts.signal?.aborted) throw timedOutError(undefined);
152
+ if (opts.signal?.aborted) {
153
+ const aborted = classifyNetworkError(
154
+ Object.assign(new Error('Request aborted while reading the response body.'), {
155
+ name: 'AbortError',
156
+ }),
157
+ { url: this.endpoint, provider: CODEX_PROVIDER_ID },
158
+ );
159
+ if (aborted) throw aborted;
160
+ }
161
+ return this.parseResponse(response, raw);
162
+ } finally {
163
+ timeout?.clear();
164
+ }
165
+ }
166
+
167
+ private parseResponse(response: Response, raw: string): TranscriptionResult {
168
+ if (!response.ok) {
169
+ const summary = summarizeCodexErrorBody(raw);
170
+ const classified = classifyHttpStatus(response.status, {
171
+ provider: CODEX_PROVIDER_ID,
172
+ url: this.endpoint,
173
+ body: summary,
174
+ });
175
+ if (classified) throw classified;
176
+
177
+ // Mirror the classified path: never embed the raw, untruncated body
178
+ // verbatim into the error cause/context — collapse HTML pages and bound
179
+ // the length so an unmapped status can't leak an arbitrary body.
180
+ const summarized = summary?.slice(0, MAX_CODEX_ERROR_CAUSE_CHARS);
181
+ throw new MoxxyError({
182
+ code: 'PROVIDER_BAD_REQUEST',
183
+ message: `Codex transcription returned HTTP ${response.status}.`,
184
+ context: {
185
+ provider: CODEX_PROVIDER_ID,
186
+ url: this.endpoint,
187
+ status: response.status,
188
+ ...(summarized ? { body: summarized } : {}),
189
+ },
190
+ ...(summarized ? { cause: new Error(summarized) } : {}),
191
+ });
192
+ }
193
+
194
+ let payload: unknown;
195
+ try {
196
+ payload = JSON.parse(raw) as unknown;
197
+ } catch (err) {
198
+ throw new MoxxyError({
199
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
200
+ message: 'Codex transcription returned invalid JSON.',
201
+ context: { provider: CODEX_PROVIDER_ID, url: this.endpoint },
202
+ cause: err,
203
+ });
204
+ }
205
+
206
+ // A 200 with a string `text` field is a SUCCESS even when that string is
207
+ // empty/whitespace — that just means the clip held no intelligible speech
208
+ // (silence, a clipped tap, a muted mic). Return `{ text: '' }` so callers
209
+ // take their graceful "no speech" path (the TUI shows a notice, the desktop
210
+ // a hint, Telegram/HTTP their own empty-text replies) instead of treating a
211
+ // normal outcome as a provider failure — every caller already guards on an
212
+ // empty transcript, and this is the one backend that used to throw past
213
+ // them. Only a response MISSING the `text` field (or whose `text` isn't a
214
+ // string) is a real contract violation worth throwing on.
215
+ if (
216
+ !payload ||
217
+ typeof payload !== 'object' ||
218
+ typeof (payload as { text?: unknown }).text !== 'string'
219
+ ) {
220
+ throw new MoxxyError({
221
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
222
+ message: 'Codex transcription response was missing a text field.',
223
+ context: { provider: CODEX_PROVIDER_ID, url: this.endpoint },
224
+ });
225
+ }
226
+
227
+ // Surface the richer fields when the backend reports them (it returns only
228
+ // `{ text }` today, so these are normally absent and the result is
229
+ // unchanged). Guarding each field keeps a schema drift from corrupting the
230
+ // result while letting diarization/segment-aware consumers benefit if the
231
+ // endpoint ever starts emitting them.
232
+ const obj = payload as {
233
+ text: string;
234
+ language?: unknown;
235
+ duration?: unknown;
236
+ segments?: unknown;
237
+ };
238
+ const result: {
239
+ text: string;
240
+ language?: string;
241
+ durationSec?: number;
242
+ segments?: ReadonlyArray<{ start: number; end: number; text: string }>;
243
+ } = { text: obj.text.trim() };
244
+ if (typeof obj.language === 'string') result.language = obj.language;
245
+ if (typeof obj.duration === 'number') result.durationSec = obj.duration;
246
+ if (Array.isArray(obj.segments)) {
247
+ const segments = obj.segments
248
+ .filter(
249
+ (s): s is { start: number; end: number; text: string } =>
250
+ !!s &&
251
+ typeof s === 'object' &&
252
+ typeof (s as { start?: unknown }).start === 'number' &&
253
+ typeof (s as { end?: unknown }).end === 'number' &&
254
+ typeof (s as { text?: unknown }).text === 'string',
255
+ )
256
+ .map((s) => ({ start: s.start, end: s.end, text: s.text }));
257
+ if (segments.length > 0) result.segments = segments;
258
+ }
259
+ return result;
260
+ }
261
+
262
+ /**
263
+ * Arm an internal abort timer for the request deadline. Returns `undefined`
264
+ * when the timeout is disabled (`<= 0`). The caller MUST `clear()` it once the
265
+ * response (or rejection) is in hand so a bare timer can't keep the process
266
+ * alive.
267
+ */
268
+ private armTimeout(): TimeoutHandle | undefined {
269
+ if (!(this.requestTimeoutMs > 0)) return undefined;
270
+ const controller = new AbortController();
271
+ let fired = false;
272
+ const timer = setTimeout(() => {
273
+ fired = true;
274
+ controller.abort();
275
+ }, this.requestTimeoutMs);
276
+ timer.unref?.();
277
+ return {
278
+ signal: controller.signal,
279
+ timedOut: () => fired,
280
+ clear: () => clearTimeout(timer),
281
+ };
282
+ }
283
+
284
+ private async loadTokens(): Promise<CodexTokens> {
285
+ try {
286
+ return await ensureFreshCodexTokens(this.vault);
287
+ } catch (err) {
288
+ if (
289
+ MoxxyError.isMoxxyError(err) &&
290
+ (err.code === 'AUTH_NO_CREDENTIALS' || err.code === 'AUTH_EXPIRED')
291
+ ) {
292
+ throw new MoxxyError({
293
+ code: err.code,
294
+ message: `No OpenAI Codex OAuth credentials available. Run \`moxxy login openai-codex\` to sign in.`,
295
+ hint: err.hint ?? 'Run `moxxy login openai-codex` to sign in.',
296
+ context: { provider: CODEX_PROVIDER_ID },
297
+ cause: err,
298
+ });
299
+ }
300
+ throw err;
301
+ }
302
+ }
303
+ }
304
+
305
+ function isLoopbackHostname(hostname: string): boolean {
306
+ // URL() wraps IPv6 literals in brackets.
307
+ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
308
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
309
+ }
310
+
311
+ export function buildCodexTranscribeUrl(baseUrl = DEFAULT_CODEX_TRANSCRIBE_BASE_URL): string {
312
+ let url: URL;
313
+ try {
314
+ url = new URL(baseUrl);
315
+ } catch (cause) {
316
+ throw new MoxxyError({
317
+ code: 'CONFIG_INVALID',
318
+ message: `Invalid Codex transcribe base URL: ${baseUrl}`,
319
+ context: { baseUrl: String(baseUrl) },
320
+ cause,
321
+ });
322
+ }
323
+
324
+ // The transcriber attaches a live `Authorization: Bearer <access-token>`
325
+ // header to this endpoint, so a config-controlled baseUrl must not be able to
326
+ // redirect that credential to an arbitrary origin. Pin to chatgpt.com over
327
+ // https, and allow loopback (the test/local seam) over http or https. Reject
328
+ // everything else as a misconfiguration rather than exfiltrating the token.
329
+ const loopback = isLoopbackHostname(url.hostname);
330
+ const httpsChatgpt = url.protocol === 'https:' && url.hostname === 'chatgpt.com';
331
+ if (!httpsChatgpt && !loopback) {
332
+ throw new MoxxyError({
333
+ code: 'CONFIG_INVALID',
334
+ message:
335
+ `Refusing to send Codex OAuth credentials to ${url.origin}. ` +
336
+ 'The Codex transcribe base URL must be https://chatgpt.com (or a loopback host for local testing).',
337
+ context: { baseUrl: String(baseUrl), origin: url.origin },
338
+ });
339
+ }
340
+ if (loopback && url.protocol !== 'http:' && url.protocol !== 'https:') {
341
+ throw new MoxxyError({
342
+ code: 'CONFIG_INVALID',
343
+ message: `Unsupported scheme for Codex transcribe base URL: ${url.protocol}`,
344
+ context: { baseUrl: String(baseUrl), protocol: url.protocol },
345
+ });
346
+ }
347
+
348
+ let pathname = url.pathname.replace(/\/+$/, '');
349
+ if (!pathname || pathname === '/') pathname = '';
350
+ if (url.hostname === 'chatgpt.com' && !pathname.endsWith('/backend-api')) {
351
+ pathname = `${pathname}/backend-api`;
352
+ }
353
+ if (!pathname.endsWith('/transcribe')) {
354
+ pathname = `${pathname}/transcribe`;
355
+ }
356
+ url.pathname = pathname;
357
+ url.search = '';
358
+ url.hash = '';
359
+ return url.toString();
360
+ }
361
+
362
+ function buildCodexTranscribeHeaders(tokens: CodexTokens, sessionId: string): Headers {
363
+ const headers = new Headers({
364
+ accept: 'application/json',
365
+ authorization: `Bearer ${tokens.access}`,
366
+ originator: CODEX_TRANSCRIBE_ORIGINATOR,
367
+ origin: 'https://chatgpt.com',
368
+ referer: 'https://chatgpt.com/',
369
+ 'user-agent': CODEX_TRANSCRIBE_USER_AGENT,
370
+ session_id: sessionId,
371
+ });
372
+ if (tokens.accountId) headers.set('ChatGPT-Account-Id', tokens.accountId);
373
+ return headers;
374
+ }
375
+
376
+ /**
377
+ * Read a response body to text, but stop once `maxBytes` have been buffered and
378
+ * cancel the rest of the stream. A success body is a tiny `{ text }` JSON; only
379
+ * a hostile/broken backend would stream more, so a tight cap is safe and keeps a
380
+ * multi-GB body from ballooning memory before we parse/inspect it.
381
+ *
382
+ * `signal` carries the same request deadline (and caller abort) the fetch used:
383
+ * a slow-loris peer that returns headers promptly then dribbles the body would
384
+ * otherwise hang this read forever (the size cap doesn't help when bytes arrive
385
+ * slowly, not all at once), so the read also aborts on the deadline. A missing
386
+ * body, an abort, or a decode/read failure yields whatever was read so far (or
387
+ * `''`); the caller checks the timeout flag to decide whether to surface it as
388
+ * NETWORK_TIMEOUT.
389
+ */
390
+ async function readCappedBody(
391
+ response: Response,
392
+ maxBytes: number,
393
+ signal?: AbortSignal,
394
+ ): Promise<string> {
395
+ const body = response.body;
396
+ if (!body) {
397
+ // No stream (e.g. some fetch polyfills / mocks); fall back to text() — those
398
+ // bodies are already in memory so there's nothing to cap. Race it against the
399
+ // deadline so even a polyfill that buffers slowly can't hang us.
400
+ try {
401
+ return await abortable(response.text(), signal);
402
+ } catch {
403
+ return '';
404
+ }
405
+ }
406
+ const reader = body.getReader();
407
+ const decoder = new TextDecoder('utf-8');
408
+ let received = 0;
409
+ let out = '';
410
+ const onAbort = () => {
411
+ // Unblock a pending reader.read() on the deadline / caller abort.
412
+ reader.cancel().catch(() => {});
413
+ };
414
+ if (signal) {
415
+ if (signal.aborted) onAbort();
416
+ else signal.addEventListener('abort', onAbort, { once: true });
417
+ }
418
+ try {
419
+ while (received < maxBytes) {
420
+ const { done, value } = await reader.read();
421
+ if (done) break;
422
+ if (value) {
423
+ received += value.byteLength;
424
+ out += decoder.decode(value, { stream: true });
425
+ }
426
+ }
427
+ return out;
428
+ } catch {
429
+ return out;
430
+ } finally {
431
+ signal?.removeEventListener('abort', onAbort);
432
+ reader.cancel().catch(() => {});
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Reject `promise` as soon as `signal` aborts, so a body that can't be streamed
438
+ * (e.g. a mock's `text()` that never settles) still honours the deadline. The
439
+ * underlying work is left to settle/garbage-collect on its own — we only stop
440
+ * awaiting it.
441
+ */
442
+ function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
443
+ if (!signal) return promise;
444
+ if (signal.aborted) return Promise.reject(new Error('aborted'));
445
+ return new Promise<T>((resolve, reject) => {
446
+ const onAbort = () => reject(new Error('aborted'));
447
+ signal.addEventListener('abort', onAbort, { once: true });
448
+ promise.then(
449
+ (v) => {
450
+ signal.removeEventListener('abort', onAbort);
451
+ resolve(v);
452
+ },
453
+ (e) => {
454
+ signal.removeEventListener('abort', onAbort);
455
+ reject(e instanceof Error ? e : new Error(String(e)));
456
+ },
457
+ );
458
+ });
459
+ }
460
+
461
+ function summarizeCodexErrorBody(raw: string): string | undefined {
462
+ const body = raw.trim();
463
+ if (!body) return undefined;
464
+ if (/^(?:<!doctype html|<html[\s>])/i.test(body)) {
465
+ return 'HTML error page from ChatGPT backend';
466
+ }
467
+ return body;
468
+ }