@steipete/oracle 0.15.0 → 0.15.2

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.
Files changed (46) hide show
  1. package/dist/bin/oracle-cli.js +14 -6
  2. package/dist/docs-site/bridge.html +17 -1
  3. package/dist/docs-site/browser-mode.html +2 -2
  4. package/dist/docs-site/configuration.html +12 -2
  5. package/dist/docs-site/openai-endpoints.html +12 -0
  6. package/dist/scripts/test-browser.js +13 -2
  7. package/dist/src/browser/actions/assistantResponse.js +81 -50
  8. package/dist/src/browser/actions/attachments.js +31 -5
  9. package/dist/src/browser/actions/deepResearch.js +218 -73
  10. package/dist/src/browser/actions/modelSelection.js +30 -7
  11. package/dist/src/browser/actions/promptComposer.js +75 -19
  12. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  13. package/dist/src/browser/artifacts.js +191 -6
  14. package/dist/src/browser/chatgptFiles.js +529 -98
  15. package/dist/src/browser/chatgptImages.js +3 -4
  16. package/dist/src/browser/chromeLifecycle.js +1 -0
  17. package/dist/src/browser/constants.js +6 -0
  18. package/dist/src/browser/conversationTurns.js +16 -0
  19. package/dist/src/browser/conversationUrlMonitor.js +64 -0
  20. package/dist/src/browser/cookies.js +72 -0
  21. package/dist/src/browser/index.js +103 -94
  22. package/dist/src/browser/projectSourcesRunner.js +3 -2
  23. package/dist/src/browser/reattach.js +27 -11
  24. package/dist/src/browser/reattachHelpers.js +14 -5
  25. package/dist/src/browser/sessionRunner.js +9 -3
  26. package/dist/src/cli/bridge/client.js +4 -1
  27. package/dist/src/cli/bridge/doctor.js +19 -0
  28. package/dist/src/cli/runOptions.js +11 -2
  29. package/dist/src/cli/sessionDisplay.js +6 -1
  30. package/dist/src/cli/sessionRunner.js +28 -10
  31. package/dist/src/config.js +3 -0
  32. package/dist/src/oracle/client.js +2 -0
  33. package/dist/src/oracle/modelResolver.js +85 -0
  34. package/dist/src/oracle/multiModelRunner.js +4 -1
  35. package/dist/src/oracle/oscProgress.js +3 -2
  36. package/dist/src/oracle/run.js +4 -1
  37. package/dist/src/remote/client.js +253 -22
  38. package/dist/src/remote/health.js +27 -0
  39. package/dist/src/remote/server.js +239 -4
  40. package/dist/src/remote/types.js +1 -1
  41. package/dist/src/sessionManager.js +1 -0
  42. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  43. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  44. package/package.json +20 -20
  45. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  46. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -5,8 +5,9 @@ import { mkdtemp, mkdir, rm } from "node:fs/promises";
5
5
  import { waitForAssistantResponse, captureAssistantMarkdown, navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, } from "./pageActions.js";
6
6
  import { launchChrome, connectToChrome, hideChromeWindow, connectToRemoteChromeTarget, listRemoteChromeTargets, } from "./chromeLifecycle.js";
7
7
  import { resolveBrowserConfig } from "./config.js";
8
- import { syncCookies } from "./cookies.js";
9
- import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR } from "./constants.js";
8
+ import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
9
+ import { CHATGPT_URL } from "./constants.js";
10
+ import { buildConversationTurnListExpression } from "./conversationTurns.js";
10
11
  import { cleanupStaleProfileState } from "./profileState.js";
11
12
  import { readDevToolsActivePortInfo } from "./detect.js";
12
13
  import { pickTarget, extractConversationIdFromUrl, buildConversationUrl, withTimeout, openConversationFromSidebar, openConversationFromSidebarWithRetry, waitForLocationChange, readConversationTurnIndex, buildPromptEchoMatcher, recoverPromptEcho, alignPromptEchoMarkdown, } from "./reattachHelpers.js";
@@ -14,6 +15,12 @@ import { waitForDeepResearchCompletion } from "./actions/deepResearch.js";
14
15
  export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
15
16
  const recoverSession = deps.recoverSession ??
16
17
  (async (runtimeMeta, configMeta) => resumeBrowserSessionViaNewChrome(runtimeMeta, configMeta, logger, deps));
18
+ let closeAttachedConnection = null;
19
+ const closeAttached = async () => {
20
+ const close = closeAttachedConnection;
21
+ closeAttachedConnection = null;
22
+ await close?.().catch(() => undefined);
23
+ };
17
24
  if (!runtime.chromePort && !runtime.chromeBrowserWSEndpoint) {
18
25
  logger("No running Chrome detected; reopening browser to locate the session.");
19
26
  return recoverSession(runtime, config);
@@ -37,8 +44,8 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
37
44
  targetId: target?.targetId ?? target?.id,
38
45
  closeTargetOnDispose: false,
39
46
  })
40
- : {
41
- client: (await (deps.connect ?? ((options) => CDP(options)))(browserWSEndpoint
47
+ : await (async () => {
48
+ const client = (await (deps.connect ?? ((options) => CDP(options)))(browserWSEndpoint
42
49
  ? {
43
50
  target: browserWSEndpoint,
44
51
  local: true,
@@ -48,9 +55,10 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
48
55
  host,
49
56
  port,
50
57
  target: target?.targetId ?? target?.id,
51
- })),
52
- close: async () => undefined,
53
- };
58
+ }));
59
+ return { client, close: () => client.close() };
60
+ })();
61
+ closeAttachedConnection = () => connection.close();
54
62
  const client = connection.client;
55
63
  const { Runtime, DOM, Page } = client;
56
64
  if (Runtime?.enable) {
@@ -97,7 +105,7 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
97
105
  const researchResult = await withTimeout(waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client, {
98
106
  requireScopedTargetOwner: true,
99
107
  }), timeoutMs + 5_000, "Reattach Deep Research response timed out");
100
- await connection.close().catch(() => undefined);
108
+ await closeAttached();
101
109
  return {
102
110
  answerText: researchResult.text,
103
111
  answerMarkdown: researchResult.text,
@@ -108,10 +116,11 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
108
116
  const recovered = await recoverPromptEcho(Runtime, answer, promptEcho, logger, minTurnIndex, timeoutMs);
109
117
  const markdown = (await withTimeout(captureMarkdown(Runtime, recovered.meta, logger), 15_000, "Reattach markdown capture timed out")) ?? recovered.text;
110
118
  const aligned = alignPromptEchoMarkdown(recovered.text, markdown, promptEcho, logger);
111
- await connection.close().catch(() => undefined);
119
+ await closeAttached();
112
120
  return { answerText: aligned.answerText, answerMarkdown: aligned.answerMarkdown };
113
121
  }
114
122
  catch (error) {
123
+ await closeAttached();
115
124
  const message = error instanceof Error ? error.message : String(error);
116
125
  logger(`Existing Chrome reattach failed (${message}); reopening browser to locate the session.`);
117
126
  return recoverSession(runtime, config);
@@ -163,7 +172,7 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
163
172
  const chrome = await launchChrome(resolved, userDataDir, logger);
164
173
  const chromeHost = chrome.host ?? "127.0.0.1";
165
174
  const client = await connectToChrome(chrome.port, logger, chromeHost);
166
- const { Network, Page, Runtime, DOM } = client;
175
+ const { Network, Page, Runtime, DOM, Target } = client;
167
176
  if (Runtime?.enable) {
168
177
  await Runtime.enable();
169
178
  }
@@ -183,6 +192,13 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
183
192
  waitMs: resolved.cookieSyncWaitMs ?? 0,
184
193
  });
185
194
  }
195
+ await clearStaleChatGptConversationCookies(Network, Target, logger, {
196
+ preserveConversationIds: [
197
+ runtime.conversationId,
198
+ extractConversationIdFromUrl(runtime.tabUrl ?? ""),
199
+ extractConversationIdFromUrl(resolved.url),
200
+ ],
201
+ });
186
202
  await navigateToChatGPT(Page, Runtime, CHATGPT_URL, logger);
187
203
  await ensureNotBlocked(Runtime, resolved.headless, logger);
188
204
  await ensureLoggedIn(Runtime, logger, { appliedCookies });
@@ -268,7 +284,7 @@ async function readPromptPreviewTurnIndex(Runtime, promptPreview) {
268
284
  const needle = ${JSON.stringify(preview.toLowerCase().replace(/\s+/g, " ").slice(0, 120))};
269
285
  if (!needle) return null;
270
286
  const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
271
- const turns = Array.from(document.querySelectorAll(${JSON.stringify(CONVERSATION_TURN_SELECTOR)}));
287
+ const turns = ${buildConversationTurnListExpression()};
272
288
  let matched = null;
273
289
  for (const [index, node] of turns.entries()) {
274
290
  const attr = (node.getAttribute('data-message-author-role') || node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
@@ -1,15 +1,25 @@
1
1
  import { CONVERSATION_TURN_SELECTOR } from "./constants.js";
2
+ import { buildConversationTurnCountExpression } from "./conversationTurns.js";
2
3
  import { delay } from "./utils.js";
3
4
  import { readAssistantSnapshot } from "./pageActions.js";
4
5
  export function pickTarget(targets, runtime) {
5
6
  if (!Array.isArray(targets) || targets.length === 0) {
6
7
  return undefined;
7
8
  }
8
- if (runtime.chromeTargetId) {
9
- const byId = targets.find((t) => (t.targetId ?? t.id) === runtime.chromeTargetId);
10
- if (byId)
9
+ const conversationId = runtime.conversationId ?? extractConversationIdFromUrl(runtime.tabUrl ?? "");
10
+ const byId = runtime.chromeTargetId
11
+ ? targets.find((target) => (target.targetId ?? target.id) === runtime.chromeTargetId)
12
+ : undefined;
13
+ if (conversationId) {
14
+ if (byId && extractConversationIdFromUrl(byId.url ?? "") === conversationId) {
11
15
  return byId;
16
+ }
17
+ const byConversation = targets.find((target) => extractConversationIdFromUrl(target.url ?? "") === conversationId);
18
+ if (byConversation)
19
+ return byConversation;
12
20
  }
21
+ if (byId)
22
+ return byId;
13
23
  if (runtime.tabUrl) {
14
24
  const byUrl = targets.find((t) => t.url?.startsWith(runtime.tabUrl)) ||
15
25
  targets.find((t) => runtime.tabUrl.startsWith(t.url || ""));
@@ -253,10 +263,9 @@ export async function waitForLocationChange(Runtime, timeoutMs) {
253
263
  }
254
264
  }
255
265
  export async function readConversationTurnIndex(Runtime, logger) {
256
- const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
257
266
  try {
258
267
  const { result } = await Runtime.evaluate({
259
- expression: `document.querySelectorAll(${selectorLiteral}).length`,
268
+ expression: buildConversationTurnCountExpression(),
260
269
  returnByValue: true,
261
270
  });
262
271
  const raw = typeof result?.value === "number" ? result.value : Number(result?.value);
@@ -127,11 +127,17 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
127
127
  generateImagePath: runOptions.generateImage,
128
128
  outputPath: runOptions.outputPath,
129
129
  followUpPrompts: runOptions.browserFollowUps,
130
- runtimeHintCb: async (runtime) => {
131
- await persistRuntimeHint({
130
+ runtimeHintCb: async (runtime, modelSelection) => {
131
+ const runtimeWithController = {
132
132
  ...runtime,
133
133
  controllerPid: runtime.controllerPid ?? process.pid,
134
- });
134
+ };
135
+ if (modelSelection) {
136
+ await persistRuntimeHint(runtimeWithController, modelSelection);
137
+ }
138
+ else {
139
+ await persistRuntimeHint(runtimeWithController);
140
+ }
135
141
  },
136
142
  });
137
143
  }
@@ -21,7 +21,10 @@ export async function runBridgeClient(options) {
21
21
  const suffix = health.statusCode ? ` (HTTP ${health.statusCode})` : "";
22
22
  throw new Error(`Remote service health check failed: ${health.error ?? "unknown error"}${suffix}`);
23
23
  }
24
- console.log(chalk.green(`Remote service OK (${remoteHost})${health.version ? ` — oracle ${health.version}` : ""}`));
24
+ const artifactTransfer = health.capabilities?.artifactTransfer
25
+ ? ` — artifacts bridge v${health.capabilities.artifactProtocolVersion}`
26
+ : " — artifact transfer unavailable; file downloads require manual copy";
27
+ console.log(chalk.green(`Remote service OK (${remoteHost})${health.version ? ` — oracle ${health.version}` : ""}${artifactTransfer}`));
25
28
  }
26
29
  const configFilePath = options.config?.trim() || defaultConfigPath();
27
30
  if (options.writeConfig !== false) {
@@ -60,6 +60,13 @@ export async function runBridgeDoctor(_options) {
60
60
  if (health.ok) {
61
61
  const meta = health.version ? `oracle ${health.version}` : "ok";
62
62
  lines.push(chalk.dim(`Auth (/health): ${chalk.green(meta)}`));
63
+ if (health.capabilities?.artifactTransfer) {
64
+ lines.push(chalk.dim(`Artifact transfer: ${chalk.green(`bridge v${health.capabilities.artifactProtocolVersion}`)} (${formatBytes(health.capabilities.maxArtifactBytes)} max)`));
65
+ }
66
+ else {
67
+ warn.push("Remote host does not advertise bridge artifact transfer; ChatGPT-generated files may need manual copy from the browser host.");
68
+ lines.push(chalk.dim(`Artifact transfer: ${chalk.yellow("manual fallback")}`));
69
+ }
63
70
  }
64
71
  else {
65
72
  const detail = health.error ?? "unknown error";
@@ -118,3 +125,15 @@ export async function runBridgeDoctor(_options) {
118
125
  console.log(lines.join("\n"));
119
126
  process.exitCode = fail.length ? 1 : 0;
120
127
  }
128
+ function formatBytes(bytes) {
129
+ if (!Number.isFinite(bytes) || bytes <= 0)
130
+ return "unknown";
131
+ const units = ["B", "KB", "MB", "GB"];
132
+ let value = bytes;
133
+ let unit = 0;
134
+ while (value >= 1024 && unit < units.length - 1) {
135
+ value /= 1024;
136
+ unit += 1;
137
+ }
138
+ return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
139
+ }
@@ -2,6 +2,7 @@ import { DEFAULT_MODEL, MODEL_CONFIGS } from "../oracle.js";
2
2
  import { resolveEngine } from "./engine.js";
3
3
  import { normalizeModelOption, inferModelFromLabel, resolveApiModel, normalizeBaseUrl, } from "./options.js";
4
4
  import { resolveGeminiModelId } from "../oracle/gemini.js";
5
+ import { resolveOverriddenApiModel } from "../oracle/modelResolver.js";
5
6
  import { PromptValidationError } from "../oracle/errors.js";
6
7
  import { normalizeChatGptModelForBrowser } from "./browserConfig.js";
7
8
  import { resolveConfiguredMaxFileSizeBytes } from "./fileSize.js";
@@ -62,7 +63,8 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
62
63
  // Silent coerce; multi-model still forces API.
63
64
  }
64
65
  const chosenModel = uniqueMultiModels[0] ?? resolvedModel;
65
- const effectiveModelId = resolveEffectiveModelId(chosenModel);
66
+ const apiModelOverrides = fixedEngine === "api" ? userConfig?.modelOverrides : undefined;
67
+ const effectiveModelId = resolveEffectiveModelId(chosenModel, apiModelOverrides);
66
68
  const runOptions = {
67
69
  prompt: promptWithSuffix,
68
70
  model: chosenModel,
@@ -76,6 +78,7 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
76
78
  baseUrl,
77
79
  azure,
78
80
  effectiveModelId,
81
+ modelOverrides: apiModelOverrides,
79
82
  };
80
83
  return { runOptions, resolvedEngine: fixedEngine, engineCoercedToApi };
81
84
  }
@@ -90,7 +93,13 @@ function resolveAzureOptions(userConfig, env) {
90
93
  apiVersion: env.AZURE_OPENAI_API_VERSION ?? userConfig?.azure?.apiVersion,
91
94
  };
92
95
  }
93
- function resolveEffectiveModelId(model) {
96
+ function resolveEffectiveModelId(model, modelOverrides) {
97
+ // A user-config override of a known model's apiModel must win, since this id
98
+ // becomes the on-wire request model id in run.ts (including for Gemini aliases).
99
+ const overridden = resolveOverriddenApiModel(model, modelOverrides);
100
+ if (overridden) {
101
+ return overridden;
102
+ }
94
103
  if (typeof model === "string" && model.startsWith("gemini")) {
95
104
  return resolveGeminiModelId(model);
96
105
  }
@@ -306,7 +306,12 @@ export async function attachSession(sessionId, options) {
306
306
  for (const artifact of metadata.artifacts) {
307
307
  const label = artifact.label ?? artifact.kind;
308
308
  const size = artifact.sizeBytes ? ` (${formatBytes(artifact.sizeBytes)})` : "";
309
- console.log(`- ${chalk.cyan(label)} ${artifact.path}${size}`);
309
+ const checksum = artifact.sha256 ? ` sha256=${artifact.sha256.slice(0, 12)}…` : "";
310
+ const validation = artifact.validation
311
+ ? ` validation=${artifact.validation.ok ? "ok" : (artifact.validation.error ?? "failed")}`
312
+ : "";
313
+ const transfer = artifact.transfer?.status ? ` transfer=${artifact.transfer.status}` : "";
314
+ console.log(`- ${chalk.cyan(label)} — ${artifact.path}${size}${checksum}${validation}${transfer}`);
310
315
  }
311
316
  }
312
317
  const responseSummary = formatResponseMetadata(metadata.response);
@@ -33,6 +33,9 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
33
33
  write(chunk);
34
34
  return muteStdout ? true : process.stdout.write(chunk);
35
35
  };
36
+ let currentBrowser = browserConfig
37
+ ? { config: browserConfig }
38
+ : sessionMeta.browser;
36
39
  await sessionStore.updateSession(sessionMeta.id, {
37
40
  status: "running",
38
41
  startedAt: new Date().toISOString(),
@@ -54,11 +57,19 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
54
57
  }
55
58
  const runnerDeps = {
56
59
  ...browserDeps,
57
- persistRuntimeHint: async (runtime) => {
60
+ persistRuntimeHint: async (runtime, modelSelection) => {
61
+ const browser = {
62
+ config: browserConfig,
63
+ runtime,
64
+ ...(modelSelection ? { modelSelection } : {}),
65
+ };
58
66
  await sessionStore.updateSession(sessionMeta.id, {
59
67
  status: "running",
60
- browser: { config: browserConfig, runtime },
68
+ browser,
61
69
  });
70
+ // Keep this attempt's copy fresh so error paths fall back to the
71
+ // latest persisted browser evidence instead of stale session input.
72
+ currentBrowser = browser;
62
73
  },
63
74
  };
64
75
  const result = await runBrowserSessionExecution({
@@ -112,6 +123,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
112
123
  const modelConfig = await resolveModelConfig(primaryModel, {
113
124
  baseUrl: runOptions.baseUrl,
114
125
  openRouterApiKey: process.env.OPENROUTER_API_KEY,
126
+ modelOverrides: runOptions.modelOverrides,
115
127
  });
116
128
  const files = await readFiles(runOptions.file ?? [], {
117
129
  cwd,
@@ -403,7 +415,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
403
415
  if (connectionLost && mode === "browser" && browserCanReattach) {
404
416
  const runtime = userError.details
405
417
  ?.runtime;
406
- const recoverableRuntime = runtime ?? sessionMeta.browser?.runtime;
418
+ const recoverableRuntime = runtime ?? currentBrowser?.runtime;
407
419
  if (!hasRecoverableChatGptConversation(recoverableRuntime) &&
408
420
  recoverableRuntime?.promptSubmitted !== true) {
409
421
  log(dim("Chrome disconnected before a ChatGPT conversation was created; marking session error."));
@@ -425,6 +437,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
425
437
  errorMessage: message,
426
438
  mode,
427
439
  browser: {
440
+ ...currentBrowser,
428
441
  config: browserConfig,
429
442
  runtime: recoverableRuntime,
430
443
  },
@@ -449,8 +462,9 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
449
462
  errorMessage: message,
450
463
  mode,
451
464
  browser: {
465
+ ...currentBrowser,
452
466
  config: browserConfig,
453
- runtime: runtime ?? sessionMeta.browser?.runtime,
467
+ runtime: runtime ?? currentBrowser?.runtime,
454
468
  },
455
469
  response: { status: "running", incompleteReason: "chrome-disconnected" },
456
470
  });
@@ -479,8 +493,9 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
479
493
  errorMessage: message,
480
494
  mode,
481
495
  browser: {
496
+ ...currentBrowser,
482
497
  config: browserConfig,
483
- runtime: runtime ?? sessionMeta.browser?.runtime,
498
+ runtime: runtime ?? currentBrowser?.runtime,
484
499
  },
485
500
  response: { status: "incomplete", incompleteReason: "incomplete-capture" },
486
501
  error: {
@@ -491,11 +506,12 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
491
506
  });
492
507
  const autoReattachIntervalMs = browserConfig?.autoReattachIntervalMs ?? 0;
493
508
  if (autoReattachIntervalMs > 0) {
494
- const autoRuntime = runtime ?? sessionMeta.browser?.runtime;
509
+ const autoRuntime = runtime ?? currentBrowser?.runtime;
495
510
  const success = await autoReattachUntilComplete({
496
511
  sessionMeta,
497
512
  runtime: autoRuntime ?? undefined,
498
513
  browserConfig,
514
+ browserMetadata: currentBrowser,
499
515
  runOptions,
500
516
  modelForStatus,
501
517
  notificationSettings,
@@ -505,7 +521,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
505
521
  return;
506
522
  }
507
523
  }
508
- logBrowserReattachGuidance(runtime ?? sessionMeta.browser?.runtime);
524
+ logBrowserReattachGuidance(runtime ?? currentBrowser?.runtime);
509
525
  return;
510
526
  }
511
527
  if (cloudflareChallenge && mode === "browser") {
@@ -537,7 +553,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
537
553
  ? userError?.details?.runtime
538
554
  : undefined;
539
555
  if (!cloudflareChallenge && browserCanReattach) {
540
- logBrowserReattachGuidance(browserRuntime ?? sessionMeta.browser?.runtime);
556
+ logBrowserReattachGuidance(browserRuntime ?? currentBrowser?.runtime);
541
557
  }
542
558
  await sessionStore.updateSession(sessionMeta.id, {
543
559
  status: "error",
@@ -546,8 +562,9 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
546
562
  mode,
547
563
  browser: browserConfig
548
564
  ? {
565
+ ...currentBrowser,
549
566
  config: browserConfig,
550
- runtime: browserRuntime ?? undefined,
567
+ runtime: browserRuntime ?? currentBrowser?.runtime,
551
568
  }
552
569
  : undefined,
553
570
  response: responseMetadata,
@@ -816,7 +833,7 @@ async function writeAssistantOutput(targetPath, content, log) {
816
833
  log(dim(`write-output failed (${reason}); session completed anyway.`));
817
834
  }
818
835
  }
819
- async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig, runOptions, modelForStatus, notificationSettings, log, }) {
836
+ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig, browserMetadata, runOptions, modelForStatus, notificationSettings, log, }) {
820
837
  if (!runtime || !browserConfig) {
821
838
  log(dim("Auto-reattach disabled: missing runtime or browser config."));
822
839
  return false;
@@ -898,6 +915,7 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
898
915
  },
899
916
  errorMessage: undefined,
900
917
  browser: {
918
+ ...browserMetadata,
901
919
  config: browserConfig,
902
920
  runtime,
903
921
  },
@@ -125,6 +125,9 @@ function sanitizeProjectConfig(config) {
125
125
  sanitized.background = config.background;
126
126
  if (config.promptSuffix !== undefined)
127
127
  sanitized.promptSuffix = config.promptSuffix;
128
+ // NOTE: `modelOverrides` is intentionally NOT copied here. Model routing
129
+ // overrides are user-config only; allowing them from project configs would let
130
+ // an untrusted repository silently redirect API calls (apiModel) or reasoning.
128
131
  if (config.browser) {
129
132
  sanitized.browser = {};
130
133
  const browser = config.browser;
@@ -141,6 +141,8 @@ function buildOpenRouterCompletionClient(instance) {
141
141
  model: body.model,
142
142
  messages,
143
143
  max_tokens: body.max_output_tokens,
144
+ // Custom gateways use Chat Completions, whose effort field differs from Responses.
145
+ ...(body.reasoning?.effort ? { reasoning_effort: body.reasoning.effort } : {}),
144
146
  };
145
147
  const streaming = { ...base, stream: true };
146
148
  const nonStreaming = { ...base, stream: false };
@@ -120,6 +120,12 @@ function mapToOpenRouterId(candidate, catalog, providerHint) {
120
120
  return candidate;
121
121
  }
122
122
  export async function resolveModelConfig(model, options = {}) {
123
+ const base = await resolveBaseModelConfig(model, options);
124
+ // Apply user-config per-model overrides last, after known/OpenRouter/synthesized
125
+ // resolution, so an explicit override always wins.
126
+ return applyModelOverride(base, model, options.modelOverrides);
127
+ }
128
+ async function resolveBaseModelConfig(model, options = {}) {
123
129
  const known = isKnownModel(model) ? MODEL_CONFIGS[model] : null;
124
130
  const fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
125
131
  const openRouterActive = isOpenRouterBaseUrl(options.baseUrl) || Boolean(options.openRouterApiKey);
@@ -186,6 +192,85 @@ export async function resolveModelConfig(model, options = {}) {
186
192
  export function isProModel(model) {
187
193
  return isKnownModel(model) && PRO_MODELS.has(model);
188
194
  }
195
+ const VALID_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"];
196
+ function isRecord(value) {
197
+ return typeof value === "object" && value !== null && !Array.isArray(value);
198
+ }
199
+ /**
200
+ * Returns the override's `apiModel` for a *known* model when present and non-empty,
201
+ * otherwise `undefined`. Single source of truth for the override apiModel rule,
202
+ * shared by {@link applyModelOverride} and the CLI's `effectiveModelId` resolution.
203
+ */
204
+ export function resolveOverriddenApiModel(model, overrides) {
205
+ if (!overrides || !isKnownModel(model))
206
+ return undefined;
207
+ const override = overrides[model];
208
+ if (!isRecord(override))
209
+ return undefined;
210
+ if (typeof override.apiModel === "string" && override.apiModel.trim() !== "") {
211
+ return override.apiModel.trim();
212
+ }
213
+ return undefined;
214
+ }
215
+ /**
216
+ * Apply a user-config per-model override on top of a resolved config.
217
+ *
218
+ * Scope is intentionally narrow: only *known* models can be overridden, so the
219
+ * tokenizer (a function, not expressible in JSON) and any unspecified fields are
220
+ * inherited from the base config. Override fields are validated defensively
221
+ * because they come from user-authored JSON5.
222
+ */
223
+ export function applyModelOverride(base, model, overrides) {
224
+ if (!overrides || !isKnownModel(model))
225
+ return base;
226
+ const override = overrides[model];
227
+ if (!isRecord(override))
228
+ return base;
229
+ const result = { ...base };
230
+ const apiModel = resolveOverriddenApiModel(model, overrides);
231
+ if (apiModel) {
232
+ result.apiModel = apiModel;
233
+ }
234
+ if (Object.hasOwn(override, "reasoning")) {
235
+ const reasoning = override.reasoning;
236
+ if (reasoning === null) {
237
+ // Explicit null clears the known model's reasoning effort.
238
+ result.reasoning = null;
239
+ }
240
+ else if (isRecord(reasoning) &&
241
+ typeof reasoning.effort === "string" &&
242
+ VALID_REASONING_EFFORTS.includes(reasoning.effort)) {
243
+ result.reasoning = { effort: reasoning.effort };
244
+ }
245
+ // Malformed reasoning override is ignored (base value preserved).
246
+ }
247
+ if (typeof override.inputLimit === "number" &&
248
+ Number.isSafeInteger(override.inputLimit) &&
249
+ override.inputLimit > 0) {
250
+ result.inputLimit = override.inputLimit;
251
+ }
252
+ // Non-positive or non-integer inputLimit (e.g. 0, 0.5, NaN, Infinity) is ignored.
253
+ if (Object.hasOwn(override, "pricing")) {
254
+ const pricing = override.pricing;
255
+ if (pricing === null) {
256
+ result.pricing = null;
257
+ }
258
+ else if (isRecord(pricing) &&
259
+ typeof pricing.inputPerToken === "number" &&
260
+ Number.isFinite(pricing.inputPerToken) &&
261
+ pricing.inputPerToken >= 0 &&
262
+ typeof pricing.outputPerToken === "number" &&
263
+ Number.isFinite(pricing.outputPerToken) &&
264
+ pricing.outputPerToken >= 0) {
265
+ result.pricing = {
266
+ inputPerToken: pricing.inputPerToken,
267
+ outputPerToken: pricing.outputPerToken,
268
+ };
269
+ }
270
+ // Malformed pricing override is ignored (base value preserved).
271
+ }
272
+ return result;
273
+ }
189
274
  export function resetOpenRouterCatalogCacheForTest() {
190
275
  catalogCache.clear();
191
276
  }
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { runOracle, OracleResponseError, OracleTransportError, extractResponseMetadata, asOracleUserError, extractTextOutput, classifyProviderFailure, } from "../oracle.js";
4
4
  import { sessionStore } from "../sessionStore.js";
5
+ import { resolveOverriddenApiModel } from "./modelResolver.js";
5
6
  import { findOscProgressSequences, OSC_PROGRESS_PREFIX } from "osc-progress";
6
7
  function forwardOscProgress(chunk, shouldForward) {
7
8
  if (!shouldForward || !chunk.includes(OSC_PROGRESS_PREFIX)) {
@@ -81,7 +82,9 @@ function startModelExecution({ sessionMeta, runOptions, model, cwd, store, runOr
81
82
  });
82
83
  const result = await runOracleImpl({
83
84
  ...perModelOptions,
84
- effectiveModelId: model,
85
+ // Respect a user-config apiModel override for this known model; falls back
86
+ // to the canonical model id (unchanged behavior) when no override applies.
87
+ effectiveModelId: resolveOverriddenApiModel(model, runOptions.modelOverrides) ?? model,
85
88
  // Drop per-model preamble; the aggregate runner prints the shared header and tips once.
86
89
  suppressHeader: true,
87
90
  suppressAnswerHeader: true,
@@ -1,6 +1,6 @@
1
1
  import process from "node:process";
2
2
  import { startOscProgress as startOscProgressShared, supportsOscProgress as supportsOscProgressShared, } from "osc-progress";
3
- export function supportsOscProgress(env = process.env, isTty = process.stdout.isTTY) {
3
+ export function supportsOscProgress(env = process.env, isTty = process.stdout.isTTY === true) {
4
4
  if (env.CODEX_MANAGED_BY_NPM === "1" && env.ORACLE_FORCE_OSC_PROGRESS !== "1") {
5
5
  return false;
6
6
  }
@@ -16,8 +16,9 @@ export function startOscProgress(options = {}) {
16
16
  }
17
17
  return startOscProgressShared({
18
18
  ...options,
19
- // Preserve Oracle's previous default: progress emits to stdout.
19
+ // Preserve Oracle's previous defaults: progress emits to and checks stdout.
20
20
  write: options.write ?? ((text) => process.stdout.write(text)),
21
+ isTty: options.isTty ?? process.stdout.isTTY === true,
21
22
  disableEnvVar: "ORACLE_NO_OSC_PROGRESS",
22
23
  forceEnvVar: "ORACLE_FORCE_OSC_PROGRESS",
23
24
  });
@@ -25,7 +25,7 @@ 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 { isOpenRouterBaseUrl, isProModel, resolveModelConfig } from "./modelResolver.js";
28
+ import { isOpenRouterBaseUrl, isProModel, resolveModelConfig, resolveOverriddenApiModel, } from "./modelResolver.js";
29
29
  import { validateProviderRouting } from "./providerRouting.js";
30
30
  import { formatRouteTargetForLog, resolveProviderRoute, } from "./providerRoutePlan.js";
31
31
  const isStdoutTty = process.stdout.isTTY && chalk.level > 0;
@@ -133,6 +133,7 @@ export async function runOracle(options, deps = {}) {
133
133
  const modelConfig = await resolveModelConfig(options.model, {
134
134
  baseUrl,
135
135
  openRouterApiKey: resolverOpenRouterApiKey,
136
+ modelOverrides: options.modelOverrides,
136
137
  });
137
138
  const isLongRunningModel = isProTierModel;
138
139
  const supportsBackground = modelConfig.supportsBackground !== false;
@@ -194,6 +195,8 @@ export async function runOracle(options, deps = {}) {
194
195
  // Track the concrete model id we dispatch to (especially for Gemini preview aliases)
195
196
  const effectiveModelId = azureDeploymentName ??
196
197
  options.effectiveModelId ??
198
+ // A user-config apiModel override (known models only) wins over Gemini alias remapping.
199
+ resolveOverriddenApiModel(options.model, options.modelOverrides) ??
197
200
  (options.model.startsWith("gemini")
198
201
  ? resolveGeminiModelId(options.model)
199
202
  : (modelConfig.apiModel ?? modelConfig.model));