@steipete/oracle 0.11.1 → 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 (47) hide show
  1. package/README.md +55 -10
  2. package/dist/bin/oracle-cli.js +440 -98
  3. package/dist/src/browser/actions/modelSelection.js +53 -15
  4. package/dist/src/browser/actions/navigation.js +5 -3
  5. package/dist/src/browser/actions/promptComposer.js +75 -18
  6. package/dist/src/browser/actions/thinkingTime.js +23 -8
  7. package/dist/src/browser/constants.js +1 -1
  8. package/dist/src/browser/index.js +41 -7
  9. package/dist/src/browser/manualLoginProfile.js +54 -0
  10. package/dist/src/browser/projectSourcesRunner.js +16 -5
  11. package/dist/src/browser/prompt.js +56 -37
  12. package/dist/src/browser/sessionRunner.js +72 -1
  13. package/dist/src/browser/utils.js +1 -47
  14. package/dist/src/browser/zipBundle.js +152 -0
  15. package/dist/src/cli/browserConfig.js +13 -11
  16. package/dist/src/cli/browserDefaults.js +2 -1
  17. package/dist/src/cli/docsCheck.js +186 -0
  18. package/dist/src/cli/engine.js +11 -4
  19. package/dist/src/cli/options.js +12 -6
  20. package/dist/src/cli/perfTrace.js +242 -0
  21. package/dist/src/cli/promptRequirement.js +2 -0
  22. package/dist/src/cli/providerDoctor.js +85 -0
  23. package/dist/src/cli/runOptions.js +46 -16
  24. package/dist/src/cli/sessionDisplay.js +39 -4
  25. package/dist/src/cli/sessionLifecycle.js +38 -0
  26. package/dist/src/cli/sessionRunner.js +228 -3
  27. package/dist/src/cli/sessionTable.js +2 -1
  28. package/dist/src/duration.js +47 -0
  29. package/dist/src/mcp/tools/consult.js +19 -3
  30. package/dist/src/mcp/types.js +1 -0
  31. package/dist/src/mcp/utils.js +4 -1
  32. package/dist/src/oracle/baseUrl.js +17 -0
  33. package/dist/src/oracle/client.js +1 -22
  34. package/dist/src/oracle/config.js +17 -4
  35. package/dist/src/oracle/gemini.js +2 -22
  36. package/dist/src/oracle/geminiModels.js +21 -0
  37. package/dist/src/oracle/modelResolver.js +7 -1
  38. package/dist/src/oracle/multiModelRunner.js +20 -2
  39. package/dist/src/oracle/providerFailures.js +204 -0
  40. package/dist/src/oracle/providerRoutePlan.js +281 -0
  41. package/dist/src/oracle/providerRouting.js +92 -0
  42. package/dist/src/oracle/run.js +157 -54
  43. package/dist/src/oracle.js +1 -0
  44. package/dist/src/remote/client.js +8 -0
  45. package/dist/src/remote/server.js +26 -0
  46. package/dist/src/sessionManager.js +5 -1
  47. package/package.json +3 -1
@@ -11,6 +11,7 @@ export const consultInputSchema = z
11
11
  browserModelLabel: z.string().optional(),
12
12
  browserAttachments: z.enum(["auto", "never", "always"]).optional(),
13
13
  browserBundleFiles: z.boolean().optional(),
14
+ browserBundleFormat: z.enum(["text", "zip"]).optional(),
14
15
  browserThinkingTime: z.enum(["light", "standard", "extended", "heavy"]).optional(),
15
16
  browserModelStrategy: z.enum(["select", "current", "ignore"]).optional(),
16
17
  browserResearchMode: z.enum(["deep"]).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;
@@ -0,0 +1,204 @@
1
+ import { OracleTransportError, asOracleUserError } from "./errors.js";
2
+ import { buildProviderRoutePlan } from "./providerRoutePlan.js";
3
+ export function classifyProviderFailure(error, context) {
4
+ const userError = asOracleUserError(error);
5
+ if (userError) {
6
+ return null;
7
+ }
8
+ const normalizedContext = normalizeContext(context);
9
+ const route = inferFailureRoute(normalizedContext);
10
+ const rawProviderMessage = extractProviderMessage(error);
11
+ const lower = rawProviderMessage.toLowerCase();
12
+ const category = classifyMessage(lower, error);
13
+ if (!category) {
14
+ return null;
15
+ }
16
+ const providerMessage = sanitizeProviderMessage(rawProviderMessage);
17
+ return {
18
+ category,
19
+ label: labelForCategory(category),
20
+ provider: route.provider,
21
+ keyEnv: route.keySource,
22
+ providerMessage,
23
+ fix: fixForCategory(category, route.provider, normalizedContext.model, route.keySource),
24
+ };
25
+ }
26
+ function classifyMessage(lower, error) {
27
+ if (isLocalPermissionError(error, lower)) {
28
+ return null;
29
+ }
30
+ if (lower.includes("expired") &&
31
+ (lower.includes("api key") || lower.includes("credential") || lower.includes("token"))) {
32
+ return "auth-expired";
33
+ }
34
+ if (lower.includes("invalid x-api-key") ||
35
+ lower.includes("invalid api key") ||
36
+ lower.includes("api key is invalid") ||
37
+ lower.includes("api key not valid") ||
38
+ lower.includes("incorrect api key") ||
39
+ lower.includes("unauthorized") ||
40
+ lower.includes("unauthenticated") ||
41
+ hasStatusCode(lower, "401")) {
42
+ return "auth-failed";
43
+ }
44
+ if (lower.includes("insufficient_quota") ||
45
+ lower.includes("quota exceeded") ||
46
+ lower.includes("billing") ||
47
+ lower.includes("resource_exhausted")) {
48
+ return "quota-exceeded";
49
+ }
50
+ if (lower.includes("rate limit") || lower.includes("rate_limit") || hasStatusCode(lower, "429")) {
51
+ return "rate-limited";
52
+ }
53
+ if (error instanceof OracleTransportError && error.reason === "model-unavailable") {
54
+ return "model-unavailable";
55
+ }
56
+ if (lower.includes("model not available") ||
57
+ lower.includes("model_not_found") ||
58
+ lower.includes("unknown model") ||
59
+ lower.includes("does not exist")) {
60
+ return "model-unavailable";
61
+ }
62
+ return null;
63
+ }
64
+ function hasStatusCode(lower, status) {
65
+ return new RegExp(`(^|\\D)${status}(\\D|$)`).test(lower);
66
+ }
67
+ function isLocalPermissionError(error, lower) {
68
+ const code = typeof error === "object" && error !== null && "code" in error
69
+ ? String(error.code).toUpperCase()
70
+ : "";
71
+ return (code === "EACCES" ||
72
+ code === "EPERM" ||
73
+ lower.includes("eacces:") ||
74
+ lower.includes("eperm:") ||
75
+ /permission denied, (open|scandir|mkdir|access|unlink|rename)\b/.test(lower));
76
+ }
77
+ function extractProviderMessage(error) {
78
+ return error instanceof Error ? error.message : String(error);
79
+ }
80
+ export function sanitizeProviderMessage(message) {
81
+ return message
82
+ .replace(/\bBearer\s+[A-Za-z0-9._\-+/=]+/gi, "Bearer [redacted]")
83
+ .replace(/\b(api[-_ ]?key\s+provided)(\s*[:=]?\s*)["']?[^"',;\s]+/gi, "$1$2[redacted]")
84
+ .replace(/\b(api[-_ ]?key|x-api-key|authorization|token)(\s*[:=]\s*)["']?[^"',;\s]+/gi, "$1$2[redacted]")
85
+ .replace(/\bsk-(?:ant-|or-)?[A-Za-z0-9_-]{8,}\b/g, "sk-...[redacted]")
86
+ .replace(/\bxai-[A-Za-z0-9_-]{8,}\b/g, "xai-...[redacted]")
87
+ .replace(/\bAIza[0-9A-Za-z_-]{8,}\b/g, "AIza...[redacted]");
88
+ }
89
+ function normalizeContext(context) {
90
+ if (typeof context === "string") {
91
+ return { model: context };
92
+ }
93
+ return context ?? {};
94
+ }
95
+ function inferFailureRoute(context) {
96
+ if (context.model) {
97
+ const plan = buildProviderRoutePlan({
98
+ model: context.model,
99
+ providerMode: context.providerMode,
100
+ azure: context.azure,
101
+ baseUrl: context.baseUrl,
102
+ apiKey: context.apiKey,
103
+ env: context.env,
104
+ });
105
+ const keySource = normalizeKeySource(plan.keySource);
106
+ if (plan.providerLabel === "OpenRouter" || plan.keySource.includes("OPENROUTER_API_KEY")) {
107
+ return { provider: "openrouter", keySource };
108
+ }
109
+ if (plan.provider === "azure")
110
+ return { provider: "azure", keySource };
111
+ if (plan.provider === "google")
112
+ return { provider: "gemini", keySource };
113
+ return { provider: plan.provider, keySource };
114
+ }
115
+ const normalized = context.model?.toLowerCase() ?? "";
116
+ const baseUrl = context.baseUrl?.toLowerCase() ?? "";
117
+ if (baseUrl.includes("openrouter.ai") || (normalized.includes("/") && !baseUrl)) {
118
+ return { provider: "openrouter", keySource: keyEnvForProvider("openrouter") };
119
+ }
120
+ if (context.azure?.endpoint?.trim() &&
121
+ context.providerMode !== "openai" &&
122
+ (normalized.startsWith("gpt") || normalized.startsWith("openai/") || !normalized.includes("/"))) {
123
+ return { provider: "azure", keySource: keyEnvForProvider("azure") };
124
+ }
125
+ if (normalized.startsWith("anthropic/"))
126
+ return providerRoute("anthropic");
127
+ if (normalized.startsWith("google/"))
128
+ return providerRoute("gemini");
129
+ if (normalized.startsWith("xai/"))
130
+ return providerRoute("xai");
131
+ if (normalized.startsWith("openai/"))
132
+ return providerRoute("openai");
133
+ if (normalized.startsWith("claude"))
134
+ return providerRoute("anthropic");
135
+ if (normalized.startsWith("gemini"))
136
+ return providerRoute("gemini");
137
+ if (normalized.startsWith("grok"))
138
+ return providerRoute("xai");
139
+ return providerRoute("openai");
140
+ }
141
+ function providerRoute(provider) {
142
+ return { provider, keySource: keyEnvForProvider(provider) };
143
+ }
144
+ function normalizeKeySource(keySource) {
145
+ if (!keySource || keySource.includes("|")) {
146
+ return undefined;
147
+ }
148
+ return keySource;
149
+ }
150
+ function keyEnvForProvider(provider) {
151
+ switch (provider) {
152
+ case "anthropic":
153
+ return "ANTHROPIC_API_KEY";
154
+ case "gemini":
155
+ return "GEMINI_API_KEY";
156
+ case "xai":
157
+ return "XAI_API_KEY";
158
+ case "azure":
159
+ return "AZURE_OPENAI_API_KEY";
160
+ case "openrouter":
161
+ return "OPENROUTER_API_KEY";
162
+ case "openai":
163
+ return "OPENAI_API_KEY";
164
+ default:
165
+ return undefined;
166
+ }
167
+ }
168
+ function doctorCommand(model) {
169
+ return model ? `oracle doctor --providers --models ${model}` : "oracle doctor --providers";
170
+ }
171
+ function labelForCategory(category) {
172
+ switch (category) {
173
+ case "auth-failed":
174
+ return "auth failed";
175
+ case "auth-expired":
176
+ return "auth expired";
177
+ case "quota-exceeded":
178
+ return "quota exceeded";
179
+ case "rate-limited":
180
+ return "rate limited";
181
+ case "model-unavailable":
182
+ return "model unavailable";
183
+ }
184
+ }
185
+ function fixForCategory(category, provider, model, keySource) {
186
+ const doctor = doctorCommand(model);
187
+ const key = keySource ?? keyEnvForProvider(provider) ?? "the provider API key";
188
+ switch (category) {
189
+ case "auth-failed":
190
+ return key === "apiKey option"
191
+ ? `check --api-key value or run \`${doctor}\``
192
+ : `refresh ${key} or run \`${doctor}\``;
193
+ case "auth-expired":
194
+ return key === "apiKey option"
195
+ ? "replace --api-key value, then rerun the failed model"
196
+ : `rotate ${key}, then rerun the failed model`;
197
+ case "quota-exceeded":
198
+ return `check ${provider} billing/quota, then rerun the failed model`;
199
+ case "rate-limited":
200
+ return "retry later or reduce parallel model fan-out";
201
+ case "model-unavailable":
202
+ return `check model access/ID with \`${doctor}\``;
203
+ }
204
+ }