pi-free 2.0.5 → 2.0.6

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.
@@ -1,688 +1,702 @@
1
- /**
2
- * Benchmark lookup logic — extracted from hardcoded-benchmarks.ts
3
- * for maintainability (the data file is ~10k lines of JSON-like entries).
4
- *
5
- * This module re-exports everything consumers currently import from
6
- * hardcoded-benchmarks, so you can switch imports to this file without
7
- * breaking anything.
8
- *
9
- * ENHANCED: Added debug logging and provider-specific normalizers
10
- */
11
-
12
- import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
13
- import { homedir } from "node:os";
14
- import { join } from "node:path";
15
- import {
16
- HARDCODED_BENCHMARKS,
17
- type HardcodedBenchmark,
18
- } from "./hardcoded-benchmarks.ts";
19
-
20
- // Re-export the type and data so callers can migrate imports here
21
- export { HARDCODED_BENCHMARKS, type HardcodedBenchmark };
22
-
23
- // =============================================================================
24
- // Debug Logging
25
- // =============================================================================
26
-
27
- const LOG_DIR = join(homedir(), ".pi");
28
- const LOG_FILE = join(LOG_DIR, "modelmatch.log");
29
- let debugEnabled = true;
30
-
31
- /**
32
- * Enable/disable debug logging
33
- */
34
- export function setDebugLogging(enabled: boolean): void {
35
- debugEnabled = enabled;
36
- }
37
-
38
- /**
39
- * Log a message to the modelmatch.log file
40
- */
41
- function logDebug(entry: {
42
- provider?: string;
43
- modelId: string;
44
- modelName: string;
45
- action: "attempt" | "match" | "miss" | "normalized";
46
- strategy?: string;
47
- normalizedId?: string;
48
- matchKey?: string;
49
- codingIndex?: number;
50
- details?: string;
51
- }): void {
52
- if (!debugEnabled) return;
53
-
54
- try {
55
- // Ensure log directory exists
56
- if (!existsSync(LOG_DIR)) {
57
- mkdirSync(LOG_DIR, { recursive: true });
58
- }
59
-
60
- // Initialize log file with header if it doesn't exist
61
- if (!existsSync(LOG_FILE)) {
62
- writeFileSync(
63
- LOG_FILE,
64
- "timestamp|provider|modelId|modelName|action|strategy|normalizedId|matchKey|codingIndex|details\n",
65
- );
66
- }
67
-
68
- const timestamp = new Date().toISOString();
69
- const line = [
70
- timestamp,
71
- entry.provider || "unknown",
72
- entry.modelId,
73
- entry.modelName,
74
- entry.action,
75
- entry.strategy || "",
76
- entry.normalizedId || "",
77
- entry.matchKey || "",
78
- entry.codingIndex !== undefined ? entry.codingIndex.toFixed(1) : "",
79
- entry.details || "",
80
- ]
81
- .map((f) => f.replace(/[\\|]/g, "\\$&")) // Escape backslashes and pipes
82
- .join("|");
83
-
84
- appendFileSync(LOG_FILE, `${line}\n`);
85
- } catch {
86
- // Silently fail - don't break functionality for logging issues
87
- }
88
- }
89
-
90
- /**
91
- * Get the path to the log file for user reference
92
- */
93
- export function getMatchLogPath(): string {
94
- return LOG_FILE;
95
- }
96
-
97
- /**
98
- * Clear the match log
99
- */
100
- export function clearMatchLog(): void {
101
- try {
102
- if (existsSync(LOG_FILE)) {
103
- writeFileSync(
104
- LOG_FILE,
105
- "timestamp|provider|modelId|modelName|action|strategy|normalizedId|matchKey|codingIndex|details\n",
106
- );
107
- }
108
- } catch {
109
- // Ignore errors
110
- }
111
- }
112
-
113
- // =============================================================================
114
- // Provider-Specific Normalizers
115
- // =============================================================================
116
-
117
- /**
118
- * Apply provider-specific ID normalization to handle naming conventions
119
- */
120
- function applyProviderNormalization(
121
- modelId: string,
122
- provider?: string,
123
- ): { normalized: string; strategy: string } {
124
- let normalized = modelId.toLowerCase();
125
- const strategies: string[] = [];
126
-
127
- // Provider-specific prefix stripping
128
- if (provider === "nvidia") {
129
- // NVIDIA uses prefixes like meta/, mistralai/, microsoft/, qwen/
130
- const prefixMatch = normalized.match(
131
- /^(meta|mistralai|microsoft|qwen|nvidia|ibm|google|ai21labs|bigcode|databricks|deepseek-ai|01-ai|adept|aisingapore|baai|bytedance|luma|stabilityai|fireworks|upstage|voyage|snowflake|recursal|kdan|unity|cloudflare|fblgit|nttdata|dito|nousresearch|espressomodels|ftmsh|huggingface|isolationai|pinglab|functionnetwork|huggingfaceh4|mcw|shutterstock)[^/]*\//,
132
- );
133
- if (prefixMatch) {
134
- normalized = normalized.replace(/^[^/]+\//, "");
135
- strategies.push("strip-nvidia-prefix");
136
- }
137
- }
138
-
139
- if (provider === "cloudflare") {
140
- // Cloudflare uses @cf/namespace/model format
141
- if (normalized.startsWith("@cf/")) {
142
- normalized = normalized.replace(/^@cf\/[^/]+\//, "");
143
- strategies.push("strip-cf-namespace");
144
- }
145
- }
146
-
147
- // Provider-agnostic normalization
148
- // Strip :free suffix (common in OpenRouter)
149
- if (normalized.includes(":free")) {
150
- normalized = normalized.replace(/:free$/, "");
151
- strategies.push("strip-free-suffix");
152
- }
153
-
154
- // Handle Ollama format (model:tag)
155
- if (provider === "ollama" && normalized.includes(":")) {
156
- normalized = normalized.replace(/:/g, "-");
157
- strategies.push("ollama-colon-to-dash");
158
- }
159
-
160
- // Handle Groq suffixes
161
- if (provider === "groq") {
162
- if (/-\d+$/.test(normalized)) {
163
- // Strip numeric suffixes like -32768, -131072
164
- normalized = normalized.replace(/-\d+$/, "");
165
- strategies.push("strip-groq-numeric-suffix");
166
- }
167
- if (normalized.includes("-versatile")) {
168
- normalized = normalized.replace(/-versatile$/, "");
169
- strategies.push("strip-groq-versatile");
170
- }
171
- }
172
-
173
- // Handle Cerebras format (llama3.1-8b -> llama-3.1-8b)
174
- if (provider === "cerebras") {
175
- if (/^llama\d/.test(normalized)) {
176
- normalized = normalized.replace(/^llama(\d)/, "llama-$1");
177
- strategies.push("cerebras-llama-dash");
178
- }
179
- // Add instruct if missing for llama models
180
- if (
181
- /^llama-[\d.]+-\d+b$/.test(normalized) &&
182
- !normalized.includes("instruct")
183
- ) {
184
- normalized = normalized.replace(/^(llama-[\d.]+-\d+b)/, "$1-instruct");
185
- strategies.push("add-instruct-suffix");
186
- }
187
- }
188
-
189
- // Handle Mistral -latest suffix
190
- if (provider === "mistral" && normalized.includes("-latest")) {
191
- normalized = normalized.replace(/-latest$/, "");
192
- strategies.push("strip-mistral-latest");
193
- }
194
-
195
- // Strip common suffixes that aren't in benchmark keys
196
- const suffixesToStrip = [
197
- /-\d{8}$/, // Date suffixes like -20250514
198
- /-v\d+(\.\d+)?$/, // Version suffixes like -v1.1
199
- /-\d{3,}$/, // Numeric suffixes like -001, -2603
200
- /-it$/, // -it (Gemma convention)
201
- /-fp\d+$/, // -fp8, -fp16
202
- /-bf\d+$/, // -bf16
203
- /-preview$/, // -preview
204
- /-exp$/, // -exp (experimental)
205
- /-instruct-0\.\d+$/, // HuggingFace revision tags
206
- ];
207
-
208
- for (const pattern of suffixesToStrip) {
209
- if (pattern.test(normalized)) {
210
- normalized = normalized.replace(pattern, "");
211
- strategies.push(
212
- `strip-${pattern.source.replace(/[\\^$.*+?()[\]{}|]/g, "").slice(0, 10)}`,
213
- );
214
- }
215
- }
216
-
217
- return {
218
- normalized,
219
- strategy: strategies.join(","),
220
- };
221
- }
222
-
223
- // =============================================================================
224
- // Prefix fallback helpers
225
- // =============================================================================
226
-
227
- /**
228
- * Segments that indicate a variant of the same base model
229
- * (effort level, reasoning mode, date, preview) — NOT a fundamentally different model.
230
- * Used to filter prefix matches so we don't cross model boundaries
231
- * (e.g. gpt-4o → gpt-4o-mini is wrong, but gpt-4o → gpt-4o-aug-24 is fine).
232
- */
233
- const VARIANT_QUALIFIER_SEGMENTS = new Set([
234
- "reasoning",
235
- "non-reasoning",
236
- "high",
237
- "low",
238
- "medium",
239
- "xhigh",
240
- "preview",
241
- "adaptive",
242
- "fast",
243
- ]);
244
-
245
- /**
246
- * Check if a segment is a variant qualifier rather than a different model identifier.
247
- * Accepts effort levels, reasoning modes, date codes, size specifiers, and version numbers.
248
- */
249
- function isVariantQualifier(segment: string): boolean {
250
- if (VARIANT_QUALIFIER_SEGMENTS.has(segment)) return true;
251
- // Date codes like "0528", "20250514"
252
- if (/^\d{4,8}$/.test(segment)) return true;
253
- // Month names (from date suffixes like "may-25", "mar-24")
254
- if (/^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)$/.test(segment))
255
- return true;
256
- // Size specifiers like "70b", "8b", "a35b", "a3b" (MoE notation)
257
- if (/^a?\d+(\.\d+)?b$/i.test(segment)) return true;
258
- // Version numbers like "v3.2", "v2.5", "v1"
259
- if (/^v\d+(\.\d+)?$/.test(segment)) return true;
260
- // Two-digit year like "25", "24"
261
- if (/^\d{2}$/.test(segment)) return true;
262
- // Special variant suffixes
263
- if (segment === "speciale" || segment === "chatgpt" || segment === "latest")
264
- return true;
265
- return false;
266
- }
267
-
268
- /**
269
- * Normalize model ID by reordering size tokens to match AA convention.
270
- * Converts "70b-instruct" → "instruct-70b", "405b-chat" → "chat-405b".
271
- * AA uses instruct-70b order while providers often use 70b-instruct.
272
- */
273
- function normalizeSizeTokenOrder(id: string): string {
274
- return id.replace(/(\d+(?:\.\d+)?b)-(instruct|chat)/gi, "$2-$1");
275
- }
276
-
277
- /**
278
- * Extract the base model ID from a provider model ID.
279
- * Strips ALL provider prefixes ("openai/", "@cf/meta/", "@cf/qwen/"), :free suffix, date suffixes, and version suffixes.
280
- */
281
- function extractBaseModelId(modelId: string): string {
282
- return modelId
283
- .toLowerCase()
284
- .replace(/^.*\//, "") // Strip ALL path prefixes - keep only last segment
285
- .replace(/:free$/, "") // Strip :free suffix
286
- .replace(/-\d{8}$/, "") // Strip date suffixes like -20250514
287
- .replace(/-v\d+(\.\d+)?$/, "") // Strip version suffixes like -v1.1
288
- .replace(/-\d{3,}$/, "") // Strip numeric suffixes like -001, -2603
289
- .replace(/-it$/, "") // Strip -it suffix (Gemma convention for "instruct")
290
- .replace(/-fp\d+$/, "") // Strip -fp8, -fp16 suffixes
291
- .replace(/-bf\d+$/, "") // Strip -bf16 suffixes
292
- .trim();
293
- }
294
-
295
- /**
296
- * Find the best benchmark variant by prefix matching.
297
- * Given a base model ID, finds all benchmark keys that are variants of it
298
- * (same base model with effort/reasoning/date qualifiers) and returns the
299
- * variant with the highest codingIndex.
300
- */
301
- function findBestVariantByPrefix(
302
- baseId: string,
303
- provider?: string,
304
- originalId?: string,
305
- ): HardcodedBenchmark | null {
306
- const prefixKey = baseId + "-";
307
- const candidates: { key: string; data: HardcodedBenchmark }[] = [];
308
-
309
- for (const [key, data] of Object.entries(HARDCODED_BENCHMARKS) as [
310
- string,
311
- HardcodedBenchmark,
312
- ][]) {
313
- // Exact match
314
- if (key === baseId) {
315
- if (data.codingIndex !== undefined) {
316
- logDebug({
317
- provider,
318
- modelId: originalId || baseId,
319
- modelName: "",
320
- action: "match",
321
- strategy: "exact-prefix-match",
322
- matchKey: key,
323
- codingIndex: data.codingIndex,
324
- });
325
- return data;
326
- }
327
- continue;
328
- }
329
-
330
- // Prefix match: key starts with baseId + "-"
331
- if (key.startsWith(prefixKey)) {
332
- // Check that the first segment after the prefix is a qualifier
333
- // (prevents gpt-4o → gpt-4o-mini cross-model matches)
334
- const remainder = key.slice(prefixKey.length);
335
- const firstSegment = remainder.split("-")[0]!;
336
- if (isVariantQualifier(firstSegment)) {
337
- candidates.push({ key, data });
338
- }
339
- }
340
- }
341
-
342
- if (candidates.length === 0) return null;
343
-
344
- // Pick the candidate with the highest codingIndex
345
- // If tied or no CI, use normalizedScore as tiebreaker
346
- candidates.sort((a, b) => {
347
- const ciA = a.data.codingIndex ?? -1;
348
- const ciB = b.data.codingIndex ?? -1;
349
- if (ciB !== ciA) return ciB - ciA;
350
- return (b.data.normalizedScore ?? 0) - (a.data.normalizedScore ?? 0);
351
- });
352
-
353
- // Only return if the best candidate has a codingIndex
354
- if (candidates[0]!.data.codingIndex !== undefined) {
355
- logDebug({
356
- provider,
357
- modelId: originalId || baseId,
358
- modelName: "",
359
- action: "match",
360
- strategy: "variant-prefix-match",
361
- normalizedId: baseId,
362
- matchKey: candidates[0]!.key,
363
- codingIndex: candidates[0]!.data.codingIndex,
364
- details: `${candidates.length} candidates`,
365
- });
366
- return candidates[0]!.data;
367
- }
368
-
369
- return null;
370
- }
371
-
372
- // =============================================================================
373
- // Variant alias mappings
374
- // =============================================================================
375
-
376
- const MODEL_VARIANTS: Record<string, string[]> = {
377
- "gpt-4o-aug-24": ["gpt-4o", "gpt-4-o"],
378
- "gpt-4": ["gpt-4", "gpt4"],
379
- "claude-3.5-sonnet-oct-24": [
380
- "claude-3.5-sonnet",
381
- "claude-3-5-sonnet",
382
- "sonnet-3.5",
383
- ],
384
- "claude-3-opus": ["claude-3-opus", "opus-3"],
385
- "llama-3.1-instruct-405b": ["llama-3.1-405b", "llama3.1-405b", "llama-405b"],
386
- "llama-3.1-instruct-70b": ["llama-3.1-70b", "llama3.1-70b", "llama-70b"],
387
- "gemini-1.5-pro": ["gemini-1.5-pro", "gemini1.5-pro", "gemini-pro-1.5"],
388
- "qwen2.5-instruct-72b": ["qwen2.5-72b", "qwen-2.5-72b"],
389
- "deepseek-v3.2-non-reasoning": ["deepseek-v3", "deepseekv3", "deepseek-chat"],
390
- "mimo-v2-pro": ["mimo-v2-pro", "mimo-v2-pro-free", "mimo-pro"],
391
- "mimo-v2-omni": ["mimo-v2-omni", "mimo-v2-omni-free", "mimo-omni"],
392
- "mimo-v2-flash": ["mimo-v2-flash", "mimo-v2-flash-free", "mimo-flash"],
393
- "big-pickle": ["big-pickle", "bigpickle"],
394
- "minimax-m2.5": ["minimax-m2.5", "minimax-m2.5-free", "minimax-m25"],
395
- "nvidia-nemotron-3-super-120b-a12b-reasoning": [
396
- "nemotron-3-super",
397
- "nemotron-3-super-free",
398
- "nemotron-super",
399
- "nemotron-3",
400
- ],
401
- };
402
-
403
- // =============================================================================
404
- // Strategy steps
405
- // =============================================================================
406
-
407
- function tryDirectSubstringMatch(
408
- search: string,
409
- provider: string | undefined,
410
- modelId: string,
411
- modelName: string,
412
- ): HardcodedBenchmark | null {
413
- for (const [key, data] of Object.entries(HARDCODED_BENCHMARKS) as [
414
- string,
415
- HardcodedBenchmark,
416
- ][]) {
417
- if (search.includes(key.toLowerCase())) {
418
- logDebug({
419
- provider,
420
- modelId,
421
- modelName,
422
- action: "match",
423
- strategy: "direct-substring",
424
- matchKey: key,
425
- codingIndex: data.codingIndex,
426
- });
427
- return data;
428
- }
429
- }
430
- return null;
431
- }
432
-
433
- function tryVariantAliasMatch(
434
- search: string,
435
- provider: string | undefined,
436
- modelId: string,
437
- modelName: string,
438
- ): HardcodedBenchmark | null {
439
- for (const [canonical, names] of Object.entries(MODEL_VARIANTS)) {
440
- if (names.some((n) => search.includes(n.toLowerCase()))) {
441
- const data = HARDCODED_BENCHMARKS[canonical];
442
- if (data) {
443
- logDebug({
444
- provider,
445
- modelId,
446
- modelName,
447
- action: "match",
448
- strategy: "variant-alias",
449
- matchKey: canonical,
450
- codingIndex: data.codingIndex,
451
- });
452
- return data;
453
- }
454
- }
455
- }
456
- return null;
457
- }
458
-
459
- function tryProviderNormalizedMatch(
460
- modelId: string,
461
- provider: string | undefined,
462
- modelName: string,
463
- ): { result: HardcodedBenchmark | null; normalized: string } {
464
- const { normalized, strategy } = applyProviderNormalization(
465
- modelId,
466
- provider,
467
- );
468
-
469
- if (normalized === modelId.toLowerCase()) {
470
- return { result: null, normalized };
471
- }
472
-
473
- logDebug({
474
- provider,
475
- modelId,
476
- modelName,
477
- action: "normalized",
478
- strategy,
479
- normalizedId: normalized,
480
- });
481
-
482
- for (const [key, data] of Object.entries(HARDCODED_BENCHMARKS) as [
483
- string,
484
- HardcodedBenchmark,
485
- ][]) {
486
- if (normalized.includes(key.toLowerCase())) {
487
- logDebug({
488
- provider,
489
- modelId,
490
- modelName,
491
- action: "match",
492
- strategy: `provider-normalized:${strategy}`,
493
- matchKey: key,
494
- codingIndex: data.codingIndex,
495
- });
496
- return { result: data, normalized };
497
- }
498
- }
499
-
500
- return { result: null, normalized };
501
- }
502
-
503
- function tryPrefixFallback(
504
- normalizedId: string,
505
- provider: string | undefined,
506
- modelId: string,
507
- modelName: string,
508
- ): HardcodedBenchmark | null {
509
- const baseId = extractBaseModelId(normalizedId);
510
- if (!baseId) return null;
511
-
512
- const best = findBestVariantByPrefix(baseId, provider, modelId);
513
- if (best) return best;
514
-
515
- // Try with word-order normalization
516
- // (e.g., llama-3.3-70b-instruct → llama-3.3-instruct-70b)
517
- const reordered = normalizeSizeTokenOrder(baseId);
518
- if (reordered === baseId) return null;
519
-
520
- logDebug({
521
- provider,
522
- modelId,
523
- modelName,
524
- action: "normalized",
525
- strategy: "size-token-reorder",
526
- normalizedId: reordered,
527
- });
528
-
529
- return findBestVariantByPrefix(reordered, provider, modelId);
530
- }
531
-
532
- // =============================================================================
533
- // Main lookup
534
- // =============================================================================
535
-
536
- export function findHardcodedBenchmark(
537
- modelName: string,
538
- modelId: string,
539
- provider?: string,
540
- ): HardcodedBenchmark | null {
541
- const search = `${modelName} ${modelId}`.toLowerCase();
542
-
543
- logDebug({ provider, modelId, modelName, action: "attempt" });
544
-
545
- // 1. Direct substring match
546
- const direct = tryDirectSubstringMatch(search, provider, modelId, modelName);
547
- if (direct) return direct;
548
-
549
- // 2. Variant alias matching
550
- const variant = tryVariantAliasMatch(search, provider, modelId, modelName);
551
- if (variant) return variant;
552
-
553
- // 3. Provider-specific normalization
554
- const { result: normalizedResult, normalized } = tryProviderNormalizedMatch(
555
- modelId,
556
- provider,
557
- modelName,
558
- );
559
- if (normalizedResult) return normalizedResult;
560
-
561
- // 4. Prefix fallback with base model extraction
562
- const prefix = tryPrefixFallback(normalized, provider, modelId, modelName);
563
- if (prefix) return prefix;
564
-
565
- // No match found
566
- logDebug({
567
- provider,
568
- modelId,
569
- modelName,
570
- action: "miss",
571
- strategy: "all-strategies-failed",
572
- normalizedId: normalized,
573
- details: `Final normalized: ${normalized}`,
574
- });
575
-
576
- return null;
577
- }
578
-
579
- /**
580
- * Get score from hardcoded data
581
- */
582
- export function getHardcodedScore(
583
- modelName: string,
584
- modelId: string,
585
- provider?: string,
586
- ): number | null {
587
- const benchmark = findHardcodedBenchmark(modelName, modelId, provider);
588
- return benchmark?.normalizedScore ?? null;
589
- }
590
-
591
- /**
592
- * Enhance model name with Coding Index score
593
- * Returns model name with CI score appended if available
594
- */
595
- export function enhanceModelNameWithCodingIndex(
596
- modelName: string,
597
- modelId: string,
598
- provider?: string,
599
- ): string {
600
- const benchmark = findHardcodedBenchmark(modelName, modelId, provider);
601
- if (benchmark?.codingIndex !== undefined) {
602
- return `${modelName} [CI: ${benchmark.codingIndex.toFixed(1)}]`;
603
- }
604
- return modelName;
605
- }
606
-
607
- // =============================================================================
608
- // Stats and Reporting
609
- // =============================================================================
610
-
611
- /**
612
- * Get statistics about model matching from the current session
613
- * Note: This reads the log file and computes stats
614
- */
615
- interface LogStats {
616
- totalAttempts: number;
617
- matches: number;
618
- misses: number;
619
- byProvider: Record<
620
- string,
621
- { attempts: number; matches: number; misses: number }
622
- >;
623
- }
624
-
625
- function parseLogLine(stats: LogStats, line: string): void {
626
- if (!line.trim()) return;
627
- const parts = line.split("|");
628
- if (parts.length < 5) return;
629
-
630
- const provider = parts[1] || "unknown";
631
- const action = parts[4];
632
-
633
- if (!stats.byProvider[provider]) {
634
- stats.byProvider[provider] = { attempts: 0, matches: 0, misses: 0 };
635
- }
636
-
637
- if (action === "attempt") {
638
- stats.totalAttempts++;
639
- stats.byProvider[provider].attempts++;
640
- } else if (action === "match") {
641
- stats.matches++;
642
- stats.byProvider[provider].matches++;
643
- } else if (action === "miss") {
644
- stats.misses++;
645
- stats.byProvider[provider].misses++;
646
- }
647
- }
648
-
649
- function computeMatchRate(stats: LogStats): number {
650
- const total = stats.matches + stats.misses;
651
- return total > 0 ? Math.round((stats.matches / total) * 100) : 0;
652
- }
653
-
654
- export function getMatchingStats(): {
655
- totalAttempts: number;
656
- matches: number;
657
- misses: number;
658
- matchRate: number;
659
- byProvider: Record<
660
- string,
661
- { attempts: number; matches: number; misses: number }
662
- >;
663
- } {
664
- const stats: LogStats = {
665
- totalAttempts: 0,
666
- matches: 0,
667
- misses: 0,
668
- byProvider: {},
669
- };
670
-
671
- try {
672
- if (!existsSync(LOG_FILE)) {
673
- return { ...stats, matchRate: 0 };
674
- }
675
-
676
- const content = readFileSync(LOG_FILE, "utf-8");
677
- for (const line of content.split("\n").slice(1)) {
678
- parseLogLine(stats, line);
679
- }
680
- } catch {
681
- // Return empty stats on error
682
- }
683
-
684
- return { ...stats, matchRate: computeMatchRate(stats) };
685
- }
686
-
687
- // Need to import readFileSync for stats
688
- import { readFileSync } from "node:fs";
1
+ /**
2
+ * Benchmark lookup logic — extracted from hardcoded-benchmarks.ts
3
+ * for maintainability (the data file is ~10k lines of JSON-like entries).
4
+ *
5
+ * This module re-exports everything consumers currently import from
6
+ * hardcoded-benchmarks, so you can switch imports to this file without
7
+ * breaking anything.
8
+ *
9
+ * ENHANCED: Added debug logging and provider-specific normalizers
10
+ */
11
+
12
+ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import {
16
+ HARDCODED_BENCHMARKS,
17
+ type HardcodedBenchmark,
18
+ } from "./hardcoded-benchmarks.ts";
19
+
20
+ // Re-export the type and data so callers can migrate imports here
21
+ export { HARDCODED_BENCHMARKS, type HardcodedBenchmark };
22
+
23
+ // =============================================================================
24
+ // Debug Logging
25
+ // =============================================================================
26
+
27
+ const LOG_DIR = join(homedir(), ".pi");
28
+ const LOG_FILE = join(LOG_DIR, "modelmatch.log");
29
+ let debugEnabled = true;
30
+
31
+ /**
32
+ * Enable/disable debug logging
33
+ */
34
+ export function setDebugLogging(enabled: boolean): void {
35
+ debugEnabled = enabled;
36
+ }
37
+
38
+ /**
39
+ * Log a message to the modelmatch.log file
40
+ */
41
+ function logDebug(entry: {
42
+ provider?: string;
43
+ modelId: string;
44
+ modelName: string;
45
+ action: "attempt" | "match" | "miss" | "normalized";
46
+ strategy?: string;
47
+ normalizedId?: string;
48
+ matchKey?: string;
49
+ codingIndex?: number;
50
+ details?: string;
51
+ }): void {
52
+ if (!debugEnabled) return;
53
+
54
+ try {
55
+ // Ensure log directory exists
56
+ if (!existsSync(LOG_DIR)) {
57
+ mkdirSync(LOG_DIR, { recursive: true });
58
+ }
59
+
60
+ // Initialize log file with header if it doesn't exist
61
+ if (!existsSync(LOG_FILE)) {
62
+ writeFileSync(
63
+ LOG_FILE,
64
+ "timestamp|provider|modelId|modelName|action|strategy|normalizedId|matchKey|codingIndex|details\n",
65
+ );
66
+ }
67
+
68
+ const timestamp = new Date().toISOString();
69
+ const line = [
70
+ timestamp,
71
+ entry.provider || "unknown",
72
+ entry.modelId,
73
+ entry.modelName,
74
+ entry.action,
75
+ entry.strategy || "",
76
+ entry.normalizedId || "",
77
+ entry.matchKey || "",
78
+ entry.codingIndex !== undefined ? entry.codingIndex.toFixed(1) : "",
79
+ entry.details || "",
80
+ ]
81
+ .map((f) => f.replace(/[\\|]/g, "\\$&")) // Escape backslashes and pipes
82
+ .join("|");
83
+
84
+ appendFileSync(LOG_FILE, `${line}\n`);
85
+ } catch {
86
+ // Silently fail - don't break functionality for logging issues
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Get the path to the log file for user reference
92
+ */
93
+ export function getMatchLogPath(): string {
94
+ return LOG_FILE;
95
+ }
96
+
97
+ /**
98
+ * Clear the match log
99
+ */
100
+ export function clearMatchLog(): void {
101
+ try {
102
+ if (existsSync(LOG_FILE)) {
103
+ writeFileSync(
104
+ LOG_FILE,
105
+ "timestamp|provider|modelId|modelName|action|strategy|normalizedId|matchKey|codingIndex|details\n",
106
+ );
107
+ }
108
+ } catch {
109
+ // Ignore errors
110
+ }
111
+ }
112
+
113
+ // =============================================================================
114
+ // Provider-Specific Normalizers
115
+ // =============================================================================
116
+
117
+ /**
118
+ * Apply provider-specific ID normalization to handle naming conventions
119
+ */
120
+ function applyProviderNormalization(
121
+ modelId: string,
122
+ provider?: string,
123
+ ): { normalized: string; strategy: string } {
124
+ let normalized = modelId.toLowerCase();
125
+ const strategies: string[] = [];
126
+
127
+ // Provider-specific prefix stripping
128
+ if (provider === "nvidia") {
129
+ // NVIDIA uses prefixes like meta/, mistralai/, microsoft/, qwen/
130
+ const prefixMatch = normalized.match(
131
+ /^(meta|mistralai|microsoft|qwen|nvidia|ibm|google|ai21labs|bigcode|databricks|deepseek-ai|01-ai|adept|aisingapore|baai|bytedance|luma|stabilityai|fireworks|upstage|voyage|snowflake|recursal|kdan|unity|cloudflare|fblgit|nttdata|dito|nousresearch|espressomodels|ftmsh|huggingface|isolationai|pinglab|functionnetwork|huggingfaceh4|mcw|shutterstock)[^/]*\//,
132
+ );
133
+ if (prefixMatch) {
134
+ normalized = normalized.replace(/^[^/]+\//, "");
135
+ strategies.push("strip-nvidia-prefix");
136
+ }
137
+ }
138
+
139
+ if (provider === "cloudflare") {
140
+ // Cloudflare uses @cf/namespace/model format
141
+ if (normalized.startsWith("@cf/")) {
142
+ normalized = normalized.replace(/^@cf\/[^/]+\//, "");
143
+ strategies.push("strip-cf-namespace");
144
+ }
145
+ }
146
+
147
+ // Provider-agnostic normalization
148
+ // Strip :free suffix (common in OpenRouter)
149
+ if (normalized.includes(":free")) {
150
+ normalized = normalized.replace(/:free$/, "");
151
+ strategies.push("strip-free-suffix");
152
+ }
153
+
154
+ // Handle Ollama format (model:tag)
155
+ if (provider === "ollama" && normalized.includes(":")) {
156
+ normalized = normalized.replace(/:/g, "-");
157
+ strategies.push("ollama-colon-to-dash");
158
+ }
159
+
160
+ // Handle Groq suffixes
161
+ if (provider === "groq") {
162
+ if (/-\d+$/.test(normalized)) {
163
+ // Strip numeric suffixes like -32768, -131072
164
+ normalized = normalized.replace(/-\d+$/, "");
165
+ strategies.push("strip-groq-numeric-suffix");
166
+ }
167
+ if (normalized.includes("-versatile")) {
168
+ normalized = normalized.replace(/-versatile$/, "");
169
+ strategies.push("strip-groq-versatile");
170
+ }
171
+ }
172
+
173
+ // Handle Cerebras format (llama3.1-8b -> llama-3.1-8b)
174
+ if (provider === "cerebras") {
175
+ if (/^llama\d/.test(normalized)) {
176
+ normalized = normalized.replace(/^llama(\d)/, "llama-$1");
177
+ strategies.push("cerebras-llama-dash");
178
+ }
179
+ // Add instruct if missing for llama models
180
+ if (
181
+ /^llama-[\d.]+-\d+b$/.test(normalized) &&
182
+ !normalized.includes("instruct")
183
+ ) {
184
+ normalized = normalized.replace(/^(llama-[\d.]+-\d+b)/, "$1-instruct");
185
+ strategies.push("add-instruct-suffix");
186
+ }
187
+ }
188
+
189
+ // Handle Mistral -latest suffix
190
+ if (provider === "mistral" && normalized.includes("-latest")) {
191
+ normalized = normalized.replace(/-latest$/, "");
192
+ strategies.push("strip-mistral-latest");
193
+ }
194
+
195
+ // Strip common suffixes that aren't in benchmark keys
196
+ const suffixesToStrip = [
197
+ /-\d{8}$/, // Date suffixes like -20250514
198
+ /-v\d+(\.\d+)?$/, // Version suffixes like -v1.1
199
+ /-\d{3,}$/, // Numeric suffixes like -001, -2603
200
+ /-it$/, // -it (Gemma convention)
201
+ /-fp\d+$/, // -fp8, -fp16
202
+ /-bf\d+$/, // -bf16
203
+ /-preview$/, // -preview
204
+ /-exp$/, // -exp (experimental)
205
+ /-instruct-0\.\d+$/, // HuggingFace revision tags
206
+ ];
207
+
208
+ for (const pattern of suffixesToStrip) {
209
+ if (pattern.test(normalized)) {
210
+ normalized = normalized.replace(pattern, "");
211
+ strategies.push(
212
+ `strip-${pattern.source.replace(/[\\^$.*+?()[\]{}|]/g, "").slice(0, 10)}`,
213
+ );
214
+ }
215
+ }
216
+
217
+ return {
218
+ normalized,
219
+ strategy: strategies.join(","),
220
+ };
221
+ }
222
+
223
+ // =============================================================================
224
+ // Prefix fallback helpers
225
+ // =============================================================================
226
+
227
+ /**
228
+ * Segments that indicate a variant of the same base model
229
+ * (effort level, reasoning mode, date, preview) — NOT a fundamentally different model.
230
+ * Used to filter prefix matches so we don't cross model boundaries
231
+ * (e.g. gpt-4o → gpt-4o-mini is wrong, but gpt-4o → gpt-4o-aug-24 is fine).
232
+ */
233
+ const VARIANT_QUALIFIER_SEGMENTS = new Set([
234
+ "reasoning",
235
+ "non-reasoning",
236
+ "high",
237
+ "low",
238
+ "medium",
239
+ "xhigh",
240
+ "preview",
241
+ "adaptive",
242
+ "fast",
243
+ ]);
244
+
245
+ /**
246
+ * Check if a segment is a variant qualifier rather than a different model identifier.
247
+ * Accepts effort levels, reasoning modes, date codes, size specifiers, and version numbers.
248
+ */
249
+ function isVariantQualifier(segment: string): boolean {
250
+ if (VARIANT_QUALIFIER_SEGMENTS.has(segment)) return true;
251
+ // Date codes like "0528", "20250514"
252
+ if (/^\d{4,8}$/.test(segment)) return true;
253
+ // Month names (from date suffixes like "may-25", "mar-24")
254
+ if (/^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)$/.test(segment))
255
+ return true;
256
+ // Size specifiers like "70b", "8b", "a35b", "a3b" (MoE notation)
257
+ if (/^a?\d+(\.\d+)?b$/i.test(segment)) return true;
258
+ // Version numbers like "v3.2", "v2.5", "v1"
259
+ if (/^v\d+(\.\d+)?$/.test(segment)) return true;
260
+ // Two-digit year like "25", "24"
261
+ if (/^\d{2}$/.test(segment)) return true;
262
+ // Special variant suffixes
263
+ if (segment === "speciale" || segment === "chatgpt" || segment === "latest")
264
+ return true;
265
+ return false;
266
+ }
267
+
268
+ /**
269
+ * Normalize model ID by reordering size tokens to match AA convention.
270
+ * Converts "70b-instruct" → "instruct-70b", "405b-chat" → "chat-405b".
271
+ * AA uses instruct-70b order while providers often use 70b-instruct.
272
+ */
273
+ function normalizeSizeTokenOrder(id: string): string {
274
+ // Convert "70b-instruct" → "instruct-70b", "405b-chat" → "chat-405b"
275
+ const suffixes = new Set(["instruct", "chat"]);
276
+ const parts = id.split("-");
277
+ for (let i = 0; i < parts.length - 1; i++) {
278
+ const lower = parts[i].toLowerCase();
279
+ if (lower.endsWith("b") && suffixes.has(parts[i + 1].toLowerCase())) {
280
+ // Validate the part before 'b' is a number
281
+ const num = lower.slice(0, -1);
282
+ if (num.length > 0 && !Number.isNaN(Number.parseFloat(num))) {
283
+ [parts[i], parts[i + 1]] = [parts[i + 1], parts[i]];
284
+ break;
285
+ }
286
+ }
287
+ }
288
+ return parts.join("-");
289
+ }
290
+
291
+ /**
292
+ * Extract the base model ID from a provider model ID.
293
+ * Strips ALL provider prefixes ("openai/", "@cf/meta/", "@cf/qwen/"), :free suffix, date suffixes, and version suffixes.
294
+ */
295
+ function extractBaseModelId(modelId: string): string {
296
+ return modelId
297
+ .toLowerCase()
298
+ .replace(/^.*\//, "") // Strip ALL path prefixes - keep only last segment
299
+ .replace(/:free$/, "") // Strip :free suffix
300
+ .replace(/-\d{8}$/, "") // Strip date suffixes like -20250514
301
+ .replace(/-v\d+(\.\d+)?$/, "") // Strip version suffixes like -v1.1
302
+ .replace(/-\d{3,}$/, "") // Strip numeric suffixes like -001, -2603
303
+ .replace(/-it$/, "") // Strip -it suffix (Gemma convention for "instruct")
304
+ .replace(/-fp\d+$/, "") // Strip -fp8, -fp16 suffixes
305
+ .replace(/-bf\d+$/, "") // Strip -bf16 suffixes
306
+ .trim();
307
+ }
308
+
309
+ /**
310
+ * Find the best benchmark variant by prefix matching.
311
+ * Given a base model ID, finds all benchmark keys that are variants of it
312
+ * (same base model with effort/reasoning/date qualifiers) and returns the
313
+ * variant with the highest codingIndex.
314
+ */
315
+ function findBestVariantByPrefix(
316
+ baseId: string,
317
+ provider?: string,
318
+ originalId?: string,
319
+ ): HardcodedBenchmark | null {
320
+ const prefixKey = baseId + "-";
321
+ const candidates: { key: string; data: HardcodedBenchmark }[] = [];
322
+
323
+ for (const [key, data] of Object.entries(HARDCODED_BENCHMARKS) as [
324
+ string,
325
+ HardcodedBenchmark,
326
+ ][]) {
327
+ // Exact match
328
+ if (key === baseId) {
329
+ if (data.codingIndex !== undefined) {
330
+ logDebug({
331
+ provider,
332
+ modelId: originalId || baseId,
333
+ modelName: "",
334
+ action: "match",
335
+ strategy: "exact-prefix-match",
336
+ matchKey: key,
337
+ codingIndex: data.codingIndex,
338
+ });
339
+ return data;
340
+ }
341
+ continue;
342
+ }
343
+
344
+ // Prefix match: key starts with baseId + "-"
345
+ if (key.startsWith(prefixKey)) {
346
+ // Check that the first segment after the prefix is a qualifier
347
+ // (prevents gpt-4o gpt-4o-mini cross-model matches)
348
+ const remainder = key.slice(prefixKey.length);
349
+ const firstSegment = remainder.split("-")[0]!;
350
+ if (isVariantQualifier(firstSegment)) {
351
+ candidates.push({ key, data });
352
+ }
353
+ }
354
+ }
355
+
356
+ if (candidates.length === 0) return null;
357
+
358
+ // Pick the candidate with the highest codingIndex
359
+ // If tied or no CI, use normalizedScore as tiebreaker
360
+ candidates.sort((a, b) => {
361
+ const ciA = a.data.codingIndex ?? -1;
362
+ const ciB = b.data.codingIndex ?? -1;
363
+ if (ciB !== ciA) return ciB - ciA;
364
+ return (b.data.normalizedScore ?? 0) - (a.data.normalizedScore ?? 0);
365
+ });
366
+
367
+ // Only return if the best candidate has a codingIndex
368
+ if (candidates[0]!.data.codingIndex !== undefined) {
369
+ logDebug({
370
+ provider,
371
+ modelId: originalId || baseId,
372
+ modelName: "",
373
+ action: "match",
374
+ strategy: "variant-prefix-match",
375
+ normalizedId: baseId,
376
+ matchKey: candidates[0]!.key,
377
+ codingIndex: candidates[0]!.data.codingIndex,
378
+ details: `${candidates.length} candidates`,
379
+ });
380
+ return candidates[0]!.data;
381
+ }
382
+
383
+ return null;
384
+ }
385
+
386
+ // =============================================================================
387
+ // Variant alias mappings
388
+ // =============================================================================
389
+
390
+ const MODEL_VARIANTS: Record<string, string[]> = {
391
+ "gpt-4o-aug-24": ["gpt-4o", "gpt-4-o"],
392
+ "gpt-4": ["gpt-4", "gpt4"],
393
+ "claude-3.5-sonnet-oct-24": [
394
+ "claude-3.5-sonnet",
395
+ "claude-3-5-sonnet",
396
+ "sonnet-3.5",
397
+ ],
398
+ "claude-3-opus": ["claude-3-opus", "opus-3"],
399
+ "llama-3.1-instruct-405b": ["llama-3.1-405b", "llama3.1-405b", "llama-405b"],
400
+ "llama-3.1-instruct-70b": ["llama-3.1-70b", "llama3.1-70b", "llama-70b"],
401
+ "gemini-1.5-pro": ["gemini-1.5-pro", "gemini1.5-pro", "gemini-pro-1.5"],
402
+ "qwen2.5-instruct-72b": ["qwen2.5-72b", "qwen-2.5-72b"],
403
+ "deepseek-v3.2-non-reasoning": ["deepseek-v3", "deepseekv3", "deepseek-chat"],
404
+ "mimo-v2-pro": ["mimo-v2-pro", "mimo-v2-pro-free", "mimo-pro"],
405
+ "mimo-v2-omni": ["mimo-v2-omni", "mimo-v2-omni-free", "mimo-omni"],
406
+ "mimo-v2-flash": ["mimo-v2-flash", "mimo-v2-flash-free", "mimo-flash"],
407
+ "big-pickle": ["big-pickle", "bigpickle"],
408
+ "minimax-m2.5": ["minimax-m2.5", "minimax-m2.5-free", "minimax-m25"],
409
+ "nvidia-nemotron-3-super-120b-a12b-reasoning": [
410
+ "nemotron-3-super",
411
+ "nemotron-3-super-free",
412
+ "nemotron-super",
413
+ "nemotron-3",
414
+ ],
415
+ };
416
+
417
+ // =============================================================================
418
+ // Strategy steps
419
+ // =============================================================================
420
+
421
+ function tryDirectSubstringMatch(
422
+ search: string,
423
+ provider: string | undefined,
424
+ modelId: string,
425
+ modelName: string,
426
+ ): HardcodedBenchmark | null {
427
+ for (const [key, data] of Object.entries(HARDCODED_BENCHMARKS) as [
428
+ string,
429
+ HardcodedBenchmark,
430
+ ][]) {
431
+ if (search.includes(key.toLowerCase())) {
432
+ logDebug({
433
+ provider,
434
+ modelId,
435
+ modelName,
436
+ action: "match",
437
+ strategy: "direct-substring",
438
+ matchKey: key,
439
+ codingIndex: data.codingIndex,
440
+ });
441
+ return data;
442
+ }
443
+ }
444
+ return null;
445
+ }
446
+
447
+ function tryVariantAliasMatch(
448
+ search: string,
449
+ provider: string | undefined,
450
+ modelId: string,
451
+ modelName: string,
452
+ ): HardcodedBenchmark | null {
453
+ for (const [canonical, names] of Object.entries(MODEL_VARIANTS)) {
454
+ if (names.some((n) => search.includes(n.toLowerCase()))) {
455
+ const data = HARDCODED_BENCHMARKS[canonical];
456
+ if (data) {
457
+ logDebug({
458
+ provider,
459
+ modelId,
460
+ modelName,
461
+ action: "match",
462
+ strategy: "variant-alias",
463
+ matchKey: canonical,
464
+ codingIndex: data.codingIndex,
465
+ });
466
+ return data;
467
+ }
468
+ }
469
+ }
470
+ return null;
471
+ }
472
+
473
+ function tryProviderNormalizedMatch(
474
+ modelId: string,
475
+ provider: string | undefined,
476
+ modelName: string,
477
+ ): { result: HardcodedBenchmark | null; normalized: string } {
478
+ const { normalized, strategy } = applyProviderNormalization(
479
+ modelId,
480
+ provider,
481
+ );
482
+
483
+ if (normalized === modelId.toLowerCase()) {
484
+ return { result: null, normalized };
485
+ }
486
+
487
+ logDebug({
488
+ provider,
489
+ modelId,
490
+ modelName,
491
+ action: "normalized",
492
+ strategy,
493
+ normalizedId: normalized,
494
+ });
495
+
496
+ for (const [key, data] of Object.entries(HARDCODED_BENCHMARKS) as [
497
+ string,
498
+ HardcodedBenchmark,
499
+ ][]) {
500
+ if (normalized.includes(key.toLowerCase())) {
501
+ logDebug({
502
+ provider,
503
+ modelId,
504
+ modelName,
505
+ action: "match",
506
+ strategy: `provider-normalized:${strategy}`,
507
+ matchKey: key,
508
+ codingIndex: data.codingIndex,
509
+ });
510
+ return { result: data, normalized };
511
+ }
512
+ }
513
+
514
+ return { result: null, normalized };
515
+ }
516
+
517
+ function tryPrefixFallback(
518
+ normalizedId: string,
519
+ provider: string | undefined,
520
+ modelId: string,
521
+ modelName: string,
522
+ ): HardcodedBenchmark | null {
523
+ const baseId = extractBaseModelId(normalizedId);
524
+ if (!baseId) return null;
525
+
526
+ const best = findBestVariantByPrefix(baseId, provider, modelId);
527
+ if (best) return best;
528
+
529
+ // Try with word-order normalization
530
+ // (e.g., llama-3.3-70b-instruct → llama-3.3-instruct-70b)
531
+ const reordered = normalizeSizeTokenOrder(baseId);
532
+ if (reordered === baseId) return null;
533
+
534
+ logDebug({
535
+ provider,
536
+ modelId,
537
+ modelName,
538
+ action: "normalized",
539
+ strategy: "size-token-reorder",
540
+ normalizedId: reordered,
541
+ });
542
+
543
+ return findBestVariantByPrefix(reordered, provider, modelId);
544
+ }
545
+
546
+ // =============================================================================
547
+ // Main lookup
548
+ // =============================================================================
549
+
550
+ export function findHardcodedBenchmark(
551
+ modelName: string,
552
+ modelId: string,
553
+ provider?: string,
554
+ ): HardcodedBenchmark | null {
555
+ const search = `${modelName} ${modelId}`.toLowerCase();
556
+
557
+ logDebug({ provider, modelId, modelName, action: "attempt" });
558
+
559
+ // 1. Direct substring match
560
+ const direct = tryDirectSubstringMatch(search, provider, modelId, modelName);
561
+ if (direct) return direct;
562
+
563
+ // 2. Variant alias matching
564
+ const variant = tryVariantAliasMatch(search, provider, modelId, modelName);
565
+ if (variant) return variant;
566
+
567
+ // 3. Provider-specific normalization
568
+ const { result: normalizedResult, normalized } = tryProviderNormalizedMatch(
569
+ modelId,
570
+ provider,
571
+ modelName,
572
+ );
573
+ if (normalizedResult) return normalizedResult;
574
+
575
+ // 4. Prefix fallback with base model extraction
576
+ const prefix = tryPrefixFallback(normalized, provider, modelId, modelName);
577
+ if (prefix) return prefix;
578
+
579
+ // No match found
580
+ logDebug({
581
+ provider,
582
+ modelId,
583
+ modelName,
584
+ action: "miss",
585
+ strategy: "all-strategies-failed",
586
+ normalizedId: normalized,
587
+ details: `Final normalized: ${normalized}`,
588
+ });
589
+
590
+ return null;
591
+ }
592
+
593
+ /**
594
+ * Get score from hardcoded data
595
+ */
596
+ export function getHardcodedScore(
597
+ modelName: string,
598
+ modelId: string,
599
+ provider?: string,
600
+ ): number | null {
601
+ const benchmark = findHardcodedBenchmark(modelName, modelId, provider);
602
+ return benchmark?.normalizedScore ?? null;
603
+ }
604
+
605
+ /**
606
+ * Enhance model name with Coding Index score
607
+ * Returns model name with CI score appended if available
608
+ */
609
+ export function enhanceModelNameWithCodingIndex(
610
+ modelName: string,
611
+ modelId: string,
612
+ provider?: string,
613
+ ): string {
614
+ const benchmark = findHardcodedBenchmark(modelName, modelId, provider);
615
+ if (benchmark?.codingIndex !== undefined) {
616
+ return `${modelName} [CI: ${benchmark.codingIndex.toFixed(1)}]`;
617
+ }
618
+ return modelName;
619
+ }
620
+
621
+ // =============================================================================
622
+ // Stats and Reporting
623
+ // =============================================================================
624
+
625
+ /**
626
+ * Get statistics about model matching from the current session
627
+ * Note: This reads the log file and computes stats
628
+ */
629
+ interface LogStats {
630
+ totalAttempts: number;
631
+ matches: number;
632
+ misses: number;
633
+ byProvider: Record<
634
+ string,
635
+ { attempts: number; matches: number; misses: number }
636
+ >;
637
+ }
638
+
639
+ function parseLogLine(stats: LogStats, line: string): void {
640
+ if (!line.trim()) return;
641
+ const parts = line.split("|");
642
+ if (parts.length < 5) return;
643
+
644
+ const provider = parts[1] || "unknown";
645
+ const action = parts[4];
646
+
647
+ if (!stats.byProvider[provider]) {
648
+ stats.byProvider[provider] = { attempts: 0, matches: 0, misses: 0 };
649
+ }
650
+
651
+ if (action === "attempt") {
652
+ stats.totalAttempts++;
653
+ stats.byProvider[provider].attempts++;
654
+ } else if (action === "match") {
655
+ stats.matches++;
656
+ stats.byProvider[provider].matches++;
657
+ } else if (action === "miss") {
658
+ stats.misses++;
659
+ stats.byProvider[provider].misses++;
660
+ }
661
+ }
662
+
663
+ function computeMatchRate(stats: LogStats): number {
664
+ const total = stats.matches + stats.misses;
665
+ return total > 0 ? Math.round((stats.matches / total) * 100) : 0;
666
+ }
667
+
668
+ export function getMatchingStats(): {
669
+ totalAttempts: number;
670
+ matches: number;
671
+ misses: number;
672
+ matchRate: number;
673
+ byProvider: Record<
674
+ string,
675
+ { attempts: number; matches: number; misses: number }
676
+ >;
677
+ } {
678
+ const stats: LogStats = {
679
+ totalAttempts: 0,
680
+ matches: 0,
681
+ misses: 0,
682
+ byProvider: {},
683
+ };
684
+
685
+ try {
686
+ if (!existsSync(LOG_FILE)) {
687
+ return { ...stats, matchRate: 0 };
688
+ }
689
+
690
+ const content = readFileSync(LOG_FILE, "utf-8");
691
+ for (const line of content.split("\n").slice(1)) {
692
+ parseLogLine(stats, line);
693
+ }
694
+ } catch {
695
+ // Return empty stats on error
696
+ }
697
+
698
+ return { ...stats, matchRate: computeMatchRate(stats) };
699
+ }
700
+
701
+ // Need to import readFileSync for stats
702
+ import { readFileSync } from "node:fs";