@steipete/oracle 0.12.0 → 0.12.1

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.
@@ -59,7 +59,7 @@ function assertResolvedModelSelection(desiredModel, resolvedLabel) {
59
59
  }
60
60
  if (!hasCurrentProSignal(resolved) ||
61
61
  hasLegacyProVersionLabel(resolved) ||
62
- (resolved.includes("thinking") && !resolved.includes("pro"))) {
62
+ resolved.includes("thinking")) {
63
63
  throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-5.5 Pro. Use model "gpt-5.5" with browser thinking time for the Thinking variant.`);
64
64
  }
65
65
  }
@@ -70,12 +70,7 @@ function normalizeResolvedModelLabel(value) {
70
70
  .trim();
71
71
  }
72
72
  function hasCurrentProSignal(resolved) {
73
- return (resolved.includes(" pro") ||
74
- resolved.endsWith("pro") ||
75
- resolved.includes("pro ") ||
76
- resolved.includes("extended") ||
77
- resolved.includes("gpt-5.5-pro") ||
78
- resolved.includes("gpt 5 5 pro"));
73
+ return normalizeResolvedModelLabel(resolved).split(" ").includes("pro");
79
74
  }
80
75
  function hasLegacyProVersionLabel(resolved) {
81
76
  const normalized = normalizeResolvedModelLabel(resolved);
@@ -128,6 +123,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
128
123
  .replace(/\\s+/g, ' ')
129
124
  .trim();
130
125
  };
126
+ const hasToken = (value, token) => normalizeText(value).split(' ').includes(token);
131
127
  // Normalize every candidate token to keep fuzzy matching deterministic.
132
128
  const normalizedTarget = normalizeText(PRIMARY_LABEL);
133
129
  const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
@@ -173,7 +169,17 @@ function buildModelSelectionExpression(targetModel, strategy) {
173
169
  return false;
174
170
  };
175
171
  const hasProComposerPill = () => Boolean(
176
- document.querySelector('button.__composer-pill, button[aria-label="Pro, click to remove"]')
172
+ Array.from(document.querySelectorAll('button.__composer-pill, button[aria-label]'))
173
+ .filter((node) => {
174
+ const label = normalizeText(node.getAttribute?.('aria-label') ?? '');
175
+ return node.matches?.('button.__composer-pill') || label.includes('click to remove');
176
+ })
177
+ .some((node) => {
178
+ const label = normalizeText(
179
+ (node.getAttribute?.('aria-label') ?? '') + ' ' + (node.textContent ?? '')
180
+ );
181
+ return hasToken(label, 'pro') && !hasToken(label, 'thinking');
182
+ })
177
183
  );
178
184
 
179
185
  const button = document.querySelector(BUTTON_SELECTOR);
@@ -209,7 +215,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
209
215
  const resolved = label || '';
210
216
  if (!wantsPro || !hasProComposerPill()) return resolved;
211
217
  const normalized = normalizeText(resolved);
212
- if (!normalized || normalized.includes('pro')) return resolved;
218
+ if (!normalized) return resolved;
219
+ if (normalized.includes('thinking')) return 'Pro';
220
+ if (normalized.includes('pro')) return resolved;
213
221
  return resolved + ' + Pro';
214
222
  };
215
223
  const getResolvedLabel = (fallback) =>
@@ -225,7 +233,15 @@ function buildModelSelectionExpression(targetModel, strategy) {
225
233
  const normalizedLabel = normalizeText(getButtonLabel());
226
234
  if (!normalizedLabel) return false;
227
235
  if (isTargetGpt55VisibleAlias(normalizedLabel)) return true;
228
- if (wantsPro && normalizedLabel === 'chatgpt' && hasProComposerPill()) {
236
+ if (
237
+ wantsPro &&
238
+ hasProComposerPill() &&
239
+ (normalizedLabel === 'chatgpt' ||
240
+ normalizedLabel === 'extended' ||
241
+ normalizedLabel === 'standard' ||
242
+ normalizedLabel === 'heavy' ||
243
+ normalizedLabel === 'light')
244
+ ) {
229
245
  return true;
230
246
  }
231
247
  if (desiredVersion) {
@@ -183,6 +183,7 @@ export async function submitPrompt(deps, prompt, logger) {
183
183
  else {
184
184
  logger("Clicked send button");
185
185
  }
186
+ await deps.onPromptSubmitted?.();
186
187
  const commitTimeoutMs = Math.max(60_000, deps.inputTimeoutMs ?? 0);
187
188
  // Learned: the send button can succeed but the turn doesn't appear immediately; verify commit via turns/stop button.
188
189
  return await verifyPromptCommitted(runtime, prompt, commitTimeoutMs, logger, deps.baselineTurns ?? undefined);
@@ -11,9 +11,11 @@ import { buildClickDispatcher } from "./domEvents.js";
11
11
  *
12
12
  * @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
13
13
  */
14
- export async function ensureThinkingTime(Runtime, level, logger) {
15
- const result = await evaluateThinkingTimeSelection(Runtime, level);
14
+ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
15
+ const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
16
16
  const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
17
+ const targetModelKind = inferThinkingTargetModelKind(desiredModel);
18
+ const strictProEffort = targetModelKind === "pro" && level === "extended";
17
19
  switch (result?.status) {
18
20
  case "already-selected":
19
21
  logger(`Thinking time: ${result.label ?? capitalizedLevel} (already selected)`);
@@ -23,13 +25,26 @@ export async function ensureThinkingTime(Runtime, level, logger) {
23
25
  return;
24
26
  case "chip-not-found":
25
27
  case "menu-not-found":
26
- case "option-not-found": {
28
+ case "option-not-found":
29
+ case "model-kind-not-found": {
27
30
  await logDomFailure(Runtime, logger, `thinking-${result.status}`);
28
- logger(`Thinking time: ${result.status.replaceAll("-", " ")} (requested ${capitalizedLevel}); continuing with ChatGPT default.`);
31
+ const kindHint = result.status === "model-kind-not-found" && result.modelKind
32
+ ? ` for ${result.modelKind}`
33
+ : targetModelKind
34
+ ? ` for ${targetModelKind}`
35
+ : "";
36
+ const message = `Thinking time: ${result.status.replaceAll("-", " ")}${kindHint} (requested ${capitalizedLevel})`;
37
+ if (strictProEffort) {
38
+ throw new Error(`${message}; refusing to submit without confirmed Pro Extended.`);
39
+ }
40
+ logger(`${message}; continuing with ChatGPT default.`);
29
41
  return;
30
42
  }
31
43
  default: {
32
44
  await logDomFailure(Runtime, logger, "thinking-time-unknown");
45
+ if (strictProEffort) {
46
+ throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed Pro Extended.`);
47
+ }
33
48
  logger(`Thinking time: unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`);
34
49
  return;
35
50
  }
@@ -40,9 +55,9 @@ export async function ensureThinkingTime(Runtime, level, logger) {
40
55
  * Safe by default: if the pill/menu/option isn't present, we continue without throwing.
41
56
  * @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
42
57
  */
43
- export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
58
+ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desiredModel) {
44
59
  try {
45
- const result = await evaluateThinkingTimeSelection(Runtime, level);
60
+ const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
46
61
  const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
47
62
  switch (result?.status) {
48
63
  case "already-selected":
@@ -54,6 +69,7 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
54
69
  case "chip-not-found":
55
70
  case "menu-not-found":
56
71
  case "option-not-found":
72
+ case "model-kind-not-found":
57
73
  if (logger.verbose) {
58
74
  logger(`Thinking time: ${result.status.replaceAll("-", " ")}; continuing with default.`);
59
75
  }
@@ -74,19 +90,20 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
74
90
  return false;
75
91
  }
76
92
  }
77
- async function evaluateThinkingTimeSelection(Runtime, level) {
93
+ async function evaluateThinkingTimeSelection(Runtime, level, desiredModel) {
78
94
  const outcome = await Runtime.evaluate({
79
- expression: buildThinkingTimeExpression(level),
95
+ expression: buildThinkingTimeExpression(level, desiredModel),
80
96
  awaitPromise: true,
81
97
  returnByValue: true,
82
98
  });
83
99
  return outcome.result?.value;
84
100
  }
85
- function buildThinkingTimeExpression(level) {
101
+ function buildThinkingTimeExpression(level, desiredModel) {
86
102
  const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
87
103
  const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
88
104
  const modelButtonLiteral = JSON.stringify(MODEL_BUTTON_SELECTOR);
89
105
  const targetLevelLiteral = JSON.stringify(level.toLowerCase());
106
+ const targetModelKindLiteral = JSON.stringify(inferThinkingTargetModelKind(desiredModel));
90
107
  return `(async () => {
91
108
  ${buildClickDispatcher()}
92
109
 
@@ -94,6 +111,7 @@ function buildThinkingTimeExpression(level) {
94
111
  const MENU_ITEM_SELECTOR = ${menuItemLiteral};
95
112
  const MODEL_BUTTON_SELECTOR = ${modelButtonLiteral};
96
113
  const TARGET_LEVEL = ${targetLevelLiteral};
114
+ const TARGET_MODEL_KIND = ${targetModelKindLiteral};
97
115
 
98
116
  // Bilingual matchers: English level token + observed Chinese variants.
99
117
  const LEVEL_TOKENS = {
@@ -119,6 +137,7 @@ function buildThinkingTimeExpression(level) {
119
137
  const t = normalize(text);
120
138
  return targetTokens.some((tok) => t.includes(String(tok).toLowerCase()));
121
139
  };
140
+ const hasToken = (text, token) => normalize(text).split(' ').includes(token);
122
141
  const optionIsSelected = (node) => {
123
142
  if (!(node instanceof HTMLElement)) return false;
124
143
  const ariaChecked = node.getAttribute('aria-checked');
@@ -207,6 +226,7 @@ function buildThinkingTimeExpression(level) {
207
226
 
208
227
  const findModelButton = () => document.querySelector(MODEL_BUTTON_SELECTOR);
209
228
  const findTrailingButtons = () => Array.from(document.querySelectorAll(TRAILING_SELECTOR));
229
+ const KIND_NOT_FOUND = { kindNotFound: true };
210
230
  const findEffortRow = (node) => {
211
231
  let current = node instanceof HTMLElement ? node.parentElement : null;
212
232
  while (current && current !== document.body) {
@@ -227,6 +247,58 @@ function buildThinkingTimeExpression(level) {
227
247
  ),
228
248
  );
229
249
  };
250
+ const rowForTrailing = (trailing) =>
251
+ trailing.closest('[role="menuitem"], [role="menuitemradio"], [data-radix-collection-item]');
252
+ const rowTextForTrailing = (trailing) => {
253
+ const row = rowForTrailing(trailing) || findEffortRow(trailing);
254
+ return normalize(
255
+ (row?.getAttribute?.('aria-label') ?? '') + ' ' +
256
+ (row?.getAttribute?.('data-testid') ?? '') + ' ' +
257
+ (row?.textContent ?? '') + ' ' +
258
+ (trailing.getAttribute?.('aria-label') ?? '') + ' ' +
259
+ (trailing.getAttribute?.('data-testid') ?? '')
260
+ );
261
+ };
262
+ const testIdTextForTrailing = (trailing) => {
263
+ const row = rowForTrailing(trailing) || findEffortRow(trailing);
264
+ return normalize(
265
+ (row?.getAttribute?.('data-testid') ?? '') + ' ' +
266
+ (trailing.getAttribute?.('data-testid') ?? '')
267
+ );
268
+ };
269
+ const modelKindFromTrailing = (trailing) => {
270
+ const idText = testIdTextForTrailing(trailing);
271
+ if (!idText.includes('model switcher')) return null;
272
+ const modelPart = normalize(idText.replace(/\\bthinking effort\\b.*$/, ''));
273
+ if (hasToken(modelPart, 'pro')) return 'pro';
274
+ if (hasToken(modelPart, 'thinking')) return 'thinking';
275
+ if (hasToken(modelPart, 'instant')) return 'instant';
276
+ return null;
277
+ };
278
+ const trailingMatchesTargetModelKind = (trailing) => {
279
+ if (!TARGET_MODEL_KIND) return false;
280
+ const idKind = modelKindFromTrailing(trailing);
281
+ if (idKind) return idKind === TARGET_MODEL_KIND;
282
+ const text = rowTextForTrailing(trailing);
283
+ if (TARGET_MODEL_KIND === 'pro') {
284
+ return hasToken(text, 'pro') && !hasToken(text, 'thinking');
285
+ }
286
+ if (TARGET_MODEL_KIND === 'thinking') {
287
+ return hasToken(text, 'thinking') && !hasToken(text, 'pro');
288
+ }
289
+ if (TARGET_MODEL_KIND === 'instant') {
290
+ return hasToken(text, 'instant') && !hasToken(text, 'thinking') && !hasToken(text, 'pro');
291
+ }
292
+ return false;
293
+ };
294
+ const hasStableBox = (node) => {
295
+ const r = node.getBoundingClientRect?.();
296
+ return Boolean(r && r.width > 0 && r.height > 0 && node.getAttribute?.('aria-hidden') !== 'true');
297
+ };
298
+ const pickSingleStableTrailing = (trailings) => {
299
+ const visible = trailings.filter((t) => hasStableBox(t));
300
+ return visible.length === 1 ? visible[0] : null;
301
+ };
230
302
  const pickTrailingForCurrentModel = () => {
231
303
  const trailings = findTrailingButtons();
232
304
  if (trailings.length === 0) return null;
@@ -236,6 +308,10 @@ function buildThinkingTimeExpression(level) {
236
308
  const row = findEffortRow(t);
237
309
  if (rowIsSelected(row)) return t;
238
310
  }
311
+ if (TARGET_MODEL_KIND) {
312
+ const targetTrailings = trailings.filter((t) => trailingMatchesTargetModelKind(t));
313
+ return pickSingleStableTrailing(targetTrailings) || KIND_NOT_FOUND;
314
+ }
239
315
  return null;
240
316
  };
241
317
 
@@ -243,7 +319,6 @@ function buildThinkingTimeExpression(level) {
243
319
  if (!modelBtn) {
244
320
  return { status: 'chip-not-found' };
245
321
  }
246
-
247
322
  // Open model menu (idempotent — leaves it open if already open).
248
323
  if (modelBtn.getAttribute('aria-expanded') !== 'true') {
249
324
  dispatchClickSequence(modelBtn);
@@ -261,6 +336,10 @@ function buildThinkingTimeExpression(level) {
261
336
  closeOpenMenus();
262
337
  return { status: 'chip-not-found' };
263
338
  }
339
+ if (trailing.kindNotFound) {
340
+ closeOpenMenus();
341
+ return { status: 'model-kind-not-found', modelKind: TARGET_MODEL_KIND };
342
+ }
264
343
 
265
344
  dispatchClickSequence(trailing);
266
345
  await sleep(STEP_WAIT_MS);
@@ -313,6 +392,26 @@ function buildThinkingTimeExpression(level) {
313
392
  return { status: already ? 'already-selected' : 'switched', label };
314
393
  })()`;
315
394
  }
316
- export function buildThinkingTimeExpressionForTest(level = "extended") {
317
- return buildThinkingTimeExpression(level);
395
+ export function buildThinkingTimeExpressionForTest(level = "extended", desiredModel) {
396
+ return buildThinkingTimeExpression(level, desiredModel);
397
+ }
398
+ function inferThinkingTargetModelKind(desiredModel) {
399
+ const normalized = (desiredModel ?? "")
400
+ .toLowerCase()
401
+ .replace(/[^a-z0-9]+/g, " ")
402
+ .replace(/\s+/g, " ")
403
+ .trim();
404
+ if (!normalized)
405
+ return null;
406
+ const tokens = normalized.split(" ");
407
+ if (tokens.includes("pro"))
408
+ return "pro";
409
+ if (tokens.includes("thinking"))
410
+ return "thinking";
411
+ if (tokens.includes("instant"))
412
+ return "instant";
413
+ return null;
414
+ }
415
+ export function inferThinkingTargetModelKindForTest(desiredModel) {
416
+ return inferThinkingTargetModelKind(desiredModel);
318
417
  }
@@ -327,6 +327,7 @@ export async function runBrowserMode(options) {
327
327
  const runtimeHintCb = options.runtimeHintCb;
328
328
  let lastTargetId;
329
329
  let lastUrl;
330
+ let promptSubmitted = false;
330
331
  let tabLease = null;
331
332
  const emitRuntimeHint = async () => {
332
333
  if (!chrome?.port) {
@@ -340,6 +341,7 @@ export async function runBrowserMode(options) {
340
341
  chromeTargetId: lastTargetId,
341
342
  tabUrl: lastUrl,
342
343
  conversationId,
344
+ promptSubmitted,
343
345
  userDataDir,
344
346
  controllerPid: process.pid,
345
347
  };
@@ -357,6 +359,13 @@ export async function runBrowserMode(options) {
357
359
  logger(`Failed to persist runtime hint: ${message}`);
358
360
  }
359
361
  };
362
+ const markPromptSubmitted = async () => {
363
+ if (promptSubmitted) {
364
+ return;
365
+ }
366
+ promptSubmitted = true;
367
+ await emitRuntimeHint();
368
+ };
360
369
  if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") {
361
370
  logger(`[browser-mode] config: ${JSON.stringify({
362
371
  ...redactBrowserConfigForDebugLog(config),
@@ -725,7 +734,8 @@ export async function runBrowserMode(options) {
725
734
  // Handle thinking time selection if specified. Deep Research owns its own effort flow.
726
735
  const thinkingTime = config.thinkingTime;
727
736
  if (thinkingTime && !deepResearch) {
728
- await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
737
+ const thinkingTargetModel = modelStrategy === "select" ? config.desiredModel : null;
738
+ await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger, thinkingTargetModel), {
729
739
  retries: 2,
730
740
  delayMs: 300,
731
741
  onRetry: (attempt, error) => {
@@ -803,6 +813,7 @@ export async function runBrowserMode(options) {
803
813
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
804
814
  baselineTurns: baselineTurns ?? undefined,
805
815
  attachmentNames,
816
+ onPromptSubmitted: markPromptSubmitted,
806
817
  };
807
818
  await runProviderSubmissionFlow(chatgptDomProvider, {
808
819
  prompt,
@@ -811,6 +822,7 @@ export async function runBrowserMode(options) {
811
822
  log: logger,
812
823
  state: providerState,
813
824
  });
825
+ await markPromptSubmitted();
814
826
  const providerBaselineTurns = providerState.baselineTurns;
815
827
  if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
816
828
  baselineTurns = providerBaselineTurns;
@@ -912,6 +924,7 @@ export async function runBrowserMode(options) {
912
924
  chromeTargetId: lastTargetId,
913
925
  tabUrl: lastUrl,
914
926
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
927
+ promptSubmitted,
915
928
  controllerPid: process.pid,
916
929
  };
917
930
  }
@@ -996,6 +1009,7 @@ export async function runBrowserMode(options) {
996
1009
  chromeTargetId: lastTargetId,
997
1010
  tabUrl: lastUrl,
998
1011
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1012
+ promptSubmitted,
999
1013
  controllerPid: process.pid,
1000
1014
  },
1001
1015
  });
@@ -1051,6 +1065,7 @@ export async function runBrowserMode(options) {
1051
1065
  chromeTargetId: lastTargetId,
1052
1066
  tabUrl: lastUrl,
1053
1067
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1068
+ promptSubmitted,
1054
1069
  controllerPid: process.pid,
1055
1070
  };
1056
1071
  throw new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime, diagnostics }, error);
@@ -1298,6 +1313,7 @@ export async function runBrowserMode(options) {
1298
1313
  chromeTargetId: lastTargetId,
1299
1314
  tabUrl: lastUrl,
1300
1315
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1316
+ promptSubmitted,
1301
1317
  controllerPid: process.pid,
1302
1318
  };
1303
1319
  }
@@ -1315,6 +1331,7 @@ export async function runBrowserMode(options) {
1315
1331
  userDataDir,
1316
1332
  chromeTargetId: lastTargetId,
1317
1333
  tabUrl: lastUrl,
1334
+ promptSubmitted,
1318
1335
  controllerPid: process.pid,
1319
1336
  };
1320
1337
  const reuseProfileHint = `oracle --engine browser --browser-manual-login ` +
@@ -1355,6 +1372,7 @@ export async function runBrowserMode(options) {
1355
1372
  userDataDir,
1356
1373
  chromeTargetId: lastTargetId,
1357
1374
  tabUrl: lastUrl,
1375
+ promptSubmitted,
1358
1376
  controllerPid: process.pid,
1359
1377
  },
1360
1378
  }, normalizedError);
@@ -1703,6 +1721,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1703
1721
  let remoteTargetId = null;
1704
1722
  let tabLease = null;
1705
1723
  let lastUrl;
1724
+ let promptSubmitted = false;
1706
1725
  let attachedExistingTab = false;
1707
1726
  let ownsTarget = true;
1708
1727
  const runtimeHintCb = options.runtimeHintCb;
@@ -1718,6 +1737,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1718
1737
  chromeTargetId: remoteTargetId ?? undefined,
1719
1738
  tabUrl: lastUrl,
1720
1739
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1740
+ promptSubmitted,
1721
1741
  controllerPid: process.pid,
1722
1742
  });
1723
1743
  await tabLease?.update({
@@ -1732,6 +1752,13 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1732
1752
  logger(`Failed to persist runtime hint: ${message}`);
1733
1753
  }
1734
1754
  };
1755
+ const markPromptSubmitted = async () => {
1756
+ if (promptSubmitted) {
1757
+ return;
1758
+ }
1759
+ promptSubmitted = true;
1760
+ await emitRuntimeHint();
1761
+ };
1735
1762
  const startedAt = Date.now();
1736
1763
  let answerText = "";
1737
1764
  let answerMarkdown = "";
@@ -1848,7 +1875,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1848
1875
  // Handle thinking time selection if specified. Deep Research owns its own effort flow.
1849
1876
  const thinkingTime = config.thinkingTime;
1850
1877
  if (thinkingTime && !deepResearch) {
1851
- await withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
1878
+ const thinkingTargetModel = modelStrategy === "select" ? config.desiredModel : null;
1879
+ await withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger, thinkingTargetModel), {
1852
1880
  retries: 2,
1853
1881
  delayMs: 300,
1854
1882
  onRetry: (attempt, error) => {
@@ -1904,6 +1932,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1904
1932
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
1905
1933
  baselineTurns: baselineTurns ?? undefined,
1906
1934
  attachmentNames,
1935
+ onPromptSubmitted: markPromptSubmitted,
1907
1936
  };
1908
1937
  await runProviderSubmissionFlow(chatgptDomProvider, {
1909
1938
  prompt,
@@ -1912,6 +1941,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1912
1941
  log: logger,
1913
1942
  state: providerState,
1914
1943
  });
1944
+ await markPromptSubmitted();
1915
1945
  const providerBaselineTurns = providerState.baselineTurns;
1916
1946
  if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
1917
1947
  baselineTurns = providerBaselineTurns;
@@ -1985,6 +2015,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1985
2015
  chromeTargetId: remoteTargetId ?? undefined,
1986
2016
  tabUrl: lastUrl,
1987
2017
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2018
+ promptSubmitted,
1988
2019
  controllerPid: process.pid,
1989
2020
  };
1990
2021
  }
@@ -2068,6 +2099,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2068
2099
  chromeTargetId: remoteTargetId ?? undefined,
2069
2100
  tabUrl: lastUrl,
2070
2101
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2102
+ promptSubmitted,
2071
2103
  controllerPid: process.pid,
2072
2104
  },
2073
2105
  });
@@ -2136,6 +2168,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2136
2168
  chromeTargetId: remoteTargetId ?? undefined,
2137
2169
  tabUrl: lastUrl,
2138
2170
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2171
+ promptSubmitted,
2139
2172
  controllerPid: process.pid,
2140
2173
  };
2141
2174
  throw new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime, diagnostics }, error);
@@ -2338,6 +2371,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2338
2371
  chromeTargetId: remoteTargetId ?? undefined,
2339
2372
  tabUrl: lastUrl,
2340
2373
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2374
+ promptSubmitted,
2341
2375
  artifacts: savedArtifacts,
2342
2376
  archive,
2343
2377
  modelSelection: modelSelectionEvidence,
@@ -2364,6 +2398,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2364
2398
  chromeProfileRoot,
2365
2399
  chromeTargetId: remoteTargetId ?? undefined,
2366
2400
  tabUrl: lastUrl,
2401
+ promptSubmitted,
2367
2402
  controllerPid: process.pid,
2368
2403
  },
2369
2404
  });
@@ -23,6 +23,7 @@ async function submitPromptViaAdapter(ctx) {
23
23
  attachmentNames: state.attachmentNames ?? [],
24
24
  baselineTurns: state.baselineTurns ?? undefined,
25
25
  inputTimeoutMs: state.inputTimeoutMs ?? undefined,
26
+ onPromptSubmitted: state.onPromptSubmitted,
26
27
  }, ctx.prompt, state.logger);
27
28
  state.committedTurns =
28
29
  typeof committedTurns === "number" && Number.isFinite(committedTurns) ? committedTurns : null;
@@ -0,0 +1,22 @@
1
+ export function hasRecoverableChatGptConversation(runtime) {
2
+ if (!runtime) {
3
+ return false;
4
+ }
5
+ if (runtime.conversationId?.trim()) {
6
+ return true;
7
+ }
8
+ const tabUrl = runtime.tabUrl?.trim();
9
+ if (!tabUrl) {
10
+ return false;
11
+ }
12
+ try {
13
+ const url = new URL(tabUrl);
14
+ if (url.hostname !== "chatgpt.com" && url.hostname !== "chat.openai.com") {
15
+ return false;
16
+ }
17
+ return /(?:^|\/)c\/[^/]+/.test(url.pathname);
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
@@ -214,6 +214,7 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
214
214
  chromeTargetId: browserResult.chromeTargetId,
215
215
  tabUrl: browserResult.tabUrl,
216
216
  conversationId: browserResult.conversationId,
217
+ promptSubmitted: browserResult.promptSubmitted,
217
218
  controllerPid: browserResult.controllerPid ?? process.pid,
218
219
  },
219
220
  archive: browserResult.archive,
@@ -6,6 +6,7 @@ import { formatFinishLine } from "../oracle/finishLine.js";
6
6
  import { sessionStore, wait } from "../sessionStore.js";
7
7
  import { formatTokenCount, formatTokenValue } from "../oracle/runUtils.js";
8
8
  import { resumeBrowserSession } from "../browser/reattach.js";
9
+ import { hasRecoverableChatGptConversation } from "../browser/reattachability.js";
9
10
  import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "../browser/artifacts.js";
10
11
  import { estimateTokenCount } from "../browser/utils.js";
11
12
  import { formatSessionTableHeader, formatSessionTableRow, resolveSessionCost, } from "./sessionTable.js";
@@ -174,9 +175,16 @@ export async function attachSession(sessionId, options) {
174
175
  hasFallbackSessionInfo &&
175
176
  isDeepResearchPlaceholderCapture(metadata, await sessionStore.readLog(sessionId).catch(() => ""));
176
177
  const completedDeepResearchPlaceholder = metadata.status === "completed" && deepResearchPlaceholderCapture;
178
+ const hasRecoverableConversation = hasRecoverableChatGptConversation(runtime);
179
+ const hasLiveChromeFallback = Boolean((metadata.status === "running" || hasIncompleteCapture || completedDeepResearchPlaceholder) &&
180
+ (runtime?.chromePort || runtime?.chromeBrowserWSEndpoint || runtime?.chromeProfileRoot));
177
181
  const canReattach = (statusAllowsReattach || completedDeepResearchPlaceholder) &&
178
182
  metadata.mode === "browser" &&
179
183
  hasFallbackSessionInfo &&
184
+ (hasRecoverableConversation ||
185
+ runtime?.promptSubmitted ||
186
+ hasLiveChromeFallback ||
187
+ completedDeepResearchPlaceholder) &&
180
188
  (hasChromeDisconnect ||
181
189
  hasIncompleteCapture ||
182
190
  completedDeepResearchPlaceholder ||
@@ -21,6 +21,7 @@ import { sanitizeOscProgress } from "./oscUtils.js";
21
21
  import { readFiles } from "../oracle/files.js";
22
22
  import { cwd as getCwd } from "node:process";
23
23
  import { resumeBrowserSession } from "../browser/reattach.js";
24
+ import { hasRecoverableChatGptConversation } from "../browser/reattachability.js";
24
25
  import { estimateTokenCount } from "../browser/utils.js";
25
26
  import { formatElapsed } from "../oracle/format.js";
26
27
  const isTty = process.stdout.isTTY;
@@ -390,6 +391,40 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
390
391
  if (connectionLost && mode === "browser") {
391
392
  const runtime = userError.details
392
393
  ?.runtime;
394
+ const recoverableRuntime = runtime ?? sessionMeta.browser?.runtime;
395
+ if (!hasRecoverableChatGptConversation(recoverableRuntime) &&
396
+ recoverableRuntime?.promptSubmitted !== true) {
397
+ log(dim("Chrome disconnected before a ChatGPT conversation was created; marking session error."));
398
+ if (modelForStatus) {
399
+ await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
400
+ status: "error",
401
+ completedAt: new Date().toISOString(),
402
+ response: { status: "error", incompleteReason: "chrome-disconnected" },
403
+ error: {
404
+ category: userError.category,
405
+ message: userError.message,
406
+ details: userError.details,
407
+ },
408
+ });
409
+ }
410
+ await sessionStore.updateSession(sessionMeta.id, {
411
+ status: "error",
412
+ completedAt: new Date().toISOString(),
413
+ errorMessage: message,
414
+ mode,
415
+ browser: {
416
+ config: browserConfig,
417
+ runtime: recoverableRuntime,
418
+ },
419
+ response: { status: "error", incompleteReason: "chrome-disconnected" },
420
+ error: {
421
+ category: userError.category,
422
+ message: userError.message,
423
+ details: userError.details,
424
+ },
425
+ });
426
+ throw error;
427
+ }
393
428
  log(dim("Chrome disconnected before completion; keeping session running for reattach."));
394
429
  if (modelForStatus) {
395
430
  await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
@@ -576,13 +611,22 @@ function sanitizeMultiModelFailureForThrow(error, context) {
576
611
  if (!(error instanceof Error)) {
577
612
  return new Error(message);
578
613
  }
579
- error.message = message;
614
+ let sanitized;
615
+ if (error instanceof OracleTransportError) {
616
+ sanitized = new OracleTransportError(error.reason, message);
617
+ }
618
+ else if (error instanceof OracleResponseError) {
619
+ sanitized = new OracleResponseError(message, error.response);
620
+ }
621
+ else {
622
+ sanitized = new Error(message);
623
+ sanitized.name = error.name;
624
+ }
580
625
  if (error.stack) {
581
- const [firstLine, ...rest] = error.stack.split("\n");
582
- const prefix = firstLine.includes(":") ? firstLine.split(":", 1)[0] : error.name;
583
- error.stack = [prefix ? `${prefix}: ${message}` : message, ...rest].join("\n");
626
+ const [, ...rest] = error.stack.split("\n");
627
+ sanitized.stack = [sanitized.name ? `${sanitized.name}: ${message}` : message, ...rest].join("\n");
584
628
  }
585
- return error;
629
+ return sanitized;
586
630
  }
587
631
  export function deriveOutputManifestPath(basePath) {
588
632
  const ext = path.extname(basePath);
@@ -8,7 +8,14 @@ const DEFAULT_PROVIDER_HOSTS = {
8
8
  openai: "api.openai.com",
9
9
  xai: "api.x.ai",
10
10
  };
11
+ export function resolveProviderRoute(input) {
12
+ return buildResolvedProviderRoute(input);
13
+ }
11
14
  export function buildProviderRoutePlan(input) {
15
+ const { apiKey: _apiKey, baseUrl: _baseUrl, nativeProvider: _nativeProvider, openRouterFallback: _openRouterFallback, azureEndpoint: _azureEndpoint, ...plan } = buildResolvedProviderRoute(input);
16
+ return plan;
17
+ }
18
+ function buildResolvedProviderRoute(input) {
12
19
  const env = input.env ?? process.env;
13
20
  const providerMode = input.providerMode ?? "auto";
14
21
  const azureConfigured = Boolean(input.azure?.endpoint?.trim());
@@ -49,7 +56,12 @@ export function buildProviderRoutePlan(input) {
49
56
  keySource: key.source,
50
57
  keyPreview: key.preview,
51
58
  keyPresent: key.present,
59
+ apiKey: key.value,
60
+ nativeProvider: provider,
61
+ baseUrl: input.baseUrl,
62
+ openRouterFallback: false,
52
63
  isAzureOpenAI,
64
+ azureEndpoint: state?.azureEndpoint ?? input.azure?.endpoint,
53
65
  azureConfigured,
54
66
  azureDeploymentName: state?.azureDeploymentName,
55
67
  azureNote: azureNote(providerMode, azureConfigured, isAzureOpenAI),
@@ -145,7 +157,12 @@ export function buildProviderRoutePlan(input) {
145
157
  keySource: key.source,
146
158
  keyPreview: key.preview,
147
159
  keyPresent: key.present,
160
+ apiKey: key.value,
161
+ nativeProvider: provider,
162
+ baseUrl,
163
+ openRouterFallback,
148
164
  isAzureOpenAI,
165
+ azureEndpoint: state.azureEndpoint,
149
166
  azureConfigured,
150
167
  azureDeploymentName: state.azureDeploymentName,
151
168
  azureNote: azureNote(providerMode, azureConfigured, isAzureOpenAI),
@@ -166,7 +183,12 @@ function getNativeKey({ model, provider, providerMode, isAzureOpenAI, apiKey, en
166
183
  }
167
184
  function getKeyForRoute({ model, provider, providerMode, isAzureOpenAI, baseUrl, openRouterFallback, apiKey, env, }) {
168
185
  if (apiKey) {
169
- return { source: "apiKey option", preview: maskApiKey(apiKey) ?? "set", present: true };
186
+ return {
187
+ source: "apiKey option",
188
+ preview: maskApiKey(apiKey) ?? "set",
189
+ present: true,
190
+ value: apiKey,
191
+ };
170
192
  }
171
193
  if (isAzureOpenAI) {
172
194
  return readKey(["AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"], env);
@@ -201,7 +223,12 @@ function readKey(names, env) {
201
223
  for (const name of names) {
202
224
  const value = env[name]?.trim();
203
225
  if (value) {
204
- return { source: name, preview: `${name}=${maskApiKey(value) ?? "set"}`, present: true };
226
+ return {
227
+ source: name,
228
+ preview: `${name}=${maskApiKey(value) ?? "set"}`,
229
+ present: true,
230
+ value,
231
+ };
205
232
  }
206
233
  }
207
234
  return { source: names.join("|"), preview: "missing", present: false };
@@ -14,7 +14,7 @@ import { getFileTokenStats, printFileTokenStats } from "./tokenStats.js";
14
14
  import { OracleResponseError, OracleTransportError, PromptValidationError, describeTransportError, toTransportError, } from "./errors.js";
15
15
  import { isCustomBaseUrl } from "./baseUrl.js";
16
16
  import { createDefaultClientFactory } from "./client.js";
17
- import { formatBaseUrlForLog, maskApiKey } from "./logging.js";
17
+ import { maskApiKey } from "./logging.js";
18
18
  import { startHeartbeat } from "../heartbeat.js";
19
19
  import { startOscProgress } from "./oscProgress.js";
20
20
  import { createFsAdapter } from "./fsAdapter.js";
@@ -25,60 +25,49 @@ import { createMarkdownStreamer } from "markdansi";
25
25
  import { executeBackgroundResponse } from "./background.js";
26
26
  import { formatTokenEstimate, formatTokenValue, resolvePreviewMode } from "./runUtils.js";
27
27
  import { estimateUsdCost } from "tokentally";
28
- import { defaultOpenRouterBaseUrl, isOpenRouterBaseUrl, isProModel, resolveModelConfig, normalizeOpenRouterBaseUrl, } from "./modelResolver.js";
28
+ import { isOpenRouterBaseUrl, isProModel, resolveModelConfig } from "./modelResolver.js";
29
29
  import { validateProviderRouting } from "./providerRouting.js";
30
+ import { formatRouteTargetForLog, resolveProviderRoute, } from "./providerRoutePlan.js";
30
31
  const isStdoutTty = process.stdout.isTTY && chalk.level > 0;
31
32
  const dim = (text) => (isStdoutTty ? kleur.dim(text) : text);
32
33
  // Default timeout for non-pro API runs (fast models) — give them up to 120s.
33
34
  const DEFAULT_TIMEOUT_NON_PRO_MS = 120_000;
34
35
  const DEFAULT_TIMEOUT_PRO_MS = 60 * 60 * 1000;
35
- const DEFAULT_PROVIDER_HOSTS = {
36
- anthropic: "api.anthropic.com",
37
- google: "generativelanguage.googleapis.com",
38
- openai: "api.openai.com",
39
- xai: "api.x.ai",
40
- };
41
36
  const defaultWait = (ms) => new Promise((resolve) => {
42
37
  setTimeout(resolve, ms);
43
38
  });
44
- function formatRouteTargetForLog(raw, fallbackHost = "") {
45
- if (!raw)
46
- return fallbackHost;
47
- try {
48
- const parsed = new URL(raw);
49
- const segments = parsed.pathname.split("/").filter(Boolean);
50
- let path = "";
51
- if (segments.length > 0) {
52
- path = `/${segments[0]}`;
53
- if (segments.length > 1) {
54
- path += "/...";
55
- }
56
- }
57
- return `${parsed.host}${path}`;
58
- }
59
- catch {
60
- const formatted = formatBaseUrlForLog(raw).replace(/^https?:\/\//u, "");
61
- return formatted || fallbackHost;
39
+ function formatProviderRouteLogLine(route, keySource) {
40
+ if (route.isAzureOpenAI) {
41
+ return `Provider: Azure OpenAI | endpoint: ${formatRouteTargetForLog(route.azureEndpoint)} | deployment: ${route.azureDeploymentName || "none"} | key: ${keySource}`;
62
42
  }
43
+ return `Provider: ${route.providerLabel} | base: ${route.base} | key: ${keySource}`;
63
44
  }
64
- function formatProviderRouteLogLine({ provider, baseUrl, openRouterFallback, isAzureOpenAI, azureEndpoint, azureDeploymentName, envVar, }) {
65
- if (isAzureOpenAI) {
66
- return `Provider: Azure OpenAI | endpoint: ${formatRouteTargetForLog(azureEndpoint)} | deployment: ${azureDeploymentName || "none"} | key: ${envVar}`;
45
+ function runtimeKeySource({ route, providerMode, optionsApiKey, }) {
46
+ if (optionsApiKey &&
47
+ (route.isAzureOpenAI ||
48
+ providerMode === "openai" ||
49
+ route.provider === "openai" ||
50
+ route.providerLabel === "OpenAI-compatible")) {
51
+ return "apiKey option";
67
52
  }
68
- const isOpenRouter = isOpenRouterBaseUrl(baseUrl) || openRouterFallback;
69
- const routeProvider = isOpenRouter
70
- ? "OpenRouter"
71
- : baseUrl && isCustomBaseUrl(baseUrl)
72
- ? "OpenAI-compatible"
73
- : provider === "anthropic"
74
- ? "Anthropic"
75
- : provider === "google"
76
- ? "Google Gemini"
77
- : provider === "xai"
78
- ? "xAI"
79
- : "OpenAI";
80
- const fallbackHost = DEFAULT_PROVIDER_HOSTS[provider] ?? DEFAULT_PROVIDER_HOSTS.openai;
81
- return `Provider: ${routeProvider} | base: ${formatRouteTargetForLog(baseUrl, fallbackHost)} | key: ${envVar}`;
53
+ if (route.isAzureOpenAI) {
54
+ return "AZURE_OPENAI_API_KEY|OPENAI_API_KEY";
55
+ }
56
+ if (providerMode === "openai") {
57
+ return "OPENAI_API_KEY";
58
+ }
59
+ if (isOpenRouterBaseUrl(route.baseUrl) || route.openRouterFallback || route.model.includes("/")) {
60
+ return "OPENROUTER_API_KEY";
61
+ }
62
+ if (route.model.startsWith("gpt"))
63
+ return "OPENAI_API_KEY";
64
+ if (route.model.startsWith("gemini"))
65
+ return "GEMINI_API_KEY";
66
+ if (route.model.startsWith("claude"))
67
+ return "ANTHROPIC_API_KEY";
68
+ if (route.model.startsWith("grok"))
69
+ return "XAI_API_KEY";
70
+ return optionsApiKey ? "apiKey option" : route.keySource;
82
71
  }
83
72
  export async function runOracle(options, deps = {}) {
84
73
  const { apiKey: optionsApiKey = options.apiKey, cwd = process.cwd(), fs: fsModule = createFsAdapter(fs), log = console.log, write: sinkWrite = (_text) => true, allowStdout = true, stdoutWrite: stdoutWriteDep, now = () => performance.now(), clientFactory = createDefaultClientFactory(), client, wait = defaultWait, } = deps;
@@ -86,13 +75,10 @@ export async function runOracle(options, deps = {}) {
86
75
  ? (stdoutWriteDep ?? process.stdout.write.bind(process.stdout))
87
76
  : () => true;
88
77
  const isTty = allowStdout && isStdoutTty;
89
- const resolvedXaiBaseUrl = process.env.XAI_BASE_URL?.trim() || "https://api.x.ai/v1";
90
- const openRouterApiKey = process.env.OPENROUTER_API_KEY?.trim();
91
- const defaultOpenRouterBase = defaultOpenRouterBaseUrl();
92
78
  const previewMode = resolvePreviewMode(options.previewMode ?? options.preview);
93
79
  const isPreview = Boolean(previewMode);
94
80
  const providerMode = options.provider ?? "auto";
95
- const routing = validateProviderRouting({
81
+ validateProviderRouting({
96
82
  model: options.model,
97
83
  providerMode,
98
84
  azure: options.azure,
@@ -103,99 +89,23 @@ export async function runOracle(options, deps = {}) {
103
89
  }
104
90
  },
105
91
  });
106
- const { provider, isAzureOpenAI, azureEndpoint, azureDeploymentName } = routing;
107
- const hasOpenAIKey = Boolean(optionsApiKey) ||
108
- Boolean(process.env.OPENAI_API_KEY) ||
109
- Boolean(providerMode !== "openai" && process.env.AZURE_OPENAI_API_KEY && options.azure?.endpoint);
110
- const hasAnthropicKey = Boolean(optionsApiKey) || Boolean(process.env.ANTHROPIC_API_KEY);
111
- const hasGeminiKey = Boolean(optionsApiKey) || Boolean(process.env.GEMINI_API_KEY);
112
- const hasXaiKey = Boolean(optionsApiKey) || Boolean(process.env.XAI_API_KEY);
113
- let baseUrl = options.baseUrl?.trim();
114
- const providerQualifiedOpenRouterCandidate = !isAzureOpenAI && providerMode !== "openai" && options.model.includes("/");
115
- if (baseUrl &&
116
- providerQualifiedOpenRouterCandidate &&
117
- !isOpenRouterBaseUrl(baseUrl) &&
118
- !isCustomBaseUrl(baseUrl)) {
119
- baseUrl = undefined;
120
- }
121
- if (!baseUrl) {
122
- let envBaseUrl;
123
- if (options.model.startsWith("grok")) {
124
- envBaseUrl = resolvedXaiBaseUrl;
125
- }
126
- else if (provider === "anthropic") {
127
- envBaseUrl = process.env.ANTHROPIC_BASE_URL?.trim();
128
- }
129
- else {
130
- envBaseUrl = process.env.OPENAI_BASE_URL?.trim();
131
- }
132
- if (!providerQualifiedOpenRouterCandidate || (envBaseUrl && isCustomBaseUrl(envBaseUrl))) {
133
- baseUrl = envBaseUrl;
134
- }
135
- }
136
- const providerKeyMissing = !isAzureOpenAI &&
137
- (providerMode === "openai"
138
- ? !hasOpenAIKey
139
- : (provider === "openai" && !hasOpenAIKey) ||
140
- (provider === "anthropic" && !hasAnthropicKey) ||
141
- (provider === "google" && !hasGeminiKey) ||
142
- (provider === "xai" && !hasXaiKey) ||
143
- provider === "other");
144
- const providerQualifiedOpenRouterRoute = providerQualifiedOpenRouterCandidate && !baseUrl;
145
- const openRouterFallback = !baseUrl &&
146
- (providerQualifiedOpenRouterRoute ||
147
- (providerMode !== "openai" &&
148
- providerKeyMissing &&
149
- (provider === "other" || Boolean(openRouterApiKey))));
150
- if (!baseUrl || openRouterFallback) {
151
- if (openRouterFallback) {
152
- baseUrl = defaultOpenRouterBase;
153
- }
154
- }
155
- if (baseUrl && isOpenRouterBaseUrl(baseUrl)) {
156
- baseUrl = normalizeOpenRouterBaseUrl(baseUrl);
157
- }
92
+ const route = resolveProviderRoute({
93
+ model: options.model,
94
+ providerMode,
95
+ azure: options.azure,
96
+ baseUrl: options.baseUrl,
97
+ apiKey: optionsApiKey,
98
+ env: process.env,
99
+ });
100
+ const { isAzureOpenAI, azureDeploymentName } = route;
101
+ const baseUrl = route.baseUrl;
102
+ const openRouterFallback = route.openRouterFallback;
158
103
  const logVerbose = (message) => {
159
104
  if (options.verbose) {
160
105
  log(dim(`[verbose] ${message}`));
161
106
  }
162
107
  };
163
- const getApiKeyForModel = (model) => {
164
- if (isAzureOpenAI) {
165
- if (optionsApiKey)
166
- return { key: optionsApiKey, source: "apiKey option" };
167
- const key = process.env.AZURE_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY;
168
- return { key, source: "AZURE_OPENAI_API_KEY|OPENAI_API_KEY" };
169
- }
170
- if (providerMode === "openai") {
171
- if (optionsApiKey)
172
- return { key: optionsApiKey, source: "apiKey option" };
173
- return { key: process.env.OPENAI_API_KEY, source: "OPENAI_API_KEY" };
174
- }
175
- if (isOpenRouterBaseUrl(baseUrl) || openRouterFallback) {
176
- return { key: optionsApiKey ?? openRouterApiKey, source: "OPENROUTER_API_KEY" };
177
- }
178
- if (typeof model === "string" && model.startsWith("gpt")) {
179
- if (optionsApiKey)
180
- return { key: optionsApiKey, source: "apiKey option" };
181
- return { key: process.env.OPENAI_API_KEY, source: "OPENAI_API_KEY" };
182
- }
183
- if (typeof model === "string" && model.startsWith("gemini")) {
184
- return { key: optionsApiKey ?? process.env.GEMINI_API_KEY, source: "GEMINI_API_KEY" };
185
- }
186
- if (typeof model === "string" && model.startsWith("claude")) {
187
- return { key: optionsApiKey ?? process.env.ANTHROPIC_API_KEY, source: "ANTHROPIC_API_KEY" };
188
- }
189
- if (typeof model === "string" && model.startsWith("grok")) {
190
- return { key: optionsApiKey ?? process.env.XAI_API_KEY, source: "XAI_API_KEY" };
191
- }
192
- return {
193
- key: optionsApiKey ?? openRouterApiKey,
194
- source: optionsApiKey ? "apiKey option" : "OPENROUTER_API_KEY",
195
- };
196
- };
197
- const apiKeyResult = getApiKeyForModel(options.model);
198
- const apiKey = apiKeyResult.key;
108
+ const apiKey = route.apiKey;
199
109
  if (!apiKey) {
200
110
  const envVar = isAzureOpenAI
201
111
  ? "AZURE_OPENAI_API_KEY (or OPENAI_API_KEY)"
@@ -203,15 +113,7 @@ export async function runOracle(options, deps = {}) {
203
113
  ? "OPENAI_API_KEY"
204
114
  : isOpenRouterBaseUrl(baseUrl) || openRouterFallback
205
115
  ? "OPENROUTER_API_KEY"
206
- : options.model.startsWith("gpt")
207
- ? "OPENAI_API_KEY"
208
- : options.model.startsWith("gemini")
209
- ? "GEMINI_API_KEY"
210
- : options.model.startsWith("claude")
211
- ? "ANTHROPIC_API_KEY"
212
- : options.model.startsWith("grok")
213
- ? "XAI_API_KEY"
214
- : "OPENROUTER_API_KEY";
116
+ : route.keySource;
215
117
  const browserModeHint = options.model.startsWith("gpt")
216
118
  ? ' If you have a ChatGPT Pro subscription, retry with --engine browser (or MCP engine:"browser" / preset:"chatgpt-pro-heavy"); browser mode uses your signed-in ChatGPT session instead of an API key.'
217
119
  : "";
@@ -219,7 +121,7 @@ export async function runOracle(options, deps = {}) {
219
121
  env: envVar,
220
122
  });
221
123
  }
222
- const envVar = apiKeyResult.source;
124
+ const envVar = runtimeKeySource({ route, providerMode, optionsApiKey });
223
125
  const minPromptLength = Number.parseInt(process.env.ORACLE_MIN_PROMPT_CHARS ?? "10", 10);
224
126
  const promptLength = options.prompt?.trim().length ?? 0;
225
127
  // Enforce the short-prompt guardrail on pro-tier models because they're costly; cheaper models can run short prompts without blocking.
@@ -227,7 +129,7 @@ export async function runOracle(options, deps = {}) {
227
129
  if (isProTierModel && !Number.isNaN(minPromptLength) && promptLength < minPromptLength) {
228
130
  throw new PromptValidationError(`Prompt is too short (<${minPromptLength} chars). This was likely accidental; please provide more detail.`, { minPromptLength, promptLength });
229
131
  }
230
- const resolverOpenRouterApiKey = openRouterFallback || isOpenRouterBaseUrl(baseUrl) ? (openRouterApiKey ?? apiKey) : undefined;
132
+ const resolverOpenRouterApiKey = openRouterFallback || isOpenRouterBaseUrl(baseUrl) ? apiKey : undefined;
231
133
  const modelConfig = await resolveModelConfig(options.model, {
232
134
  baseUrl,
233
135
  openRouterApiKey: resolverOpenRouterApiKey,
@@ -326,15 +228,7 @@ export async function runOracle(options, deps = {}) {
326
228
  if (!isPreview) {
327
229
  if (!options.suppressHeader) {
328
230
  log(headerLine);
329
- log(dim(formatProviderRouteLogLine({
330
- provider,
331
- baseUrl,
332
- openRouterFallback,
333
- isAzureOpenAI,
334
- azureEndpoint,
335
- azureDeploymentName,
336
- envVar,
337
- })));
231
+ log(dim(formatProviderRouteLogLine(route, envVar)));
338
232
  }
339
233
  const maskedKey = maskApiKey(apiKey);
340
234
  if (maskedKey && options.verbose) {
@@ -409,7 +303,7 @@ export async function runOracle(options, deps = {}) {
409
303
  : proxyCompatibleBaseUrl
410
304
  ? proxyCompatibleBaseUrl
411
305
  : modelConfig.model.startsWith("claude")
412
- ? (process.env.ANTHROPIC_BASE_URL ?? baseUrl)
306
+ ? baseUrl
413
307
  : baseUrl;
414
308
  const clientInstance = client ??
415
309
  clientFactory(apiKey, {
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs/promises";
3
- import { createWriteStream } from "node:fs";
3
+ import { createWriteStream, mkdirSync } from "node:fs";
4
4
  import net from "node:net";
5
5
  import { DEFAULT_MODEL } from "./oracle/config.js";
6
6
  import { formatElapsed } from "./oracle/format.js";
@@ -86,14 +86,26 @@ async function fileExists(targetPath) {
86
86
  return false;
87
87
  }
88
88
  }
89
- async function ensureUniqueSessionId(baseSlug) {
89
+ function isFileExistsError(error) {
90
+ return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
91
+ }
92
+ async function reserveUniqueSessionDir(baseSlug) {
90
93
  let candidate = baseSlug;
91
94
  let suffix = 2;
92
- while (await fileExists(sessionDir(candidate))) {
95
+ for (;;) {
96
+ const dir = sessionDir(candidate);
97
+ try {
98
+ await fs.mkdir(dir, { recursive: false });
99
+ return candidate;
100
+ }
101
+ catch (error) {
102
+ if (!isFileExistsError(error)) {
103
+ throw error;
104
+ }
105
+ }
93
106
  candidate = `${baseSlug}-${suffix}`;
94
107
  suffix += 1;
95
108
  }
96
- return candidate;
97
109
  }
98
110
  async function listModelRunFiles(sessionId) {
99
111
  const dir = modelsDir(sessionId);
@@ -153,9 +165,7 @@ export async function readModelRunMetadata(sessionId, model) {
153
165
  export async function initializeSession(options, cwd, notifications, baseSlugOverride) {
154
166
  await ensureSessionStorage();
155
167
  const baseSlug = baseSlugOverride || createSessionId(options.prompt || DEFAULT_SLUG, options.slug);
156
- const sessionId = await ensureUniqueSessionId(baseSlug);
157
- const dir = sessionDir(sessionId);
158
- await ensureDir(dir);
168
+ const sessionId = await reserveUniqueSessionDir(baseSlug);
159
169
  const mode = options.mode ?? "api";
160
170
  const browserConfig = options.browserConfig;
161
171
  const modelList = Array.isArray(options.models) && options.models.length > 0
@@ -239,25 +249,25 @@ export async function initializeSession(options, cwd, notifications, baseSlugOve
239
249
  return metadata;
240
250
  }
241
251
  export async function readSessionMetadata(sessionId) {
242
- const modern = await readModernSessionMetadata(sessionId);
252
+ const modern = await readModernSessionMetadata(sessionId, { reconcile: true, persist: false });
243
253
  if (modern) {
244
254
  return modern;
245
255
  }
246
- const legacy = await readLegacySessionMetadata(sessionId);
256
+ const legacy = await readLegacySessionMetadata(sessionId, { reconcile: true, persist: false });
247
257
  if (legacy) {
248
258
  return legacy;
249
259
  }
250
260
  return null;
251
261
  }
252
262
  export async function updateSessionMetadata(sessionId, updates) {
253
- const existing = (await readModernSessionMetadata(sessionId)) ??
254
- (await readLegacySessionMetadata(sessionId)) ??
263
+ const existing = (await readModernSessionMetadata(sessionId, { reconcile: false, persist: false })) ??
264
+ (await readLegacySessionMetadata(sessionId, { reconcile: false, persist: false })) ??
255
265
  { id: sessionId };
256
266
  const next = { ...existing, ...updates };
257
267
  await fs.writeFile(metaPath(sessionId), JSON.stringify(next, null, 2), "utf8");
258
268
  return next;
259
269
  }
260
- async function readModernSessionMetadata(sessionId) {
270
+ async function readModernSessionMetadata(sessionId, options) {
261
271
  try {
262
272
  const raw = await fs.readFile(metaPath(sessionId), "utf8");
263
273
  const parsed = JSON.parse(raw);
@@ -265,25 +275,31 @@ async function readModernSessionMetadata(sessionId) {
265
275
  return null;
266
276
  }
267
277
  const enriched = await attachModelRuns(parsed, sessionId);
268
- const runtimeChecked = await markDeadBrowser(enriched, { persist: false });
269
- return await markZombie(runtimeChecked, { persist: false });
278
+ return options.reconcile ? reconcileSessionMetadata(enriched, options) : enriched;
270
279
  }
271
280
  catch {
272
281
  return null;
273
282
  }
274
283
  }
275
- async function readLegacySessionMetadata(sessionId) {
284
+ async function readLegacySessionMetadata(sessionId, options) {
276
285
  try {
277
286
  const raw = await fs.readFile(legacySessionPath(sessionId), "utf8");
278
287
  const parsed = JSON.parse(raw);
279
288
  const enriched = await attachModelRuns(parsed, sessionId);
280
- const runtimeChecked = await markDeadBrowser(enriched, { persist: false });
281
- return await markZombie(runtimeChecked, { persist: false });
289
+ return options.reconcile ? reconcileSessionMetadata(enriched, options) : enriched;
282
290
  }
283
291
  catch {
284
292
  return null;
285
293
  }
286
294
  }
295
+ async function readRawSessionMetadata(sessionId) {
296
+ return ((await readModernSessionMetadata(sessionId, { reconcile: false, persist: false })) ??
297
+ (await readLegacySessionMetadata(sessionId, { reconcile: false, persist: false })));
298
+ }
299
+ async function reconcileSessionMetadata(meta, { persist }) {
300
+ const runtimeChecked = await markDeadBrowser(meta, { persist });
301
+ return await markZombie(runtimeChecked, { persist });
302
+ }
287
303
  function isSessionMetadataRecord(value) {
288
304
  return Boolean(value && typeof value.id === "string" && value.status);
289
305
  }
@@ -297,7 +313,7 @@ async function attachModelRuns(meta, sessionId) {
297
313
  export function createSessionLogWriter(sessionId, model) {
298
314
  const targetPath = model ? modelLogPath(sessionId, model) : logPath(sessionId);
299
315
  if (model) {
300
- void ensureDir(modelsDir(sessionId));
316
+ mkdirSync(modelsDir(sessionId), { recursive: true });
301
317
  }
302
318
  const stream = createWriteStream(targetPath, { flags: "a" });
303
319
  const logLine = (line = "") => {
@@ -314,10 +330,10 @@ export async function listSessionsMetadata() {
314
330
  const entries = await fs.readdir(getSessionsDir()).catch(() => []);
315
331
  const metas = [];
316
332
  for (const entry of entries) {
317
- let meta = await readSessionMetadata(entry);
333
+ let meta = await readRawSessionMetadata(entry);
318
334
  if (meta) {
319
- meta = await markDeadBrowser(meta, { persist: true });
320
- meta = await markZombie(meta, { persist: true }); // keep stored metadata consistent with zombie detection
335
+ // Keep stored metadata consistent with status reconciliation done by `oracle status`.
336
+ meta = await reconcileSessionMetadata(meta, { persist: true });
321
337
  metas.push(meta);
322
338
  }
323
339
  }
@@ -388,7 +404,7 @@ export async function readModelLog(sessionId, model) {
388
404
  }
389
405
  }
390
406
  export async function readSessionRequest(sessionId) {
391
- const modern = await readModernSessionMetadata(sessionId);
407
+ const modern = await readModernSessionMetadata(sessionId, { reconcile: false, persist: false });
392
408
  if (modern?.options) {
393
409
  return modern.options;
394
410
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.5 Pro, GPT-5.5, GPT-5.4, GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/steipete/oracle#readme",
@@ -46,7 +46,7 @@
46
46
  "test": "vitest run",
47
47
  "test:mcp": "pnpm run build && pnpm run test:mcp:unit && pnpm run test:mcp:mcporter",
48
48
  "test:mcp:unit": "vitest run tests/mcp*.test.ts tests/mcp/**/*.test.ts",
49
- "test:mcp:mcporter": "pnpm dlx mcporter list oracle-local --schema --config config/mcporter.json && pnpm dlx mcporter call oracle-local.sessions limit:1 --config config/mcporter.json",
49
+ "test:mcp:mcporter": "CHROME_DEVTOOLS_URL=http://127.0.0.1:0 pnpm dlx mcporter list oracle-local --schema --config config/mcporter.json && CHROME_DEVTOOLS_URL=http://127.0.0.1:0 pnpm dlx mcporter call oracle-local.sessions limit:1 --config config/mcporter.json",
50
50
  "test:browser": "pnpm run build && tsx scripts/test-browser.ts && ./scripts/browser-smoke.sh",
51
51
  "test:packed-cli": "node scripts/packed-cli-smoke.mjs",
52
52
  "test:live": "ORACLE_LIVE_TEST=1 vitest run tests/live --exclude tests/live/openai-live.test.ts",
@@ -61,7 +61,7 @@
61
61
  "@google/genai": "^2.0.1",
62
62
  "@google/generative-ai": "^0.24.1",
63
63
  "@modelcontextprotocol/sdk": "^1.29.0",
64
- "@steipete/sweet-cookie": "^0.2.0",
64
+ "@steipete/sweet-cookie": "^0.3.0",
65
65
  "chalk": "^5.6.2",
66
66
  "chrome-launcher": "^1.2.1",
67
67
  "chrome-remote-interface": "^0.34.0",
@@ -70,11 +70,11 @@
70
70
  "dotenv": "^17.4.2",
71
71
  "fast-glob": "^3.3.3",
72
72
  "gpt-tokenizer": "^3.4.0",
73
- "inquirer": "13.4.2",
73
+ "inquirer": "13.4.3",
74
74
  "json5": "^2.2.3",
75
75
  "kleur": "^4.1.5",
76
76
  "markdansi": "0.2.1",
77
- "openai": "^6.35.0",
77
+ "openai": "^6.38.0",
78
78
  "osc-progress": "^0.3.0",
79
79
  "qs": "^6.15.1",
80
80
  "shiki": "^4.0.2",
@@ -84,17 +84,17 @@
84
84
  },
85
85
  "devDependencies": {
86
86
  "@anthropic-ai/tokenizer": "^0.0.4",
87
- "@types/chrome-remote-interface": "^0.33.0",
87
+ "@types/chrome-remote-interface": "^0.34.0",
88
88
  "@types/inquirer": "^9.0.9",
89
89
  "@types/node": "^25.6.0",
90
- "@typescript/native-preview": "7.0.0-dev.20260508.1",
91
- "@vitest/coverage-v8": "4.1.5",
92
- "devtools-protocol": "0.0.1627472",
90
+ "@typescript/native-preview": "7.0.0-dev.20260516.1",
91
+ "@vitest/coverage-v8": "4.1.6",
92
+ "devtools-protocol": "0.0.1629771",
93
93
  "es-toolkit": "^1.46.1",
94
94
  "esbuild": "^0.28.0",
95
- "oxfmt": "0.48.0",
95
+ "oxfmt": "0.50.0",
96
96
  "oxlint": "^1.62.0",
97
- "puppeteer-core": "^24.42.0",
97
+ "puppeteer-core": "^25.0.2",
98
98
  "tsx": "^4.21.0",
99
99
  "typescript": "^6.0.3",
100
100
  "vitest": "^4.1.5"
@@ -113,7 +113,8 @@
113
113
  "packageManager": "pnpm@10.33.2",
114
114
  "pnpm": {
115
115
  "overrides": {
116
- "devtools-protocol": "0.0.1627472"
116
+ "devtools-protocol": "0.0.1629771",
117
+ "vite": "7.3.2"
117
118
  },
118
119
  "onlyBuiltDependencies": [
119
120
  "esbuild"