@steipete/oracle 0.4.5 → 0.5.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 (48) hide show
  1. package/README.md +11 -9
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +16 -48
  4. package/dist/scripts/agent-send.js +147 -0
  5. package/dist/scripts/docs-list.js +110 -0
  6. package/dist/scripts/git-policy.js +125 -0
  7. package/dist/scripts/runner.js +1378 -0
  8. package/dist/scripts/test-browser.js +103 -0
  9. package/dist/scripts/test-remote-chrome.js +68 -0
  10. package/dist/src/browser/actions/attachments.js +47 -16
  11. package/dist/src/browser/actions/promptComposer.js +67 -18
  12. package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
  13. package/dist/src/browser/chromeCookies.js +44 -6
  14. package/dist/src/browser/chromeLifecycle.js +166 -25
  15. package/dist/src/browser/config.js +25 -1
  16. package/dist/src/browser/constants.js +22 -3
  17. package/dist/src/browser/index.js +384 -22
  18. package/dist/src/browser/profileSync.js +141 -0
  19. package/dist/src/browser/prompt.js +3 -1
  20. package/dist/src/browser/reattach.js +59 -0
  21. package/dist/src/browser/sessionRunner.js +15 -1
  22. package/dist/src/browser/windowsCookies.js +2 -1
  23. package/dist/src/cli/browserConfig.js +11 -0
  24. package/dist/src/cli/browserDefaults.js +41 -0
  25. package/dist/src/cli/detach.js +2 -2
  26. package/dist/src/cli/dryRun.js +4 -2
  27. package/dist/src/cli/engine.js +2 -2
  28. package/dist/src/cli/help.js +2 -2
  29. package/dist/src/cli/options.js +2 -1
  30. package/dist/src/cli/runOptions.js +1 -1
  31. package/dist/src/cli/sessionDisplay.js +102 -104
  32. package/dist/src/cli/sessionRunner.js +39 -6
  33. package/dist/src/cli/sessionTable.js +88 -0
  34. package/dist/src/cli/tui/index.js +19 -89
  35. package/dist/src/heartbeat.js +2 -2
  36. package/dist/src/oracle/background.js +10 -2
  37. package/dist/src/oracle/client.js +107 -0
  38. package/dist/src/oracle/config.js +10 -2
  39. package/dist/src/oracle/errors.js +24 -4
  40. package/dist/src/oracle/modelResolver.js +144 -0
  41. package/dist/src/oracle/oscProgress.js +1 -1
  42. package/dist/src/oracle/run.js +83 -34
  43. package/dist/src/oracle/runUtils.js +12 -8
  44. package/dist/src/remote/server.js +214 -23
  45. package/dist/src/sessionManager.js +5 -2
  46. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  47. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  48. package/package.json +14 -14
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { createGeminiClient } from './gemini.js';
5
5
  import { createClaudeClient } from './claude.js';
6
+ import { isOpenRouterBaseUrl } from './modelResolver.js';
6
7
  export function createDefaultClientFactory() {
7
8
  const customFactory = loadCustomClientFactory();
8
9
  if (customFactory)
@@ -16,6 +17,8 @@ export function createDefaultClientFactory() {
16
17
  return createClaudeClient(key, options.model, options.resolvedModelId, options.baseUrl);
17
18
  }
18
19
  let instance;
20
+ const openRouter = isOpenRouterBaseUrl(options?.baseUrl);
21
+ const defaultHeaders = openRouter ? buildOpenRouterHeaders() : undefined;
19
22
  if (options?.azure?.endpoint) {
20
23
  instance = new AzureOpenAI({
21
24
  apiKey: key,
@@ -30,8 +33,12 @@ export function createDefaultClientFactory() {
30
33
  apiKey: key,
31
34
  timeout: 20 * 60 * 1000,
32
35
  baseURL: options?.baseUrl,
36
+ defaultHeaders,
33
37
  });
34
38
  }
39
+ if (openRouter) {
40
+ return buildOpenRouterCompletionClient(instance);
41
+ }
35
42
  return {
36
43
  responses: {
37
44
  stream: (body) => instance.responses.stream(body),
@@ -41,6 +48,18 @@ export function createDefaultClientFactory() {
41
48
  };
42
49
  };
43
50
  }
51
+ function buildOpenRouterHeaders() {
52
+ const headers = {};
53
+ const referer = process.env.OPENROUTER_REFERER ?? process.env.OPENROUTER_HTTP_REFERER ?? 'https://github.com/steipete/oracle';
54
+ const title = process.env.OPENROUTER_TITLE ?? 'Oracle CLI';
55
+ if (referer) {
56
+ headers['HTTP-Referer'] = referer;
57
+ }
58
+ if (title) {
59
+ headers['X-Title'] = title;
60
+ }
61
+ return headers;
62
+ }
44
63
  function loadCustomClientFactory() {
45
64
  const override = process.env.ORACLE_CLIENT_FACTORY;
46
65
  if (!override) {
@@ -85,3 +104,91 @@ function loadCustomClientFactory() {
85
104
  }
86
105
  // Exposed for tests
87
106
  export { loadCustomClientFactory as __loadCustomClientFactory };
107
+ function buildOpenRouterCompletionClient(instance) {
108
+ const adaptRequest = (body) => {
109
+ const messages = [];
110
+ if (body.instructions) {
111
+ messages.push({ role: 'system', content: body.instructions });
112
+ }
113
+ for (const entry of body.input) {
114
+ const textParts = entry.content
115
+ .map((c) => (c.type === 'input_text' ? c.text : ''))
116
+ .filter((t) => t)
117
+ .join('\n\n');
118
+ messages.push({ role: entry.role ?? 'user', content: textParts });
119
+ }
120
+ const base = {
121
+ model: body.model,
122
+ messages,
123
+ max_tokens: body.max_output_tokens,
124
+ };
125
+ const streaming = { ...base, stream: true };
126
+ const nonStreaming = { ...base, stream: false };
127
+ return { streaming, nonStreaming };
128
+ };
129
+ const adaptResponse = (response) => {
130
+ const text = response.choices?.[0]?.message?.content ?? '';
131
+ const usage = {
132
+ input_tokens: response.usage?.prompt_tokens ?? 0,
133
+ output_tokens: response.usage?.completion_tokens ?? 0,
134
+ total_tokens: response.usage?.total_tokens ?? 0,
135
+ };
136
+ return {
137
+ id: response.id ?? `openrouter-${Date.now()}`,
138
+ status: 'completed',
139
+ output_text: [text],
140
+ output: [{ type: 'text', text }],
141
+ usage,
142
+ };
143
+ };
144
+ const stream = async (body) => {
145
+ const { streaming } = adaptRequest(body);
146
+ let finalUsage;
147
+ let finalId;
148
+ let aggregated = '';
149
+ async function* iterator() {
150
+ const completion = await instance.chat.completions.create(streaming);
151
+ for await (const chunk of completion) {
152
+ finalId = chunk.id ?? finalId;
153
+ const delta = chunk.choices?.[0]?.delta?.content ?? '';
154
+ if (delta) {
155
+ aggregated += delta;
156
+ yield { type: 'chunk', delta };
157
+ }
158
+ if (chunk.usage) {
159
+ finalUsage = chunk.usage;
160
+ }
161
+ }
162
+ }
163
+ const gen = iterator();
164
+ return {
165
+ [Symbol.asyncIterator]() {
166
+ return gen;
167
+ },
168
+ async finalResponse() {
169
+ return adaptResponse({
170
+ id: finalId ?? `openrouter-${Date.now()}`,
171
+ choices: [{ message: { role: 'assistant', content: aggregated } }],
172
+ usage: finalUsage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
173
+ created: Math.floor(Date.now() / 1000),
174
+ model: '',
175
+ object: 'chat.completion',
176
+ });
177
+ },
178
+ };
179
+ };
180
+ const create = async (body) => {
181
+ const { nonStreaming } = adaptRequest(body);
182
+ const response = (await instance.chat.completions.create(nonStreaming));
183
+ return adaptResponse(response);
184
+ };
185
+ return {
186
+ responses: {
187
+ stream,
188
+ create,
189
+ retrieve: async () => {
190
+ throw new Error('retrieve is not supported for OpenRouter chat/completions fallback.');
191
+ },
192
+ },
193
+ };
194
+ }
@@ -3,11 +3,12 @@ import { countTokens as countTokensGpt5Pro } from 'gpt-tokenizer/model/gpt-5-pro
3
3
  import { countTokens as countTokensAnthropicRaw } from '@anthropic-ai/tokenizer';
4
4
  import { stringifyTokenizerInput } from './tokenStringifier.js';
5
5
  export const DEFAULT_MODEL = 'gpt-5.1-pro';
6
- export const PRO_MODELS = new Set(['gpt-5.1-pro', 'gpt-5-pro', 'claude-4.1-opus']);
6
+ export const PRO_MODELS = new Set(['gpt-5.1-pro', 'gpt-5-pro', 'claude-4.5-sonnet', 'claude-4.1-opus']);
7
7
  const countTokensAnthropic = (input) => countTokensAnthropicRaw(stringifyTokenizerInput(input));
8
8
  export const MODEL_CONFIGS = {
9
9
  'gpt-5.1-pro': {
10
10
  model: 'gpt-5.1-pro',
11
+ provider: 'openai',
11
12
  tokenizer: countTokensGpt5Pro,
12
13
  inputLimit: 196000,
13
14
  pricing: {
@@ -18,6 +19,7 @@ export const MODEL_CONFIGS = {
18
19
  },
19
20
  'gpt-5-pro': {
20
21
  model: 'gpt-5-pro',
22
+ provider: 'openai',
21
23
  tokenizer: countTokensGpt5Pro,
22
24
  inputLimit: 196000,
23
25
  pricing: {
@@ -28,6 +30,7 @@ export const MODEL_CONFIGS = {
28
30
  },
29
31
  'gpt-5.1': {
30
32
  model: 'gpt-5.1',
33
+ provider: 'openai',
31
34
  tokenizer: countTokensGpt5,
32
35
  inputLimit: 196000,
33
36
  pricing: {
@@ -38,6 +41,7 @@ export const MODEL_CONFIGS = {
38
41
  },
39
42
  'gpt-5.1-codex': {
40
43
  model: 'gpt-5.1-codex',
44
+ provider: 'openai',
41
45
  tokenizer: countTokensGpt5,
42
46
  inputLimit: 196000,
43
47
  pricing: {
@@ -48,6 +52,7 @@ export const MODEL_CONFIGS = {
48
52
  },
49
53
  'gemini-3-pro': {
50
54
  model: 'gemini-3-pro',
55
+ provider: 'google',
51
56
  tokenizer: countTokensGpt5Pro,
52
57
  inputLimit: 200000,
53
58
  pricing: {
@@ -61,6 +66,7 @@ export const MODEL_CONFIGS = {
61
66
  'claude-4.5-sonnet': {
62
67
  model: 'claude-4.5-sonnet',
63
68
  apiModel: 'claude-sonnet-4-5',
69
+ provider: 'anthropic',
64
70
  tokenizer: countTokensAnthropic,
65
71
  inputLimit: 200000,
66
72
  pricing: {
@@ -74,6 +80,7 @@ export const MODEL_CONFIGS = {
74
80
  'claude-4.1-opus': {
75
81
  model: 'claude-4.1-opus',
76
82
  apiModel: 'claude-opus-4-1',
83
+ provider: 'anthropic',
77
84
  tokenizer: countTokensAnthropic,
78
85
  inputLimit: 200000,
79
86
  pricing: {
@@ -87,6 +94,7 @@ export const MODEL_CONFIGS = {
87
94
  'grok-4.1': {
88
95
  model: 'grok-4.1',
89
96
  apiModel: 'grok-4-1-fast-reasoning',
97
+ provider: 'xai',
90
98
  tokenizer: countTokensGpt5Pro,
91
99
  inputLimit: 2_000_000,
92
100
  pricing: {
@@ -101,6 +109,6 @@ export const MODEL_CONFIGS = {
101
109
  };
102
110
  export const DEFAULT_SYSTEM_PROMPT = [
103
111
  'You are Oracle, a focused one-shot problem solver.',
104
- 'Emphasize direct answers, cite any files referenced, and clearly note when the search tool was used.',
112
+ 'Emphasize direct answers and cite any files referenced.',
105
113
  ].join(' ');
106
114
  export const TOKENIZER_OPTIONS = { allowedSpecial: 'all' };
@@ -74,7 +74,7 @@ export function extractResponseMetadata(response) {
74
74
  }
75
75
  return metadata;
76
76
  }
77
- export function toTransportError(error) {
77
+ export function toTransportError(error, model) {
78
78
  if (error instanceof OracleTransportError) {
79
79
  return error;
80
80
  }
@@ -87,10 +87,26 @@ export function toTransportError(error) {
87
87
  if (error instanceof APIConnectionError) {
88
88
  return new OracleTransportError('connection-lost', 'Connection to OpenAI dropped before the response completed.', error);
89
89
  }
90
- if (error instanceof APIError) {
91
- if (error.status === 404 || error.status === 405) {
92
- return new OracleTransportError('unsupported-endpoint', 'HTTP 404/405 from the Responses API; this base URL or gateway likely does not expose /v1/responses. Set OPENAI_BASE_URL to api.openai.com/v1, update your Azure API version/deployment, or use the browser engine.', error);
90
+ const isApiError = error instanceof APIError || error?.name === 'APIError';
91
+ if (isApiError) {
92
+ const apiError = error;
93
+ const code = apiError.code ?? apiError.error?.code;
94
+ const messageText = apiError.message?.toLowerCase?.() ?? '';
95
+ const apiMessage = apiError.error?.message ||
96
+ apiError.message ||
97
+ (apiError.status ? `${apiError.status} OpenAI API error` : 'OpenAI API error');
98
+ // TODO: Remove once gpt-5.1-pro is available via the Responses API.
99
+ if (model === 'gpt-5.1-pro' &&
100
+ (code === 'model_not_found' ||
101
+ messageText.includes('does not exist') ||
102
+ messageText.includes('unknown model') ||
103
+ messageText.includes('model_not_found'))) {
104
+ return new OracleTransportError('model-unavailable', 'gpt-5.1-pro is not yet available on this API base. Use gpt-5-pro for now until OpenAI enables it. // TODO: Remove once gpt-5.1-pro is available', apiError);
93
105
  }
106
+ if (apiError.status === 404 || apiError.status === 405) {
107
+ return new OracleTransportError('unsupported-endpoint', 'HTTP 404/405 from the Responses API; this base URL or gateway likely does not expose /v1/responses. Set OPENAI_BASE_URL to api.openai.com/v1, update your Azure API version/deployment, or use the browser engine.', apiError);
108
+ }
109
+ return new OracleTransportError('api-error', apiMessage, apiError);
94
110
  }
95
111
  return new OracleTransportError('unknown', error instanceof Error ? error.message : 'Unknown transport failure.', error);
96
112
  }
@@ -104,6 +120,10 @@ export function describeTransportError(error, deadlineMs) {
104
120
  return 'Connection to OpenAI ended unexpectedly before the response completed.';
105
121
  case 'client-abort':
106
122
  return 'Request was aborted before OpenAI completed the response.';
123
+ case 'api-error':
124
+ return error.message;
125
+ case 'model-unavailable':
126
+ return error.message;
107
127
  case 'unsupported-endpoint':
108
128
  return 'The Responses API returned 404/405 — your base URL/gateway probably lacks /v1/responses (check OPENAI_BASE_URL or switch to api.openai.com / browser engine).';
109
129
  default:
@@ -0,0 +1,144 @@
1
+ import { MODEL_CONFIGS, PRO_MODELS } from './config.js';
2
+ import { countTokens as countTokensGpt5Pro } from 'gpt-tokenizer/model/gpt-5-pro';
3
+ const OPENROUTER_DEFAULT_BASE = 'https://openrouter.ai/api/v1';
4
+ const OPENROUTER_MODELS_ENDPOINT = 'https://openrouter.ai/api/v1/models';
5
+ export function isKnownModel(model) {
6
+ return Object.hasOwn(MODEL_CONFIGS, model);
7
+ }
8
+ export function isOpenRouterBaseUrl(baseUrl) {
9
+ if (!baseUrl)
10
+ return false;
11
+ try {
12
+ const url = new URL(baseUrl);
13
+ return url.hostname.includes('openrouter.ai');
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ export function defaultOpenRouterBaseUrl() {
20
+ return OPENROUTER_DEFAULT_BASE;
21
+ }
22
+ export function normalizeOpenRouterBaseUrl(baseUrl) {
23
+ try {
24
+ const url = new URL(baseUrl);
25
+ // If user passed the responses endpoint, trim it so the client does not double-append.
26
+ if (url.pathname.endsWith('/responses')) {
27
+ url.pathname = url.pathname.replace(/\/responses\/?$/, '');
28
+ }
29
+ return url.toString().replace(/\/+$/, '');
30
+ }
31
+ catch {
32
+ return baseUrl;
33
+ }
34
+ }
35
+ export function safeModelSlug(model) {
36
+ return model.replace(/[/\\]/g, '__').replace(/[:*?"<>|]/g, '_');
37
+ }
38
+ const catalogCache = new Map();
39
+ const CACHE_TTL_MS = 5 * 60 * 1000;
40
+ async function fetchOpenRouterCatalog(apiKey, fetcher) {
41
+ const cached = catalogCache.get(apiKey);
42
+ const now = Date.now();
43
+ if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
44
+ return cached.models;
45
+ }
46
+ const response = await fetcher(OPENROUTER_MODELS_ENDPOINT, {
47
+ headers: {
48
+ authorization: `Bearer ${apiKey}`,
49
+ },
50
+ });
51
+ if (!response.ok) {
52
+ throw new Error(`Failed to load OpenRouter models (${response.status})`);
53
+ }
54
+ const json = (await response.json());
55
+ const models = json?.data ?? [];
56
+ catalogCache.set(apiKey, { fetchedAt: now, models });
57
+ return models;
58
+ }
59
+ function mapToOpenRouterId(candidate, catalog, providerHint) {
60
+ if (candidate.includes('/'))
61
+ return candidate;
62
+ const byExact = catalog.find((entry) => entry.id === candidate);
63
+ if (byExact)
64
+ return byExact.id;
65
+ const bySuffix = catalog.find((entry) => entry.id.endsWith(`/${candidate}`));
66
+ if (bySuffix)
67
+ return bySuffix.id;
68
+ if (providerHint) {
69
+ return `${providerHint}/${candidate}`;
70
+ }
71
+ return candidate;
72
+ }
73
+ export async function resolveModelConfig(model, options = {}) {
74
+ const known = isKnownModel(model) ? MODEL_CONFIGS[model] : null;
75
+ const fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
76
+ const openRouterActive = isOpenRouterBaseUrl(options.baseUrl) || Boolean(options.openRouterApiKey);
77
+ if (known && !openRouterActive) {
78
+ return known;
79
+ }
80
+ // Try to enrich from OpenRouter catalog when available.
81
+ if (openRouterActive && options.openRouterApiKey) {
82
+ try {
83
+ const catalog = await fetchOpenRouterCatalog(options.openRouterApiKey, fetcher);
84
+ const targetId = mapToOpenRouterId(typeof model === 'string' ? model : String(model), catalog, known?.provider);
85
+ const info = catalog.find((entry) => entry.id === targetId) ?? null;
86
+ if (info) {
87
+ return {
88
+ ...(known ?? {
89
+ model,
90
+ tokenizer: countTokensGpt5Pro,
91
+ inputLimit: info.context_length ?? 200_000,
92
+ reasoning: null,
93
+ }),
94
+ apiModel: targetId,
95
+ openRouterId: targetId,
96
+ provider: known?.provider ?? 'other',
97
+ inputLimit: info.context_length ?? known?.inputLimit ?? 200_000,
98
+ pricing: info.pricing && info.pricing.prompt != null && info.pricing.completion != null
99
+ ? {
100
+ inputPerToken: info.pricing.prompt / 1_000_000,
101
+ outputPerToken: info.pricing.completion / 1_000_000,
102
+ }
103
+ : known?.pricing ?? null,
104
+ supportsBackground: known?.supportsBackground ?? true,
105
+ supportsSearch: known?.supportsSearch ?? true,
106
+ };
107
+ }
108
+ // No metadata hit; fall through to synthesized config.
109
+ return {
110
+ ...(known ?? {
111
+ model,
112
+ tokenizer: countTokensGpt5Pro,
113
+ inputLimit: 200_000,
114
+ reasoning: null,
115
+ }),
116
+ apiModel: targetId,
117
+ openRouterId: targetId,
118
+ provider: known?.provider ?? 'other',
119
+ supportsBackground: known?.supportsBackground ?? true,
120
+ supportsSearch: known?.supportsSearch ?? true,
121
+ pricing: known?.pricing ?? null,
122
+ };
123
+ }
124
+ catch {
125
+ // If catalog fetch fails, fall back to a synthesized config.
126
+ }
127
+ }
128
+ // Synthesized generic config for custom endpoints or failed catalog fetch.
129
+ return {
130
+ ...(known ?? {
131
+ model,
132
+ tokenizer: countTokensGpt5Pro,
133
+ inputLimit: 200_000,
134
+ reasoning: null,
135
+ }),
136
+ provider: known?.provider ?? 'other',
137
+ supportsBackground: known?.supportsBackground ?? true,
138
+ supportsSearch: known?.supportsSearch ?? true,
139
+ pricing: known?.pricing ?? null,
140
+ };
141
+ }
142
+ export function isProModel(model) {
143
+ return isKnownModel(model) && PRO_MODELS.has(model);
144
+ }
@@ -55,7 +55,7 @@ export function startOscProgress(options = {}) {
55
55
  timer.unref?.();
56
56
  let stopped = false;
57
57
  return () => {
58
- // biome-ignore lint/nursery/noUnnecessaryConditions: multiple callers may try to stop
58
+ // multiple callers may try to stop
59
59
  if (stopped) {
60
60
  return;
61
61
  }
@@ -4,7 +4,7 @@ import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
6
  import { performance } from 'node:perf_hooks';
7
- import { DEFAULT_SYSTEM_PROMPT, MODEL_CONFIGS, PRO_MODELS, TOKENIZER_OPTIONS } from './config.js';
7
+ import { DEFAULT_SYSTEM_PROMPT, MODEL_CONFIGS, TOKENIZER_OPTIONS } from './config.js';
8
8
  import { readFiles } from './files.js';
9
9
  import { buildPrompt, buildRequestBody } from './request.js';
10
10
  import { estimateRequestTokens } from './tokenEstimate.js';
@@ -21,6 +21,7 @@ import { resolveClaudeModelId } from './claude.js';
21
21
  import { renderMarkdownAnsi } from '../cli/markdownRenderer.js';
22
22
  import { executeBackgroundResponse } from './background.js';
23
23
  import { formatTokenEstimate, formatTokenValue, resolvePreviewMode } from './runUtils.js';
24
+ import { defaultOpenRouterBaseUrl, isKnownModel, isOpenRouterBaseUrl, isProModel, resolveModelConfig, normalizeOpenRouterBaseUrl, } from './modelResolver.js';
24
25
  const isStdoutTty = process.stdout.isTTY && chalk.level > 0;
25
26
  const dim = (text) => (isStdoutTty ? kleur.dim(text) : text);
26
27
  // Default timeout for non-pro API runs (fast models) — give them up to 120s.
@@ -36,15 +37,42 @@ export async function runOracle(options, deps = {}) {
36
37
  : () => true;
37
38
  const isTty = allowStdout && isStdoutTty;
38
39
  const resolvedXaiBaseUrl = process.env.XAI_BASE_URL?.trim() || 'https://api.x.ai/v1';
40
+ const openRouterApiKey = process.env.OPENROUTER_API_KEY?.trim();
41
+ const defaultOpenRouterBase = defaultOpenRouterBaseUrl();
42
+ const knownModelConfig = isKnownModel(options.model) ? MODEL_CONFIGS[options.model] : undefined;
43
+ const provider = knownModelConfig?.provider ?? 'other';
44
+ const hasOpenAIKey = Boolean(optionsApiKey) ||
45
+ Boolean(process.env.OPENAI_API_KEY) ||
46
+ Boolean(process.env.AZURE_OPENAI_API_KEY && options.azure?.endpoint);
47
+ const hasAnthropicKey = Boolean(optionsApiKey) || Boolean(process.env.ANTHROPIC_API_KEY);
48
+ const hasGeminiKey = Boolean(optionsApiKey) || Boolean(process.env.GEMINI_API_KEY);
49
+ const hasXaiKey = Boolean(optionsApiKey) || Boolean(process.env.XAI_API_KEY);
39
50
  let baseUrl = options.baseUrl?.trim();
40
51
  if (!baseUrl) {
41
52
  if (options.model.startsWith('grok')) {
42
53
  baseUrl = resolvedXaiBaseUrl;
43
54
  }
55
+ else if (provider === 'anthropic') {
56
+ baseUrl = process.env.ANTHROPIC_BASE_URL?.trim();
57
+ }
44
58
  else {
45
59
  baseUrl = process.env.OPENAI_BASE_URL?.trim();
46
60
  }
47
61
  }
62
+ const providerKeyMissing = (provider === 'openai' && !hasOpenAIKey) ||
63
+ (provider === 'anthropic' && !hasAnthropicKey) ||
64
+ (provider === 'google' && !hasGeminiKey) ||
65
+ (provider === 'xai' && !hasXaiKey) ||
66
+ provider === 'other';
67
+ const openRouterFallback = providerKeyMissing && Boolean(openRouterApiKey);
68
+ if (!baseUrl || openRouterFallback) {
69
+ if (openRouterFallback) {
70
+ baseUrl = defaultOpenRouterBase;
71
+ }
72
+ }
73
+ if (baseUrl && isOpenRouterBaseUrl(baseUrl)) {
74
+ baseUrl = normalizeOpenRouterBaseUrl(baseUrl);
75
+ }
48
76
  const logVerbose = (message) => {
49
77
  if (options.verbose) {
50
78
  log(dim(`[verbose] ${message}`));
@@ -54,51 +82,62 @@ export async function runOracle(options, deps = {}) {
54
82
  const isPreview = Boolean(previewMode);
55
83
  const isAzureOpenAI = Boolean(options.azure?.endpoint);
56
84
  const getApiKeyForModel = (model) => {
57
- if (model.startsWith('gpt')) {
85
+ if (isOpenRouterBaseUrl(baseUrl) || openRouterFallback) {
86
+ return { key: optionsApiKey ?? openRouterApiKey, source: 'OPENROUTER_API_KEY' };
87
+ }
88
+ if (typeof model === 'string' && model.startsWith('gpt')) {
58
89
  if (optionsApiKey)
59
- return optionsApiKey;
90
+ return { key: optionsApiKey, source: 'apiKey option' };
60
91
  if (isAzureOpenAI) {
61
- return process.env.AZURE_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY;
92
+ const key = process.env.AZURE_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY;
93
+ return { key, source: 'AZURE_OPENAI_API_KEY|OPENAI_API_KEY' };
62
94
  }
63
- return process.env.OPENAI_API_KEY;
95
+ return { key: process.env.OPENAI_API_KEY, source: 'OPENAI_API_KEY' };
64
96
  }
65
- if (model.startsWith('gemini')) {
66
- return optionsApiKey ?? process.env.GEMINI_API_KEY;
97
+ if (typeof model === 'string' && model.startsWith('gemini')) {
98
+ return { key: optionsApiKey ?? process.env.GEMINI_API_KEY, source: 'GEMINI_API_KEY' };
67
99
  }
68
- if (model.startsWith('claude')) {
69
- return optionsApiKey ?? process.env.ANTHROPIC_API_KEY;
100
+ if (typeof model === 'string' && model.startsWith('claude')) {
101
+ return { key: optionsApiKey ?? process.env.ANTHROPIC_API_KEY, source: 'ANTHROPIC_API_KEY' };
70
102
  }
71
- if (model.startsWith('grok')) {
72
- return optionsApiKey ?? process.env.XAI_API_KEY;
103
+ if (typeof model === 'string' && model.startsWith('grok')) {
104
+ return { key: optionsApiKey ?? process.env.XAI_API_KEY, source: 'XAI_API_KEY' };
73
105
  }
74
- return undefined;
106
+ return { key: optionsApiKey ?? openRouterApiKey, source: optionsApiKey ? 'apiKey option' : 'OPENROUTER_API_KEY' };
75
107
  };
76
- const envVar = options.model.startsWith('gpt')
77
- ? isAzureOpenAI
78
- ? 'AZURE_OPENAI_API_KEY (or OPENAI_API_KEY)'
79
- : 'OPENAI_API_KEY'
80
- : options.model.startsWith('gemini')
81
- ? 'GEMINI_API_KEY'
82
- : options.model.startsWith('claude')
83
- ? 'ANTHROPIC_API_KEY'
84
- : 'XAI_API_KEY';
85
- const apiKey = getApiKeyForModel(options.model);
108
+ const apiKeyResult = getApiKeyForModel(options.model);
109
+ const apiKey = apiKeyResult.key;
86
110
  if (!apiKey) {
111
+ const envVar = isOpenRouterBaseUrl(baseUrl) || openRouterFallback
112
+ ? 'OPENROUTER_API_KEY'
113
+ : options.model.startsWith('gpt')
114
+ ? isAzureOpenAI
115
+ ? 'AZURE_OPENAI_API_KEY (or OPENAI_API_KEY)'
116
+ : 'OPENAI_API_KEY'
117
+ : options.model.startsWith('gemini')
118
+ ? 'GEMINI_API_KEY'
119
+ : options.model.startsWith('claude')
120
+ ? 'ANTHROPIC_API_KEY'
121
+ : options.model.startsWith('grok')
122
+ ? 'XAI_API_KEY'
123
+ : 'OPENROUTER_API_KEY';
87
124
  throw new PromptValidationError(`Missing ${envVar}. Set it via the environment or a .env file.`, {
88
125
  env: envVar,
89
126
  });
90
127
  }
128
+ const envVar = apiKeyResult.source;
91
129
  const minPromptLength = Number.parseInt(process.env.ORACLE_MIN_PROMPT_CHARS ?? '10', 10);
92
130
  const promptLength = options.prompt?.trim().length ?? 0;
93
131
  // Enforce the short-prompt guardrail on pro-tier models because they're costly; cheaper models can run short prompts without blocking.
94
- const isProTierModel = PRO_MODELS.has(options.model);
132
+ const isProTierModel = isProModel(options.model);
95
133
  if (isProTierModel && !Number.isNaN(minPromptLength) && promptLength < minPromptLength) {
96
134
  throw new PromptValidationError(`Prompt is too short (<${minPromptLength} chars). This was likely accidental; please provide more detail.`, { minPromptLength, promptLength });
97
135
  }
98
- const modelConfig = MODEL_CONFIGS[options.model];
99
- if (!modelConfig) {
100
- throw new PromptValidationError(`Unsupported model "${options.model}". Choose one of: ${Object.keys(MODEL_CONFIGS).join(', ')}`, { model: options.model });
101
- }
136
+ const resolverOpenRouterApiKey = openRouterFallback || isOpenRouterBaseUrl(baseUrl) ? openRouterApiKey ?? apiKey : undefined;
137
+ const modelConfig = await resolveModelConfig(options.model, {
138
+ baseUrl,
139
+ openRouterApiKey: resolverOpenRouterApiKey,
140
+ });
102
141
  const isLongRunningModel = isProTierModel;
103
142
  const supportsBackground = modelConfig.supportsBackground !== false;
104
143
  const useBackground = supportsBackground ? options.background ?? isLongRunningModel : false;
@@ -227,9 +266,11 @@ export async function runOracle(options, deps = {}) {
227
266
  }
228
267
  const apiEndpoint = modelConfig.model.startsWith('gemini')
229
268
  ? undefined
230
- : modelConfig.model.startsWith('claude')
231
- ? process.env.ANTHROPIC_BASE_URL ?? baseUrl
232
- : baseUrl;
269
+ : isOpenRouterBaseUrl(baseUrl)
270
+ ? baseUrl
271
+ : modelConfig.model.startsWith('claude')
272
+ ? process.env.ANTHROPIC_BASE_URL ?? baseUrl
273
+ : baseUrl;
233
274
  const clientInstance = client ??
234
275
  clientFactory(apiKey, {
235
276
  baseUrl: apiEndpoint,
@@ -289,7 +330,15 @@ export async function runOracle(options, deps = {}) {
289
330
  elapsedMs = now() - runStart;
290
331
  }
291
332
  else {
292
- const stream = await clientInstance.responses.stream(requestBody);
333
+ let stream;
334
+ try {
335
+ stream = await clientInstance.responses.stream(requestBody);
336
+ }
337
+ catch (streamInitError) {
338
+ const transportError = toTransportError(streamInitError, requestBody.model);
339
+ log(chalk.yellow(describeTransportError(transportError, timeoutMs)));
340
+ throw transportError;
341
+ }
293
342
  let heartbeatActive = false;
294
343
  let stopHeartbeat = null;
295
344
  const stopHeartbeatNow = () => {
@@ -348,7 +397,7 @@ export async function runOracle(options, deps = {}) {
348
397
  catch (streamError) {
349
398
  // stream.abort() is not available on the interface
350
399
  stopHeartbeatNow();
351
- const transportError = toTransportError(streamError);
400
+ const transportError = toTransportError(streamError, requestBody.model);
352
401
  log(chalk.yellow(describeTransportError(transportError, timeoutMs)));
353
402
  throw transportError;
354
403
  }
@@ -364,7 +413,7 @@ export async function runOracle(options, deps = {}) {
364
413
  if (!response) {
365
414
  throw new Error('API did not return a response.');
366
415
  }
367
- // biome-ignore lint/nursery/noUnnecessaryConditions: we only add spacing when any streamed text was printed
416
+ // We only add spacing when streamed text was printed.
368
417
  if (sawTextDelta && !options.silent) {
369
418
  const fullStreamedText = streamedChunks.join('');
370
419
  const shouldRenderAfterStream = isTty && !renderPlain && fullStreamedText.length > 0;
@@ -410,7 +459,7 @@ export async function runOracle(options, deps = {}) {
410
459
  }
411
460
  const answerText = extractTextOutput(response);
412
461
  if (!options.silent) {
413
- // biome-ignore lint/nursery/noUnnecessaryConditions: flips true when streaming events arrive
462
+ // Flag flips to true when streaming events arrive.
414
463
  if (sawTextDelta) {
415
464
  // Already handled above (rendered or streamed); avoid double-printing.
416
465
  }
@@ -8,20 +8,24 @@ export function resolvePreviewMode(value) {
8
8
  }
9
9
  return undefined;
10
10
  }
11
- export function formatTokenEstimate(value, format = (text) => text) {
12
- if (value >= 1000) {
13
- const abbreviated = Math.floor(value / 100) / 10; // 4,252 -> 4.2
14
- const text = `${abbreviated.toFixed(1).replace(/\.0$/, '')}k`;
15
- return format(text);
11
+ /**
12
+ * Format a token count, abbreviating thousands as e.g. 11.38k and trimming trailing zeros.
13
+ */
14
+ export function formatTokenCount(value) {
15
+ if (Math.abs(value) >= 1000) {
16
+ const abbreviated = (value / 1000).toFixed(2).replace(/\.0+$/, '').replace(/\.([1-9]*)0$/, '.$1');
17
+ return `${abbreviated}k`;
16
18
  }
17
- const text = value.toLocaleString();
18
- return format(text);
19
+ return value.toLocaleString();
20
+ }
21
+ export function formatTokenEstimate(value, format = (text) => text) {
22
+ return format(formatTokenCount(value));
19
23
  }
20
24
  export function formatTokenValue(value, usage, index) {
21
25
  const estimatedFlag = (index === 0 && usage?.input_tokens == null) ||
22
26
  (index === 1 && usage?.output_tokens == null) ||
23
27
  (index === 2 && usage?.reasoning_tokens == null) ||
24
28
  (index === 3 && usage?.total_tokens == null);
25
- const text = value.toLocaleString();
29
+ const text = formatTokenCount(value);
26
30
  return estimatedFlag ? `${text}*` : text;
27
31
  }