@makerbi/remodex 1.5.4 → 1.5.8

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.
@@ -1,10 +1,12 @@
1
1
  // FILE: voice-handler.js
2
2
  // Purpose: Handles bridge-owned voice transcription requests without exposing auth tokens to iPhone.
3
3
  // Layer: Bridge handler
4
- // Exports: createVoiceHandler
4
+ // Exports: createVoiceHandler, resolveVoiceAuth
5
5
  // Depends on: global fetch/FormData/Blob, local codex app-server auth via sendCodexRequest
6
6
 
7
+ const OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions";
7
8
  const CHATGPT_TRANSCRIPTIONS_URL = "https://chatgpt.com/backend-api/transcribe";
9
+ const DEFAULT_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe";
8
10
  const MAX_AUDIO_BYTES = 10 * 1024 * 1024;
9
11
  const MAX_DURATION_MS = 120_000;
10
12
 
@@ -14,6 +16,7 @@ function createVoiceHandler({
14
16
  FormDataImpl = globalThis.FormData,
15
17
  BlobImpl = globalThis.Blob,
16
18
  logPrefix = "[remodex]",
19
+ env = process.env,
17
20
  } = {}) {
18
21
  function handleVoiceRequest(rawMessage, sendResponse) {
19
22
  let parsed;
@@ -36,6 +39,7 @@ function createVoiceHandler({
36
39
  fetchImpl,
37
40
  FormDataImpl,
38
41
  BlobImpl,
42
+ env,
39
43
  })
40
44
  .then((result) => {
41
45
  sendResponse(JSON.stringify({ id, result }));
@@ -67,7 +71,7 @@ function createVoiceHandler({
67
71
  // Validates iPhone-owned audio input and proxies it to the official transcription endpoint.
68
72
  async function transcribeVoice(
69
73
  params,
70
- { sendCodexRequest, fetchImpl, FormDataImpl, BlobImpl }
74
+ { sendCodexRequest, fetchImpl, FormDataImpl, BlobImpl, env = process.env }
71
75
  ) {
72
76
  if (typeof sendCodexRequest !== "function") {
73
77
  throw voiceError("bridge_not_ready", "Voice transcription is not available right now.");
@@ -98,8 +102,18 @@ async function transcribeVoice(
98
102
  if (audioBuffer.length > MAX_AUDIO_BYTES) {
99
103
  throw voiceError("audio_too_large", "Voice messages are limited to 10 MB.");
100
104
  }
105
+ const wavInfo = readWavInfo(audioBuffer);
106
+ if (!wavInfo) {
107
+ throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
108
+ }
109
+ if (wavInfo.audioFormat !== 1
110
+ || wavInfo.channelCount !== 1
111
+ || wavInfo.sampleRateHz !== 24_000
112
+ || wavInfo.bitsPerSample !== 16) {
113
+ throw voiceError("unsupported_sample_rate", "Voice transcription requires 24 kHz mono WAV audio.");
114
+ }
101
115
 
102
- const authContext = await loadAuthContext(sendCodexRequest);
116
+ const authContext = await loadAuthContext(sendCodexRequest, { env });
103
117
  return requestTranscription({
104
118
  authContext,
105
119
  audioBuffer,
@@ -108,6 +122,7 @@ async function transcribeVoice(
108
122
  FormDataImpl,
109
123
  BlobImpl,
110
124
  sendCodexRequest,
125
+ env,
111
126
  });
112
127
  }
113
128
 
@@ -119,10 +134,14 @@ async function requestTranscription({
119
134
  FormDataImpl,
120
135
  BlobImpl,
121
136
  sendCodexRequest,
137
+ env,
122
138
  }) {
123
139
  const makeAttempt = async (activeAuthContext) => {
124
140
  const formData = new FormDataImpl();
125
141
  formData.append("file", new BlobImpl([audioBuffer], { type: mimeType }), "voice.wav");
142
+ if (!activeAuthContext.isChatGPT) {
143
+ formData.append("model", DEFAULT_TRANSCRIPTION_MODEL);
144
+ }
126
145
 
127
146
  const headers = {
128
147
  Authorization: `Bearer ${activeAuthContext.token}`,
@@ -135,10 +154,20 @@ async function requestTranscription({
135
154
  });
136
155
  };
137
156
 
138
- let response = await makeAttempt(authContext);
139
- if (response.status === 401) {
140
- const refreshedAuthContext = await loadAuthContext(sendCodexRequest);
141
- response = await makeAttempt(refreshedAuthContext);
157
+ let activeAuthContext = authContext;
158
+ let response = await makeAttempt(activeAuthContext);
159
+ if (response.status === 401 || response.status === 403) {
160
+ activeAuthContext = await loadAuthContext(sendCodexRequest, { env });
161
+ response = await makeAttempt(activeAuthContext);
162
+ if (!response.ok
163
+ && (response.status === 401 || response.status === 403)
164
+ && activeAuthContext.isChatGPT) {
165
+ const apiKeyContext = loadEnvApiKeyAuthContext(env);
166
+ if (apiKeyContext) {
167
+ activeAuthContext = apiKeyContext;
168
+ response = await makeAttempt(activeAuthContext);
169
+ }
170
+ }
142
171
  }
143
172
 
144
173
  if (!response.ok) {
@@ -154,7 +183,10 @@ async function requestTranscription({
154
183
  }
155
184
 
156
185
  if (response.status === 401 || response.status === 403) {
157
- throw voiceError("not_authenticated", "Your ChatGPT login has expired. Sign in again.");
186
+ const message = activeAuthContext.isChatGPT
187
+ ? "Your ChatGPT login has expired. Sign in again."
188
+ : "Your OpenAI API key was rejected. Update the API key on the Mac, then try again.";
189
+ throw voiceError("auth_rejected", message);
158
190
  }
159
191
 
160
192
  throw voiceError("transcription_failed", errorMessage);
@@ -170,32 +202,55 @@ async function requestTranscription({
170
202
  }
171
203
 
172
204
  // Reads the current bridge-owned auth state from the local codex app-server and refreshes if needed.
173
- async function loadAuthContext(sendCodexRequest) {
174
- const authStatus = await sendCodexRequest("getAuthStatus", {
175
- includeToken: true,
176
- refreshToken: true,
177
- });
205
+ async function loadAuthContext(sendCodexRequest, { env = process.env } = {}) {
206
+ const authStatus = await readVoiceAuthStatus(sendCodexRequest);
178
207
 
179
208
  const authMethod = readString(authStatus?.authMethod);
180
- const token = readString(authStatus?.authToken);
181
- const isChatGPT = authMethod === "chatgpt" || authMethod === "chatgptAuthTokens";
209
+ const token = normalizeBearerToken(authStatus?.authToken);
210
+ const isChatGPT = isChatGPTAuthMethod(authMethod);
182
211
 
183
212
  if (!token) {
184
- throw voiceError("not_authenticated", "Sign in with ChatGPT before using voice transcription.");
185
- }
186
- if (!isChatGPT) {
187
- throw voiceError("not_chatgpt", "Voice transcription requires a ChatGPT account.");
213
+ const apiKeyContext = loadEnvApiKeyAuthContext(env);
214
+ if (apiKeyContext) {
215
+ return apiKeyContext;
216
+ }
217
+ throw voiceError("not_authenticated", "Sign in with ChatGPT or configure an OpenAI API key before using voice transcription.");
188
218
  }
189
219
 
190
220
  return {
191
221
  authMethod,
192
222
  token,
193
223
  isChatGPT,
194
- transcriptionURL: CHATGPT_TRANSCRIPTIONS_URL,
195
- chatgptAccountId: readChatGPTAccountIdFromToken(token),
224
+ transcriptionURL: isChatGPT ? CHATGPT_TRANSCRIPTIONS_URL : OPENAI_TRANSCRIPTIONS_URL,
196
225
  };
197
226
  }
198
227
 
228
+ function loadEnvApiKeyAuthContext(env = process.env) {
229
+ const token = normalizeBearerToken(env?.OPENAI_API_KEY);
230
+ if (!token) {
231
+ return null;
232
+ }
233
+
234
+ return {
235
+ authMethod: "apiKey",
236
+ token,
237
+ isChatGPT: false,
238
+ transcriptionURL: OPENAI_TRANSCRIPTIONS_URL,
239
+ };
240
+ }
241
+
242
+ async function readVoiceAuthStatus(sendCodexRequest) {
243
+ try {
244
+ return await sendCodexRequest("getAuthStatus", {
245
+ includeToken: true,
246
+ refreshToken: true,
247
+ });
248
+ } catch (err) {
249
+ console.error(`[remodex] voice auth: getAuthStatus RPC failed: ${err.message}`);
250
+ throw voiceError("auth_unavailable", "Could not read OpenAI auth from the Mac runtime. Is the bridge running?");
251
+ }
252
+ }
253
+
199
254
  function decodeAudioBase64(value) {
200
255
  const normalized = normalizeBase64(value);
201
256
  if (!normalized) {
@@ -215,7 +270,7 @@ function decodeAudioBase64(value) {
215
270
  throw voiceError("invalid_audio", "The recorded audio could not be decoded.");
216
271
  }
217
272
 
218
- if (!isLikelyWavBuffer(audioBuffer)) {
273
+ if (!hasRiffWaveHeader(audioBuffer)) {
219
274
  throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
220
275
  }
221
276
 
@@ -231,45 +286,68 @@ function isLikelyBase64(value) {
231
286
  return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
232
287
  }
233
288
 
234
- function isLikelyWavBuffer(buffer) {
289
+ function hasRiffWaveHeader(buffer) {
235
290
  return buffer.length >= 44
236
291
  && buffer.toString("ascii", 0, 4) === "RIFF"
237
292
  && buffer.toString("ascii", 8, 12) === "WAVE";
238
293
  }
239
294
 
240
- function readChatGPTAccountIdFromToken(token) {
241
- const payload = decodeJWTPayload(token);
242
- const authClaim = payload?.["https://api.openai.com/auth"];
243
- return readString(
244
- authClaim?.chatgpt_account_id
245
- || authClaim?.chatgptAccountId
246
- || payload?.chatgpt_account_id
247
- || payload?.chatgptAccountId
248
- );
249
- }
250
-
251
- function decodeJWTPayload(token) {
252
- const segments = typeof token === "string" ? token.split(".") : [];
253
- if (segments.length < 2) {
295
+ // Parses chunked WAV metadata so extra chunks before fmt/data do not break valid clips.
296
+ function readWavInfo(buffer) {
297
+ if (!hasRiffWaveHeader(buffer)) {
254
298
  return null;
255
299
  }
256
300
 
257
- const normalized = segments[1]
258
- .replace(/-/g, "+")
259
- .replace(/_/g, "/")
260
- .padEnd(Math.ceil(segments[1].length / 4) * 4, "=");
301
+ let offset = 12;
302
+ let info = null;
303
+ let hasData = false;
304
+ while (offset + 8 <= buffer.length) {
305
+ const chunkId = buffer.toString("ascii", offset, offset + 4);
306
+ const chunkSize = buffer.readUInt32LE(offset + 4);
307
+ const payloadStart = offset + 8;
308
+ const payloadEnd = payloadStart + chunkSize;
309
+ if (payloadEnd > buffer.length) {
310
+ return null;
311
+ }
261
312
 
262
- try {
263
- return JSON.parse(Buffer.from(normalized, "base64").toString("utf8"));
264
- } catch {
265
- return null;
313
+ if (chunkId === "fmt ") {
314
+ if (chunkSize < 16) {
315
+ return null;
316
+ }
317
+ info = {
318
+ audioFormat: buffer.readUInt16LE(payloadStart),
319
+ channelCount: buffer.readUInt16LE(payloadStart + 2),
320
+ sampleRateHz: buffer.readUInt32LE(payloadStart + 4),
321
+ bitsPerSample: buffer.readUInt16LE(payloadStart + 14),
322
+ };
323
+ } else if (chunkId === "data") {
324
+ hasData = chunkSize > 0;
325
+ }
326
+
327
+ offset = payloadEnd + (chunkSize % 2);
266
328
  }
329
+
330
+ return info && hasData ? info : null;
267
331
  }
268
332
 
269
333
  function readString(value) {
270
334
  return typeof value === "string" && value.trim() ? value.trim() : null;
271
335
  }
272
336
 
337
+ function normalizeBearerToken(value) {
338
+ const token = readString(value);
339
+ if (!token) {
340
+ return null;
341
+ }
342
+ const match = token.match(/^bearer\s+(.+)$/i);
343
+ return match ? match[1].trim() : token;
344
+ }
345
+
346
+ function isChatGPTAuthMethod(value) {
347
+ const normalized = readString(value)?.toLowerCase().replace(/[^a-z0-9]/g, "") || "";
348
+ return normalized.includes("chatgpt");
349
+ }
350
+
273
351
  function readPositiveNumber(value) {
274
352
  const numericValue = typeof value === "number" ? value : Number(value);
275
353
  return Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0;
@@ -282,33 +360,22 @@ function voiceError(errorCode, userMessage) {
282
360
  return error;
283
361
  }
284
362
 
285
- // Returns an ephemeral ChatGPT token so the phone can call the transcription API directly.
286
- // Uses its own token resolution instead of loadAuthContext so errors are specific and actionable.
363
+ // Serves older phone builds that upload directly to ChatGPT with a Mac-owned token.
287
364
  async function resolveVoiceAuth(sendCodexRequest) {
288
- let authStatus;
289
- try {
290
- authStatus = await sendCodexRequest("getAuthStatus", {
291
- includeToken: true,
292
- refreshToken: true,
293
- });
294
- } catch (err) {
295
- console.error(`[remodex] voice/resolveAuth: getAuthStatus RPC failed: ${err.message}`);
296
- throw voiceError("auth_unavailable", "Could not read ChatGPT session from the Mac runtime. Is the bridge running?");
365
+ if (typeof sendCodexRequest !== "function") {
366
+ throw voiceError("bridge_not_ready", "Voice transcription is not available right now.");
297
367
  }
298
368
 
369
+ const authStatus = await readVoiceAuthStatus(sendCodexRequest);
299
370
  const authMethod = readString(authStatus?.authMethod);
300
- const token = readString(authStatus?.authToken);
301
- const isChatGPT = authMethod === "chatgpt" || authMethod === "chatgptAuthTokens";
371
+ const token = normalizeBearerToken(authStatus?.authToken);
372
+ const isChatGPT = isChatGPTAuthMethod(authMethod);
302
373
 
303
- // Check for a usable ChatGPT token first. The runtime may set requiresOpenaiAuth
304
- // even when a valid ChatGPT session is present (the flag is about the runtime's
305
- // preferred auth mode, not whether ChatGPT tokens are actually available).
306
374
  if (isChatGPT && token) {
307
375
  return { token };
308
376
  }
309
377
 
310
378
  if (!token) {
311
- console.error(`[remodex] voice/resolveAuth: no token. authMethod=${authMethod || "none"} requiresOpenaiAuth=${authStatus?.requiresOpenaiAuth}`);
312
379
  throw voiceError("token_missing", "No ChatGPT session token available. Sign in to ChatGPT on the Mac.");
313
380
  }
314
381