@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.
- package/README.md +11 -9
- package/dist/.DS_Store +0 -0
- package/dist/bin/oracle-cli.js +16 -48
- package/dist/scripts/agent-send.js +147 -0
- package/dist/scripts/docs-list.js +110 -0
- package/dist/scripts/git-policy.js +125 -0
- package/dist/scripts/runner.js +1378 -0
- package/dist/scripts/test-browser.js +103 -0
- package/dist/scripts/test-remote-chrome.js +68 -0
- package/dist/src/browser/actions/attachments.js +47 -16
- package/dist/src/browser/actions/promptComposer.js +67 -18
- package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
- package/dist/src/browser/chromeCookies.js +44 -6
- package/dist/src/browser/chromeLifecycle.js +166 -25
- package/dist/src/browser/config.js +25 -1
- package/dist/src/browser/constants.js +22 -3
- package/dist/src/browser/index.js +384 -22
- package/dist/src/browser/profileSync.js +141 -0
- package/dist/src/browser/prompt.js +3 -1
- package/dist/src/browser/reattach.js +59 -0
- package/dist/src/browser/sessionRunner.js +15 -1
- package/dist/src/browser/windowsCookies.js +2 -1
- package/dist/src/cli/browserConfig.js +11 -0
- package/dist/src/cli/browserDefaults.js +41 -0
- package/dist/src/cli/detach.js +2 -2
- package/dist/src/cli/dryRun.js +4 -2
- package/dist/src/cli/engine.js +2 -2
- package/dist/src/cli/help.js +2 -2
- package/dist/src/cli/options.js +2 -1
- package/dist/src/cli/runOptions.js +1 -1
- package/dist/src/cli/sessionDisplay.js +102 -104
- package/dist/src/cli/sessionRunner.js +39 -6
- package/dist/src/cli/sessionTable.js +88 -0
- package/dist/src/cli/tui/index.js +19 -89
- package/dist/src/heartbeat.js +2 -2
- package/dist/src/oracle/background.js +10 -2
- package/dist/src/oracle/client.js +107 -0
- package/dist/src/oracle/config.js +10 -2
- package/dist/src/oracle/errors.js +24 -4
- package/dist/src/oracle/modelResolver.js +144 -0
- package/dist/src/oracle/oscProgress.js +1 -1
- package/dist/src/oracle/run.js +83 -34
- package/dist/src/oracle/runUtils.js +12 -8
- package/dist/src/remote/server.js +214 -23
- package/dist/src/sessionManager.js +5 -2
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- 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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
//
|
|
58
|
+
// multiple callers may try to stop
|
|
59
59
|
if (stopped) {
|
|
60
60
|
return;
|
|
61
61
|
}
|
package/dist/src/oracle/run.js
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
-
|
|
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
|
|
106
|
+
return { key: optionsApiKey ?? openRouterApiKey, source: optionsApiKey ? 'apiKey option' : 'OPENROUTER_API_KEY' };
|
|
75
107
|
};
|
|
76
|
-
const
|
|
77
|
-
|
|
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 =
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
:
|
|
231
|
-
?
|
|
232
|
-
:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
29
|
+
const text = formatTokenCount(value);
|
|
26
30
|
return estimatedFlag ? `${text}*` : text;
|
|
27
31
|
}
|