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/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 };