@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/LICENSE +21 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/transcriber.d.ts +39 -0
- package/dist/transcriber.d.ts.map +1 -0
- package/dist/transcriber.js +400 -0
- package/dist/transcriber.js.map +1 -0
- package/package.json +84 -0
- package/src/index.test.ts +609 -0
- package/src/index.ts +112 -0
- package/src/transcriber.ts +468 -0
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
|
+
}
|