opencode-free-fleet 0.1.0 → 0.2.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.
@@ -0,0 +1,503 @@
1
+ /**
2
+ * The Scout - Multi-Provider Free Model Discovery & Benchmark-Based Ranking
3
+ *
4
+ * v0.2.0 Upgrade: Metadata Oracle + Smart Free Tier Detection
5
+ *
6
+ * This module discovers free LLM models from ALL connected providers,
7
+ * aggregates metadata from external APIs, and ranks them by SOTA benchmark performance.
8
+ */
9
+ import * as fs from 'fs/promises';
10
+ /**
11
+ * Metadata Oracle
12
+ * Aggregates data from multiple metadata sources
13
+ */
14
+ import { MetadataOracle } from './oracle.js';
15
+ /**
16
+ * Default scout configuration
17
+ */
18
+ const DEFAULT_CONFIG = {
19
+ antigravityPath: `${process.env.HOME || ''}/.config/opencode/antigravity-accounts.json`,
20
+ opencodeConfigPath: `${process.env.HOME || ''}/.config/opencode/oh-my-opencode.json`,
21
+ allowAntigravity: false // Default to BLOCK Google/Gemini
22
+ };
23
+ /**
24
+ * Scout class for model discovery and ranking
25
+ */
26
+ export class Scout {
27
+ config;
28
+ blocklist = new Set();
29
+ antigravityActive = false;
30
+ metadataOracle;
31
+ constructor(config = {}) {
32
+ this.config = { ...DEFAULT_CONFIG, ...config };
33
+ this.metadataOracle = new MetadataOracle();
34
+ }
35
+ /**
36
+ * Initialize metadata oracle and adapters
37
+ */
38
+ async initialize() {
39
+ // Import Metadata Oracle
40
+ console.log('šŸ”® Metadata Oracle: Initializing adapters...');
41
+ // Note: Adapters are now implemented directly in src/core/adapters/index.ts
42
+ // The Oracle uses them as needed
43
+ // Check antigravity-auth plugin presence
44
+ try {
45
+ const antigravityPath = `${process.env.HOME || ''}/.config/opencode/plugins/opencode-antigravity-auth`;
46
+ await fs.access(antigravityPath);
47
+ this.antigravityActive = true;
48
+ console.log('āœ“ Scout: Antigravity auth plugin detected');
49
+ }
50
+ catch {
51
+ this.antigravityActive = false;
52
+ console.log('ā„¹ļø Scout: Could not read antigravity-auth config (may not be configured)');
53
+ }
54
+ }
55
+ /**
56
+ * PHASE A: Safety Check - Build blocklist of paid/authenticated models
57
+ *
58
+ * Enhanced in v0.2.0:
59
+ * - Check for Antigravity auth presence/configuration
60
+ * - Respect allowAntigravity flag to optionally include Google/Gemini
61
+ * - NOTE: Blocklist is now used for metadata filtering, not model exclusion
62
+ */
63
+ async buildBlocklist() {
64
+ const blocklist = new Set();
65
+ // Antigravity auth is already checked in initialize()
66
+ if (this.antigravityActive && !this.config.allowAntigravity) {
67
+ console.log('🚫 Scout: Blocking Google/Gemini from Free Fleet (allowAntigravity=false)');
68
+ blocklist.add('google');
69
+ blocklist.add('gemini');
70
+ blocklist.add('opencode');
71
+ }
72
+ this.blocklist = blocklist;
73
+ if (blocklist.size > 0) {
74
+ console.log(`🚫 Scout: Blocklist - ${Array.from(blocklist).join(', ')}`);
75
+ }
76
+ else {
77
+ console.log('āœ“ Scout: No active blocklist (allowAntigravity=true or no Antigravity)');
78
+ }
79
+ return blocklist;
80
+ }
81
+ /**
82
+ * Detect active providers from OpenCode configuration
83
+ */
84
+ async detectActiveProviders() {
85
+ console.log('\nšŸ” Scout: Detecting active OpenCode providers...');
86
+ const providers = [];
87
+ const adapters = new Map();
88
+ const errors = [];
89
+ let uniqueProviders = [];
90
+ try {
91
+ if (!this.config.opencodeConfigPath) {
92
+ throw new Error('OpenCode config path not set');
93
+ }
94
+ const configContent = await fs.readFile(this.config.opencodeConfigPath, 'utf-8');
95
+ const config = JSON.parse(configContent);
96
+ // Check for configured providers
97
+ if (config.providers) {
98
+ providers.push(...Object.keys(config.providers));
99
+ }
100
+ // Also check categories for provider patterns
101
+ if (config.categories) {
102
+ const categoryModels = Object.values(config.categories)
103
+ .map((cat) => cat.model)
104
+ .concat(...Object.values(config.categories).map((cat) => cat.fallback || []))
105
+ .map((model) => model.split('/')[0]);
106
+ for (const provider of categoryModels) {
107
+ if (!providers.includes(provider)) {
108
+ providers.push(provider);
109
+ }
110
+ }
111
+ }
112
+ // Remove duplicates
113
+ uniqueProviders = [...new Set(providers)];
114
+ console.log(`šŸ“Š Scout: Detected providers: ${uniqueProviders.join(', ')}`);
115
+ // Create adapters for each provider
116
+ const { createAdapter } = await import('./adapters/index.js');
117
+ for (const providerId of uniqueProviders) {
118
+ try {
119
+ const adapter = createAdapter(providerId);
120
+ adapters.set(providerId, adapter);
121
+ console.log(`āœ“ Scout: Created adapter for ${providerId}`);
122
+ }
123
+ catch (error) {
124
+ const err = error;
125
+ errors.push(`Adapter for ${providerId} failed: ${err.message}`);
126
+ }
127
+ }
128
+ }
129
+ catch (error) {
130
+ const err = error;
131
+ errors.push(`Failed to read OpenCode config: ${err.message}`);
132
+ }
133
+ return {
134
+ providers: uniqueProviders,
135
+ adapters,
136
+ errors
137
+ };
138
+ }
139
+ /**
140
+ * PHASE B: Fetch and Normalize Models with Metadata Oracle
141
+ *
142
+ * Enhanced in v0.2.0:
143
+ * - Fetch models from provider adapters
144
+ * - Enrich with metadata from MetadataOracle (Models.dev)
145
+ * - Determine free tier based on aggregated metadata + confidence scoring
146
+ * - Use metadata.isFree field instead of hardcoded free tier detection
147
+ */
148
+ async fetchAllModels() {
149
+ console.log('\nšŸ“” Scout: Fetching models with metadata enrichment...\n');
150
+ const { providers, adapters, errors } = await this.detectActiveProviders();
151
+ if (errors.length > 0) {
152
+ console.error('\nāš ļø Scout: Provider detection had errors:');
153
+ errors.forEach(err => console.error(` - ${err}`));
154
+ }
155
+ if (adapters.size === 0) {
156
+ console.warn('\nāš ļø Scout: No active providers found');
157
+ return [];
158
+ }
159
+ // Fetch from all active providers
160
+ const providerModels = new Map();
161
+ for (const [providerId, adapter] of adapters.entries()) {
162
+ try {
163
+ console.log(`\nšŸ“” Scout: Fetching from ${providerId}...`);
164
+ const models = await adapter.fetchModels();
165
+ // Enrich with metadata from MetadataOracle
166
+ const enrichedModels = [];
167
+ for (const providerModel of models) {
168
+ const modelMetadata = await this.metadataOracle.fetchModelMetadata(providerModel.id);
169
+ const isFree = modelMetadata.isFree;
170
+ const isElite = await this.isEliteModel(providerModel.id);
171
+ enrichedModels.push({
172
+ id: providerModel.id,
173
+ provider: providerId,
174
+ name: providerModel.name || providerModel.id.split('/')[1],
175
+ description: providerModel.description,
176
+ contextLength: providerModel.context_length,
177
+ maxOutputTokens: providerModel.max_output_tokens,
178
+ pricing: {
179
+ prompt: modelMetadata.pricing?.prompt || '0',
180
+ completion: modelMetadata.pricing?.completion || '0',
181
+ request: modelMetadata.pricing?.request || '0'
182
+ },
183
+ isFree,
184
+ isElite,
185
+ category: this.categorizeModel(providerModel.id, providerModel),
186
+ confidence: modelMetadata.confidence || 0.5
187
+ });
188
+ }
189
+ // Apply blocklist filter (for metadata filtering)
190
+ const blocklist = await this.buildBlocklist();
191
+ const filteredModels = enrichedModels.filter(model => {
192
+ const isBlocked = blocklist.has(model.provider);
193
+ return !isBlocked;
194
+ });
195
+ providerModels.set(providerId, filteredModels);
196
+ console.log(`\nāœ“ Scout: ${providerId} - ${filteredModels.length} models, ${filteredModels.filter(m => m.isFree).length} free\n`);
197
+ }
198
+ catch (error) {
199
+ console.error(`\nāŒ Scout: Failed to fetch from ${providerId}: ${error}`);
200
+ // Add error models as empty array to continue
201
+ providerModels.set(providerId, []);
202
+ }
203
+ }
204
+ // Flatten all enriched models into single array
205
+ const allModels = [];
206
+ for (const models of providerModels.values()) {
207
+ allModels.push(...models);
208
+ }
209
+ console.log(`\nāœ“ Scout: Total models discovered: ${allModels.length}`);
210
+ return allModels;
211
+ }
212
+ /**
213
+ * Categorize a model based on its ID patterns
214
+ */
215
+ categorizeModel(modelId, providerModel) {
216
+ const id = modelId.toLowerCase();
217
+ let category = 'writing';
218
+ if (id.includes('coder') || id.includes('code') || id.includes('function')) {
219
+ category = 'coding';
220
+ }
221
+ else if (id.includes('r1') || id.includes('reasoning') || id.includes('cot') || id.includes('qwq')) {
222
+ category = 'reasoning';
223
+ }
224
+ else if (id.includes('flash') || id.includes('distill') || id.includes('nano') || id.includes('lite')) {
225
+ category = 'speed';
226
+ }
227
+ else if (id.includes('vl') || id.includes('vision') || id.includes('molmo')) {
228
+ category = 'multimodal';
229
+ }
230
+ return category;
231
+ }
232
+ /**
233
+ * Check if a model is in Elite families
234
+ */
235
+ async isEliteModel(modelId) {
236
+ const { ELITE_FAMILIES } = await import('../types/index.js');
237
+ const elitePatterns = ELITE_FAMILIES.reasoning || []; // Default to reasoning for async
238
+ const id = modelId.toLowerCase();
239
+ return elitePatterns.some((pattern) => id.includes(pattern.toLowerCase()));
240
+ }
241
+ /**
242
+ * Extract parameter count from model ID
243
+ * Looks for patterns like "70b", "8b", "32b" in the ID
244
+ */
245
+ extractParams(id) {
246
+ const match = id.match(/(\d+)b/i);
247
+ return match ? parseInt(match[1]) : 0;
248
+ }
249
+ /**
250
+ * Extract date from model ID (if available)
251
+ * Looks for patterns like "2025-01", "v0.1" in the ID
252
+ */
253
+ extractDate(id) {
254
+ // Simplified version - in production, we'd use actual metadata
255
+ return null;
256
+ }
257
+ /**
258
+ * Get provider priority from metadata
259
+ * Higher priority providers are listed first in OpenCode settings
260
+ */
261
+ getProviderPriority(providerId) {
262
+ const priorityMap = {
263
+ 'models.dev': 1,
264
+ 'openrouter': 2,
265
+ 'groq': 4,
266
+ 'cerebras': 5,
267
+ 'google': 6,
268
+ 'deepseek': 7,
269
+ 'modelscope': 8,
270
+ 'huggingface': 9
271
+ };
272
+ return priorityMap[providerId] || 99;
273
+ }
274
+ /**
275
+ * Get elite patterns for a specific category
276
+ */
277
+ async _getElitePatterns(category) {
278
+ const { ELITE_FAMILIES } = await import('../types/index.js');
279
+ return ELITE_FAMILIES[category] || [];
280
+ }
281
+ /**
282
+ * PHASE C: Ranking Algorithm - Multi-Provider SOTA Benchmarking
283
+ *
284
+ * Enhanced in v0.2.0:
285
+ * - Priority 1: Metadata confidence score (from Models.dev, etc.)
286
+ * - Priority 2: Elite family membership (SOTA benchmarks)
287
+ * - Priority 3: Provider priority (from metadata provider ranking)
288
+ * - Priority 4: Parameter count (larger > smaller, except for speed)
289
+ * - Priority 5: Release date (newer > older)
290
+ * - Priority 6: Alphabetical order (tiebreaker)
291
+ */
292
+ async rankModelsByBenchmark(models, category) {
293
+ // Need to do async sort carefully since Array.sort doesn't support async
294
+ // So we pre-calculate values that need async
295
+ const enrichedForSort = await Promise.all(models.map(async (m) => {
296
+ return {
297
+ ...m,
298
+ isElite: await this.isEliteModel(m.id)
299
+ };
300
+ }));
301
+ return enrichedForSort.sort((a, b) => {
302
+ // Priority 1: Metadata confidence score (higher is better)
303
+ const aConfidence = a.confidence || 0;
304
+ const bConfidence = b.confidence || 0;
305
+ if (aConfidence !== bConfidence) {
306
+ return bConfidence - aConfidence; // Higher confidence first
307
+ }
308
+ // Priority 2: Elite family membership (SOTA benchmarks)
309
+ // Already resolved in isElite
310
+ if (a.isElite && !b.isElite)
311
+ return -1;
312
+ if (!a.isElite && b.isElite)
313
+ return 1;
314
+ // Priority 3: Provider priority (from metadata)
315
+ const aPriority = this.getProviderPriority(a.provider);
316
+ const bPriority = this.getProviderPriority(b.provider);
317
+ if (aPriority !== bPriority) {
318
+ return aPriority - bPriority; // Lower number = higher priority
319
+ }
320
+ // Priority 4: Parameter count (larger > smaller, except for speed)
321
+ const aParams = this.extractParams(a.id);
322
+ const bParams = this.extractParams(b.id);
323
+ if (category === 'speed') {
324
+ if (aParams > 0 && bParams > 0 && aParams !== bParams) {
325
+ return aParams - bParams; // Smaller first
326
+ }
327
+ }
328
+ else {
329
+ if (aParams > 0 && bParams > 0 && aParams !== bParams) {
330
+ return bParams - aParams; // Larger first
331
+ }
332
+ }
333
+ // Priority 5: Release date (newer > older)
334
+ const aDate = this.extractDate(a.id);
335
+ const bDate = this.extractDate(b.id);
336
+ if (aDate && bDate && aDate !== bDate) {
337
+ return aDate > bDate ? -1 : 1;
338
+ }
339
+ // Priority 6: Alphabetical order (tiebreaker)
340
+ return a.id.localeCompare(b.id);
341
+ });
342
+ }
343
+ /**
344
+ * PHASE D: Functional Categorization
345
+ *
346
+ * Sorts models into functional categories based on ID patterns:
347
+ * - coding: IDs with "coder", "code", "function"
348
+ * - reasoning: IDs with "r1", "reasoning", "cot", "qwq"
349
+ * - speed: IDs with "flash", "distill", "nano", "lite"
350
+ * - multimodal: IDs with "vl", "vision"
351
+ * - writing: General purpose models not in other categories
352
+ */
353
+ categorizeModels(models) {
354
+ const categories = {
355
+ coding: [],
356
+ reasoning: [],
357
+ speed: [],
358
+ multimodal: [],
359
+ writing: []
360
+ };
361
+ for (const model of models) {
362
+ const id = model.id.toLowerCase();
363
+ let categorized = false;
364
+ // Check each category
365
+ if (id.includes('coder') || id.includes('code') || id.includes('function')) {
366
+ categories.coding.push(model);
367
+ categorized = true;
368
+ }
369
+ if (id.includes('r1') || id.includes('reasoning') || id.includes('cot') || id.includes('qwq')) {
370
+ categories.reasoning.push(model);
371
+ categorized = true;
372
+ }
373
+ if (id.includes('flash') || id.includes('distill') || id.includes('nano') || id.includes('lite')) {
374
+ categories.speed.push(model);
375
+ categorized = true;
376
+ }
377
+ if (id.includes('vl') || id.includes('vision') || id.includes('molmo')) {
378
+ categories.multimodal.push(model);
379
+ categorized = true;
380
+ }
381
+ // Add to writing if not categorized elsewhere (general purpose)
382
+ if (!categorized) {
383
+ categories.writing.push(model);
384
+ }
385
+ }
386
+ return categories;
387
+ }
388
+ /**
389
+ * Generate category configuration from ranked models
390
+ */
391
+ generateCategoryConfig(category, rankedModels) {
392
+ const topModels = rankedModels.slice(0, Math.min(5, rankedModels.length));
393
+ const modelIds = topModels.map((m) => `${m.provider}/${m.id}`);
394
+ return {
395
+ model: modelIds[0],
396
+ fallback: modelIds.slice(1),
397
+ description: `Auto-ranked by Free Fleet v0.2.0 (Metadata Oracle) - ${category.toUpperCase()} category`
398
+ };
399
+ }
400
+ /**
401
+ * Main discovery and ranking method
402
+ * Returns categorized and ranked free models from ALL active providers
403
+ * with metadata enrichment from external APIs
404
+ */
405
+ async discover() {
406
+ console.log('\nšŸ¤– Free Fleet v0.2.0 (Metadata Oracle) - Starting omni-provider discovery...\n');
407
+ // Initialize metadata oracle and antigravity check
408
+ await this.initialize();
409
+ // Detect active providers
410
+ const detectionResult = await this.detectActiveProviders();
411
+ if (detectionResult.errors.length > 0) {
412
+ console.error('\nāš ļø Scout: Provider detection had errors:');
413
+ detectionResult.errors.forEach(err => console.error(` - ${err}`));
414
+ }
415
+ if (detectionResult.providers.length === 0) {
416
+ console.warn('\nāš ļø Scout: No active providers found');
417
+ // Don't throw, return empty result to allow graceful failure
418
+ // throw new Error('No active providers detected. Please configure at least one provider in OpenCode.');
419
+ }
420
+ // PHASE B: Fetch and Normalize with Metadata Oracle
421
+ const allModels = await this.fetchAllModels();
422
+ console.log(`\nāœ“ Scout: Total models discovered: ${allModels.length}`);
423
+ console.log(`\nāœ“ Scout: Free models: ${allModels.filter(m => m.isFree).length}\n`);
424
+ // PHASE C + D: Categorize and Rank
425
+ console.log('\nšŸ“Š Scout: Categorizing and ranking models with metadata...\n');
426
+ const categorizedModels = this.categorizeModels(allModels);
427
+ for (const [category, models] of Object.entries(categorizedModels)) {
428
+ console.log(` ${category}: ${models.length} models (${models.filter(m => m.isFree).length} free)`);
429
+ }
430
+ const results = {};
431
+ for (const [category, models] of Object.entries(categorizedModels)) {
432
+ const cat = category;
433
+ // Apply multi-provider benchmark ranking
434
+ const rankedModels = await this.rankModelsByBenchmark(models, cat);
435
+ // Identify elite models
436
+ const eliteModels = rankedModels.filter((model) => model.isElite);
437
+ results[cat] = {
438
+ category: cat,
439
+ models,
440
+ rankedModels,
441
+ eliteModels
442
+ };
443
+ }
444
+ return results;
445
+ }
446
+ /**
447
+ * Print summary of results
448
+ */
449
+ printSummary(results) {
450
+ console.log('\n' + '='.repeat(60));
451
+ console.log('šŸ“ˆ Free Fleet v0.2.0 (Metadata Oracle) Discovery Results\n');
452
+ for (const [category, result] of Object.entries(results)) {
453
+ console.log(`\nšŸ“ˆ ${category.toUpperCase()} (top ${Math.min(5, result.rankedModels.length)}):`);
454
+ result.rankedModels.slice(0, 5).forEach((model, i) => {
455
+ const isElite = result.eliteModels.includes(model);
456
+ const providerTag = model.provider ? `[${model.provider.toUpperCase()}]` : '';
457
+ const confidence = model.confidence || 0;
458
+ const confidenceBadge = confidence >= 1.0 ? 'āœ…' : (confidence >= 0.7 ? 'āš ļø' : 'āœ“');
459
+ console.log(` ${i + 1}. ${providerTag}${model.id}${isElite ? ' ⭐ ELITE' : ''} [${confidence.toFixed(2)}] ${model.category?.toUpperCase()}`);
460
+ });
461
+ }
462
+ console.log('\n' + '='.repeat(60) + '\n');
463
+ // Print free tier breakdown by provider
464
+ const providerStats = new Map();
465
+ for (const [category, result] of Object.entries(results)) {
466
+ for (const model of result.models) {
467
+ const provider = model.provider || 'unknown';
468
+ if (!providerStats.has(provider)) {
469
+ providerStats.set(provider, { total: 0, free: 0 });
470
+ }
471
+ const stats = providerStats.get(provider);
472
+ // Add check for undefined
473
+ if (stats) {
474
+ stats.total++;
475
+ if (model.isFree)
476
+ stats.free++;
477
+ }
478
+ }
479
+ }
480
+ console.log('\nšŸ“Š Free Models by Provider (Metadata Oracle):');
481
+ for (const [provider, stats] of providerStats.entries()) {
482
+ console.log(` ${provider}: ${stats.free}/${stats.total} (${((stats.free / stats.total) * 100).toFixed(1)}%) free`);
483
+ }
484
+ }
485
+ /**
486
+ * Get configuration info
487
+ */
488
+ async getConfiguration() {
489
+ return {
490
+ antigravityActive: this.antigravityActive,
491
+ allowAntigravity: this.config.allowAntigravity || false,
492
+ blocklist: Array.from(this.blocklist),
493
+ hasMetadataOracle: true,
494
+ providersAvailable: Object.keys((await this.detectActiveProviders()).adapters)
495
+ };
496
+ }
497
+ }
498
+ /**
499
+ * Create a new Scout instance with optional config
500
+ */
501
+ export function createScout(config) {
502
+ return new Scout(config);
503
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * OpenCode Free Fleet
3
+ *
4
+ * Economic Load Balancing and Zero-Cost Model Discovery for OpenCode
5
+ *
6
+ * This plugin automatically discovers, ranks, and competes free LLM models
7
+ * based on SOTA benchmark performance, enabling zero-cost, zero-latency
8
+ * execution for OpenCode agents.
9
+ *
10
+ * @version 0.2.2
11
+ * @author Phorde
12
+ */
13
+ export { VERSION, BUILD_DATE } from './version.js';
14
+ export * from './types/index.js';
15
+ export { Scout, createScout } from './core/scout.js';
16
+ export { FreeModelRacer, competeFreeModels, createRacer } from './core/racer.js';
17
+ import type { Plugin } from '@opencode-ai/plugin';
18
+ /**
19
+ * Main plugin function
20
+ *
21
+ * Initializes Free Fleet plugin and returns hooks for OpenCode integration.
22
+ *
23
+ * @param ctx - Plugin context provided by OpenCode
24
+ * @returns Plugin hooks
25
+ */
26
+ export declare const FreeFleetPlugin: Plugin;
27
+ export default FreeFleetPlugin;