@steipete/oracle 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +56 -11
  2. package/dist/bin/oracle-cli.js +440 -98
  3. package/dist/src/browser/actions/archiveConversation.js +12 -0
  4. package/dist/src/browser/actions/modelSelection.js +61 -18
  5. package/dist/src/browser/actions/navigation.js +5 -3
  6. package/dist/src/browser/actions/promptComposer.js +75 -18
  7. package/dist/src/browser/actions/thinkingTime.js +23 -8
  8. package/dist/src/browser/config.js +1 -7
  9. package/dist/src/browser/constants.js +1 -1
  10. package/dist/src/browser/index.js +65 -48
  11. package/dist/src/browser/manualLoginProfile.js +54 -0
  12. package/dist/src/browser/projectSourcesRunner.js +16 -5
  13. package/dist/src/browser/prompt.js +56 -37
  14. package/dist/src/browser/sessionRunner.js +72 -1
  15. package/dist/src/browser/utils.js +1 -47
  16. package/dist/src/browser/zipBundle.js +152 -0
  17. package/dist/src/cli/browserConfig.js +13 -18
  18. package/dist/src/cli/browserDefaults.js +2 -1
  19. package/dist/src/cli/docsCheck.js +186 -0
  20. package/dist/src/cli/engine.js +11 -4
  21. package/dist/src/cli/options.js +12 -6
  22. package/dist/src/cli/perfTrace.js +242 -0
  23. package/dist/src/cli/promptRequirement.js +2 -0
  24. package/dist/src/cli/providerDoctor.js +85 -0
  25. package/dist/src/cli/runOptions.js +46 -16
  26. package/dist/src/cli/sessionDisplay.js +39 -4
  27. package/dist/src/cli/sessionLifecycle.js +38 -0
  28. package/dist/src/cli/sessionRunner.js +228 -3
  29. package/dist/src/cli/sessionTable.js +2 -1
  30. package/dist/src/duration.js +47 -0
  31. package/dist/src/mcp/tools/consult.js +19 -3
  32. package/dist/src/mcp/types.js +5 -2
  33. package/dist/src/mcp/utils.js +4 -1
  34. package/dist/src/oracle/baseUrl.js +17 -0
  35. package/dist/src/oracle/client.js +1 -22
  36. package/dist/src/oracle/config.js +17 -4
  37. package/dist/src/oracle/gemini.js +2 -22
  38. package/dist/src/oracle/geminiModels.js +21 -0
  39. package/dist/src/oracle/modelResolver.js +7 -1
  40. package/dist/src/oracle/multiModelRunner.js +20 -2
  41. package/dist/src/oracle/providerFailures.js +204 -0
  42. package/dist/src/oracle/providerRoutePlan.js +281 -0
  43. package/dist/src/oracle/providerRouting.js +92 -0
  44. package/dist/src/oracle/run.js +157 -54
  45. package/dist/src/oracle.js +1 -0
  46. package/dist/src/remote/client.js +8 -0
  47. package/dist/src/remote/server.js +26 -0
  48. package/dist/src/sessionManager.js +5 -1
  49. package/package.json +8 -6
@@ -1,7 +1,7 @@
1
1
  import kleur from "kleur";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { runOracle, OracleResponseError, OracleTransportError, extractResponseMetadata, asOracleUserError, extractTextOutput, } from "../oracle.js";
4
+ import { runOracle, OracleResponseError, OracleTransportError, extractResponseMetadata, asOracleUserError, extractTextOutput, classifyProviderFailure, } from "../oracle.js";
5
5
  import { ensureSessionArtifacts, runBrowserSessionExecution, } from "../browser/sessionRunner.js";
6
6
  import { renderMarkdownAnsi } from "./markdownRenderer.js";
7
7
  import { formatResponseMetadata, formatTransportMetadata } from "./sessionDisplay.js";
@@ -82,6 +82,8 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
82
82
  config: browserConfig,
83
83
  runtime: result.runtime,
84
84
  archive: result.archive,
85
+ modelSelection: result.modelSelection,
86
+ warnings: result.warnings,
85
87
  },
86
88
  artifacts: mergeArtifacts(sessionMeta.artifacts, result.artifacts),
87
89
  response: undefined,
@@ -245,8 +247,13 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
245
247
  });
246
248
  log(statusColor(line1));
247
249
  const hasFailure = summary.rejected.length > 0;
250
+ const allowPartial = runOptions.partialMode === "ok" && summary.fulfilled.length > 0;
251
+ if (hasFailure) {
252
+ const resultLabel = summary.fulfilled.length > 0 ? "partial success" : "failed";
253
+ log(statusColor(`Multi-model result: ${resultLabel}, ${summary.fulfilled.length}/${multiModels.length} succeeded`));
254
+ }
248
255
  await sessionStore.updateSession(sessionMeta.id, {
249
- status: hasFailure ? "error" : "completed",
256
+ status: hasFailure ? (allowPartial ? "partial" : "error") : "completed",
250
257
  completedAt: new Date().toISOString(),
251
258
  usage: aggregateUsage,
252
259
  elapsedMs: summary.elapsedMs,
@@ -273,15 +280,52 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
273
280
  savedOutputs.push({ model: entry.model, path: savedPath });
274
281
  }
275
282
  }
283
+ const sessionWithRuns = (await readSessionForManifest(sessionMeta.id)) ?? {
284
+ ...sessionMeta,
285
+ models: sessionMeta.models,
286
+ };
287
+ const runLogs = await collectMultiModelRunLogs(sessionMeta.id, sessionWithRuns.models, summary);
288
+ const manifestPath = await writeMultiModelOutputManifest({
289
+ baseOutputPath: runOptions.writeOutputPath,
290
+ sessionId: sessionMeta.id,
291
+ status: hasFailure ? (allowPartial ? "partial" : "error") : "completed",
292
+ summary,
293
+ savedOutputs,
294
+ modelRuns: sessionWithRuns.models,
295
+ runLogs,
296
+ runOptions,
297
+ log,
298
+ });
276
299
  if (savedOutputs.length > 0) {
277
300
  log(dim("Saved outputs:"));
278
301
  for (const item of savedOutputs) {
279
302
  log(dim(`- ${item.model} -> ${item.path}`));
280
303
  }
281
304
  }
305
+ if (manifestPath) {
306
+ log(dim(`Output manifest: ${manifestPath}`));
307
+ }
308
+ if (runLogs.length > 0) {
309
+ log(dim(""));
310
+ log(dim("Run logs:"));
311
+ for (const item of runLogs) {
312
+ log(dim(`- ${item.model} -> ${item.path}`));
313
+ }
314
+ }
282
315
  }
283
316
  if (hasFailure) {
284
- throw summary.rejected[0].reason;
317
+ log(dim("Failures:"));
318
+ for (const item of summary.rejected) {
319
+ const providerContext = providerFailureContextForModel(item.model, runOptions);
320
+ log(dim(`- ${item.model}: ${formatMultiModelFailure(item.reason, providerContext)}`));
321
+ for (const line of formatMultiModelFailureDetails(item.reason, providerContext)) {
322
+ log(dim(line));
323
+ }
324
+ }
325
+ }
326
+ if (hasFailure && !allowPartial) {
327
+ const firstFailure = summary.rejected[0];
328
+ throw sanitizeMultiModelFailureForThrow(firstFailure.reason, providerFailureContextForModel(firstFailure.model, runOptions));
285
329
  }
286
330
  return;
287
331
  }
@@ -483,6 +527,187 @@ function mergeArtifacts(existing, additions) {
483
527
  function formatError(error) {
484
528
  return error instanceof Error ? error.message : String(error);
485
529
  }
530
+ function providerFailureContextForModel(model, runOptions) {
531
+ return {
532
+ model,
533
+ providerMode: runOptions.provider,
534
+ azure: runOptions.azure,
535
+ baseUrl: runOptions.baseUrl,
536
+ apiKey: runOptions.apiKey,
537
+ };
538
+ }
539
+ function formatMultiModelFailure(error, context) {
540
+ const userError = asOracleUserError(error);
541
+ if (userError) {
542
+ return `${userError.category}, ${userError.message}`;
543
+ }
544
+ const providerFailure = classifyProviderFailure(error, context);
545
+ if (providerFailure) {
546
+ return providerFailure.label;
547
+ }
548
+ if (error instanceof OracleTransportError) {
549
+ return `${error.reason}, ${error.message}`;
550
+ }
551
+ if (error instanceof OracleResponseError) {
552
+ return error.message;
553
+ }
554
+ return formatError(error);
555
+ }
556
+ function formatMultiModelFailureDetails(error, context) {
557
+ const providerFailure = classifyProviderFailure(error, context);
558
+ if (!providerFailure) {
559
+ return [];
560
+ }
561
+ const lines = [];
562
+ if (providerFailure.keyEnv) {
563
+ lines.push(` key: ${providerFailure.keyEnv}`);
564
+ }
565
+ lines.push(` provider said: ${providerFailure.providerMessage}`);
566
+ lines.push(` fix: ${providerFailure.fix}`);
567
+ return lines;
568
+ }
569
+ function sanitizeMultiModelFailureForThrow(error, context) {
570
+ const providerFailure = classifyProviderFailure(error, context);
571
+ if (!providerFailure) {
572
+ return error;
573
+ }
574
+ const modelPrefix = typeof context === "object" && context?.model ? `${context.model}: ` : "";
575
+ const message = `${modelPrefix}${providerFailure.label}: ${providerFailure.providerMessage}`;
576
+ if (!(error instanceof Error)) {
577
+ return new Error(message);
578
+ }
579
+ error.message = message;
580
+ 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");
584
+ }
585
+ return error;
586
+ }
587
+ export function deriveOutputManifestPath(basePath) {
588
+ const ext = path.extname(basePath);
589
+ const stem = path.basename(basePath, ext);
590
+ const dir = path.dirname(basePath);
591
+ return path.join(dir, `${stem}.oracle.json`);
592
+ }
593
+ async function collectMultiModelRunLogs(sessionId, modelRuns, summary) {
594
+ const sessionDir = await resolveSessionDir(sessionId);
595
+ const logsByModel = new Map();
596
+ for (const run of modelRuns ?? []) {
597
+ if (run.log?.path) {
598
+ logsByModel.set(run.model, resolveSessionPath(sessionDir, run.log.path));
599
+ }
600
+ }
601
+ for (const entry of summary.fulfilled) {
602
+ if (!logsByModel.has(entry.model)) {
603
+ logsByModel.set(entry.model, entry.logPath);
604
+ }
605
+ }
606
+ return [...logsByModel.entries()].map(([model, logPath]) => ({ model, path: logPath }));
607
+ }
608
+ async function writeMultiModelOutputManifest({ baseOutputPath, sessionId, status, summary, savedOutputs, modelRuns, runLogs, runOptions, log, }) {
609
+ const manifestPath = deriveOutputManifestPath(baseOutputPath);
610
+ const normalizedTarget = path.resolve(manifestPath);
611
+ const normalizedSessionsDir = path.resolve(sessionStore.sessionsDir());
612
+ if (normalizedTarget === normalizedSessionsDir ||
613
+ normalizedTarget.startsWith(`${normalizedSessionsDir}${path.sep}`)) {
614
+ log(dim(`output manifest skipped: refusing to write inside session storage (${normalizedSessionsDir}).`));
615
+ return undefined;
616
+ }
617
+ const manifest = buildMultiModelOutputManifest({
618
+ baseOutputPath,
619
+ sessionId,
620
+ status,
621
+ summary,
622
+ savedOutputs,
623
+ modelRuns,
624
+ runLogs,
625
+ runOptions,
626
+ });
627
+ try {
628
+ await fs.mkdir(path.dirname(normalizedTarget), { recursive: true });
629
+ await fs.writeFile(normalizedTarget, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
630
+ return normalizedTarget;
631
+ }
632
+ catch (error) {
633
+ const reason = error instanceof Error ? error.message : String(error);
634
+ log(dim(`output manifest failed (${reason}); session completed anyway.`));
635
+ return undefined;
636
+ }
637
+ }
638
+ function buildMultiModelOutputManifest({ baseOutputPath, sessionId, status, summary, savedOutputs, modelRuns, runLogs, runOptions, }) {
639
+ const outputByModel = new Map(savedOutputs.map((entry) => [entry.model, entry.path]));
640
+ const logsByModel = new Map(runLogs.map((entry) => [entry.model, entry.path]));
641
+ const runsByModel = new Map((modelRuns ?? []).map((run) => [run.model, run]));
642
+ const fulfilledByModel = new Map(summary.fulfilled.map((entry) => [entry.model, entry]));
643
+ const rejectedByModel = new Map(summary.rejected.map((entry) => [entry.model, entry.reason]));
644
+ const orderedModels = [
645
+ ...summary.fulfilled.map((entry) => entry.model),
646
+ ...summary.rejected.map((entry) => entry.model),
647
+ ];
648
+ return {
649
+ version: 1,
650
+ sessionId,
651
+ status,
652
+ outputBasePath: path.resolve(baseOutputPath),
653
+ createdAt: new Date().toISOString(),
654
+ models: orderedModels.map((model) => {
655
+ const run = runsByModel.get(model);
656
+ const fulfilled = fulfilledByModel.get(model);
657
+ const reason = rejectedByModel.get(model);
658
+ const userError = reason ? asOracleUserError(reason) : undefined;
659
+ const providerFailure = reason
660
+ ? classifyProviderFailure(reason, providerFailureContextForModel(model, runOptions))
661
+ : undefined;
662
+ return {
663
+ model,
664
+ status: fulfilled ? "completed" : reason ? "error" : (run?.status ?? "error"),
665
+ outputPath: outputByModel.get(model),
666
+ logPath: logsByModel.get(model),
667
+ errorCategory: run?.error?.category ?? userError?.category ?? providerFailure?.category,
668
+ errorMessage: run?.error?.message ??
669
+ userError?.message ??
670
+ providerFailure?.label ??
671
+ (reason ? formatError(reason) : undefined),
672
+ elapsedMs: calculateModelElapsedMs(run),
673
+ usage: run?.usage ?? fulfilled?.usage,
674
+ };
675
+ }),
676
+ };
677
+ }
678
+ function calculateModelElapsedMs(run) {
679
+ if (!run?.startedAt || !run.completedAt) {
680
+ return undefined;
681
+ }
682
+ const startedMs = Date.parse(run.startedAt);
683
+ const completedMs = Date.parse(run.completedAt);
684
+ if (!Number.isFinite(startedMs) || !Number.isFinite(completedMs) || completedMs < startedMs) {
685
+ return undefined;
686
+ }
687
+ return completedMs - startedMs;
688
+ }
689
+ async function readSessionForManifest(sessionId) {
690
+ try {
691
+ return (await sessionStore.readSession(sessionId)) ?? null;
692
+ }
693
+ catch {
694
+ return null;
695
+ }
696
+ }
697
+ async function resolveSessionDir(sessionId) {
698
+ try {
699
+ return (await sessionStore.getPaths(sessionId)).dir;
700
+ }
701
+ catch {
702
+ return null;
703
+ }
704
+ }
705
+ function resolveSessionPath(sessionDir, targetPath) {
706
+ if (path.isAbsolute(targetPath) || !sessionDir) {
707
+ return targetPath;
708
+ }
709
+ return path.join(sessionDir, targetPath);
710
+ }
486
711
  async function writeAssistantOutput(targetPath, content, log) {
487
712
  if (!targetPath)
488
713
  return;
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import kleur from "kleur";
3
3
  import { MODEL_CONFIGS } from "../oracle.js";
4
4
  import { estimateUsdCost } from "tokentally";
5
+ import { formatSessionExecutionLabel } from "./sessionLifecycle.js";
5
6
  const isRich = (rich) => rich ?? Boolean(process.stdout.isTTY && chalk.level > 0);
6
7
  const dim = (text, rich) => (rich ? kleur.dim(text) : text);
7
8
  export const STATUS_PAD = 9;
@@ -19,7 +20,7 @@ export function formatSessionTableRow(meta, options) {
19
20
  const status = colorStatus(meta.status ?? "unknown", rich);
20
21
  const modelLabel = (meta.model ?? "n/a").padEnd(MODEL_PAD);
21
22
  const model = rich ? chalk.white(modelLabel) : modelLabel;
22
- const modeLabel = (meta.mode ?? meta.options?.mode ?? "api").padEnd(MODE_PAD);
23
+ const modeLabel = formatSessionExecutionLabel(meta).padEnd(MODE_PAD);
23
24
  const mode = rich ? chalk.gray(modeLabel) : modeLabel;
24
25
  const timestampLabel = formatTimestampAligned(meta.createdAt).padEnd(TIMESTAMP_PAD);
25
26
  const timestamp = rich ? chalk.gray(timestampLabel) : timestampLabel;
@@ -0,0 +1,47 @@
1
+ export function parseDuration(input, fallback) {
2
+ if (!input) {
3
+ return fallback;
4
+ }
5
+ const trimmed = input.trim();
6
+ if (!trimmed) {
7
+ return fallback;
8
+ }
9
+ const lowercase = trimmed.toLowerCase();
10
+ if (/^[0-9]+$/.test(lowercase)) {
11
+ return Number(lowercase);
12
+ }
13
+ const normalized = lowercase.replace(/\s+/g, "");
14
+ const singleMatch = /^([0-9]+)(ms|s|m|h)$/i.exec(normalized);
15
+ if (singleMatch && singleMatch[0].length === normalized.length) {
16
+ const value = Number(singleMatch[1]);
17
+ return convertUnit(value, singleMatch[2]);
18
+ }
19
+ const multiDuration = /([0-9]+)(ms|h|m|s)/g;
20
+ let total = 0;
21
+ let lastIndex = 0;
22
+ let match = multiDuration.exec(normalized);
23
+ while (match !== null) {
24
+ total += convertUnit(Number(match[1]), match[2]);
25
+ lastIndex = multiDuration.lastIndex;
26
+ match = multiDuration.exec(normalized);
27
+ }
28
+ if (total > 0 && lastIndex === normalized.length) {
29
+ return total;
30
+ }
31
+ return fallback;
32
+ }
33
+ function convertUnit(value, unitRaw) {
34
+ const unit = unitRaw?.toLowerCase();
35
+ switch (unit) {
36
+ case "ms":
37
+ return value;
38
+ case "s":
39
+ return value * 1000;
40
+ case "m":
41
+ return value * 60_000;
42
+ case "h":
43
+ return value * 3_600_000;
44
+ default:
45
+ return value;
46
+ }
47
+ }
@@ -60,6 +60,10 @@ const consultInputShape = {
60
60
  .boolean()
61
61
  .optional()
62
62
  .describe("Browser-only: bundle many files into a single upload (helps with upload limits)."),
63
+ browserBundleFormat: z
64
+ .enum(["text", "zip"])
65
+ .optional()
66
+ .describe('Browser-only: bundle upload format when browserBundleFiles is true or auto-bundling is needed. Defaults to "text"; "zip" preserves individual file names in one uploaded archive.'),
63
67
  browserThinkingTime: z
64
68
  .enum(["light", "standard", "extended", "heavy"])
65
69
  .optional()
@@ -140,6 +144,7 @@ const consultDryRunResolvedShape = z.object({
140
144
  researchMode: z.string().nullable().optional(),
141
145
  attachments: z.string().optional(),
142
146
  bundleFiles: z.boolean().optional(),
147
+ bundleFormat: z.enum(["text", "zip"]).optional(),
143
148
  keepBrowser: z.boolean().optional(),
144
149
  manualLogin: z.boolean().optional(),
145
150
  profileDir: z.string().nullable().optional(),
@@ -196,7 +201,9 @@ export function buildConsultBrowserConfig({ userConfig, env, runModel, inputMode
196
201
  ? mapModelToBrowserLabel(runModel)
197
202
  : resolveBrowserModelLabel(preferredLabel, runModel);
198
203
  const configuredUrl = configuredBrowser.chatgptUrl ?? configuredBrowser.url ?? CHATGPT_URL;
199
- const manualLogin = hasProfileDir ? true : (configuredBrowser.manualLogin ?? false);
204
+ const manualLogin = hasProfileDir
205
+ ? true
206
+ : (configuredBrowser.manualLogin ?? process.platform === "win32");
200
207
  return {
201
208
  ...configuredBrowser,
202
209
  url: configuredUrl,
@@ -224,6 +231,12 @@ export function buildConsultDryRunResolved({ resolvedEngine, runOptions, browser
224
231
  }
225
232
  if (resolvedEngine === "browser") {
226
233
  guidance.push("Browser engine uses the signed-in ChatGPT profile; run dryRun:true before live use.");
234
+ if (browserConfig?.manualLogin) {
235
+ const profile = browserConfig.manualLoginProfileDir ?? "~/.oracle/browser-profile";
236
+ guidance.push(`Manual-login browser mode uses Oracle's private Chrome profile at ${profile}, separate from your normal Chrome profile.`);
237
+ guidance.push(`First-time setup: run oracle --engine browser --browser-manual-login --browser-keep-browser --browser-manual-login-profile-dir ${JSON.stringify(profile)} -p "HI", sign into ChatGPT in that window, then retry the consult.`);
238
+ guidance.push("If this profile is not signed in, non-setup MCP/browser runs fail fast instead of waiting for the full browser timeout.");
239
+ }
227
240
  }
228
241
  const desiredModel = browserConfig?.desiredModel ?? null;
229
242
  const thinkingTime = browserConfig?.thinkingTime ?? null;
@@ -251,6 +264,7 @@ export function buildConsultDryRunResolved({ resolvedEngine, runOptions, browser
251
264
  researchMode: browserConfig?.researchMode ?? null,
252
265
  attachments: runOptions.browserAttachments,
253
266
  bundleFiles: runOptions.browserBundleFiles,
267
+ bundleFormat: runOptions.browserBundleFormat,
254
268
  keepBrowser: browserConfig?.keepBrowser,
255
269
  manualLogin: browserConfig?.manualLogin,
256
270
  profileDir: browserConfig?.manualLoginProfileDir ?? null,
@@ -277,6 +291,7 @@ export function formatConsultDryRunResolved(details) {
277
291
  lines.push(` browser research mode: ${details.browser.researchMode ?? "off"}`);
278
292
  lines.push(` browser attachments: ${details.browser.attachments ?? "auto"}`);
279
293
  lines.push(` browser bundle files: ${details.browser.bundleFiles ? "yes" : "no"}`);
294
+ lines.push(` browser bundle format: ${details.browser.bundleFormat ?? "text"}`);
280
295
  lines.push(` browser keep browser: ${details.browser.keepBrowser ? "yes" : "no"}`);
281
296
  lines.push(` browser manual login: ${details.browser.manualLogin ? "yes" : "no"}`);
282
297
  if (details.browser.profileDir) {
@@ -295,7 +310,7 @@ export function formatConsultDryRunResolved(details) {
295
310
  export function registerConsultTool(server) {
296
311
  server.registerTool("consult", {
297
312
  title: "Run an oracle session",
298
- description: 'Run an Oracle session (API or ChatGPT browser automation). Use `files` to attach project context. If `engine` is omitted, Oracle follows CLI defaults: config/ORACLE_ENGINE first, then API when OPENAI_API_KEY is set, otherwise browser. Browser GPT-5.5 Pro consults can take many minutes; use `dryRun:true` first when configuring an agent and inspect `sessions`/`oracle status` before retrying. For browser-based image/file uploads, set `browserAttachments:"always"`. Browser consults can include `browserFollowUps` for a multi-turn ChatGPT review in one conversation. Sessions are stored under `ORACLE_HOME_DIR` (shared with the CLI).',
313
+ description: 'Run an Oracle session (API or ChatGPT browser automation). Use `files` to attach project context. If `engine` is omitted, Oracle follows CLI defaults: config/ORACLE_ENGINE first, then API when OPENAI_API_KEY is set, otherwise browser. Browser GPT-5.5 Pro consults can take many minutes; use `dryRun:true` first when configuring an agent and inspect `sessions`/`oracle status` before retrying. Browser manual-login uses a private Oracle Chrome profile separate from the user\'s normal Chrome; dry-run output includes first-time setup guidance when that path is active. For browser-based image/file uploads, set `browserAttachments:"always"`. Browser consults can include `browserFollowUps` for a multi-turn ChatGPT review in one conversation. Sessions are stored under `ORACLE_HOME_DIR` (shared with the CLI).',
299
314
  // Cast to any to satisfy SDK typings across differing Zod versions.
300
315
  inputSchema: consultInputShape,
301
316
  outputSchema: consultOutputShape,
@@ -311,7 +326,7 @@ export function registerConsultTool(server) {
311
326
  content: textContent(error instanceof Error ? error.message : String(error)),
312
327
  };
313
328
  }
314
- const { prompt, files, model, models, engine, search, browserModelLabel, browserAttachments, browserBundleFiles, browserThinkingTime, browserModelStrategy, browserResearchMode, browserArchive, browserFollowUps, browserKeepBrowser, dryRun, slug, } = parsedInput;
329
+ const { prompt, files, model, models, engine, search, browserModelLabel, browserAttachments, browserBundleFiles, browserBundleFormat, browserThinkingTime, browserModelStrategy, browserResearchMode, browserArchive, browserFollowUps, browserKeepBrowser, dryRun, slug, } = parsedInput;
315
330
  const { config: userConfig } = await loadUserConfig();
316
331
  const { runOptions, resolvedEngine } = mapConsultToRunOptions({
317
332
  prompt,
@@ -322,6 +337,7 @@ export function registerConsultTool(server) {
322
337
  search,
323
338
  browserAttachments,
324
339
  browserBundleFiles,
340
+ browserBundleFormat,
325
341
  browserFollowUps,
326
342
  userConfig,
327
343
  env: process.env,
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  export const CONSULT_PRESETS = ["chatgpt-pro-heavy"];
3
- export const consultInputSchema = z.object({
3
+ export const consultInputSchema = z
4
+ .object({
4
5
  preset: z.enum(CONSULT_PRESETS).optional(),
5
6
  prompt: z.string().min(1, "Prompt is required."),
6
7
  files: z.array(z.string()).default([]),
@@ -10,6 +11,7 @@ export const consultInputSchema = z.object({
10
11
  browserModelLabel: z.string().optional(),
11
12
  browserAttachments: z.enum(["auto", "never", "always"]).optional(),
12
13
  browserBundleFiles: z.boolean().optional(),
14
+ browserBundleFormat: z.enum(["text", "zip"]).optional(),
13
15
  browserThinkingTime: z.enum(["light", "standard", "extended", "heavy"]).optional(),
14
16
  browserModelStrategy: z.enum(["select", "current", "ignore"]).optional(),
15
17
  browserResearchMode: z.enum(["deep"]).optional(),
@@ -19,7 +21,8 @@ export const consultInputSchema = z.object({
19
21
  dryRun: z.boolean().optional(),
20
22
  search: z.boolean().optional(),
21
23
  slug: z.string().optional(),
22
- });
24
+ })
25
+ .strict();
23
26
  export const sessionsInputSchema = z.object({
24
27
  id: z.string().optional(),
25
28
  hours: z.number().optional(),
@@ -1,6 +1,6 @@
1
1
  import { resolveRunOptionsFromConfig } from "../cli/runOptions.js";
2
2
  import { Launcher } from "chrome-launcher";
3
- export function mapConsultToRunOptions({ prompt, files, model, models, engine, search, browserAttachments, browserBundleFiles, browserFollowUps, userConfig, env = process.env, }) {
3
+ export function mapConsultToRunOptions({ prompt, files, model, models, engine, search, browserAttachments, browserBundleFiles, browserBundleFormat, browserFollowUps, userConfig, env = process.env, }) {
4
4
  // Normalize CLI-style inputs through the shared resolver so config/env defaults apply,
5
5
  // then overlay MCP-only overrides such as explicit search toggles.
6
6
  const mergedModels = Array.isArray(models) && models.length > 0
@@ -24,6 +24,9 @@ export function mapConsultToRunOptions({ prompt, files, model, models, engine, s
24
24
  if (typeof browserBundleFiles === "boolean") {
25
25
  result.runOptions.browserBundleFiles = browserBundleFiles;
26
26
  }
27
+ if (browserBundleFormat) {
28
+ result.runOptions.browserBundleFormat = browserBundleFormat;
29
+ }
27
30
  if (Array.isArray(browserFollowUps)) {
28
31
  result.runOptions.browserFollowUps = browserFollowUps
29
32
  .map((entry) => entry.trim())
@@ -0,0 +1,17 @@
1
+ const NATIVE_API_HOSTS = [
2
+ "api.openai.com",
3
+ "api.anthropic.com",
4
+ "generativelanguage.googleapis.com",
5
+ "api.x.ai",
6
+ ];
7
+ export function isCustomBaseUrl(baseUrl) {
8
+ if (!baseUrl)
9
+ return false;
10
+ try {
11
+ const url = new URL(baseUrl);
12
+ return !NATIVE_API_HOSTS.some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`));
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
@@ -4,28 +4,7 @@ import { createRequire } from "node:module";
4
4
  import { createGeminiClient } from "./gemini.js";
5
5
  import { createClaudeClient } from "./claude.js";
6
6
  import { isOpenRouterBaseUrl } from "./modelResolver.js";
7
- /**
8
- * Known native API base URLs that should still use their dedicated SDKs.
9
- * Any other custom base URL is treated as an OpenAI-compatible proxy and
10
- * all models are routed through the chat/completions adapter.
11
- */
12
- const NATIVE_API_HOSTS = [
13
- "api.openai.com",
14
- "api.anthropic.com",
15
- "generativelanguage.googleapis.com",
16
- "api.x.ai",
17
- ];
18
- export function isCustomBaseUrl(baseUrl) {
19
- if (!baseUrl)
20
- return false;
21
- try {
22
- const url = new URL(baseUrl);
23
- return !NATIVE_API_HOSTS.some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`));
24
- }
25
- catch {
26
- return false;
27
- }
28
- }
7
+ import { isCustomBaseUrl } from "./baseUrl.js";
29
8
  export function buildAzureResponsesBaseUrl(endpoint) {
30
9
  return `${endpoint.replace(/\/+$/, "")}/openai/v1`;
31
10
  }
@@ -1,7 +1,9 @@
1
- import { countTokens as countTokensGpt5 } from "gpt-tokenizer/model/gpt-5";
2
- import { countTokens as countTokensGpt5Pro } from "gpt-tokenizer/model/gpt-5-pro";
3
- import { countTokens as countTokensAnthropicRaw } from "@anthropic-ai/tokenizer";
1
+ import { createRequire } from "node:module";
4
2
  import { stringifyTokenizerInput } from "./tokenStringifier.js";
3
+ const require = createRequire(import.meta.url);
4
+ let countTokensGpt5Impl;
5
+ let countTokensGpt5ProImpl;
6
+ let countTokensAnthropicImpl;
5
7
  export const DEFAULT_MODEL = "gpt-5.5-pro";
6
8
  export const PRO_MODELS = new Set([
7
9
  "gpt-5.5-pro",
@@ -12,7 +14,18 @@ export const PRO_MODELS = new Set([
12
14
  "claude-4.6-sonnet",
13
15
  "claude-4.1-opus",
14
16
  ]);
15
- const countTokensAnthropic = (input) => countTokensAnthropicRaw(stringifyTokenizerInput(input));
17
+ const countTokensGpt5 = (input, options) => {
18
+ countTokensGpt5Impl ??= require("gpt-tokenizer/model/gpt-5").countTokens;
19
+ return countTokensGpt5Impl(input, options);
20
+ };
21
+ const countTokensGpt5Pro = (input, options) => {
22
+ countTokensGpt5ProImpl ??= require("gpt-tokenizer/model/gpt-5-pro").countTokens;
23
+ return countTokensGpt5ProImpl(input, options);
24
+ };
25
+ const countTokensAnthropic = (input) => {
26
+ countTokensAnthropicImpl ??= require("@anthropic-ai/tokenizer").countTokens;
27
+ return countTokensAnthropicImpl(stringifyTokenizerInput(input));
28
+ };
16
29
  export const MODEL_CONFIGS = {
17
30
  "gpt-5.5-pro": {
18
31
  model: "gpt-5.5-pro",
@@ -1,26 +1,6 @@
1
1
  import { GoogleGenAI, HarmCategory, HarmBlockThreshold, } from "@google/genai";
2
- const MODEL_ID_MAP = {
3
- "gemini-3.1-pro": "gemini-3.1-pro-preview",
4
- "gemini-3-pro": "gemini-3-pro-preview",
5
- "gpt-5.5": "gpt-5.5",
6
- "gpt-5.5-pro": "gpt-5.5-pro",
7
- "gpt-5.4": "gpt-5.4",
8
- "gpt-5.4-pro": "gpt-5.4-pro",
9
- "gpt-5.1-pro": "gpt-5.1-pro",
10
- "gpt-5-pro": "gpt-5-pro",
11
- "gpt-5.1": "gpt-5.1",
12
- "gpt-5.1-codex": "gpt-5.1-codex",
13
- "gpt-5.2": "gpt-5.2",
14
- "gpt-5.2-instant": "gpt-5.2-instant",
15
- "gpt-5.2-pro": "gpt-5.2-pro",
16
- "claude-4.6-sonnet": "claude-4.6-sonnet",
17
- "claude-4.1-opus": "claude-4.1-opus",
18
- "grok-4.1": "grok-4.1",
19
- };
20
- export function resolveGeminiModelId(modelName) {
21
- // Map our logical Gemini names to the exact model ids expected by the SDK.
22
- return MODEL_ID_MAP[modelName] ?? modelName;
23
- }
2
+ import { resolveGeminiModelId } from "./geminiModels.js";
3
+ export { resolveGeminiModelId } from "./geminiModels.js";
24
4
  export function createGeminiClient(apiKey, modelName = "gemini-3-pro", resolvedModelId) {
25
5
  const modelId = resolvedModelId ?? resolveGeminiModelId(modelName);
26
6
  const genAI = new GoogleGenAI({ apiKey });
@@ -0,0 +1,21 @@
1
+ const MODEL_ID_MAP = {
2
+ "gemini-3.1-pro": "gemini-3.1-pro-preview",
3
+ "gemini-3-pro": "gemini-3-pro-preview",
4
+ "gpt-5.5": "gpt-5.5",
5
+ "gpt-5.5-pro": "gpt-5.5-pro",
6
+ "gpt-5.4": "gpt-5.4",
7
+ "gpt-5.4-pro": "gpt-5.4-pro",
8
+ "gpt-5.1-pro": "gpt-5.1-pro",
9
+ "gpt-5-pro": "gpt-5-pro",
10
+ "gpt-5.1": "gpt-5.1",
11
+ "gpt-5.1-codex": "gpt-5.1-codex",
12
+ "gpt-5.2": "gpt-5.2",
13
+ "gpt-5.2-instant": "gpt-5.2-instant",
14
+ "gpt-5.2-pro": "gpt-5.2-pro",
15
+ "claude-4.6-sonnet": "claude-4.6-sonnet",
16
+ "claude-4.1-opus": "claude-4.1-opus",
17
+ "grok-4.1": "grok-4.1",
18
+ };
19
+ export function resolveGeminiModelId(modelName) {
20
+ return MODEL_ID_MAP[modelName] ?? modelName;
21
+ }
@@ -1,8 +1,14 @@
1
+ import { createRequire } from "node:module";
1
2
  import { MODEL_CONFIGS, PRO_MODELS } from "./config.js";
2
- import { countTokens as countTokensGpt5Pro } from "gpt-tokenizer/model/gpt-5-pro";
3
3
  import { pricingFromUsdPerMillion } from "tokentally";
4
4
  const OPENROUTER_DEFAULT_BASE = "https://openrouter.ai/api/v1";
5
5
  const OPENROUTER_MODELS_ENDPOINT = "https://openrouter.ai/api/v1/models";
6
+ const require = createRequire(import.meta.url);
7
+ let countTokensGpt5ProImpl;
8
+ const countTokensGpt5Pro = (input, options) => {
9
+ countTokensGpt5ProImpl ??= require("gpt-tokenizer/model/gpt-5-pro").countTokens;
10
+ return countTokensGpt5ProImpl(input, options);
11
+ };
6
12
  export function isKnownModel(model) {
7
13
  return Object.hasOwn(MODEL_CONFIGS, model);
8
14
  }
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { runOracle, OracleResponseError, OracleTransportError, extractResponseMetadata, asOracleUserError, extractTextOutput, } from "../oracle.js";
3
+ import { runOracle, OracleResponseError, OracleTransportError, extractResponseMetadata, asOracleUserError, extractTextOutput, classifyProviderFailure, } from "../oracle.js";
4
4
  import { sessionStore } from "../sessionStore.js";
5
5
  import { findOscProgressSequences, OSC_PROGRESS_PREFIX } from "osc-progress";
6
6
  function forwardOscProgress(chunk, shouldForward) {
@@ -113,6 +113,13 @@ function startModelExecution({ sessionMeta, runOptions, model, cwd, store, runOr
113
113
  })()
114
114
  .catch(async (error) => {
115
115
  const userError = asOracleUserError(error);
116
+ const providerFailure = classifyProviderFailure(error, {
117
+ model,
118
+ providerMode: runOptions.provider,
119
+ azure: runOptions.azure,
120
+ baseUrl: runOptions.baseUrl,
121
+ apiKey: runOptions.apiKey,
122
+ });
116
123
  const responseMetadata = error instanceof OracleResponseError ? error.metadata : undefined;
117
124
  const transportMetadata = error instanceof OracleTransportError ? { reason: error.reason } : undefined;
118
125
  await store.updateModelRun(sessionMeta.id, model, {
@@ -126,7 +133,18 @@ function startModelExecution({ sessionMeta, runOptions, model, cwd, store, runOr
126
133
  message: userError.message,
127
134
  details: userError.details,
128
135
  }
129
- : undefined,
136
+ : providerFailure
137
+ ? {
138
+ category: providerFailure.category,
139
+ message: providerFailure.label,
140
+ details: {
141
+ provider: providerFailure.provider,
142
+ keyEnv: providerFailure.keyEnv,
143
+ providerMessage: providerFailure.providerMessage,
144
+ fix: providerFailure.fix,
145
+ },
146
+ }
147
+ : undefined,
130
148
  log: await describeLog(sessionMeta.id, logWriter.logPath, store),
131
149
  });
132
150
  throw error;