@steipete/oracle 0.15.0 → 0.15.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.
Files changed (36) 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/src/browser/actions/assistantResponse.js +40 -31
  7. package/dist/src/browser/actions/attachments.js +28 -1
  8. package/dist/src/browser/actions/deepResearch.js +212 -67
  9. package/dist/src/browser/actions/modelSelection.js +30 -7
  10. package/dist/src/browser/actions/promptComposer.js +71 -14
  11. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  12. package/dist/src/browser/artifacts.js +191 -6
  13. package/dist/src/browser/chatgptFiles.js +525 -91
  14. package/dist/src/browser/constants.js +5 -0
  15. package/dist/src/browser/index.js +30 -30
  16. package/dist/src/browser/sessionRunner.js +9 -3
  17. package/dist/src/cli/bridge/client.js +4 -1
  18. package/dist/src/cli/bridge/doctor.js +19 -0
  19. package/dist/src/cli/runOptions.js +11 -2
  20. package/dist/src/cli/sessionDisplay.js +6 -1
  21. package/dist/src/cli/sessionRunner.js +28 -10
  22. package/dist/src/config.js +3 -0
  23. package/dist/src/oracle/client.js +2 -0
  24. package/dist/src/oracle/modelResolver.js +85 -0
  25. package/dist/src/oracle/multiModelRunner.js +4 -1
  26. package/dist/src/oracle/run.js +4 -1
  27. package/dist/src/remote/client.js +253 -22
  28. package/dist/src/remote/health.js +27 -0
  29. package/dist/src/remote/server.js +239 -4
  30. package/dist/src/remote/types.js +1 -1
  31. package/dist/src/sessionManager.js +1 -0
  32. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  33. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  34. package/package.json +13 -13
  35. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  36. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -63,6 +63,11 @@ export const UPLOAD_STATUS_SELECTORS = [
63
63
  '[aria-live="assertive"]',
64
64
  ];
65
65
  export const STOP_BUTTON_SELECTOR = '[data-testid="stop-button"]';
66
+ export const STOP_BUTTON_SELECTORS = [
67
+ STOP_BUTTON_SELECTOR,
68
+ '[data-testid="composer-stop-button"]',
69
+ 'button[aria-label*="stop" i]',
70
+ ];
66
71
  export const SEND_BUTTON_SELECTORS = [
67
72
  'button[data-testid="send-button"]',
68
73
  'button[data-testid*="composer-send"]',
@@ -558,6 +558,7 @@ export async function runBrowserMode(options) {
558
558
  let lastTargetId;
559
559
  let lastUrl;
560
560
  let promptSubmitted = false;
561
+ let modelSelectionEvidence;
561
562
  let tabLease = null;
562
563
  const emitRuntimeHint = async () => {
563
564
  if (!chrome?.port) {
@@ -576,7 +577,7 @@ export async function runBrowserMode(options) {
576
577
  controllerPid: process.pid,
577
578
  };
578
579
  try {
579
- await runtimeHintCb?.(hint);
580
+ await runtimeHintCb?.(hint, modelSelectionEvidence);
580
581
  await tabLease?.update({
581
582
  chromeHost,
582
583
  chromePort: chrome.port,
@@ -722,7 +723,6 @@ export async function runBrowserMode(options) {
722
723
  let answerMarkdown = "";
723
724
  let answerHtml = "";
724
725
  let runStatus = "attempted";
725
- let modelSelectionEvidence;
726
726
  let connectionClosedUnexpectedly = false;
727
727
  let stopThinkingMonitor = null;
728
728
  let removeDialogHandler = null;
@@ -1019,19 +1019,6 @@ export async function runBrowserMode(options) {
1019
1019
  },
1020
1020
  }));
1021
1021
  }
1022
- if (deepResearch) {
1023
- await raceWithDisconnect(withRetries(() => activateDeepResearch(Runtime, Input, logger), {
1024
- retries: 2,
1025
- delayMs: 500,
1026
- onRetry: (attempt, error) => {
1027
- if (options.verbose) {
1028
- logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
1029
- }
1030
- },
1031
- }));
1032
- await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1033
- logger(`Prompt textarea ready (after Deep Research activation, ${promptText.length.toLocaleString()} chars queued)`);
1034
- }
1035
1022
  const profileLockTimeoutMs = manualLogin ? (config.profileLockTimeoutMs ?? 0) : 0;
1036
1023
  let profileLock = null;
1037
1024
  const acquireProfileLockIfNeeded = async () => {
@@ -1082,6 +1069,19 @@ export async function runBrowserMode(options) {
1082
1069
  await waitForAttachmentCompletion(Runtime, attachmentWaitBudget, attachmentNames, logger);
1083
1070
  logger("All attachments uploaded");
1084
1071
  }
1072
+ if (deepResearch) {
1073
+ await raceWithDisconnect(withRetries(() => activateDeepResearch(Runtime, Input, logger), {
1074
+ retries: 2,
1075
+ delayMs: 500,
1076
+ onRetry: (attempt, error) => {
1077
+ if (options.verbose) {
1078
+ logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
1079
+ }
1080
+ },
1081
+ }));
1082
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1083
+ logger(`Prompt textarea ready (after Deep Research activation, ${prompt.length.toLocaleString()} chars queued)`);
1084
+ }
1085
1085
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
1086
1086
  // Learned: return baselineTurns so assistant polling can ignore earlier content.
1087
1087
  const providerState = {
@@ -2083,6 +2083,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2083
2083
  let tabLease = null;
2084
2084
  let lastUrl;
2085
2085
  let promptSubmitted = false;
2086
+ let modelSelectionEvidence;
2086
2087
  let attachedExistingTab = false;
2087
2088
  let ownsTarget = true;
2088
2089
  const runtimeHintCb = options.runtimeHintCb;
@@ -2100,7 +2101,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2100
2101
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2101
2102
  promptSubmitted,
2102
2103
  controllerPid: process.pid,
2103
- });
2104
+ }, modelSelectionEvidence);
2104
2105
  await tabLease?.update({
2105
2106
  chromeHost: host,
2106
2107
  chromePort: port,
@@ -2126,7 +2127,6 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2126
2127
  let answerHtml = "";
2127
2128
  let connectionClosedUnexpectedly = false;
2128
2129
  let runStatus = "attempted";
2129
- let modelSelectionEvidence;
2130
2130
  let stopThinkingMonitor = null;
2131
2131
  let removeDialogHandler = null;
2132
2132
  let connection = null;
@@ -2253,19 +2253,6 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2253
2253
  },
2254
2254
  });
2255
2255
  }
2256
- if (deepResearch) {
2257
- await withRetries(() => activateDeepResearch(Runtime, Input, logger), {
2258
- retries: 2,
2259
- delayMs: 500,
2260
- onRetry: (attempt, error) => {
2261
- if (options.verbose) {
2262
- logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
2263
- }
2264
- },
2265
- });
2266
- await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2267
- logger(`Prompt textarea ready (after Deep Research activation, ${promptText.length.toLocaleString()} chars queued)`);
2268
- }
2269
2256
  const submitOnce = async (prompt, submissionAttachments) => {
2270
2257
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
2271
2258
  const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
@@ -2295,6 +2282,19 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2295
2282
  await waitForAttachmentCompletion(Runtime, attachmentWaitBudget, attachmentNames, logger);
2296
2283
  logger("All attachments uploaded");
2297
2284
  }
2285
+ if (deepResearch) {
2286
+ await withRetries(() => activateDeepResearch(Runtime, Input, logger), {
2287
+ retries: 2,
2288
+ delayMs: 500,
2289
+ onRetry: (attempt, error) => {
2290
+ if (options.verbose) {
2291
+ logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
2292
+ }
2293
+ },
2294
+ });
2295
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2296
+ logger(`Prompt textarea ready (after Deep Research activation, ${prompt.length.toLocaleString()} chars queued)`);
2297
+ }
2298
2298
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
2299
2299
  const providerState = {
2300
2300
  runtime: Runtime,
@@ -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,
@@ -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));