ai-speedometer 1.0.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/README.md +309 -0
- package/ai-benchmark-config.json.template +21 -0
- package/cli.js +1773 -0
- package/dist/ai-speedometer +172 -0
- package/docs/README.md +147 -0
- package/docs/models-dev-integration.md +344 -0
- package/docs/token-counting-fallback.md +345 -0
- package/package.json +69 -0
package/cli.js
ADDED
|
@@ -0,0 +1,1773 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import readline from 'readline';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
6
|
+
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
7
|
+
import { streamText } from 'ai'; // Changed from streamText to generateText
|
|
8
|
+
import { testPrompt } from './test-prompt.js';
|
|
9
|
+
import { LLMBenchmark } from './benchmark-rest.js';
|
|
10
|
+
import { getAllProviders, searchProviders, getModelsForProvider } from './models-dev.js';
|
|
11
|
+
import {
|
|
12
|
+
getAllAvailableProviders,
|
|
13
|
+
addApiKey,
|
|
14
|
+
getCustomProviders,
|
|
15
|
+
migrateFromOldConfig,
|
|
16
|
+
getDebugInfo
|
|
17
|
+
} from './opencode-integration.js';
|
|
18
|
+
import 'dotenv/config';
|
|
19
|
+
import Table from 'cli-table3';
|
|
20
|
+
|
|
21
|
+
// Check for debug flag
|
|
22
|
+
const debugMode = process.argv.includes('--debug');
|
|
23
|
+
let logFile = null;
|
|
24
|
+
|
|
25
|
+
function log(message) {
|
|
26
|
+
if (!debugMode) return;
|
|
27
|
+
|
|
28
|
+
const timestamp = new Date().toISOString();
|
|
29
|
+
const logMessage = `[${timestamp}] ${message}\n`;
|
|
30
|
+
|
|
31
|
+
if (!logFile) {
|
|
32
|
+
logFile = fs.createWriteStream('debug.log', { flags: 'w' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
logFile.write(logMessage);
|
|
36
|
+
// Only print to console for important messages
|
|
37
|
+
if (message.includes('ERROR') || message.includes('Creating') || message.includes('API Request')) {
|
|
38
|
+
console.log(logMessage.trim());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Create a custom Anthropic provider function that supports baseUrl
|
|
43
|
+
function createAnthropicProvider(baseUrl, apiKey) {
|
|
44
|
+
// Use createAnthropic instead of default anthropic for custom baseURL support
|
|
45
|
+
log(`Creating Anthropic provider with baseUrl: ${baseUrl}`);
|
|
46
|
+
log(`API Key length: ${apiKey ? apiKey.length : 0}`);
|
|
47
|
+
|
|
48
|
+
// Ensure base URL ends with /v1 for AI SDK compatibility
|
|
49
|
+
let normalizedBaseUrl = baseUrl;
|
|
50
|
+
if (!baseUrl.endsWith('/v1')) {
|
|
51
|
+
normalizedBaseUrl = baseUrl.endsWith('/') ? `${baseUrl}v1` : `${baseUrl}/v1`;
|
|
52
|
+
log(`Normalized base URL to: ${normalizedBaseUrl}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Try with baseURL parameter (correct according to docs)
|
|
56
|
+
const provider = createAnthropic({
|
|
57
|
+
apiKey: apiKey,
|
|
58
|
+
baseURL: normalizedBaseUrl,
|
|
59
|
+
// Add minimal fetch logging for debugging
|
|
60
|
+
fetch: debugMode ? async (input, init) => {
|
|
61
|
+
log(`API Request to: ${input}`);
|
|
62
|
+
const response = await fetch(input, init);
|
|
63
|
+
log(`Response status: ${response.status}`);
|
|
64
|
+
return response;
|
|
65
|
+
} : undefined
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
log(`Provider created successfully: ${provider ? 'yes' : 'no'}`);
|
|
69
|
+
return provider;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const rl = readline.createInterface({
|
|
73
|
+
input: process.stdin,
|
|
74
|
+
output: process.stdout
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ANSI color codes
|
|
78
|
+
const colors = {
|
|
79
|
+
reset: '\x1b[0m',
|
|
80
|
+
red: '\x1b[31m',
|
|
81
|
+
green: '\x1b[32m',
|
|
82
|
+
yellow: '\x1b[33m',
|
|
83
|
+
blue: '\x1b[34m',
|
|
84
|
+
magenta: '\x1b[35m',
|
|
85
|
+
cyan: '\x1b[36m',
|
|
86
|
+
white: '\x1b[37m',
|
|
87
|
+
bright: '\x1b[1m',
|
|
88
|
+
dim: '\x1b[2m',
|
|
89
|
+
bgRed: '\x1b[41m',
|
|
90
|
+
bgGreen: '\x1b[42m',
|
|
91
|
+
bgYellow: '\x1b[43m',
|
|
92
|
+
bgBlue: '\x1b[44m',
|
|
93
|
+
bgMagenta: '\x1b[45m',
|
|
94
|
+
bgCyan: '\x1b[46m',
|
|
95
|
+
bgWhite: '\x1b[47m'
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function colorText(text, color) {
|
|
99
|
+
return `${colors[color]}${text}${colors.reset}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Enable raw mode for keyboard input (if available)
|
|
103
|
+
try {
|
|
104
|
+
process.stdin.setRawMode(true);
|
|
105
|
+
process.stdin.resume();
|
|
106
|
+
process.stdin.setEncoding('utf8');
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// Fallback for environments without raw mode
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function question(query) {
|
|
112
|
+
return new Promise(resolve => rl.question(query, resolve));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clearScreen() {
|
|
116
|
+
console.clear();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function showHeader() {
|
|
120
|
+
console.log(colorText('Ai-speedometer', 'cyan'));
|
|
121
|
+
console.log(colorText('=============================', 'cyan'));
|
|
122
|
+
console.log(colorText('Note: opencode uses ai-sdk', 'dim'));
|
|
123
|
+
console.log('');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Configuration management - now using opencode files
|
|
127
|
+
async function loadConfig() {
|
|
128
|
+
try {
|
|
129
|
+
// Check if we need to migrate from old config
|
|
130
|
+
const oldConfigFile = 'ai-benchmark-config.json';
|
|
131
|
+
if (fs.existsSync(oldConfigFile)) {
|
|
132
|
+
console.log(colorText('Migrating from old config format to opencode format...', 'yellow'));
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const data = fs.readFileSync(oldConfigFile, 'utf8');
|
|
136
|
+
const oldConfig = JSON.parse(data);
|
|
137
|
+
|
|
138
|
+
const migrationResults = await migrateFromOldConfig(oldConfig);
|
|
139
|
+
|
|
140
|
+
console.log(colorText(`Migration complete: ${migrationResults.migrated} items migrated`, 'green'));
|
|
141
|
+
if (migrationResults.failed > 0) {
|
|
142
|
+
console.log(colorText(`Migration warnings: ${migrationResults.failed} items failed`, 'yellow'));
|
|
143
|
+
migrationResults.errors.forEach(error => {
|
|
144
|
+
console.log(colorText(` - ${error}`, 'dim'));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Backup old config
|
|
149
|
+
fs.renameSync(oldConfigFile, `${oldConfigFile}.backup`);
|
|
150
|
+
console.log(colorText('Old config backed up as ai-benchmark-config.json.backup', 'cyan'));
|
|
151
|
+
|
|
152
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.log(colorText('Migration failed: ', 'red') + error.message);
|
|
155
|
+
await question(colorText('Press Enter to continue with empty config...', 'yellow'));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Load providers from opencode integration
|
|
160
|
+
const providers = await getAllAvailableProviders();
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
providers,
|
|
164
|
+
verifiedProviders: {} // Keep for compatibility but no longer used
|
|
165
|
+
};
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.log(colorText('Error loading config, starting fresh: ', 'yellow') + error.message);
|
|
168
|
+
return { providers: [], verifiedProviders: {} };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Save config - now using opencode files
|
|
173
|
+
async function saveConfig(config) {
|
|
174
|
+
// Note: This function is kept for compatibility but the actual saving
|
|
175
|
+
// is handled by the opencode integration functions (addApiKey, etc.)
|
|
176
|
+
console.log(colorText('Note: Configuration is now automatically saved to opencode files', 'cyan'));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Keyboard input handling
|
|
180
|
+
function getKeyPress() {
|
|
181
|
+
return new Promise(resolve => {
|
|
182
|
+
if (process.stdin.isRaw) {
|
|
183
|
+
process.stdin.once('data', key => {
|
|
184
|
+
if (key === '\u0003') {
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
resolve(key);
|
|
188
|
+
});
|
|
189
|
+
} else {
|
|
190
|
+
rl.question(colorText('Press Enter to continue...', 'yellow'), () => {
|
|
191
|
+
resolve('\r');
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Circular model selection with arrow keys and pagination
|
|
198
|
+
async function selectModelsCircular() {
|
|
199
|
+
clearScreen();
|
|
200
|
+
showHeader();
|
|
201
|
+
console.log(colorText('Select Models for Benchmark', 'magenta'));
|
|
202
|
+
console.log('');
|
|
203
|
+
|
|
204
|
+
const config = await loadConfig();
|
|
205
|
+
|
|
206
|
+
if (config.providers.length === 0) {
|
|
207
|
+
console.log(colorText('No providers available. Please add a provider first.', 'red'));
|
|
208
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const allModels = [];
|
|
213
|
+
config.providers.forEach(provider => {
|
|
214
|
+
provider.models.forEach(model => {
|
|
215
|
+
allModels.push({
|
|
216
|
+
...model,
|
|
217
|
+
providerName: provider.name,
|
|
218
|
+
providerType: provider.type,
|
|
219
|
+
providerId: provider.id,
|
|
220
|
+
providerConfig: {
|
|
221
|
+
...provider,
|
|
222
|
+
apiKey: provider.apiKey || '',
|
|
223
|
+
baseUrl: provider.baseUrl || ''
|
|
224
|
+
},
|
|
225
|
+
selected: false
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
let currentIndex = 0;
|
|
231
|
+
let currentPage = 0;
|
|
232
|
+
let searchQuery = '';
|
|
233
|
+
let filteredModels = [...allModels];
|
|
234
|
+
|
|
235
|
+
// Create a reusable filter function to avoid code duplication
|
|
236
|
+
const filterModels = (query) => {
|
|
237
|
+
if (!query.trim()) {
|
|
238
|
+
return [...allModels];
|
|
239
|
+
}
|
|
240
|
+
const lowercaseQuery = query.toLowerCase();
|
|
241
|
+
return allModels.filter(model => {
|
|
242
|
+
const modelNameMatch = model.name.toLowerCase().includes(lowercaseQuery);
|
|
243
|
+
const providerNameMatch = model.providerName.toLowerCase().includes(lowercaseQuery);
|
|
244
|
+
const providerIdMatch = model.providerId.toLowerCase().includes(lowercaseQuery);
|
|
245
|
+
const providerTypeMatch = model.providerType.toLowerCase().includes(lowercaseQuery);
|
|
246
|
+
|
|
247
|
+
return modelNameMatch || providerNameMatch || providerIdMatch || providerTypeMatch;
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Debounce function to reduce filtering frequency
|
|
252
|
+
let searchTimeout;
|
|
253
|
+
const debouncedFilter = (query, callback) => {
|
|
254
|
+
clearTimeout(searchTimeout);
|
|
255
|
+
searchTimeout = setTimeout(() => {
|
|
256
|
+
callback(filterModels(query));
|
|
257
|
+
}, 50); // 50ms debounce delay
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
while (true) {
|
|
261
|
+
// Build screen content in memory (double buffering)
|
|
262
|
+
let screenContent = '';
|
|
263
|
+
|
|
264
|
+
// Add header
|
|
265
|
+
screenContent += colorText('Ai-speedometer', 'cyan') + '\n';
|
|
266
|
+
screenContent += colorText('=============================', 'cyan') + '\n';
|
|
267
|
+
screenContent += colorText('Note: opencode uses ai-sdk', 'dim') + '\n';
|
|
268
|
+
screenContent += '\n';
|
|
269
|
+
|
|
270
|
+
screenContent += colorText('Select Models for Benchmark', 'magenta') + '\n';
|
|
271
|
+
screenContent += colorText('Use ↑↓ arrows to navigate, TAB to select/deselect, ENTER to run benchmark', 'cyan') + '\n';
|
|
272
|
+
screenContent += colorText('Type to search (real-time filtering)', 'cyan') + '\n';
|
|
273
|
+
screenContent += colorText('Press "A" to select all models, "N" to deselect all', 'cyan') + '\n';
|
|
274
|
+
screenContent += colorText('Circle states: ●=Current+Selected ○=Current+Unselected ●=Selected ○=Unselected', 'dim') + '\n';
|
|
275
|
+
screenContent += colorText('Quick run: ENTER on any model | Multi-select: TAB then ENTER', 'dim') + '\n';
|
|
276
|
+
screenContent += '\n';
|
|
277
|
+
|
|
278
|
+
// Search interface - always visible
|
|
279
|
+
screenContent += colorText('Search: ', 'yellow') + colorText(searchQuery + '_', 'bright') + '\n';
|
|
280
|
+
screenContent += '\n';
|
|
281
|
+
|
|
282
|
+
// Calculate pagination
|
|
283
|
+
const visibleItemsCount = getVisibleItemsCount(12); // Extra space for search bar
|
|
284
|
+
const totalPages = Math.ceil(filteredModels.length / visibleItemsCount);
|
|
285
|
+
|
|
286
|
+
// Ensure current page is valid
|
|
287
|
+
if (currentPage >= totalPages) currentPage = totalPages - 1;
|
|
288
|
+
if (currentPage < 0) currentPage = 0;
|
|
289
|
+
|
|
290
|
+
const startIndex = currentPage * visibleItemsCount;
|
|
291
|
+
const endIndex = Math.min(startIndex + visibleItemsCount, filteredModels.length);
|
|
292
|
+
|
|
293
|
+
// Display models in a vertical layout with pagination
|
|
294
|
+
screenContent += colorText('Available Models:', 'yellow') + '\n';
|
|
295
|
+
screenContent += '\n';
|
|
296
|
+
|
|
297
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
298
|
+
const model = filteredModels[i];
|
|
299
|
+
const isCurrent = i === currentIndex;
|
|
300
|
+
const isSelected = model.selected;
|
|
301
|
+
|
|
302
|
+
// Single circle that shows both current state and selection
|
|
303
|
+
let circle;
|
|
304
|
+
if (isCurrent && isSelected) {
|
|
305
|
+
circle = colorText('●', 'green'); // Current and selected - filled green
|
|
306
|
+
} else if (isCurrent && !isSelected) {
|
|
307
|
+
circle = colorText('○', 'green'); // Current but not selected - empty green
|
|
308
|
+
} else if (!isCurrent && isSelected) {
|
|
309
|
+
circle = colorText('●', 'cyan'); // Selected but not current - filled cyan
|
|
310
|
+
} else {
|
|
311
|
+
circle = colorText('○', 'dim'); // Not current and not selected - empty dim
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Model name highlighting
|
|
315
|
+
let modelName = isCurrent ? colorText(model.name, 'bright') : colorText(model.name, 'white');
|
|
316
|
+
|
|
317
|
+
// Provider name
|
|
318
|
+
let providerName = isCurrent ? colorText(`(${model.providerName})`, 'cyan') : colorText(`(${model.providerName})`, 'dim');
|
|
319
|
+
|
|
320
|
+
screenContent += `${circle} ${modelName} ${providerName}\n`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
screenContent += '\n';
|
|
324
|
+
screenContent += colorText(`Selected: ${allModels.filter(m => m.selected).length} models`, 'yellow') + '\n';
|
|
325
|
+
|
|
326
|
+
// Show pagination info
|
|
327
|
+
if (totalPages > 1) {
|
|
328
|
+
const pageInfo = colorText(`Page ${currentPage + 1}/${totalPages}`, 'cyan');
|
|
329
|
+
const navHint = colorText('Use Page Up/Down to navigate pages', 'dim');
|
|
330
|
+
screenContent += `${pageInfo} ${navHint}\n`;
|
|
331
|
+
|
|
332
|
+
if (currentPage < totalPages - 1) {
|
|
333
|
+
screenContent += colorText('↓ More models below', 'dim') + '\n';
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Clear screen and output entire buffer at once
|
|
338
|
+
clearScreen();
|
|
339
|
+
console.log(screenContent);
|
|
340
|
+
|
|
341
|
+
const key = await getKeyPress();
|
|
342
|
+
|
|
343
|
+
// Navigation keys - only handle special keys
|
|
344
|
+
if (key === '\u001b[A') {
|
|
345
|
+
// Up arrow - circular navigation within current page
|
|
346
|
+
const pageStartIndex = currentPage * visibleItemsCount;
|
|
347
|
+
const pageEndIndex = Math.min(pageStartIndex + visibleItemsCount, filteredModels.length);
|
|
348
|
+
|
|
349
|
+
if (currentIndex <= pageStartIndex) {
|
|
350
|
+
currentIndex = pageEndIndex - 1;
|
|
351
|
+
} else {
|
|
352
|
+
currentIndex--;
|
|
353
|
+
}
|
|
354
|
+
} else if (key === '\u001b[B') {
|
|
355
|
+
// Down arrow - circular navigation within current page
|
|
356
|
+
const pageStartIndex = currentPage * visibleItemsCount;
|
|
357
|
+
const pageEndIndex = Math.min(pageStartIndex + visibleItemsCount, filteredModels.length);
|
|
358
|
+
|
|
359
|
+
if (currentIndex >= pageEndIndex - 1) {
|
|
360
|
+
currentIndex = pageStartIndex;
|
|
361
|
+
} else {
|
|
362
|
+
currentIndex++;
|
|
363
|
+
}
|
|
364
|
+
} else if (key === '\u001b[5~') {
|
|
365
|
+
// Page Up
|
|
366
|
+
if (currentPage > 0) {
|
|
367
|
+
currentPage--;
|
|
368
|
+
currentIndex = currentPage * visibleItemsCount;
|
|
369
|
+
}
|
|
370
|
+
} else if (key === '\u001b[6~') {
|
|
371
|
+
// Page Down
|
|
372
|
+
if (currentPage < totalPages - 1) {
|
|
373
|
+
currentPage++;
|
|
374
|
+
currentIndex = currentPage * visibleItemsCount;
|
|
375
|
+
}
|
|
376
|
+
} else if (key === '\t') {
|
|
377
|
+
// Tab - select/deselect current model
|
|
378
|
+
const actualModelIndex = allModels.indexOf(filteredModels[currentIndex]);
|
|
379
|
+
if (actualModelIndex !== -1) {
|
|
380
|
+
allModels[actualModelIndex].selected = !allModels[actualModelIndex].selected;
|
|
381
|
+
}
|
|
382
|
+
} else if (key === '\r') {
|
|
383
|
+
// Enter - run benchmark on selected models
|
|
384
|
+
const currentModel = filteredModels[currentIndex];
|
|
385
|
+
if (currentModel) {
|
|
386
|
+
// Check if any models are already selected
|
|
387
|
+
const hasSelectedModels = allModels.some(model => model.selected);
|
|
388
|
+
|
|
389
|
+
if (!hasSelectedModels) {
|
|
390
|
+
// If no models are selected, select just the current model (quick single model)
|
|
391
|
+
const actualModelIndex = allModels.indexOf(currentModel);
|
|
392
|
+
if (actualModelIndex !== -1) {
|
|
393
|
+
allModels[actualModelIndex].selected = true;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// If models are already selected, keep them as is and run benchmark
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
} else if (key === '\u0003') {
|
|
400
|
+
// Ctrl+C
|
|
401
|
+
process.exit(0);
|
|
402
|
+
} else if (key === '\b' || key === '\x7f') {
|
|
403
|
+
// Backspace - delete character from search
|
|
404
|
+
if (searchQuery.length > 0) {
|
|
405
|
+
searchQuery = searchQuery.slice(0, -1);
|
|
406
|
+
debouncedFilter(searchQuery, (newFilteredModels) => {
|
|
407
|
+
filteredModels = newFilteredModels;
|
|
408
|
+
currentIndex = 0;
|
|
409
|
+
currentPage = 0;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
} else if (key === 'A') {
|
|
413
|
+
// Select all models - only when search is empty and Shift+A is pressed
|
|
414
|
+
if (searchQuery.length === 0) {
|
|
415
|
+
filteredModels.forEach(model => {
|
|
416
|
+
const actualModelIndex = allModels.indexOf(model);
|
|
417
|
+
if (actualModelIndex !== -1) {
|
|
418
|
+
allModels[actualModelIndex].selected = true;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
} else {
|
|
422
|
+
// If search is active, add 'A' to search query
|
|
423
|
+
searchQuery += key;
|
|
424
|
+
debouncedFilter(searchQuery, (newFilteredModels) => {
|
|
425
|
+
filteredModels = newFilteredModels;
|
|
426
|
+
currentIndex = 0;
|
|
427
|
+
currentPage = 0;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
} else if (key === 'N') {
|
|
431
|
+
// Deselect all models (None) - only when search is empty and Shift+N is pressed
|
|
432
|
+
if (searchQuery.length === 0) {
|
|
433
|
+
filteredModels.forEach(model => {
|
|
434
|
+
const actualModelIndex = allModels.indexOf(model);
|
|
435
|
+
if (actualModelIndex !== -1) {
|
|
436
|
+
allModels[actualModelIndex].selected = false;
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
} else {
|
|
440
|
+
// If search is active, add 'N' to search query
|
|
441
|
+
searchQuery += key;
|
|
442
|
+
debouncedFilter(searchQuery, (newFilteredModels) => {
|
|
443
|
+
filteredModels = newFilteredModels;
|
|
444
|
+
currentIndex = 0;
|
|
445
|
+
currentPage = 0;
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
} else if (key === 'a' || key === 'n') {
|
|
449
|
+
// Lowercase 'a' and 'n' go to search field (not select all/none)
|
|
450
|
+
searchQuery += key;
|
|
451
|
+
debouncedFilter(searchQuery, (newFilteredModels) => {
|
|
452
|
+
filteredModels = newFilteredModels;
|
|
453
|
+
currentIndex = 0;
|
|
454
|
+
currentPage = 0;
|
|
455
|
+
});
|
|
456
|
+
} else if (key === ' ' || key.length === 1) {
|
|
457
|
+
// Spacebar or regular character - add to search query
|
|
458
|
+
searchQuery += key;
|
|
459
|
+
debouncedFilter(searchQuery, (newFilteredModels) => {
|
|
460
|
+
filteredModels = newFilteredModels;
|
|
461
|
+
currentIndex = 0;
|
|
462
|
+
currentPage = 0;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return allModels.filter(m => m.selected);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Enhanced benchmark with streaming (run in parallel)
|
|
471
|
+
async function runStreamingBenchmark(models) {
|
|
472
|
+
if (models.length === 0) {
|
|
473
|
+
console.log(colorText('No models selected for benchmarking.', 'red'));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
clearScreen();
|
|
478
|
+
showHeader();
|
|
479
|
+
console.log(colorText('Running Benchmark...', 'green'));
|
|
480
|
+
console.log(colorText(`Running ${models.length} models in parallel...`, 'cyan'));
|
|
481
|
+
console.log('');
|
|
482
|
+
|
|
483
|
+
// Create a function to benchmark a single model
|
|
484
|
+
const benchmarkModel = async (model) => {
|
|
485
|
+
console.log(colorText(`Testing ${model.name} (${model.providerName})...`, 'yellow'));
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
let firstTokenTime = null;
|
|
489
|
+
let tokenCount = 0;
|
|
490
|
+
let startTime = Date.now();
|
|
491
|
+
|
|
492
|
+
log(`Model provider type: ${model.providerType}`);
|
|
493
|
+
log(`Model provider config: ${JSON.stringify(model.providerConfig, null, 2)}`);
|
|
494
|
+
|
|
495
|
+
// Validate required configuration
|
|
496
|
+
if (!model.providerConfig || !model.providerConfig.apiKey) {
|
|
497
|
+
throw new Error(`Missing API key for provider ${model.providerName}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (!model.providerConfig.baseUrl) {
|
|
501
|
+
throw new Error(`Missing base URL for provider ${model.providerName}`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
log(`Model provider config baseUrl: ${model.providerConfig.baseUrl}`);
|
|
505
|
+
log(`Model provider config apiKey: ${model.providerConfig.apiKey ? '***' + model.providerConfig.apiKey.slice(-4) : 'missing'}`);
|
|
506
|
+
|
|
507
|
+
// Extract the actual model ID for API calls
|
|
508
|
+
let actualModelId = model.name;
|
|
509
|
+
if (model.id && model.id.includes('_')) {
|
|
510
|
+
// For models with provider prefix, extract the actual model ID
|
|
511
|
+
actualModelId = model.id.split('_')[1];
|
|
512
|
+
log(`Using extracted model ID: ${actualModelId}`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Trim any trailing spaces from model names
|
|
516
|
+
actualModelId = actualModelId.trim();
|
|
517
|
+
log(`Using final model ID: "${actualModelId}"`);
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
let modelConfig;
|
|
522
|
+
if (model.providerType === 'openai-compatible') {
|
|
523
|
+
modelConfig = {
|
|
524
|
+
model: createOpenAICompatible({
|
|
525
|
+
name: model.providerName,
|
|
526
|
+
apiKey: model.providerConfig.apiKey,
|
|
527
|
+
baseURL: model.providerConfig.baseUrl,
|
|
528
|
+
})(actualModelId),
|
|
529
|
+
system: "", // Remove system prompt for leaner API calls
|
|
530
|
+
};
|
|
531
|
+
} else if (model.providerType === 'anthropic') {
|
|
532
|
+
modelConfig = {
|
|
533
|
+
model: createAnthropicProvider(model.providerConfig.baseUrl, model.providerConfig.apiKey)(actualModelId),
|
|
534
|
+
system: "", // Remove system prompt for leaner API calls
|
|
535
|
+
};
|
|
536
|
+
} else if (model.providerType === 'google') {
|
|
537
|
+
// For Google providers, we need to import and use the Google SDK
|
|
538
|
+
const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
|
|
539
|
+
const googleProvider = createGoogleGenerativeAI({
|
|
540
|
+
apiKey: model.providerConfig.apiKey,
|
|
541
|
+
baseURL: model.providerConfig.baseUrl,
|
|
542
|
+
});
|
|
543
|
+
modelConfig = {
|
|
544
|
+
model: googleProvider(actualModelId),
|
|
545
|
+
system: "", // Remove system prompt for leaner API calls
|
|
546
|
+
};
|
|
547
|
+
} else {
|
|
548
|
+
throw new Error(`Unsupported provider type: ${model.providerType}`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const result = streamText({
|
|
552
|
+
...modelConfig,
|
|
553
|
+
prompt: testPrompt,
|
|
554
|
+
maxTokens: 500,
|
|
555
|
+
onChunk: ({ chunk }) => {
|
|
556
|
+
if (!firstTokenTime && chunk.type === 'text-delta') {
|
|
557
|
+
firstTokenTime = Date.now();
|
|
558
|
+
}
|
|
559
|
+
if (chunk.type === 'text-delta') {
|
|
560
|
+
tokenCount++;
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// Consume the stream and count tokens manually
|
|
566
|
+
let fullText = '';
|
|
567
|
+
try {
|
|
568
|
+
for await (const textPart of result.textStream) {
|
|
569
|
+
fullText += textPart;
|
|
570
|
+
// Manual token count estimation as fallback
|
|
571
|
+
tokenCount = Math.round(fullText.length / 4); // Rough estimate
|
|
572
|
+
}
|
|
573
|
+
log(`Stream completed successfully. Total tokens: ${tokenCount}`);
|
|
574
|
+
log(`Full text length: ${fullText.length} characters`);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
log(`Stream error: ${error.message}`);
|
|
577
|
+
log(`Error stack: ${error.stack}`);
|
|
578
|
+
throw error;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Close log file when done
|
|
582
|
+
if (debugMode) {
|
|
583
|
+
process.on('exit', () => {
|
|
584
|
+
if (logFile) logFile.end();
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const endTime = Date.now();
|
|
589
|
+
const totalTime = endTime - startTime;
|
|
590
|
+
const timeToFirstToken = firstTokenTime ? firstTokenTime - startTime : totalTime;
|
|
591
|
+
const tokensPerSecond = tokenCount > 0 && totalTime > 0 ? (tokenCount / totalTime) * 1000 : 0;
|
|
592
|
+
|
|
593
|
+
// Try to get usage, but fallback to manual counting
|
|
594
|
+
let usage = null;
|
|
595
|
+
try {
|
|
596
|
+
usage = await result.usage;
|
|
597
|
+
log(`Provider usage data: ${JSON.stringify(usage, null, 2)}`);
|
|
598
|
+
} catch (e) {
|
|
599
|
+
log(`Usage not available: ${e.message}`);
|
|
600
|
+
// Usage might not be available
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Use provider token count if available, otherwise use manual count
|
|
604
|
+
const completionTokens = usage?.completionTokens || tokenCount;
|
|
605
|
+
const promptTokens = usage?.promptTokens || Math.round(testPrompt.length / 4);
|
|
606
|
+
const totalTokens = usage?.totalTokens || (completionTokens + promptTokens);
|
|
607
|
+
|
|
608
|
+
console.log(colorText('Completed!', 'green'));
|
|
609
|
+
console.log(colorText(` Total Time: ${(totalTime / 1000).toFixed(2)}s`, 'cyan'));
|
|
610
|
+
console.log(colorText(` TTFT: ${(timeToFirstToken / 1000).toFixed(2)}s`, 'cyan'));
|
|
611
|
+
console.log(colorText(` Tokens/Sec: ${tokensPerSecond.toFixed(1)}`, 'cyan'));
|
|
612
|
+
console.log(colorText(` Total Tokens: ${totalTokens}`, 'cyan'));
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
model: model.name,
|
|
616
|
+
provider: model.providerName,
|
|
617
|
+
totalTime,
|
|
618
|
+
timeToFirstToken,
|
|
619
|
+
tokenCount: completionTokens,
|
|
620
|
+
tokensPerSecond,
|
|
621
|
+
promptTokens: promptTokens,
|
|
622
|
+
totalTokens: totalTokens,
|
|
623
|
+
success: true
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
} catch (error) {
|
|
627
|
+
console.log(colorText('Failed: ', 'red') + error.message);
|
|
628
|
+
log(`Benchmark failed: ${error.message}`);
|
|
629
|
+
log(`Error stack: ${error.stack}`);
|
|
630
|
+
return {
|
|
631
|
+
model: model.name,
|
|
632
|
+
provider: model.providerName,
|
|
633
|
+
totalTime: 0,
|
|
634
|
+
timeToFirstToken: 0,
|
|
635
|
+
tokenCount: 0,
|
|
636
|
+
tokensPerSecond: 0,
|
|
637
|
+
promptTokens: 0,
|
|
638
|
+
totalTokens: 0,
|
|
639
|
+
success: false,
|
|
640
|
+
error: error.message
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
// Run all benchmarks in parallel
|
|
646
|
+
console.log(colorText('Starting parallel benchmark execution...', 'cyan'));
|
|
647
|
+
const promises = models.map(model => benchmarkModel(model));
|
|
648
|
+
const results = await Promise.all(promises);
|
|
649
|
+
|
|
650
|
+
console.log('');
|
|
651
|
+
console.log(colorText('All benchmarks completed!', 'green'));
|
|
652
|
+
|
|
653
|
+
await displayColorfulResults(results, 'AI SDK');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Colorful results display with comprehensive table and enhanced bars
|
|
657
|
+
async function displayColorfulResults(results, method = 'AI SDK') {
|
|
658
|
+
clearScreen();
|
|
659
|
+
showHeader();
|
|
660
|
+
console.log(colorText('BENCHMARK RESULTS', 'magenta'));
|
|
661
|
+
console.log(colorText('=========================', 'magenta'));
|
|
662
|
+
console.log('');
|
|
663
|
+
console.log(colorText('Method: ', 'cyan') + colorText(method, 'yellow'));
|
|
664
|
+
console.log('');
|
|
665
|
+
|
|
666
|
+
// Filter successful results for table
|
|
667
|
+
const successfulResults = results.filter(r => r.success);
|
|
668
|
+
|
|
669
|
+
if (successfulResults.length === 0) {
|
|
670
|
+
console.log(colorText('No successful benchmarks to display.', 'red'));
|
|
671
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Create comprehensive table
|
|
676
|
+
console.log(colorText('COMPREHENSIVE PERFORMANCE SUMMARY', 'yellow'));
|
|
677
|
+
|
|
678
|
+
// Add note about method differences
|
|
679
|
+
console.log(colorText('Note: ', 'cyan') + colorText('Benchmark over REST API doesn\'t utilize streaming, so TTFT is 0. AI SDK utilizes streaming, but', 'dim'));
|
|
680
|
+
console.log(colorText(' ', 'cyan') + colorText('if the model is a thinking model, TTFT will be much higher because thinking tokens are not counted as first token.', 'dim'));
|
|
681
|
+
console.log('');
|
|
682
|
+
|
|
683
|
+
const table = new Table({
|
|
684
|
+
head: [
|
|
685
|
+
colorText('Model', 'cyan'),
|
|
686
|
+
colorText('Provider', 'cyan'),
|
|
687
|
+
colorText('Total Time(s)', 'cyan'),
|
|
688
|
+
colorText('TTFT(s)', 'cyan'),
|
|
689
|
+
colorText('Tokens/Sec', 'cyan'),
|
|
690
|
+
colorText('Output Tokens', 'cyan'),
|
|
691
|
+
colorText('Prompt Tokens', 'cyan'),
|
|
692
|
+
colorText('Total Tokens', 'cyan')
|
|
693
|
+
],
|
|
694
|
+
colWidths: [20, 15, 15, 12, 15, 15, 15, 15],
|
|
695
|
+
style: {
|
|
696
|
+
head: ['cyan'],
|
|
697
|
+
border: ['dim'],
|
|
698
|
+
compact: false
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Sort results by tokens per second (descending) for table ranking
|
|
703
|
+
const sortedResults = [...successfulResults].sort((a, b) => b.tokensPerSecond - a.tokensPerSecond);
|
|
704
|
+
|
|
705
|
+
// Add data rows (already ranked by sort order)
|
|
706
|
+
sortedResults.forEach((result) => {
|
|
707
|
+
table.push([
|
|
708
|
+
colorText(result.model, 'white'),
|
|
709
|
+
colorText(result.provider, 'white'),
|
|
710
|
+
colorText((result.totalTime / 1000).toFixed(2), 'green'),
|
|
711
|
+
colorText((result.timeToFirstToken / 1000).toFixed(2), 'yellow'),
|
|
712
|
+
colorText(result.tokensPerSecond.toFixed(1), 'magenta'),
|
|
713
|
+
colorText(result.tokenCount.toString(), 'blue'),
|
|
714
|
+
colorText(result.promptTokens.toString(), 'blue'),
|
|
715
|
+
colorText(result.totalTokens.toString(), 'bright')
|
|
716
|
+
]);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
console.log(table.toString());
|
|
720
|
+
console.log('');
|
|
721
|
+
|
|
722
|
+
// Enhanced performance comparison charts with ranking and provider sections
|
|
723
|
+
console.log(colorText('PERFORMANCE COMPARISON CHARTS', 'yellow'));
|
|
724
|
+
console.log(colorText('─'.repeat(80), 'dim'));
|
|
725
|
+
console.log('');
|
|
726
|
+
|
|
727
|
+
// Group results by provider
|
|
728
|
+
const providerGroups = {};
|
|
729
|
+
successfulResults.forEach(result => {
|
|
730
|
+
if (!providerGroups[result.provider]) {
|
|
731
|
+
providerGroups[result.provider] = [];
|
|
732
|
+
}
|
|
733
|
+
providerGroups[result.provider].push(result);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
// Calculate consistent column widths for both charts
|
|
737
|
+
const maxModelLength = Math.max(...successfulResults.map(r => r.model.length));
|
|
738
|
+
const maxProviderLength = Math.max(...successfulResults.map(r => r.provider.length));
|
|
739
|
+
const maxTimeLength = 8; // "99.99s"
|
|
740
|
+
const maxTpsLength = 12; // "999.9 tok/s"
|
|
741
|
+
const maxRankLength = 6; // "1st", "2nd", "3rd", "4th", etc.
|
|
742
|
+
|
|
743
|
+
// Time comparison chart - ranked by fastest (lowest time first)
|
|
744
|
+
console.log(colorText('TOTAL TIME RANKING (fastest at top - lower is better)', 'cyan'));
|
|
745
|
+
const timeSortedResults = [...successfulResults].sort((a, b) => a.totalTime - b.totalTime);
|
|
746
|
+
const maxTime = Math.max(...successfulResults.map(r => r.totalTime));
|
|
747
|
+
|
|
748
|
+
timeSortedResults.forEach((result, index) => {
|
|
749
|
+
const rank = index + 1;
|
|
750
|
+
const barLength = Math.floor((result.totalTime / maxTime) * 25);
|
|
751
|
+
const bar = colorText('█'.repeat(barLength), 'red') + colorText('░'.repeat(25 - barLength), 'dim');
|
|
752
|
+
const timeDisplay = (result.totalTime / 1000).toFixed(2) + 's';
|
|
753
|
+
const tpsDisplay = result.tokensPerSecond.toFixed(1) + ' tok/s';
|
|
754
|
+
|
|
755
|
+
// Rank badges
|
|
756
|
+
const rankBadge = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
|
|
757
|
+
|
|
758
|
+
console.log(
|
|
759
|
+
colorText(rankBadge.padStart(maxRankLength), rank === 1 ? 'yellow' : rank === 2 ? 'white' : rank === 3 ? 'bright' : 'white') +
|
|
760
|
+
colorText(' | ', 'dim') +
|
|
761
|
+
colorText(timeDisplay.padStart(maxTimeLength), 'red') +
|
|
762
|
+
colorText(' | ', 'dim') +
|
|
763
|
+
colorText(tpsDisplay.padStart(maxTpsLength), 'magenta') +
|
|
764
|
+
colorText(' | ', 'dim') +
|
|
765
|
+
colorText(result.model.padEnd(maxModelLength), 'white') +
|
|
766
|
+
colorText(' | ', 'dim') +
|
|
767
|
+
colorText(result.provider.padEnd(maxProviderLength), 'cyan') +
|
|
768
|
+
colorText(' | ', 'dim') +
|
|
769
|
+
bar
|
|
770
|
+
);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
console.log('');
|
|
774
|
+
|
|
775
|
+
// Tokens per second comparison - ranked by highest TPS first
|
|
776
|
+
console.log(colorText('TOKENS PER SECOND RANKING (fastest at top - higher is better)', 'cyan'));
|
|
777
|
+
const tpsSortedResults = [...successfulResults].sort((a, b) => b.tokensPerSecond - a.tokensPerSecond);
|
|
778
|
+
const maxTps = Math.max(...successfulResults.map(r => r.tokensPerSecond));
|
|
779
|
+
|
|
780
|
+
tpsSortedResults.forEach((result, index) => {
|
|
781
|
+
const rank = index + 1;
|
|
782
|
+
const barLength = Math.floor((result.tokensPerSecond / maxTps) * 25);
|
|
783
|
+
const bar = colorText('█'.repeat(barLength), 'green') + colorText('░'.repeat(25 - barLength), 'dim');
|
|
784
|
+
const timeDisplay = (result.totalTime / 1000).toFixed(2) + 's';
|
|
785
|
+
const tpsDisplay = result.tokensPerSecond.toFixed(1) + ' tok/s';
|
|
786
|
+
|
|
787
|
+
// Rank badges
|
|
788
|
+
const rankBadge = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}.`;
|
|
789
|
+
|
|
790
|
+
console.log(
|
|
791
|
+
colorText(rankBadge.padStart(maxRankLength), rank === 1 ? 'yellow' : rank === 2 ? 'white' : rank === 3 ? 'bright' : 'white') +
|
|
792
|
+
colorText(' | ', 'dim') +
|
|
793
|
+
colorText(tpsDisplay.padStart(maxTpsLength), 'green') +
|
|
794
|
+
colorText(' | ', 'dim') +
|
|
795
|
+
colorText(timeDisplay.padStart(maxTimeLength), 'red') +
|
|
796
|
+
colorText(' | ', 'dim') +
|
|
797
|
+
colorText(result.model.padEnd(maxModelLength), 'white') +
|
|
798
|
+
colorText(' | ', 'dim') +
|
|
799
|
+
colorText(result.provider.padEnd(maxProviderLength), 'cyan') +
|
|
800
|
+
colorText(' | ', 'dim') +
|
|
801
|
+
bar
|
|
802
|
+
);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
console.log('');
|
|
806
|
+
|
|
807
|
+
console.log('');
|
|
808
|
+
|
|
809
|
+
// Show failed benchmarks
|
|
810
|
+
const failedResults = results.filter(r => !r.success);
|
|
811
|
+
if (failedResults.length > 0) {
|
|
812
|
+
console.log(colorText('FAILED BENCHMARKS', 'red'));
|
|
813
|
+
console.log(colorText('─'.repeat(40), 'dim'));
|
|
814
|
+
failedResults.forEach(result => {
|
|
815
|
+
console.log(colorText(`${result.model} (${result.provider}): ${result.error}`, 'red'));
|
|
816
|
+
});
|
|
817
|
+
console.log('');
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
console.log(colorText('Benchmark completed!', 'green'));
|
|
821
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Helper function to calculate visible items based on terminal height
|
|
825
|
+
function getVisibleItemsCount(headerHeight = 8) {
|
|
826
|
+
const terminalHeight = process.stdout.rows || 24;
|
|
827
|
+
return Math.max(5, terminalHeight - headerHeight);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Provider management with models.dev integration and pagination
|
|
831
|
+
async function addProvider() {
|
|
832
|
+
clearScreen();
|
|
833
|
+
showHeader();
|
|
834
|
+
console.log(colorText('Add Provider', 'magenta'));
|
|
835
|
+
console.log('');
|
|
836
|
+
|
|
837
|
+
let searchQuery = '';
|
|
838
|
+
let allProviders = [];
|
|
839
|
+
let filteredProviders = [];
|
|
840
|
+
let currentIndex = 0;
|
|
841
|
+
let currentPage = 0;
|
|
842
|
+
|
|
843
|
+
// Load providers from models.dev
|
|
844
|
+
try {
|
|
845
|
+
allProviders = await getAllProviders();
|
|
846
|
+
filteredProviders = allProviders;
|
|
847
|
+
} catch (error) {
|
|
848
|
+
console.log(colorText('Error loading providers: ', 'red') + error.message);
|
|
849
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
while (true) {
|
|
854
|
+
// Build screen content in memory (double buffering)
|
|
855
|
+
let screenContent = '';
|
|
856
|
+
|
|
857
|
+
// Add header
|
|
858
|
+
screenContent += colorText('Ai-speedometer', 'cyan') + '\n';
|
|
859
|
+
screenContent += colorText('=============================', 'cyan') + '\n';
|
|
860
|
+
screenContent += colorText('Note: opencode uses ai-sdk', 'dim') + '\n';
|
|
861
|
+
screenContent += '\n';
|
|
862
|
+
|
|
863
|
+
screenContent += colorText('Add Provider', 'magenta') + '\n';
|
|
864
|
+
screenContent += colorText('Use ↑↓ arrows to navigate, ENTER to select', 'cyan') + '\n';
|
|
865
|
+
screenContent += colorText('Type to search (real-time filtering)', 'cyan') + '\n';
|
|
866
|
+
screenContent += colorText('Navigation is circular', 'dim') + '\n';
|
|
867
|
+
screenContent += '\n';
|
|
868
|
+
|
|
869
|
+
// Search interface - always visible
|
|
870
|
+
screenContent += colorText('Search: ', 'yellow') + colorText(searchQuery + '_', 'bright') + '\n';
|
|
871
|
+
screenContent += '\n';
|
|
872
|
+
|
|
873
|
+
// Calculate pagination
|
|
874
|
+
const visibleItemsCount = getVisibleItemsCount();
|
|
875
|
+
const totalItems = filteredProviders.length + 1; // +1 for custom provider option
|
|
876
|
+
const totalPages = Math.ceil(totalItems / visibleItemsCount);
|
|
877
|
+
|
|
878
|
+
// Ensure current page is valid
|
|
879
|
+
if (currentPage >= totalPages) currentPage = totalPages - 1;
|
|
880
|
+
if (currentPage < 0) currentPage = 0;
|
|
881
|
+
|
|
882
|
+
const startIndex = currentPage * visibleItemsCount;
|
|
883
|
+
const endIndex = Math.min(startIndex + visibleItemsCount, totalItems);
|
|
884
|
+
|
|
885
|
+
// Display providers with pagination
|
|
886
|
+
screenContent += colorText('Available Providers:', 'cyan') + '\n';
|
|
887
|
+
screenContent += '\n';
|
|
888
|
+
|
|
889
|
+
// Show current page of providers
|
|
890
|
+
for (let i = startIndex; i < endIndex && i < filteredProviders.length; i++) {
|
|
891
|
+
const provider = filteredProviders[i];
|
|
892
|
+
const isCurrent = i === currentIndex;
|
|
893
|
+
const indicator = isCurrent ? colorText('●', 'green') : colorText('○', 'dim');
|
|
894
|
+
const providerName = isCurrent ? colorText(provider.name, 'bright') : colorText(provider.name, 'white');
|
|
895
|
+
const providerType = isCurrent ? colorText(`(${provider.type})`, 'cyan') : colorText(`(${provider.type})`, 'dim');
|
|
896
|
+
|
|
897
|
+
screenContent += `${indicator} ${providerName} ${providerType}\n`;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Show "Add Custom Provider" option if it's on current page
|
|
901
|
+
const customIndex = filteredProviders.length;
|
|
902
|
+
if (customIndex >= startIndex && customIndex < endIndex) {
|
|
903
|
+
const isCustomCurrent = customIndex === currentIndex;
|
|
904
|
+
const customIndicator = isCustomCurrent ? colorText('●', 'green') : colorText('○', 'dim');
|
|
905
|
+
const customText = isCustomCurrent ? colorText('Add Custom Provider', 'bright') : colorText('Add Custom Provider', 'yellow');
|
|
906
|
+
|
|
907
|
+
screenContent += `${customIndicator} ${customText}\n`;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Show pagination info
|
|
911
|
+
screenContent += '\n';
|
|
912
|
+
if (totalPages > 1) {
|
|
913
|
+
const pageInfo = colorText(`Page ${currentPage + 1}/${totalPages}`, 'cyan');
|
|
914
|
+
const navHint = colorText('Use Page Up/Down to navigate pages', 'dim');
|
|
915
|
+
screenContent += `${pageInfo} ${navHint}\n`;
|
|
916
|
+
|
|
917
|
+
if (currentPage < totalPages - 1) {
|
|
918
|
+
screenContent += colorText('↓ More items below', 'dim') + '\n';
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Clear screen and output entire buffer at once
|
|
923
|
+
clearScreen();
|
|
924
|
+
console.log(screenContent);
|
|
925
|
+
|
|
926
|
+
const key = await getKeyPress();
|
|
927
|
+
|
|
928
|
+
// Navigation keys - only handle special keys
|
|
929
|
+
if (key === '\u001b[A') {
|
|
930
|
+
// Up arrow - circular navigation within current page
|
|
931
|
+
const pageStartIndex = currentPage * visibleItemsCount;
|
|
932
|
+
const pageEndIndex = Math.min(pageStartIndex + visibleItemsCount, totalItems);
|
|
933
|
+
|
|
934
|
+
if (currentIndex <= pageStartIndex) {
|
|
935
|
+
currentIndex = pageEndIndex - 1;
|
|
936
|
+
} else {
|
|
937
|
+
currentIndex--;
|
|
938
|
+
}
|
|
939
|
+
} else if (key === '\u001b[B') {
|
|
940
|
+
// Down arrow - circular navigation within current page
|
|
941
|
+
const pageStartIndex = currentPage * visibleItemsCount;
|
|
942
|
+
const pageEndIndex = Math.min(pageStartIndex + visibleItemsCount, totalItems);
|
|
943
|
+
|
|
944
|
+
if (currentIndex >= pageEndIndex - 1) {
|
|
945
|
+
currentIndex = pageStartIndex;
|
|
946
|
+
} else {
|
|
947
|
+
currentIndex++;
|
|
948
|
+
}
|
|
949
|
+
} else if (key === '\u001b[5~') {
|
|
950
|
+
// Page Up
|
|
951
|
+
if (currentPage > 0) {
|
|
952
|
+
currentPage--;
|
|
953
|
+
currentIndex = currentPage * visibleItemsCount;
|
|
954
|
+
}
|
|
955
|
+
} else if (key === '\u001b[6~') {
|
|
956
|
+
// Page Down
|
|
957
|
+
if (currentPage < totalPages - 1) {
|
|
958
|
+
currentPage++;
|
|
959
|
+
currentIndex = currentPage * visibleItemsCount;
|
|
960
|
+
}
|
|
961
|
+
} else if (key === '\r') {
|
|
962
|
+
// Enter - select current option
|
|
963
|
+
if (currentIndex === filteredProviders.length) {
|
|
964
|
+
// Custom provider selected
|
|
965
|
+
await addCustomProvider();
|
|
966
|
+
} else {
|
|
967
|
+
// Verified provider selected - auto-add all models
|
|
968
|
+
await addVerifiedProviderAuto(filteredProviders[currentIndex]);
|
|
969
|
+
}
|
|
970
|
+
break;
|
|
971
|
+
} else if (key === '\u0003') {
|
|
972
|
+
// Ctrl+C
|
|
973
|
+
process.exit(0);
|
|
974
|
+
} else if (key === '\b' || key === '\x7f') {
|
|
975
|
+
// Backspace - delete character from search
|
|
976
|
+
if (searchQuery.length > 0) {
|
|
977
|
+
searchQuery = searchQuery.slice(0, -1);
|
|
978
|
+
filteredProviders = await searchProviders(searchQuery);
|
|
979
|
+
currentIndex = 0;
|
|
980
|
+
currentPage = 0;
|
|
981
|
+
}
|
|
982
|
+
} else if (key.length === 1) {
|
|
983
|
+
// Regular character - add to search query
|
|
984
|
+
searchQuery += key;
|
|
985
|
+
filteredProviders = await searchProviders(searchQuery);
|
|
986
|
+
currentIndex = 0;
|
|
987
|
+
currentPage = 0;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Add a verified provider from models.dev with AUTO-ADD all models
|
|
993
|
+
async function addVerifiedProviderAuto(provider) {
|
|
994
|
+
clearScreen();
|
|
995
|
+
showHeader();
|
|
996
|
+
console.log(colorText('Add Verified Provider', 'magenta'));
|
|
997
|
+
console.log('');
|
|
998
|
+
|
|
999
|
+
console.log(colorText('Provider: ', 'cyan') + colorText(provider.name, 'white'));
|
|
1000
|
+
console.log(colorText('Type: ', 'cyan') + colorText(provider.type, 'white'));
|
|
1001
|
+
console.log(colorText('Base URL: ', 'cyan') + colorText(provider.baseUrl, 'white'));
|
|
1002
|
+
console.log('');
|
|
1003
|
+
|
|
1004
|
+
// Get available models for this provider
|
|
1005
|
+
const models = await getModelsForProvider(provider.id);
|
|
1006
|
+
|
|
1007
|
+
if (models.length === 0) {
|
|
1008
|
+
console.log(colorText('No models available for this provider.', 'red'));
|
|
1009
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
console.log(colorText(`Found ${models.length} models for ${provider.name}`, 'cyan'));
|
|
1014
|
+
console.log(colorText('All models will be automatically added', 'green'));
|
|
1015
|
+
console.log('');
|
|
1016
|
+
|
|
1017
|
+
// Show first few models as preview
|
|
1018
|
+
console.log(colorText('Models to be added:', 'yellow'));
|
|
1019
|
+
const previewCount = Math.min(5, models.length);
|
|
1020
|
+
models.slice(0, previewCount).forEach(model => {
|
|
1021
|
+
console.log(colorText(` • ${model.name}`, 'white'));
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
if (models.length > previewCount) {
|
|
1025
|
+
console.log(colorText(` ... and ${models.length - previewCount} more`, 'dim'));
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
console.log('');
|
|
1029
|
+
|
|
1030
|
+
// Get API key
|
|
1031
|
+
const apiKey = await question(colorText('Enter API key: ', 'cyan'));
|
|
1032
|
+
|
|
1033
|
+
if (!apiKey) {
|
|
1034
|
+
console.log(colorText('API key is required.', 'red'));
|
|
1035
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Add API key to opencode auth.json
|
|
1040
|
+
const success = await addApiKey(provider.id, apiKey);
|
|
1041
|
+
|
|
1042
|
+
if (!success) {
|
|
1043
|
+
console.log(colorText('Failed to save API key to opencode auth.json', 'red'));
|
|
1044
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
console.log('');
|
|
1049
|
+
console.log(colorText('Provider added successfully!', 'green'));
|
|
1050
|
+
console.log(colorText(`API key saved to opencode auth.json`, 'cyan'));
|
|
1051
|
+
console.log(colorText(`Models will be loaded dynamically from ${provider.name}`, 'cyan'));
|
|
1052
|
+
console.log(colorText(`Found ${models.length} available models`, 'cyan'));
|
|
1053
|
+
|
|
1054
|
+
await question(colorText('\nPress Enter to continue...', 'yellow'));
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
// Add a custom provider (now integrated with opencode.json)
|
|
1060
|
+
async function addCustomProvider() {
|
|
1061
|
+
clearScreen();
|
|
1062
|
+
showHeader();
|
|
1063
|
+
console.log(colorText('Add Custom Provider', 'magenta'));
|
|
1064
|
+
console.log('');
|
|
1065
|
+
console.log(colorText('Note: Custom providers are saved to opencode.json', 'cyan'));
|
|
1066
|
+
console.log('');
|
|
1067
|
+
|
|
1068
|
+
const providerOptions = [
|
|
1069
|
+
{ id: 1, text: 'OpenAI Compatible', type: 'openai-compatible' },
|
|
1070
|
+
{ id: 2, text: 'Anthropic', type: 'anthropic' },
|
|
1071
|
+
{ id: 3, text: 'Back to provider selection', action: 'back' }
|
|
1072
|
+
];
|
|
1073
|
+
|
|
1074
|
+
let currentIndex = 0;
|
|
1075
|
+
let selectedChoice = null;
|
|
1076
|
+
|
|
1077
|
+
while (true) {
|
|
1078
|
+
// Build screen content in memory (double buffering)
|
|
1079
|
+
let screenContent = '';
|
|
1080
|
+
|
|
1081
|
+
// Add header
|
|
1082
|
+
screenContent += colorText('Ai-speedometer', 'cyan') + '\n';
|
|
1083
|
+
screenContent += colorText('=============================', 'cyan') + '\n';
|
|
1084
|
+
screenContent += colorText('Note: opencode uses ai-sdk', 'dim') + '\n';
|
|
1085
|
+
screenContent += '\n';
|
|
1086
|
+
|
|
1087
|
+
screenContent += colorText('Add Custom Provider', 'magenta') + '\n';
|
|
1088
|
+
screenContent += colorText('Use ↑↓ arrows to navigate, ENTER to select', 'cyan') + '\n';
|
|
1089
|
+
screenContent += colorText('Navigation is circular', 'dim') + '\n';
|
|
1090
|
+
screenContent += '\n';
|
|
1091
|
+
|
|
1092
|
+
screenContent += colorText('Select provider type:', 'cyan') + '\n';
|
|
1093
|
+
screenContent += '\n';
|
|
1094
|
+
|
|
1095
|
+
// Display provider options with arrow key navigation
|
|
1096
|
+
providerOptions.forEach((option, index) => {
|
|
1097
|
+
const isCurrent = index === currentIndex;
|
|
1098
|
+
const indicator = isCurrent ? colorText('●', 'green') : colorText('○', 'dim');
|
|
1099
|
+
const optionText = isCurrent ? colorText(option.text, 'bright') : colorText(option.text, 'yellow');
|
|
1100
|
+
|
|
1101
|
+
screenContent += `${indicator} ${optionText}\n`;
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// Clear screen and output entire buffer at once
|
|
1105
|
+
clearScreen();
|
|
1106
|
+
console.log(screenContent);
|
|
1107
|
+
|
|
1108
|
+
const key = await getKeyPress();
|
|
1109
|
+
|
|
1110
|
+
if (key === '\u001b[A') {
|
|
1111
|
+
// Up arrow - circular navigation
|
|
1112
|
+
currentIndex = (currentIndex - 1 + providerOptions.length) % providerOptions.length;
|
|
1113
|
+
} else if (key === '\u001b[B') {
|
|
1114
|
+
// Down arrow - circular navigation
|
|
1115
|
+
currentIndex = (currentIndex + 1) % providerOptions.length;
|
|
1116
|
+
} else if (key === '\r') {
|
|
1117
|
+
// Enter - select current option
|
|
1118
|
+
selectedChoice = providerOptions[currentIndex];
|
|
1119
|
+
break;
|
|
1120
|
+
} else if (key === '\u0003') {
|
|
1121
|
+
// Ctrl+C
|
|
1122
|
+
process.exit(0);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (selectedChoice.action === 'back') return;
|
|
1127
|
+
|
|
1128
|
+
if (selectedChoice.type === 'openai-compatible') {
|
|
1129
|
+
// OpenAI Compatible
|
|
1130
|
+
const providerId = await question(colorText('Enter provider ID (e.g., my-openai): ', 'cyan'));
|
|
1131
|
+
const name = await question(colorText('Enter provider name (e.g., MyOpenAI): ', 'cyan'));
|
|
1132
|
+
const baseUrl = await question(colorText('Enter base URL (e.g., https://api.openai.com/v1): ', 'cyan'));
|
|
1133
|
+
const apiKey = await question(colorText('Enter API key: ', 'cyan'));
|
|
1134
|
+
|
|
1135
|
+
// Ask if user wants to add multiple models
|
|
1136
|
+
console.log('');
|
|
1137
|
+
console.log(colorText('Do you want to add multiple models?', 'cyan'));
|
|
1138
|
+
console.log(colorText('1. Add single model', 'yellow'));
|
|
1139
|
+
console.log(colorText('2. Add multiple models', 'yellow'));
|
|
1140
|
+
|
|
1141
|
+
const modelChoice = await question(colorText('Enter choice (1 or 2): ', 'cyan'));
|
|
1142
|
+
|
|
1143
|
+
let models = {};
|
|
1144
|
+
|
|
1145
|
+
if (modelChoice === '2') {
|
|
1146
|
+
// Multiple models mode
|
|
1147
|
+
console.log('');
|
|
1148
|
+
console.log(colorText('Enter model names (one per line, empty line to finish):', 'cyan'));
|
|
1149
|
+
console.log(colorText('Examples: gpt-4, gpt-4-turbo, gpt-3.5-turbo', 'dim'));
|
|
1150
|
+
console.log('');
|
|
1151
|
+
|
|
1152
|
+
while (true) {
|
|
1153
|
+
const modelName = await question(colorText('Model name: ', 'cyan'));
|
|
1154
|
+
if (!modelName.trim()) break;
|
|
1155
|
+
|
|
1156
|
+
const modelId = modelName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
1157
|
+
models[modelId] = {
|
|
1158
|
+
name: modelName.trim()
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
} else {
|
|
1162
|
+
// Single model mode
|
|
1163
|
+
const modelName = await question(colorText('Enter model name (e.g., gpt-4): ', 'cyan'));
|
|
1164
|
+
const modelId = modelName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
1165
|
+
models[modelId] = {
|
|
1166
|
+
name: modelName
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (Object.keys(models).length === 0) {
|
|
1171
|
+
console.log(colorText('At least one model is required.', 'red'));
|
|
1172
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Create opencode.json format
|
|
1177
|
+
const { readOpencodeConfig } = await import('./opencode-integration.js');
|
|
1178
|
+
const config = await readOpencodeConfig();
|
|
1179
|
+
|
|
1180
|
+
config.provider = config.provider || {};
|
|
1181
|
+
config.provider[providerId] = {
|
|
1182
|
+
name,
|
|
1183
|
+
options: {
|
|
1184
|
+
apiKey,
|
|
1185
|
+
baseURL: baseUrl
|
|
1186
|
+
},
|
|
1187
|
+
models
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
// Save to opencode.json using the integration module
|
|
1191
|
+
const { writeOpencodeConfig } = await import('./opencode-integration.js');
|
|
1192
|
+
const success = await writeOpencodeConfig(config);
|
|
1193
|
+
|
|
1194
|
+
if (!success) {
|
|
1195
|
+
console.log(colorText('Warning: Could not save to opencode.json', 'yellow'));
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
console.log(colorText('Provider added successfully!', 'green'));
|
|
1199
|
+
console.log(colorText(`Added ${Object.keys(models).length} model(s)`, 'cyan'));
|
|
1200
|
+
console.log(colorText(`Saved to opencode.json`, 'cyan'));
|
|
1201
|
+
|
|
1202
|
+
} else if (selectedChoice.type === 'anthropic') {
|
|
1203
|
+
// Anthropic
|
|
1204
|
+
const providerId = await question(colorText('Enter provider ID (e.g., my-anthropic): ', 'cyan'));
|
|
1205
|
+
const name = await question(colorText('Enter provider name (e.g., MyAnthropic): ', 'cyan'));
|
|
1206
|
+
const baseUrl = await question(colorText('Enter base URL (e.g., https://api.anthropic.com): ', 'cyan'));
|
|
1207
|
+
const apiKey = await question(colorText('Enter Anthropic API key: ', 'cyan'));
|
|
1208
|
+
|
|
1209
|
+
// Ask if user wants to add multiple models
|
|
1210
|
+
console.log('');
|
|
1211
|
+
console.log(colorText('Do you want to add multiple models?', 'cyan'));
|
|
1212
|
+
console.log(colorText('1. Add single model', 'yellow'));
|
|
1213
|
+
console.log(colorText('2. Add multiple models', 'yellow'));
|
|
1214
|
+
|
|
1215
|
+
const modelChoice = await question(colorText('Enter choice (1 or 2): ', 'cyan'));
|
|
1216
|
+
|
|
1217
|
+
let models = {};
|
|
1218
|
+
|
|
1219
|
+
if (modelChoice === '2') {
|
|
1220
|
+
// Multiple models mode
|
|
1221
|
+
console.log('');
|
|
1222
|
+
console.log(colorText('Enter model names (one per line, empty line to finish):', 'cyan'));
|
|
1223
|
+
console.log(colorText('Examples: claude-3-sonnet-20240229, claude-3-haiku-20240307', 'dim'));
|
|
1224
|
+
console.log('');
|
|
1225
|
+
|
|
1226
|
+
while (true) {
|
|
1227
|
+
const modelName = await question(colorText('Model name: ', 'cyan'));
|
|
1228
|
+
if (!modelName.trim()) break;
|
|
1229
|
+
|
|
1230
|
+
const modelId = modelName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
1231
|
+
models[modelId] = {
|
|
1232
|
+
name: modelName.trim()
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
} else {
|
|
1236
|
+
// Single model mode
|
|
1237
|
+
const modelName = await question(colorText('Enter model name (e.g., claude-3-sonnet-20240229): ', 'cyan'));
|
|
1238
|
+
const modelId = modelName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
1239
|
+
models[modelId] = {
|
|
1240
|
+
name: modelName
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
if (Object.keys(models).length === 0) {
|
|
1245
|
+
console.log(colorText('At least one model is required.', 'red'));
|
|
1246
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Create opencode.json format
|
|
1251
|
+
const { readOpencodeConfig } = await import('./opencode-integration.js');
|
|
1252
|
+
const config = await readOpencodeConfig();
|
|
1253
|
+
|
|
1254
|
+
config.provider = config.provider || {};
|
|
1255
|
+
config.provider[providerId] = {
|
|
1256
|
+
name,
|
|
1257
|
+
options: {
|
|
1258
|
+
apiKey,
|
|
1259
|
+
baseURL: baseUrl
|
|
1260
|
+
},
|
|
1261
|
+
models
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
// Save to opencode.json using the integration module
|
|
1265
|
+
const { writeOpencodeConfig } = await import('./opencode-integration.js');
|
|
1266
|
+
const success = await writeOpencodeConfig(config);
|
|
1267
|
+
|
|
1268
|
+
if (!success) {
|
|
1269
|
+
console.log(colorText('Warning: Could not save to opencode.json', 'yellow'));
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
console.log(colorText('Provider added successfully!', 'green'));
|
|
1273
|
+
console.log(colorText(`Added ${Object.keys(models).length} model(s)`, 'cyan'));
|
|
1274
|
+
console.log(colorText(`Saved to opencode.json`, 'cyan'));
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
await question(colorText('\nPress Enter to continue...', 'yellow'));
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Show debug information about opencode integration
|
|
1281
|
+
async function showDebugInfo() {
|
|
1282
|
+
clearScreen();
|
|
1283
|
+
showHeader();
|
|
1284
|
+
console.log(colorText('OpenCode Integration Debug Info', 'magenta'));
|
|
1285
|
+
console.log('');
|
|
1286
|
+
|
|
1287
|
+
const debugInfo = await getDebugInfo();
|
|
1288
|
+
|
|
1289
|
+
console.log(colorText('File Paths:', 'cyan'));
|
|
1290
|
+
console.log(colorText(` auth.json: ${debugInfo.paths.authJson}`, 'white'));
|
|
1291
|
+
console.log(colorText(` opencode.json: ${debugInfo.paths.opencodeJson}`, 'white'));
|
|
1292
|
+
console.log('');
|
|
1293
|
+
|
|
1294
|
+
console.log(colorText('File Status:', 'cyan'));
|
|
1295
|
+
console.log(colorText(` auth.json exists: ${debugInfo.authExists ? 'Yes' : 'No'}`, 'white'));
|
|
1296
|
+
console.log(colorText(` opencode.json exists: ${debugInfo.configExists ? 'Yes' : 'No'}`, 'white'));
|
|
1297
|
+
console.log('');
|
|
1298
|
+
|
|
1299
|
+
console.log(colorText('Authenticated Providers:', 'cyan'));
|
|
1300
|
+
if (debugInfo.authData.length === 0) {
|
|
1301
|
+
console.log(colorText(' None', 'dim'));
|
|
1302
|
+
} else {
|
|
1303
|
+
debugInfo.authData.forEach(provider => {
|
|
1304
|
+
console.log(colorText(` - ${provider}`, 'white'));
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
console.log('');
|
|
1308
|
+
|
|
1309
|
+
console.log(colorText('Custom Providers:', 'cyan'));
|
|
1310
|
+
if (debugInfo.configProviders.length === 0) {
|
|
1311
|
+
console.log(colorText(' None', 'dim'));
|
|
1312
|
+
} else {
|
|
1313
|
+
debugInfo.configProviders.forEach(provider => {
|
|
1314
|
+
console.log(colorText(` - ${provider}`, 'white'));
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
console.log('');
|
|
1318
|
+
|
|
1319
|
+
console.log(colorText('XDG Paths:', 'cyan'));
|
|
1320
|
+
console.log(colorText(` Data: ${debugInfo.xdgPaths.data}`, 'white'));
|
|
1321
|
+
console.log(colorText(` Config: ${debugInfo.xdgPaths.config}`, 'white'));
|
|
1322
|
+
console.log('');
|
|
1323
|
+
|
|
1324
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
async function listProviders() {
|
|
1328
|
+
clearScreen();
|
|
1329
|
+
showHeader();
|
|
1330
|
+
console.log(colorText('Existing Providers', 'magenta'));
|
|
1331
|
+
console.log('');
|
|
1332
|
+
|
|
1333
|
+
const config = await loadConfig();
|
|
1334
|
+
|
|
1335
|
+
if (config.providers.length === 0) {
|
|
1336
|
+
console.log(colorText('No providers configured yet.', 'yellow'));
|
|
1337
|
+
} else {
|
|
1338
|
+
config.providers.forEach((provider, index) => {
|
|
1339
|
+
console.log(colorText(`${index + 1}. ${provider.name} (${provider.type})`, 'cyan'));
|
|
1340
|
+
|
|
1341
|
+
if (provider.models.length > 0) {
|
|
1342
|
+
console.log(colorText(' Models:', 'dim'));
|
|
1343
|
+
provider.models.forEach((model, modelIndex) => {
|
|
1344
|
+
console.log(colorText(` ${modelIndex + 1}. ${model.name}`, 'yellow'));
|
|
1345
|
+
});
|
|
1346
|
+
} else {
|
|
1347
|
+
console.log(colorText(' Models: None', 'dim'));
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
console.log('');
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
async function addModelToProvider() {
|
|
1358
|
+
clearScreen();
|
|
1359
|
+
showHeader();
|
|
1360
|
+
console.log(colorText('Add Model to Provider', 'magenta'));
|
|
1361
|
+
console.log('');
|
|
1362
|
+
|
|
1363
|
+
const config = await loadConfig();
|
|
1364
|
+
|
|
1365
|
+
if (config.providers.length === 0) {
|
|
1366
|
+
console.log(colorText('No providers available. Please add a provider first.', 'red'));
|
|
1367
|
+
await question(colorText('Press Enter to continue...', 'yellow'));
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
let currentIndex = 0;
|
|
1372
|
+
|
|
1373
|
+
while (true) {
|
|
1374
|
+
// Build screen content in memory (double buffering)
|
|
1375
|
+
let screenContent = '';
|
|
1376
|
+
|
|
1377
|
+
// Add header
|
|
1378
|
+
screenContent += colorText('Ai-speedometer', 'cyan') + '\n';
|
|
1379
|
+
screenContent += colorText('=============================', 'cyan') + '\n';
|
|
1380
|
+
screenContent += colorText('Note: opencode uses ai-sdk', 'dim') + '\n';
|
|
1381
|
+
screenContent += '\n';
|
|
1382
|
+
|
|
1383
|
+
screenContent += colorText('Add Model to Provider', 'magenta') + '\n';
|
|
1384
|
+
screenContent += colorText('Use ↑↓ arrows to navigate, ENTER to select', 'cyan') + '\n';
|
|
1385
|
+
screenContent += colorText('Navigation is circular', 'dim') + '\n';
|
|
1386
|
+
screenContent += '\n';
|
|
1387
|
+
|
|
1388
|
+
screenContent += colorText('Select provider:', 'cyan') + '\n';
|
|
1389
|
+
screenContent += '\n';
|
|
1390
|
+
|
|
1391
|
+
// Display providers with arrow key navigation
|
|
1392
|
+
config.providers.forEach((provider, index) => {
|
|
1393
|
+
const isCurrent = index === currentIndex;
|
|
1394
|
+
const indicator = isCurrent ? colorText('●', 'green') : colorText('○', 'dim');
|
|
1395
|
+
const providerName = isCurrent ? colorText(provider.name, 'bright') : colorText(provider.name, 'yellow');
|
|
1396
|
+
|
|
1397
|
+
screenContent += `${indicator} ${providerName}\n`;
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
// Clear screen and output entire buffer at once
|
|
1401
|
+
clearScreen();
|
|
1402
|
+
console.log(screenContent);
|
|
1403
|
+
|
|
1404
|
+
const key = await getKeyPress();
|
|
1405
|
+
|
|
1406
|
+
if (key === '\u001b[A') {
|
|
1407
|
+
// Up arrow - circular navigation
|
|
1408
|
+
currentIndex = (currentIndex - 1 + config.providers.length) % config.providers.length;
|
|
1409
|
+
} else if (key === '\u001b[B') {
|
|
1410
|
+
// Down arrow - circular navigation
|
|
1411
|
+
currentIndex = (currentIndex + 1) % config.providers.length;
|
|
1412
|
+
} else if (key === '\r') {
|
|
1413
|
+
// Enter - select current provider
|
|
1414
|
+
break;
|
|
1415
|
+
} else if (key === '\u0003') {
|
|
1416
|
+
// Ctrl+C
|
|
1417
|
+
process.exit(0);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const provider = config.providers[currentIndex];
|
|
1422
|
+
const modelName = await question(colorText('Enter new model name: ', 'cyan'));
|
|
1423
|
+
|
|
1424
|
+
// Find the provider in customProviders and add the model
|
|
1425
|
+
const customProvider = config.customProviders.find(p => p.id === provider.id);
|
|
1426
|
+
if (customProvider) {
|
|
1427
|
+
customProvider.models.push({
|
|
1428
|
+
name: modelName,
|
|
1429
|
+
id: Date.now().toString() + '_model'
|
|
1430
|
+
});
|
|
1431
|
+
} else {
|
|
1432
|
+
// If it's a verified provider, we need to convert it to custom
|
|
1433
|
+
provider.models.push({
|
|
1434
|
+
name: modelName,
|
|
1435
|
+
id: Date.now().toString() + '_model'
|
|
1436
|
+
});
|
|
1437
|
+
// Remove from verifiedProviders and add to customProviders
|
|
1438
|
+
if (config.verifiedProviders && config.verifiedProviders[provider.id]) {
|
|
1439
|
+
delete config.verifiedProviders[provider.id];
|
|
1440
|
+
}
|
|
1441
|
+
config.customProviders.push(provider);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
await saveConfig(config);
|
|
1445
|
+
console.log(colorText('Model added successfully!', 'green'));
|
|
1446
|
+
await question(colorText('\nPress Enter to continue...', 'yellow'));
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// REST API benchmark function using direct API calls
|
|
1450
|
+
async function runRestApiBenchmark(models) {
|
|
1451
|
+
if (models.length === 0) {
|
|
1452
|
+
console.log(colorText('No models selected for benchmarking.', 'red'));
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
clearScreen();
|
|
1457
|
+
showHeader();
|
|
1458
|
+
console.log(colorText('Running REST API Benchmark...', 'green'));
|
|
1459
|
+
console.log(colorText(`Running ${models.length} models in parallel...`, 'cyan'));
|
|
1460
|
+
console.log(colorText('Note: This uses direct REST API calls instead of AI SDK', 'dim'));
|
|
1461
|
+
console.log('');
|
|
1462
|
+
|
|
1463
|
+
// Create a function to benchmark a single model using REST API
|
|
1464
|
+
const benchmarkModelRest = async (model) => {
|
|
1465
|
+
console.log(colorText(`Testing ${model.name} (${model.providerName}) via REST API...`, 'yellow'));
|
|
1466
|
+
|
|
1467
|
+
try {
|
|
1468
|
+
// Validate required configuration
|
|
1469
|
+
if (!model.providerConfig || !model.providerConfig.apiKey) {
|
|
1470
|
+
throw new Error(`Missing API key for provider ${model.providerName}`);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (!model.providerConfig.baseUrl) {
|
|
1474
|
+
throw new Error(`Missing base URL for provider ${model.providerName}`);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const startTime = Date.now();
|
|
1478
|
+
|
|
1479
|
+
// Use correct endpoint based on provider type
|
|
1480
|
+
let endpoint;
|
|
1481
|
+
if (model.providerType === 'anthropic') {
|
|
1482
|
+
endpoint = '/messages';
|
|
1483
|
+
} else if (model.providerType === 'google') {
|
|
1484
|
+
endpoint = '/models/' + actualModelId + ':generateContent';
|
|
1485
|
+
} else {
|
|
1486
|
+
endpoint = '/chat/completions';
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// Ensure baseUrl doesn't end with slash and endpoint doesn't start with slash
|
|
1490
|
+
const baseUrl = model.providerConfig.baseUrl.replace(/\/$/, '');
|
|
1491
|
+
const url = `${baseUrl}${endpoint}`;
|
|
1492
|
+
|
|
1493
|
+
// Extract the actual model ID for API calls
|
|
1494
|
+
let actualModelId = model.name;
|
|
1495
|
+
if (model.id && model.id.includes('_')) {
|
|
1496
|
+
// For models with provider prefix, extract the actual model ID
|
|
1497
|
+
actualModelId = model.id.split('_')[1];
|
|
1498
|
+
console.log(colorText(` Using extracted model ID: ${actualModelId}`, 'cyan'));
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// Trim any trailing spaces from model names
|
|
1502
|
+
actualModelId = actualModelId.trim();
|
|
1503
|
+
console.log(colorText(` Using final model ID: "${actualModelId}"`, 'cyan'));
|
|
1504
|
+
|
|
1505
|
+
const headers = {
|
|
1506
|
+
'Content-Type': 'application/json',
|
|
1507
|
+
'Authorization': `Bearer ${model.providerConfig.apiKey}`
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
// Add provider-specific headers
|
|
1511
|
+
if (model.providerType === 'anthropic') {
|
|
1512
|
+
headers['x-api-key'] = model.providerConfig.apiKey;
|
|
1513
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
1514
|
+
} else if (model.providerType === 'google') {
|
|
1515
|
+
// Google uses different auth
|
|
1516
|
+
delete headers['Authorization'];
|
|
1517
|
+
headers['x-goog-api-key'] = model.providerConfig.apiKey;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const body = {
|
|
1521
|
+
model: actualModelId,
|
|
1522
|
+
messages: [
|
|
1523
|
+
{ role: 'user', content: testPrompt }
|
|
1524
|
+
],
|
|
1525
|
+
max_tokens: 500,
|
|
1526
|
+
temperature: 0.7
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
// Adjust for provider-specific formats
|
|
1530
|
+
if (model.providerType === 'anthropic') {
|
|
1531
|
+
body.max_tokens = 500;
|
|
1532
|
+
} else if (model.providerType === 'google') {
|
|
1533
|
+
// Google format is slightly different
|
|
1534
|
+
body.contents = [{ parts: [{ text: testPrompt }] }];
|
|
1535
|
+
body.generationConfig = {
|
|
1536
|
+
maxOutputTokens: 500,
|
|
1537
|
+
temperature: 0.7
|
|
1538
|
+
};
|
|
1539
|
+
delete body.messages;
|
|
1540
|
+
delete body.max_tokens;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
console.log(colorText(` Making request to: ${url}`, 'cyan'));
|
|
1544
|
+
console.log(colorText(` Using model: ${actualModelId}`, 'cyan'));
|
|
1545
|
+
|
|
1546
|
+
const response = await fetch(url, {
|
|
1547
|
+
method: 'POST',
|
|
1548
|
+
headers: headers,
|
|
1549
|
+
body: JSON.stringify(body)
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
console.log(colorText(` Response status: ${response.status}`, 'cyan'));
|
|
1553
|
+
|
|
1554
|
+
if (!response.ok) {
|
|
1555
|
+
const errorText = await response.text();
|
|
1556
|
+
console.log(colorText(` Error: ${errorText.slice(0, 200)}...`, 'red'));
|
|
1557
|
+
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const data = await response.json();
|
|
1561
|
+
const endTime = Date.now();
|
|
1562
|
+
const totalTime = endTime - startTime;
|
|
1563
|
+
|
|
1564
|
+
// Calculate tokens based on provider type
|
|
1565
|
+
let inputTokens, outputTokens;
|
|
1566
|
+
|
|
1567
|
+
if (model.providerType === 'anthropic') {
|
|
1568
|
+
inputTokens = data.usage?.input_tokens || Math.round(testPrompt.length / 4);
|
|
1569
|
+
outputTokens = data.usage?.output_tokens || Math.round(data.content?.[0]?.text?.length / 4 || 0);
|
|
1570
|
+
} else if (model.providerType === 'google') {
|
|
1571
|
+
inputTokens = data.usageMetadata?.promptTokenCount || Math.round(testPrompt.length / 4);
|
|
1572
|
+
outputTokens = data.usageMetadata?.candidatesTokenCount || Math.round(data.candidates?.[0]?.content?.parts?.[0]?.text?.length / 4 || 0);
|
|
1573
|
+
} else {
|
|
1574
|
+
inputTokens = data.usage?.prompt_tokens || Math.round(testPrompt.length / 4);
|
|
1575
|
+
outputTokens = data.usage?.completion_tokens || Math.round(data.choices?.[0]?.message?.content?.length / 4 || 0);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const totalTokens = inputTokens + outputTokens;
|
|
1579
|
+
const tokensPerSecond = totalTime > 0 ? (totalTokens / totalTime) * 1000 : 0;
|
|
1580
|
+
|
|
1581
|
+
console.log(colorText('Completed!', 'green'));
|
|
1582
|
+
console.log(colorText(` Total Time: ${(totalTime / 1000).toFixed(2)}s`, 'cyan'));
|
|
1583
|
+
console.log(colorText(` Tokens/Sec: ${tokensPerSecond.toFixed(1)}`, 'cyan'));
|
|
1584
|
+
console.log(colorText(` Input Tokens: ${inputTokens}`, 'cyan'));
|
|
1585
|
+
console.log(colorText(` Output Tokens: ${outputTokens}`, 'cyan'));
|
|
1586
|
+
console.log(colorText(` Total Tokens: ${totalTokens}`, 'cyan'));
|
|
1587
|
+
|
|
1588
|
+
return {
|
|
1589
|
+
model: model.name,
|
|
1590
|
+
provider: model.providerName,
|
|
1591
|
+
totalTime: totalTime,
|
|
1592
|
+
timeToFirstToken: 0, // REST API doesn't track TTFT
|
|
1593
|
+
tokenCount: outputTokens,
|
|
1594
|
+
tokensPerSecond: tokensPerSecond,
|
|
1595
|
+
promptTokens: inputTokens,
|
|
1596
|
+
totalTokens: totalTokens,
|
|
1597
|
+
success: true
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
console.log(colorText('Failed: ', 'red') + error.message);
|
|
1602
|
+
return {
|
|
1603
|
+
model: model.name,
|
|
1604
|
+
provider: model.providerName,
|
|
1605
|
+
totalTime: 0,
|
|
1606
|
+
timeToFirstToken: 0,
|
|
1607
|
+
tokenCount: 0,
|
|
1608
|
+
tokensPerSecond: 0,
|
|
1609
|
+
promptTokens: 0,
|
|
1610
|
+
totalTokens: 0,
|
|
1611
|
+
success: false,
|
|
1612
|
+
error: error.message
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
// Run all benchmarks in parallel
|
|
1618
|
+
console.log(colorText('Starting parallel REST API benchmark execution...', 'cyan'));
|
|
1619
|
+
const promises = models.map(model => benchmarkModelRest(model));
|
|
1620
|
+
const results = await Promise.all(promises);
|
|
1621
|
+
|
|
1622
|
+
console.log('');
|
|
1623
|
+
console.log(colorText('All REST API benchmarks completed!', 'green'));
|
|
1624
|
+
|
|
1625
|
+
await displayColorfulResults(results, 'REST API');
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Main menu with arrow key navigation
|
|
1629
|
+
async function showMainMenu() {
|
|
1630
|
+
const menuOptions = [
|
|
1631
|
+
{ id: 1, text: 'Set Model', action: () => showModelMenu() },
|
|
1632
|
+
{ id: 2, text: 'Run Benchmark (AI SDK)', action: async () => {
|
|
1633
|
+
const selectedModels = await selectModelsCircular();
|
|
1634
|
+
if (selectedModels.length > 0) {
|
|
1635
|
+
await runStreamingBenchmark(selectedModels);
|
|
1636
|
+
}
|
|
1637
|
+
}},
|
|
1638
|
+
{ id: 3, text: 'Run Benchmark (REST API)', action: async () => {
|
|
1639
|
+
const selectedModels = await selectModelsCircular();
|
|
1640
|
+
if (selectedModels.length > 0) {
|
|
1641
|
+
await runRestApiBenchmark(selectedModels);
|
|
1642
|
+
}
|
|
1643
|
+
}},
|
|
1644
|
+
{ id: 4, text: 'Exit', action: () => {
|
|
1645
|
+
console.log(colorText('Goodbye!', 'green'));
|
|
1646
|
+
rl.close();
|
|
1647
|
+
process.exit(0);
|
|
1648
|
+
}}
|
|
1649
|
+
];
|
|
1650
|
+
|
|
1651
|
+
let currentIndex = 0;
|
|
1652
|
+
|
|
1653
|
+
while (true) {
|
|
1654
|
+
// Build screen content in memory (double buffering)
|
|
1655
|
+
let screenContent = '';
|
|
1656
|
+
|
|
1657
|
+
// Add header
|
|
1658
|
+
screenContent += colorText('Ai-speedometer', 'cyan') + '\n';
|
|
1659
|
+
screenContent += colorText('=============================', 'cyan') + '\n';
|
|
1660
|
+
screenContent += colorText('Note: opencode uses ai-sdk', 'dim') + '\n';
|
|
1661
|
+
screenContent += '\n';
|
|
1662
|
+
|
|
1663
|
+
screenContent += colorText('Main Menu:', 'cyan') + '\n';
|
|
1664
|
+
screenContent += colorText('Use ↑↓ arrows to navigate, ENTER to select', 'cyan') + '\n';
|
|
1665
|
+
screenContent += colorText('Navigation is circular', 'dim') + '\n';
|
|
1666
|
+
screenContent += '\n';
|
|
1667
|
+
|
|
1668
|
+
// Display menu options
|
|
1669
|
+
menuOptions.forEach((option, index) => {
|
|
1670
|
+
const isCurrent = index === currentIndex;
|
|
1671
|
+
const indicator = isCurrent ? colorText('●', 'green') : colorText('○', 'dim');
|
|
1672
|
+
const optionText = isCurrent ? colorText(option.text, 'bright') : colorText(option.text, 'yellow');
|
|
1673
|
+
|
|
1674
|
+
screenContent += `${indicator} ${optionText}\n`;
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
// Clear screen and output entire buffer at once
|
|
1678
|
+
clearScreen();
|
|
1679
|
+
console.log(screenContent);
|
|
1680
|
+
|
|
1681
|
+
const key = await getKeyPress();
|
|
1682
|
+
|
|
1683
|
+
if (key === '\u001b[A') {
|
|
1684
|
+
// Up arrow - circular navigation
|
|
1685
|
+
currentIndex = (currentIndex - 1 + menuOptions.length) % menuOptions.length;
|
|
1686
|
+
} else if (key === '\u001b[B') {
|
|
1687
|
+
// Down arrow - circular navigation
|
|
1688
|
+
currentIndex = (currentIndex + 1) % menuOptions.length;
|
|
1689
|
+
} else if (key === '\r') {
|
|
1690
|
+
// Enter - select current option
|
|
1691
|
+
await menuOptions[currentIndex].action();
|
|
1692
|
+
} else if (key === '\u0003') {
|
|
1693
|
+
// Ctrl+C
|
|
1694
|
+
process.exit(0);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
async function showModelMenu() {
|
|
1700
|
+
const menuOptions = [
|
|
1701
|
+
{ id: 1, text: 'Add Provider', action: () => addProvider() },
|
|
1702
|
+
{ id: 2, text: 'List Existing Providers', action: () => listProviders() },
|
|
1703
|
+
{ id: 3, text: 'Add Model to Provider', action: () => addModelToProvider() },
|
|
1704
|
+
{ id: 4, text: 'Debug Info', action: () => showDebugInfo() },
|
|
1705
|
+
{ id: 5, text: 'Back to Main Menu', action: () => 'back' }
|
|
1706
|
+
];
|
|
1707
|
+
|
|
1708
|
+
let currentIndex = 0;
|
|
1709
|
+
|
|
1710
|
+
while (true) {
|
|
1711
|
+
// Build screen content in memory (double buffering)
|
|
1712
|
+
let screenContent = '';
|
|
1713
|
+
|
|
1714
|
+
// Add header
|
|
1715
|
+
screenContent += colorText('Ai-speedometer', 'cyan') + '\n';
|
|
1716
|
+
screenContent += colorText('=============================', 'cyan') + '\n';
|
|
1717
|
+
screenContent += colorText('Note: opencode uses ai-sdk', 'dim') + '\n';
|
|
1718
|
+
screenContent += '\n';
|
|
1719
|
+
|
|
1720
|
+
screenContent += colorText('Model Management:', 'cyan') + '\n';
|
|
1721
|
+
screenContent += colorText('Use ↑↓ arrows to navigate, ENTER to select', 'cyan') + '\n';
|
|
1722
|
+
screenContent += colorText('Navigation is circular', 'dim') + '\n';
|
|
1723
|
+
screenContent += '\n';
|
|
1724
|
+
|
|
1725
|
+
// Display menu options
|
|
1726
|
+
menuOptions.forEach((option, index) => {
|
|
1727
|
+
const isCurrent = index === currentIndex;
|
|
1728
|
+
const indicator = isCurrent ? colorText('●', 'green') : colorText('○', 'dim');
|
|
1729
|
+
const optionText = isCurrent ? colorText(option.text, 'bright') : colorText(option.text, 'yellow');
|
|
1730
|
+
|
|
1731
|
+
screenContent += `${indicator} ${optionText}\n`;
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
// Clear screen and output entire buffer at once
|
|
1735
|
+
clearScreen();
|
|
1736
|
+
console.log(screenContent);
|
|
1737
|
+
|
|
1738
|
+
const key = await getKeyPress();
|
|
1739
|
+
|
|
1740
|
+
if (key === '\u001b[A') {
|
|
1741
|
+
// Up arrow - circular navigation
|
|
1742
|
+
currentIndex = (currentIndex - 1 + menuOptions.length) % menuOptions.length;
|
|
1743
|
+
} else if (key === '\u001b[B') {
|
|
1744
|
+
// Down arrow - circular navigation
|
|
1745
|
+
currentIndex = (currentIndex + 1) % menuOptions.length;
|
|
1746
|
+
} else if (key === '\r') {
|
|
1747
|
+
// Enter - select current option
|
|
1748
|
+
const result = await menuOptions[currentIndex].action();
|
|
1749
|
+
if (result === 'back') {
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
} else if (key === '\u0003') {
|
|
1753
|
+
// Ctrl+C
|
|
1754
|
+
process.exit(0);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Handle process interruption
|
|
1760
|
+
process.on('SIGINT', () => {
|
|
1761
|
+
console.log(colorText('\n\nCLI interrupted by user', 'yellow'));
|
|
1762
|
+
rl.close();
|
|
1763
|
+
process.exit(0);
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
// Start the CLI
|
|
1767
|
+
if (import.meta.url === `file://${process.argv[1]}` ||
|
|
1768
|
+
process.argv.length === 2 ||
|
|
1769
|
+
(process.argv.length === 3 && process.argv[2] === '--debug')) {
|
|
1770
|
+
showMainMenu();
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
export { showMainMenu, addProvider, listProviders, selectModelsCircular, runStreamingBenchmark, loadConfig, saveConfig };
|