@indykish/oracle 0.9.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.
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/assets-oracle-icon.png +0 -0
- package/dist/bin/oracle-cli.js +1252 -0
- package/dist/bin/oracle-mcp.js +6 -0
- package/dist/scripts/agent-send.js +147 -0
- package/dist/scripts/browser-tools.js +536 -0
- package/dist/scripts/check.js +21 -0
- package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
- package/dist/scripts/docs-list.js +110 -0
- package/dist/scripts/git-policy.js +125 -0
- package/dist/scripts/run-cli.js +14 -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/bridge/connection.js +103 -0
- package/dist/src/bridge/userConfigFile.js +28 -0
- package/dist/src/browser/actions/assistantResponse.js +1067 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
- package/dist/src/browser/actions/attachments.js +1910 -0
- package/dist/src/browser/actions/domEvents.js +19 -0
- package/dist/src/browser/actions/modelSelection.js +485 -0
- package/dist/src/browser/actions/navigation.js +445 -0
- package/dist/src/browser/actions/promptComposer.js +485 -0
- package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
- package/dist/src/browser/actions/thinkingTime.js +206 -0
- package/dist/src/browser/chromeLifecycle.js +344 -0
- package/dist/src/browser/config.js +103 -0
- package/dist/src/browser/constants.js +71 -0
- package/dist/src/browser/cookies.js +191 -0
- package/dist/src/browser/detect.js +164 -0
- package/dist/src/browser/domDebug.js +36 -0
- package/dist/src/browser/index.js +1741 -0
- package/dist/src/browser/modelStrategy.js +13 -0
- package/dist/src/browser/pageActions.js +5 -0
- package/dist/src/browser/policies.js +43 -0
- package/dist/src/browser/profileState.js +280 -0
- package/dist/src/browser/prompt.js +152 -0
- package/dist/src/browser/promptSummary.js +20 -0
- package/dist/src/browser/reattach.js +186 -0
- package/dist/src/browser/reattachHelpers.js +382 -0
- package/dist/src/browser/sessionRunner.js +119 -0
- package/dist/src/browser/types.js +1 -0
- package/dist/src/browser/utils.js +122 -0
- package/dist/src/browserMode.js +1 -0
- package/dist/src/cli/bridge/claudeConfig.js +54 -0
- package/dist/src/cli/bridge/client.js +73 -0
- package/dist/src/cli/bridge/codexConfig.js +43 -0
- package/dist/src/cli/bridge/doctor.js +107 -0
- package/dist/src/cli/bridge/host.js +259 -0
- package/dist/src/cli/browserConfig.js +278 -0
- package/dist/src/cli/browserDefaults.js +81 -0
- package/dist/src/cli/bundleWarnings.js +9 -0
- package/dist/src/cli/clipboard.js +10 -0
- package/dist/src/cli/detach.js +11 -0
- package/dist/src/cli/dryRun.js +105 -0
- package/dist/src/cli/duplicatePromptGuard.js +14 -0
- package/dist/src/cli/engine.js +41 -0
- package/dist/src/cli/errorUtils.js +9 -0
- package/dist/src/cli/format.js +13 -0
- package/dist/src/cli/help.js +77 -0
- package/dist/src/cli/hiddenAliases.js +22 -0
- package/dist/src/cli/markdownBundle.js +17 -0
- package/dist/src/cli/markdownRenderer.js +97 -0
- package/dist/src/cli/notifier.js +306 -0
- package/dist/src/cli/options.js +281 -0
- package/dist/src/cli/oscUtils.js +2 -0
- package/dist/src/cli/promptRequirement.js +17 -0
- package/dist/src/cli/renderFlags.js +9 -0
- package/dist/src/cli/renderOutput.js +26 -0
- package/dist/src/cli/rootAlias.js +30 -0
- package/dist/src/cli/runOptions.js +78 -0
- package/dist/src/cli/sessionCommand.js +111 -0
- package/dist/src/cli/sessionDisplay.js +567 -0
- package/dist/src/cli/sessionRunner.js +602 -0
- package/dist/src/cli/sessionTable.js +92 -0
- package/dist/src/cli/tagline.js +258 -0
- package/dist/src/cli/tui/index.js +486 -0
- package/dist/src/cli/writeOutputPath.js +21 -0
- package/dist/src/config.js +26 -0
- package/dist/src/gemini-web/client.js +328 -0
- package/dist/src/gemini-web/executor.js +285 -0
- package/dist/src/gemini-web/index.js +1 -0
- package/dist/src/gemini-web/types.js +1 -0
- package/dist/src/heartbeat.js +43 -0
- package/dist/src/mcp/server.js +40 -0
- package/dist/src/mcp/tools/consult.js +290 -0
- package/dist/src/mcp/tools/sessionResources.js +75 -0
- package/dist/src/mcp/tools/sessions.js +105 -0
- package/dist/src/mcp/types.js +22 -0
- package/dist/src/mcp/utils.js +37 -0
- package/dist/src/oracle/background.js +141 -0
- package/dist/src/oracle/claude.js +101 -0
- package/dist/src/oracle/client.js +197 -0
- package/dist/src/oracle/config.js +227 -0
- package/dist/src/oracle/errors.js +132 -0
- package/dist/src/oracle/files.js +378 -0
- package/dist/src/oracle/finishLine.js +32 -0
- package/dist/src/oracle/format.js +30 -0
- package/dist/src/oracle/fsAdapter.js +10 -0
- package/dist/src/oracle/gemini.js +195 -0
- package/dist/src/oracle/logging.js +36 -0
- package/dist/src/oracle/markdown.js +46 -0
- package/dist/src/oracle/modelResolver.js +183 -0
- package/dist/src/oracle/multiModelRunner.js +153 -0
- package/dist/src/oracle/oscProgress.js +24 -0
- package/dist/src/oracle/promptAssembly.js +13 -0
- package/dist/src/oracle/request.js +50 -0
- package/dist/src/oracle/run.js +596 -0
- package/dist/src/oracle/runUtils.js +31 -0
- package/dist/src/oracle/tokenEstimate.js +37 -0
- package/dist/src/oracle/tokenStats.js +39 -0
- package/dist/src/oracle/tokenStringifier.js +24 -0
- package/dist/src/oracle/types.js +1 -0
- package/dist/src/oracle.js +12 -0
- package/dist/src/oracleHome.js +13 -0
- package/dist/src/remote/client.js +129 -0
- package/dist/src/remote/health.js +113 -0
- package/dist/src/remote/remoteServiceConfig.js +31 -0
- package/dist/src/remote/server.js +533 -0
- package/dist/src/remote/types.js +1 -0
- package/dist/src/sessionManager.js +637 -0
- package/dist/src/sessionStore.js +56 -0
- package/dist/src/version.js +39 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/dist/vendor/oracle-notifier/README.md +24 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
- package/package.json +115 -0
- package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/vendor/oracle-notifier/README.md +24 -0
- package/vendor/oracle-notifier/build-notifier.sh +93 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { GoogleGenAI, HarmCategory, HarmBlockThreshold } from '@google/genai';
|
|
2
|
+
const MODEL_ID_MAP = {
|
|
3
|
+
'gemini-3-pro': 'gemini-3-pro-preview',
|
|
4
|
+
'gemini-3.5-pro': 'gemini-3.5-pro-preview',
|
|
5
|
+
'gpt-5.1-pro': 'gpt-5.1-pro',
|
|
6
|
+
'gpt-5-pro': 'gpt-5-pro',
|
|
7
|
+
'gpt-5.1': 'gpt-5.1',
|
|
8
|
+
'gpt-5.1-codex': 'gpt-5.1-codex',
|
|
9
|
+
'gpt-5.2': 'gpt-5.2',
|
|
10
|
+
'gpt-5.2-instant': 'gpt-5.2-instant',
|
|
11
|
+
'gpt-5.2-pro': 'gpt-5.2-pro',
|
|
12
|
+
'gpt-5.3': 'gpt-5.3',
|
|
13
|
+
'gpt-5.3-pro': 'gpt-5.3-pro',
|
|
14
|
+
'claude-4.5-sonnet': 'claude-4.5-sonnet',
|
|
15
|
+
'claude-4.6-sonnet': 'claude-4.6-sonnet',
|
|
16
|
+
'claude-4.1-opus': 'claude-4.1-opus',
|
|
17
|
+
'claude-4.6-opus': 'claude-4.6-opus',
|
|
18
|
+
'grok-4.1': 'grok-4.1',
|
|
19
|
+
'grok-4.2': 'grok-4.2',
|
|
20
|
+
};
|
|
21
|
+
export function resolveGeminiModelId(modelName) {
|
|
22
|
+
// Map our logical Gemini names to the exact model ids expected by the SDK.
|
|
23
|
+
return MODEL_ID_MAP[modelName] ?? modelName;
|
|
24
|
+
}
|
|
25
|
+
export function createGeminiClient(apiKey, modelName = 'gemini-3-pro', resolvedModelId) {
|
|
26
|
+
const modelId = resolvedModelId ?? resolveGeminiModelId(modelName);
|
|
27
|
+
const genAI = new GoogleGenAI({ apiKey });
|
|
28
|
+
const adaptBodyToGemini = (body) => {
|
|
29
|
+
const contents = body.input.map((inputItem) => ({
|
|
30
|
+
role: inputItem.role === 'user' ? 'user' : 'model',
|
|
31
|
+
parts: inputItem.content
|
|
32
|
+
.map((contentPart) => {
|
|
33
|
+
if (contentPart.type === 'input_text') {
|
|
34
|
+
return { text: contentPart.text };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
})
|
|
38
|
+
.filter((part) => part !== null),
|
|
39
|
+
}));
|
|
40
|
+
const tools = body.tools
|
|
41
|
+
?.map((tool) => {
|
|
42
|
+
if (tool.type === 'web_search_preview') {
|
|
43
|
+
return {
|
|
44
|
+
googleSearch: {},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {};
|
|
48
|
+
})
|
|
49
|
+
.filter((t) => Object.keys(t).length > 0);
|
|
50
|
+
const safetySettings = [
|
|
51
|
+
{
|
|
52
|
+
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
|
|
53
|
+
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
|
57
|
+
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
|
61
|
+
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
|
65
|
+
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
const systemInstruction = body.instructions
|
|
69
|
+
? { role: 'system', parts: [{ text: body.instructions }] }
|
|
70
|
+
: undefined;
|
|
71
|
+
return {
|
|
72
|
+
model: modelId,
|
|
73
|
+
contents,
|
|
74
|
+
config: {
|
|
75
|
+
maxOutputTokens: body.max_output_tokens,
|
|
76
|
+
safetySettings,
|
|
77
|
+
tools,
|
|
78
|
+
systemInstruction,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const adaptGeminiResponseToOracle = (geminiResponse) => {
|
|
83
|
+
const outputText = [];
|
|
84
|
+
const output = [];
|
|
85
|
+
geminiResponse.candidates?.forEach((candidate) => {
|
|
86
|
+
candidate.content?.parts?.forEach((part) => {
|
|
87
|
+
if (part.text) {
|
|
88
|
+
outputText.push(part.text);
|
|
89
|
+
output.push({ type: 'text', text: part.text });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
const usage = {
|
|
94
|
+
input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
|
|
95
|
+
output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0,
|
|
96
|
+
total_tokens: (geminiResponse.usageMetadata?.promptTokenCount || 0) + (geminiResponse.usageMetadata?.candidatesTokenCount || 0),
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
id: geminiResponse.responseId ?? `gemini-${Date.now()}`,
|
|
100
|
+
status: 'completed',
|
|
101
|
+
output_text: outputText,
|
|
102
|
+
output,
|
|
103
|
+
usage,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
const adaptAggregatedTextToOracle = (text, usageMetadata, responseId) => {
|
|
107
|
+
const usage = {
|
|
108
|
+
input_tokens: usageMetadata?.promptTokenCount ?? 0,
|
|
109
|
+
output_tokens: usageMetadata?.candidatesTokenCount ?? 0,
|
|
110
|
+
total_tokens: (usageMetadata?.promptTokenCount ?? 0) + (usageMetadata?.candidatesTokenCount ?? 0),
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
id: responseId ?? `gemini-${Date.now()}`,
|
|
114
|
+
status: 'completed',
|
|
115
|
+
output_text: [text],
|
|
116
|
+
output: [{ type: 'text', text }],
|
|
117
|
+
usage,
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
const enrichGeminiError = (error) => {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
if (message.includes('404')) {
|
|
123
|
+
return new Error(`Gemini model not available to this API key/region. Confirm preview access and model ID (${modelId}). Original: ${message}`);
|
|
124
|
+
}
|
|
125
|
+
return error instanceof Error ? error : new Error(message);
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
responses: {
|
|
129
|
+
stream: (body) => {
|
|
130
|
+
const geminiBody = adaptBodyToGemini(body);
|
|
131
|
+
let finalResponsePromise = null;
|
|
132
|
+
let aggregatedText = '';
|
|
133
|
+
let lastUsage;
|
|
134
|
+
let responseId;
|
|
135
|
+
async function* iterator() {
|
|
136
|
+
let streamingResp;
|
|
137
|
+
try {
|
|
138
|
+
streamingResp = await genAI.models.generateContentStream(geminiBody);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
throw enrichGeminiError(error);
|
|
142
|
+
}
|
|
143
|
+
for await (const chunk of streamingResp) {
|
|
144
|
+
const text = chunk.text;
|
|
145
|
+
if (text) {
|
|
146
|
+
aggregatedText += text;
|
|
147
|
+
yield { type: 'chunk', delta: text };
|
|
148
|
+
}
|
|
149
|
+
if (chunk.usageMetadata) {
|
|
150
|
+
lastUsage = chunk.usageMetadata;
|
|
151
|
+
}
|
|
152
|
+
if (chunk.responseId) {
|
|
153
|
+
responseId = chunk.responseId;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
finalResponsePromise = Promise.resolve(adaptAggregatedTextToOracle(aggregatedText, lastUsage, responseId));
|
|
157
|
+
}
|
|
158
|
+
const generator = iterator();
|
|
159
|
+
return {
|
|
160
|
+
[Symbol.asyncIterator]: () => generator,
|
|
161
|
+
finalResponse: async () => {
|
|
162
|
+
// Ensure the stream has been consumed or at least started to get the promise
|
|
163
|
+
if (!finalResponsePromise) {
|
|
164
|
+
// In case the user calls finalResponse before iterating, we need to consume the stream
|
|
165
|
+
// This is a bit edge-casey but safe.
|
|
166
|
+
for await (const _ of generator) { }
|
|
167
|
+
}
|
|
168
|
+
if (!finalResponsePromise) {
|
|
169
|
+
throw new Error('Response promise not initialized');
|
|
170
|
+
}
|
|
171
|
+
return finalResponsePromise;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
create: async (body) => {
|
|
176
|
+
const geminiBody = adaptBodyToGemini(body);
|
|
177
|
+
let result;
|
|
178
|
+
try {
|
|
179
|
+
result = await genAI.models.generateContent(geminiBody);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
throw enrichGeminiError(error);
|
|
183
|
+
}
|
|
184
|
+
return adaptGeminiResponseToOracle(result);
|
|
185
|
+
},
|
|
186
|
+
retrieve: async (id) => {
|
|
187
|
+
return {
|
|
188
|
+
id,
|
|
189
|
+
status: 'error',
|
|
190
|
+
error: { message: 'Retrieve by ID not supported for Gemini API yet.' },
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function maskApiKey(key) {
|
|
2
|
+
if (!key)
|
|
3
|
+
return null;
|
|
4
|
+
if (key.length <= 8)
|
|
5
|
+
return `${key[0] ?? ''}***${key[key.length - 1] ?? ''}`;
|
|
6
|
+
const prefix = key.slice(0, 4);
|
|
7
|
+
const suffix = key.slice(-4);
|
|
8
|
+
return `${prefix}****${suffix}`;
|
|
9
|
+
}
|
|
10
|
+
export function formatBaseUrlForLog(raw) {
|
|
11
|
+
if (!raw)
|
|
12
|
+
return '';
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(raw);
|
|
15
|
+
const segments = parsed.pathname.split('/').filter(Boolean);
|
|
16
|
+
let path = '';
|
|
17
|
+
if (segments.length > 0) {
|
|
18
|
+
path = `/${segments[0]}`;
|
|
19
|
+
if (segments.length > 1) {
|
|
20
|
+
path += '/...';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const allowedQueryKeys = ['api-version'];
|
|
24
|
+
const maskedQuery = allowedQueryKeys
|
|
25
|
+
.filter((key) => parsed.searchParams.has(key))
|
|
26
|
+
.map((key) => `${key}=***`);
|
|
27
|
+
const query = maskedQuery.length > 0 ? `?${maskedQuery.join('&')}` : '';
|
|
28
|
+
return `${parsed.protocol}//${parsed.host}${path}${query}`;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
const trimmed = raw.trim();
|
|
32
|
+
if (trimmed.length <= 64)
|
|
33
|
+
return trimmed;
|
|
34
|
+
return `${trimmed.slice(0, 32)}…${trimmed.slice(-8)}`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const EXT_TO_LANG = {
|
|
3
|
+
'.ts': 'ts',
|
|
4
|
+
'.tsx': 'tsx',
|
|
5
|
+
'.js': 'js',
|
|
6
|
+
'.jsx': 'jsx',
|
|
7
|
+
'.json': 'json',
|
|
8
|
+
'.swift': 'swift',
|
|
9
|
+
'.md': 'md',
|
|
10
|
+
'.sh': 'bash',
|
|
11
|
+
'.bash': 'bash',
|
|
12
|
+
'.zsh': 'bash',
|
|
13
|
+
'.py': 'python',
|
|
14
|
+
'.rb': 'ruby',
|
|
15
|
+
'.rs': 'rust',
|
|
16
|
+
'.go': 'go',
|
|
17
|
+
'.java': 'java',
|
|
18
|
+
'.c': 'c',
|
|
19
|
+
'.h': 'c',
|
|
20
|
+
'.cpp': 'cpp',
|
|
21
|
+
'.hpp': 'cpp',
|
|
22
|
+
'.css': 'css',
|
|
23
|
+
'.scss': 'scss',
|
|
24
|
+
'.sql': 'sql',
|
|
25
|
+
'.yaml': 'yaml',
|
|
26
|
+
'.yml': 'yaml',
|
|
27
|
+
};
|
|
28
|
+
function detectFenceLanguage(displayPath) {
|
|
29
|
+
const ext = path.extname(displayPath).toLowerCase();
|
|
30
|
+
return EXT_TO_LANG[ext] ?? null;
|
|
31
|
+
}
|
|
32
|
+
function pickFence(content) {
|
|
33
|
+
// Choose a fence longer than any backtick run inside the file so the block can't prematurely close.
|
|
34
|
+
const matches = [...content.matchAll(/`+/g)];
|
|
35
|
+
const maxTicks = matches.reduce((max, m) => Math.max(max, m[0].length), 0);
|
|
36
|
+
const fenceLength = Math.max(3, maxTicks + 1);
|
|
37
|
+
return '`'.repeat(fenceLength);
|
|
38
|
+
}
|
|
39
|
+
export function formatFileSection(displayPath, content) {
|
|
40
|
+
const fence = pickFence(content);
|
|
41
|
+
const lang = detectFenceLanguage(displayPath);
|
|
42
|
+
const normalized = content.replace(/\s+$/u, '');
|
|
43
|
+
const header = `### File: ${displayPath}`;
|
|
44
|
+
const fenceOpen = lang ? `${fence}${lang}` : fence;
|
|
45
|
+
return [header, fenceOpen, normalized, fence, ''].join('\n');
|
|
46
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { MODEL_CONFIGS, PRO_MODELS } from './config.js';
|
|
2
|
+
import { countTokens as countTokensGpt5Pro } from 'gpt-tokenizer/model/gpt-5-pro';
|
|
3
|
+
import { pricingFromUsdPerMillion } from 'tokentally';
|
|
4
|
+
const OPENROUTER_DEFAULT_BASE = 'https://openrouter.ai/api/v1';
|
|
5
|
+
const OPENROUTER_MODELS_ENDPOINT = 'https://openrouter.ai/api/v1/models';
|
|
6
|
+
export function isKnownModel(model) {
|
|
7
|
+
return Object.hasOwn(MODEL_CONFIGS, model);
|
|
8
|
+
}
|
|
9
|
+
export function isOpenRouterBaseUrl(baseUrl) {
|
|
10
|
+
if (!baseUrl)
|
|
11
|
+
return false;
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(baseUrl);
|
|
14
|
+
return url.hostname.includes('openrouter.ai');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function defaultOpenRouterBaseUrl() {
|
|
21
|
+
return OPENROUTER_DEFAULT_BASE;
|
|
22
|
+
}
|
|
23
|
+
export function normalizeOpenRouterBaseUrl(baseUrl) {
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(baseUrl);
|
|
26
|
+
// If user passed the responses endpoint, trim it so the client does not double-append.
|
|
27
|
+
if (url.pathname.endsWith('/responses')) {
|
|
28
|
+
url.pathname = url.pathname.replace(/\/responses\/?$/, '');
|
|
29
|
+
}
|
|
30
|
+
return url.toString().replace(/\/+$/, '');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return baseUrl;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function safeModelSlug(model) {
|
|
37
|
+
return model.replace(/[/\\]/g, '__').replace(/[:*?"<>|]/g, '_');
|
|
38
|
+
}
|
|
39
|
+
const catalogCache = new Map();
|
|
40
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
41
|
+
const MAX_CACHE_ENTRIES = 20;
|
|
42
|
+
/**
|
|
43
|
+
* Prune stale entries from the catalog cache to prevent unbounded growth.
|
|
44
|
+
* Removes entries older than TTL and enforces a maximum cache size.
|
|
45
|
+
*/
|
|
46
|
+
function pruneCatalogCache(now) {
|
|
47
|
+
// Remove stale entries first
|
|
48
|
+
for (const [key, entry] of catalogCache) {
|
|
49
|
+
if (now - entry.fetchedAt >= CACHE_TTL_MS) {
|
|
50
|
+
catalogCache.delete(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// If still over limit, evict oldest fetched entries (not true LRU; no last-access tracking).
|
|
54
|
+
if (catalogCache.size > MAX_CACHE_ENTRIES) {
|
|
55
|
+
const entries = [...catalogCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
|
56
|
+
const toRemove = entries.slice(0, catalogCache.size - MAX_CACHE_ENTRIES);
|
|
57
|
+
for (const [key] of toRemove) {
|
|
58
|
+
catalogCache.delete(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function fetchOpenRouterCatalog(apiKey, fetcher) {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const cached = catalogCache.get(apiKey);
|
|
65
|
+
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
|
|
66
|
+
return cached.models;
|
|
67
|
+
}
|
|
68
|
+
const response = await fetcher(OPENROUTER_MODELS_ENDPOINT, {
|
|
69
|
+
headers: {
|
|
70
|
+
authorization: `Bearer ${apiKey}`,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(`Failed to load OpenRouter models (${response.status})`);
|
|
75
|
+
}
|
|
76
|
+
const json = (await response.json());
|
|
77
|
+
const models = json?.data ?? [];
|
|
78
|
+
catalogCache.set(apiKey, { fetchedAt: now, models });
|
|
79
|
+
// Prune after insert so the max-size constraint is strictly enforced.
|
|
80
|
+
pruneCatalogCache(now);
|
|
81
|
+
return models;
|
|
82
|
+
}
|
|
83
|
+
function mapToOpenRouterId(candidate, catalog, providerHint) {
|
|
84
|
+
if (candidate.includes('/'))
|
|
85
|
+
return candidate;
|
|
86
|
+
const byExact = catalog.find((entry) => entry.id === candidate);
|
|
87
|
+
if (byExact)
|
|
88
|
+
return byExact.id;
|
|
89
|
+
const bySuffix = catalog.find((entry) => entry.id.endsWith(`/${candidate}`));
|
|
90
|
+
if (bySuffix)
|
|
91
|
+
return bySuffix.id;
|
|
92
|
+
if (providerHint) {
|
|
93
|
+
return `${providerHint}/${candidate}`;
|
|
94
|
+
}
|
|
95
|
+
return candidate;
|
|
96
|
+
}
|
|
97
|
+
export async function resolveModelConfig(model, options = {}) {
|
|
98
|
+
const known = isKnownModel(model) ? MODEL_CONFIGS[model] : null;
|
|
99
|
+
const fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
|
|
100
|
+
const openRouterActive = isOpenRouterBaseUrl(options.baseUrl) || Boolean(options.openRouterApiKey);
|
|
101
|
+
if (known && !openRouterActive) {
|
|
102
|
+
return known;
|
|
103
|
+
}
|
|
104
|
+
// Try to enrich from OpenRouter catalog when available.
|
|
105
|
+
if (openRouterActive && options.openRouterApiKey) {
|
|
106
|
+
try {
|
|
107
|
+
const catalog = await fetchOpenRouterCatalog(options.openRouterApiKey, fetcher);
|
|
108
|
+
const targetId = mapToOpenRouterId(typeof model === 'string' ? model : String(model), catalog, known?.provider);
|
|
109
|
+
const info = catalog.find((entry) => entry.id === targetId) ?? null;
|
|
110
|
+
if (info) {
|
|
111
|
+
return {
|
|
112
|
+
...(known ?? {
|
|
113
|
+
model,
|
|
114
|
+
tokenizer: countTokensGpt5Pro,
|
|
115
|
+
inputLimit: info.context_length ?? 200_000,
|
|
116
|
+
reasoning: null,
|
|
117
|
+
}),
|
|
118
|
+
apiModel: targetId,
|
|
119
|
+
openRouterId: targetId,
|
|
120
|
+
provider: known?.provider ?? 'other',
|
|
121
|
+
inputLimit: info.context_length ?? known?.inputLimit ?? 200_000,
|
|
122
|
+
pricing: info.pricing && info.pricing.prompt != null && info.pricing.completion != null
|
|
123
|
+
? (() => {
|
|
124
|
+
const pricing = pricingFromUsdPerMillion({
|
|
125
|
+
inputUsdPerMillion: info.pricing.prompt,
|
|
126
|
+
outputUsdPerMillion: info.pricing.completion,
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
inputPerToken: pricing.inputUsdPerToken,
|
|
130
|
+
outputPerToken: pricing.outputUsdPerToken,
|
|
131
|
+
};
|
|
132
|
+
})()
|
|
133
|
+
: known?.pricing ?? null,
|
|
134
|
+
supportsBackground: known?.supportsBackground ?? true,
|
|
135
|
+
supportsSearch: known?.supportsSearch ?? true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// No metadata hit; fall through to synthesized config.
|
|
139
|
+
return {
|
|
140
|
+
...(known ?? {
|
|
141
|
+
model,
|
|
142
|
+
tokenizer: countTokensGpt5Pro,
|
|
143
|
+
inputLimit: 200_000,
|
|
144
|
+
reasoning: null,
|
|
145
|
+
}),
|
|
146
|
+
apiModel: targetId,
|
|
147
|
+
openRouterId: targetId,
|
|
148
|
+
provider: known?.provider ?? 'other',
|
|
149
|
+
supportsBackground: known?.supportsBackground ?? true,
|
|
150
|
+
supportsSearch: known?.supportsSearch ?? true,
|
|
151
|
+
pricing: known?.pricing ?? null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// If catalog fetch fails, fall back to a synthesized config.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Synthesized generic config for custom endpoints or failed catalog fetch.
|
|
159
|
+
return {
|
|
160
|
+
...(known ?? {
|
|
161
|
+
model,
|
|
162
|
+
tokenizer: countTokensGpt5Pro,
|
|
163
|
+
inputLimit: 200_000,
|
|
164
|
+
reasoning: null,
|
|
165
|
+
}),
|
|
166
|
+
provider: known?.provider ?? 'other',
|
|
167
|
+
supportsBackground: known?.supportsBackground ?? true,
|
|
168
|
+
supportsSearch: known?.supportsSearch ?? true,
|
|
169
|
+
pricing: known?.pricing ?? null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
export function isProModel(model) {
|
|
173
|
+
return isKnownModel(model) && PRO_MODELS.has(model);
|
|
174
|
+
}
|
|
175
|
+
export function resetOpenRouterCatalogCacheForTest() {
|
|
176
|
+
catalogCache.clear();
|
|
177
|
+
}
|
|
178
|
+
export function getOpenRouterCatalogCacheSizeForTest() {
|
|
179
|
+
return catalogCache.size;
|
|
180
|
+
}
|
|
181
|
+
export function getOpenRouterCatalogCacheMaxEntriesForTest() {
|
|
182
|
+
return MAX_CACHE_ENTRIES;
|
|
183
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { runOracle, OracleResponseError, OracleTransportError, extractResponseMetadata, asOracleUserError, extractTextOutput, } from '../oracle.js';
|
|
4
|
+
import { sessionStore } from '../sessionStore.js';
|
|
5
|
+
import { findOscProgressSequences, OSC_PROGRESS_PREFIX } from 'osc-progress';
|
|
6
|
+
function forwardOscProgress(chunk, shouldForward) {
|
|
7
|
+
if (!shouldForward || !chunk.includes(OSC_PROGRESS_PREFIX)) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
for (const seq of findOscProgressSequences(chunk)) {
|
|
11
|
+
process.stdout.write(seq.raw);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const defaultDeps = {
|
|
15
|
+
store: sessionStore,
|
|
16
|
+
runOracleImpl: runOracle,
|
|
17
|
+
now: () => Date.now(),
|
|
18
|
+
};
|
|
19
|
+
export async function runMultiModelApiSession(params, deps = defaultDeps) {
|
|
20
|
+
const { sessionMeta, runOptions, models, cwd } = params;
|
|
21
|
+
const { onModelDone } = params;
|
|
22
|
+
const store = deps.store ?? sessionStore;
|
|
23
|
+
const runOracleImpl = deps.runOracleImpl ?? runOracle;
|
|
24
|
+
const now = deps.now ?? (() => Date.now());
|
|
25
|
+
const startMark = now();
|
|
26
|
+
const executions = models.map((model) => startModelExecution({
|
|
27
|
+
sessionMeta,
|
|
28
|
+
runOptions,
|
|
29
|
+
model,
|
|
30
|
+
cwd,
|
|
31
|
+
store,
|
|
32
|
+
runOracleImpl,
|
|
33
|
+
}));
|
|
34
|
+
const settled = await Promise.allSettled(executions.map((exec) => exec.promise.then(async (value) => {
|
|
35
|
+
if (onModelDone) {
|
|
36
|
+
await onModelDone(value);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}, (error) => {
|
|
40
|
+
throw error;
|
|
41
|
+
})));
|
|
42
|
+
const fulfilled = [];
|
|
43
|
+
const rejected = [];
|
|
44
|
+
settled.forEach((result, index) => {
|
|
45
|
+
const exec = executions[index];
|
|
46
|
+
if (result.status === 'fulfilled') {
|
|
47
|
+
fulfilled.push(result.value);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
rejected.push({ model: exec.model, reason: result.reason });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
fulfilled,
|
|
55
|
+
rejected,
|
|
56
|
+
elapsedMs: now() - startMark,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function startModelExecution({ sessionMeta, runOptions, model, cwd, store, runOracleImpl, }) {
|
|
60
|
+
const logWriter = store.createLogWriter(sessionMeta.id, model);
|
|
61
|
+
const perModelOptions = {
|
|
62
|
+
...runOptions,
|
|
63
|
+
model,
|
|
64
|
+
models: undefined,
|
|
65
|
+
sessionId: `${sessionMeta.id}:${model}`,
|
|
66
|
+
};
|
|
67
|
+
const perModelLog = (message) => {
|
|
68
|
+
logWriter.logLine(message ?? '');
|
|
69
|
+
};
|
|
70
|
+
const mirrorOscProgress = process.stdout.isTTY === true;
|
|
71
|
+
const perModelWrite = (chunk) => {
|
|
72
|
+
logWriter.writeChunk(chunk);
|
|
73
|
+
forwardOscProgress(chunk, mirrorOscProgress);
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
const promise = (async () => {
|
|
77
|
+
await store.updateModelRun(sessionMeta.id, model, {
|
|
78
|
+
status: 'running',
|
|
79
|
+
queuedAt: new Date().toISOString(),
|
|
80
|
+
startedAt: new Date().toISOString(),
|
|
81
|
+
});
|
|
82
|
+
const result = await runOracleImpl({
|
|
83
|
+
...perModelOptions,
|
|
84
|
+
effectiveModelId: model,
|
|
85
|
+
// Drop per-model preamble; the aggregate runner prints the shared header and tips once.
|
|
86
|
+
suppressHeader: true,
|
|
87
|
+
suppressAnswerHeader: true,
|
|
88
|
+
suppressTips: true,
|
|
89
|
+
}, {
|
|
90
|
+
cwd,
|
|
91
|
+
log: perModelLog,
|
|
92
|
+
write: perModelWrite,
|
|
93
|
+
});
|
|
94
|
+
if (result.mode !== 'live') {
|
|
95
|
+
throw new Error('Unexpected preview result while running a session.');
|
|
96
|
+
}
|
|
97
|
+
const answerText = extractTextOutput(result.response);
|
|
98
|
+
await store.updateModelRun(sessionMeta.id, model, {
|
|
99
|
+
status: 'completed',
|
|
100
|
+
completedAt: new Date().toISOString(),
|
|
101
|
+
usage: result.usage,
|
|
102
|
+
response: extractResponseMetadata(result.response),
|
|
103
|
+
transport: undefined,
|
|
104
|
+
error: undefined,
|
|
105
|
+
log: await describeLog(sessionMeta.id, logWriter.logPath, store),
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
model,
|
|
109
|
+
usage: result.usage,
|
|
110
|
+
answerText,
|
|
111
|
+
logPath: logWriter.logPath,
|
|
112
|
+
};
|
|
113
|
+
})()
|
|
114
|
+
.catch(async (error) => {
|
|
115
|
+
const userError = asOracleUserError(error);
|
|
116
|
+
const responseMetadata = error instanceof OracleResponseError ? error.metadata : undefined;
|
|
117
|
+
const transportMetadata = error instanceof OracleTransportError ? { reason: error.reason } : undefined;
|
|
118
|
+
await store.updateModelRun(sessionMeta.id, model, {
|
|
119
|
+
status: 'error',
|
|
120
|
+
completedAt: new Date().toISOString(),
|
|
121
|
+
response: responseMetadata,
|
|
122
|
+
transport: transportMetadata,
|
|
123
|
+
error: userError
|
|
124
|
+
? {
|
|
125
|
+
category: userError.category,
|
|
126
|
+
message: userError.message,
|
|
127
|
+
details: userError.details,
|
|
128
|
+
}
|
|
129
|
+
: undefined,
|
|
130
|
+
log: await describeLog(sessionMeta.id, logWriter.logPath, store),
|
|
131
|
+
});
|
|
132
|
+
throw error;
|
|
133
|
+
})
|
|
134
|
+
.finally(() => {
|
|
135
|
+
logWriter.stream.end();
|
|
136
|
+
});
|
|
137
|
+
return { model, promise };
|
|
138
|
+
}
|
|
139
|
+
async function describeLog(sessionId, logFilePath, store) {
|
|
140
|
+
const { dir } = await store.getPaths(sessionId);
|
|
141
|
+
const relative = path.relative(dir, logFilePath);
|
|
142
|
+
try {
|
|
143
|
+
const stats = await fsStat(logFilePath);
|
|
144
|
+
return { path: relative, bytes: stats.size };
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { path: relative };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function fsStat(target) {
|
|
151
|
+
const stats = await fs.stat(target);
|
|
152
|
+
return { size: stats.size };
|
|
153
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { startOscProgress as startOscProgressShared, supportsOscProgress as supportsOscProgressShared, } from 'osc-progress';
|
|
3
|
+
export function supportsOscProgress(env = process.env, isTty = process.stdout.isTTY) {
|
|
4
|
+
if (env.CODEX_MANAGED_BY_NPM === '1' && env.ORACLE_FORCE_OSC_PROGRESS !== '1') {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
return supportsOscProgressShared(env, isTty, {
|
|
8
|
+
disableEnvVar: 'ORACLE_NO_OSC_PROGRESS',
|
|
9
|
+
forceEnvVar: 'ORACLE_FORCE_OSC_PROGRESS',
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export function startOscProgress(options = {}) {
|
|
13
|
+
const env = options.env ?? process.env;
|
|
14
|
+
if (env.CODEX_MANAGED_BY_NPM === '1' && env.ORACLE_FORCE_OSC_PROGRESS !== '1') {
|
|
15
|
+
return () => { };
|
|
16
|
+
}
|
|
17
|
+
return startOscProgressShared({
|
|
18
|
+
...options,
|
|
19
|
+
// Preserve Oracle's previous default: progress emits to stdout.
|
|
20
|
+
write: options.write ?? ((text) => process.stdout.write(text)),
|
|
21
|
+
disableEnvVar: 'ORACLE_NO_OSC_PROGRESS',
|
|
22
|
+
forceEnvVar: 'ORACLE_FORCE_OSC_PROGRESS',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { formatFileSection } from './markdown.js';
|
|
2
|
+
/**
|
|
3
|
+
* Build the shared markdown structure for system/user/file sections.
|
|
4
|
+
* Collapses excessive blank lines and trims trailing whitespace to keep
|
|
5
|
+
* snapshots stable across CLI and browser modes.
|
|
6
|
+
*/
|
|
7
|
+
export function buildPromptMarkdown(systemPrompt, userPrompt, sections) {
|
|
8
|
+
const lines = ['[SYSTEM]', systemPrompt, '', '[USER]', userPrompt, ''];
|
|
9
|
+
sections.forEach((section) => {
|
|
10
|
+
lines.push(formatFileSection(section.displayPath, section.content));
|
|
11
|
+
});
|
|
12
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
13
|
+
}
|