llm-checker 3.5.14 → 3.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/analyzer/compatibility.js +5 -0
- package/analyzer/performance.js +5 -4
- package/bin/cli.js +5 -39
- package/bin/enhanced_cli.js +88 -19
- package/bin/mcp-server.mjs +266 -101
- package/package.json +7 -7
- package/src/ai/multi-objective-selector.js +118 -11
- package/src/calibration/calibration-manager.js +4 -1
- package/src/data/model-database.js +39 -5
- package/src/data/sync-manager.js +32 -18
- package/src/hardware/backends/apple-silicon.js +5 -1
- package/src/hardware/backends/cuda-detector.js +47 -19
- package/src/hardware/backends/intel-detector.js +6 -2
- package/src/hardware/backends/rocm-detector.js +6 -2
- package/src/hardware/detector.js +57 -30
- package/src/hardware/unified-detector.js +129 -25
- package/src/models/ai-check-selector.js +36 -5
- package/src/models/deterministic-selector.js +163 -15
- package/src/models/expanded_database.js +9 -5
- package/src/models/intelligent-selector.js +87 -1
- package/src/models/requirements.js +16 -11
- package/src/models/scoring-core.js +341 -0
- package/src/models/scoring-engine.js +9 -2
- package/src/ollama/capacity-planner.js +15 -2
- package/src/ollama/client.js +70 -30
- package/src/ollama/enhanced-client.js +20 -2
- package/src/ollama/manager.js +14 -2
- package/src/policy/cli-policy.js +8 -2
- package/src/policy/policy-engine.js +2 -1
- package/src/provenance/model-provenance.js +4 -1
- package/src/ui/cli-theme.js +57 -7
- package/src/ui/interactive-panel.js +176 -20
|
@@ -10,6 +10,7 @@ const ScoringEngine = require('./scoring-engine');
|
|
|
10
10
|
const UnifiedDetector = require('../hardware/unified-detector');
|
|
11
11
|
const PolicyManager = require('../policy/policy-manager');
|
|
12
12
|
const PolicyEngine = require('../policy/policy-engine');
|
|
13
|
+
const { rankModels } = require('./scoring-core');
|
|
13
14
|
|
|
14
15
|
function isPlainObject(value) {
|
|
15
16
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -66,7 +67,9 @@ class IntelligentSelector {
|
|
|
66
67
|
// Apply filters
|
|
67
68
|
const filtered = this.applyFilters(variants, opts, hardware);
|
|
68
69
|
|
|
69
|
-
// Score all filtered variants
|
|
70
|
+
// Score all filtered variants. ScoringEngine still produces the
|
|
71
|
+
// per-variant `score` objects (final/components/meta) consumed by the
|
|
72
|
+
// smart-recommend display, but the RANKING is unified below.
|
|
70
73
|
const scored = this.scoring.filterAndScore(filtered, hardware, {
|
|
71
74
|
useCase: opts.useCase,
|
|
72
75
|
targetContext: opts.targetContext,
|
|
@@ -75,6 +78,12 @@ class IntelligentSelector {
|
|
|
75
78
|
headroom: opts.headroom || 2
|
|
76
79
|
});
|
|
77
80
|
|
|
81
|
+
// Unify ranking with the canonical scoring core (issue #88): re-order
|
|
82
|
+
// the scored list and rewrite each item's final score using the shared
|
|
83
|
+
// DeterministicModelSelector so smart-recommend agrees with
|
|
84
|
+
// `check`/`recommend` and inherits the PR #89 high-capacity floor.
|
|
85
|
+
await this.applyUnifiedRanking(scored, hardware, opts);
|
|
86
|
+
|
|
78
87
|
const policyEngine = this.resolvePolicyEngine(opts);
|
|
79
88
|
const scoredWithPolicy = policyEngine.evaluateScoredVariants(
|
|
80
89
|
scored,
|
|
@@ -114,6 +123,83 @@ class IntelligentSelector {
|
|
|
114
123
|
};
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Re-rank the ScoringEngine-scored variants using the canonical scoring
|
|
128
|
+
* core so smart-recommend's ordering and headline scores match
|
|
129
|
+
* `check`/`recommend` and inherit the high-capacity right-sizing floor.
|
|
130
|
+
*
|
|
131
|
+
* Mutates `scored` in place: it is sorted by the unified score and each
|
|
132
|
+
* item's `score.final` is overwritten with the canonical 0-100 score.
|
|
133
|
+
* Component/meta sub-scores are left intact so the existing display (which
|
|
134
|
+
* shows Q/S/F and estimated TPS) keeps working. If the core cannot rank a
|
|
135
|
+
* variant (or throws), that item keeps its original ScoringEngine score and
|
|
136
|
+
* sorts after the unified ones, preserving a sensible fallback ordering.
|
|
137
|
+
*/
|
|
138
|
+
async applyUnifiedRanking(scored, hardware, opts = {}) {
|
|
139
|
+
if (!Array.isArray(scored) || scored.length === 0) return scored;
|
|
140
|
+
|
|
141
|
+
let ranking;
|
|
142
|
+
try {
|
|
143
|
+
ranking = await rankModels(
|
|
144
|
+
scored.map((item) => item.variant),
|
|
145
|
+
hardware,
|
|
146
|
+
{
|
|
147
|
+
category: opts.useCase || 'general',
|
|
148
|
+
optimizeFor: opts.optimizeFor || opts.optimize || 'balanced',
|
|
149
|
+
runtime: opts.runtime || 'ollama',
|
|
150
|
+
topN: scored.length
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return scored; // Defensive: keep original ScoringEngine ordering.
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!ranking || !Array.isArray(ranking.candidates)) return scored;
|
|
158
|
+
|
|
159
|
+
// Map each source variant -> its unified score + ordering index.
|
|
160
|
+
const unifiedByVariant = new Map();
|
|
161
|
+
ranking.candidates.forEach((candidate, index) => {
|
|
162
|
+
const source = candidate?.meta?.__source;
|
|
163
|
+
if (!source) return;
|
|
164
|
+
unifiedByVariant.set(source, {
|
|
165
|
+
unifiedScore: Math.round(candidate.score * 10) / 10,
|
|
166
|
+
rank: index,
|
|
167
|
+
quant: candidate.quant,
|
|
168
|
+
estimatedTPS: candidate.estTPS
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
for (const item of scored) {
|
|
173
|
+
const unified = unifiedByVariant.get(item.variant);
|
|
174
|
+
if (!unified) {
|
|
175
|
+
// Not ranked by the core (e.g. filtered out): sort last and tag
|
|
176
|
+
// so any downstream tie-breaks are deterministic.
|
|
177
|
+
item.__unifiedRank = Number.MAX_SAFE_INTEGER;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
item.__unifiedRank = unified.rank;
|
|
181
|
+
if (item.score) {
|
|
182
|
+
item.score.final = Math.min(100, Math.max(0, Math.round(unified.unifiedScore)));
|
|
183
|
+
if (item.score.meta) {
|
|
184
|
+
item.score.meta.unifiedScore = unified.unifiedScore;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
scored.sort((a, b) => {
|
|
190
|
+
const ra = Number.isFinite(a.__unifiedRank) ? a.__unifiedRank : Number.MAX_SAFE_INTEGER;
|
|
191
|
+
const rb = Number.isFinite(b.__unifiedRank) ? b.__unifiedRank : Number.MAX_SAFE_INTEGER;
|
|
192
|
+
if (ra !== rb) return ra - rb;
|
|
193
|
+
return (b.score?.final || 0) - (a.score?.final || 0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
for (const item of scored) {
|
|
197
|
+
delete item.__unifiedRank;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return scored;
|
|
201
|
+
}
|
|
202
|
+
|
|
117
203
|
/**
|
|
118
204
|
* Resolve policy engine from explicit options, in-memory policy, or policy file.
|
|
119
205
|
*/
|
|
@@ -134,17 +134,22 @@ class RequirementsCalculator {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
parseModelSize(sizeString) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
137
|
+
// Anchor the number to its unit instead of globally stripping every char
|
|
138
|
+
// that isn't 0-9.kmb: the old approach kept stray k/m/b from model words, so
|
|
139
|
+
// "Llama 3.2 3B" normalized to "m3.23b" and parsed as 0.003B, and unit-only
|
|
140
|
+
// inputs produced NaN. Prefer a number that carries a B/M/K unit (the real
|
|
141
|
+
// size token, "3B") over a bare number (a version like "3.2").
|
|
142
|
+
const text = String(sizeString || '');
|
|
143
|
+
const match = text.match(/(\d+(?:\.\d+)?)\s*([kmb])\b/i) || text.match(/(\d+(?:\.\d+)?)/);
|
|
144
|
+
if (!match) return 1;
|
|
145
|
+
|
|
146
|
+
const value = parseFloat(match[1]);
|
|
147
|
+
if (!Number.isFinite(value)) return 1;
|
|
148
|
+
|
|
149
|
+
const unit = (match[2] || 'b').toLowerCase();
|
|
150
|
+
if (unit === 'k') return value / 1_000_000; // thousands of params -> billions
|
|
151
|
+
if (unit === 'm') return value / 1000; // millions of params -> billions
|
|
152
|
+
return value; // billions
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
getContextMultiplier(contextLength) {
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Scoring Core (GitHub issue #88)
|
|
3
|
+
* =======================================
|
|
4
|
+
*
|
|
5
|
+
* Historically the three user-facing recommendation surfaces each ran a
|
|
6
|
+
* DIFFERENT scoring engine, so they could disagree about the best model and
|
|
7
|
+
* only one of them ("recommend") received the PR #89 high-capacity
|
|
8
|
+
* right-sizing fix:
|
|
9
|
+
*
|
|
10
|
+
* - `check` -> MultiObjectiveSelector (src/ai/multi-objective-selector.js)
|
|
11
|
+
* - `recommend` -> DeterministicModelSelector (src/models/deterministic-selector.js)
|
|
12
|
+
* - `smart-recommend` -> ScoringEngine (src/models/scoring-engine.js)
|
|
13
|
+
*
|
|
14
|
+
* This module establishes the DeterministicModelSelector as the SINGLE
|
|
15
|
+
* canonical ranking core (it already carries PR #89's
|
|
16
|
+
* `calculateHighCapacitySizeAdjustment` / `getHighCapacitySizeTarget`). The
|
|
17
|
+
* other commands keep their own model SOURCE and DISPLAY shape, but route the
|
|
18
|
+
* actual ranking/selection through `rankModels()` below so that, for identical
|
|
19
|
+
* (model, hardware) inputs, every command produces identical scores and the
|
|
20
|
+
* high-capacity floor applies everywhere.
|
|
21
|
+
*
|
|
22
|
+
* The hard part is that each command feeds the engine a different model shape:
|
|
23
|
+
* - `check` uses ExpandedModelsDatabase rows ({ name: "Llama 3.1 8B", size: "8B", ... })
|
|
24
|
+
* - `smart-recommend` uses sql.js variant rows ({ model_id, params_b, size_gb, quant, ... })
|
|
25
|
+
* - `recommend` uses the Ollama scrape/catalog (already deterministic-friendly)
|
|
26
|
+
*
|
|
27
|
+
* `normalizeToDeterministic()` converts any of those into the
|
|
28
|
+
* already-normalized deterministic model shape and keeps a back-reference
|
|
29
|
+
* (`__source`) so callers can recover their original object after ranking.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const DeterministicModelSelector = require('./deterministic-selector');
|
|
33
|
+
|
|
34
|
+
// One shared selector instance: it is stateless w.r.t. the model pool (the
|
|
35
|
+
// pool is always passed in via options), so sharing it is safe and avoids
|
|
36
|
+
// re-allocating the large prior tables on every call.
|
|
37
|
+
const canonicalSelector = new DeterministicModelSelector();
|
|
38
|
+
|
|
39
|
+
const PARAM_HINT_REGEX = /(\d+(?:\.\d+)?)\s*([bm])\b/i;
|
|
40
|
+
|
|
41
|
+
function toFiniteNumber(value) {
|
|
42
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
43
|
+
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
|
|
44
|
+
return Number(value);
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Best-effort parameter-count extraction (in billions) from a model's various
|
|
51
|
+
* shapes. Mirrors the heuristics the individual engines used so the unified
|
|
52
|
+
* ranking sees the same parameter counts they would have.
|
|
53
|
+
*/
|
|
54
|
+
function deriveParamsB(model = {}) {
|
|
55
|
+
const direct =
|
|
56
|
+
toFiniteNumber(model.paramsB) ??
|
|
57
|
+
toFiniteNumber(model.params_b) ??
|
|
58
|
+
toFiniteNumber(model.parameter_count_b);
|
|
59
|
+
if (Number.isFinite(direct) && direct > 0) return direct;
|
|
60
|
+
|
|
61
|
+
// Parse from explicit "size" / name strings (e.g. "8B", "Qwen 0.5B", "405b").
|
|
62
|
+
const textCandidates = [model.size, model.parameter_size, model.name, model.model_name, model.model_id, model.model_identifier, model.tag];
|
|
63
|
+
for (const candidate of textCandidates) {
|
|
64
|
+
if (typeof candidate !== 'string') continue;
|
|
65
|
+
const match = candidate.match(PARAM_HINT_REGEX);
|
|
66
|
+
if (match) {
|
|
67
|
+
const value = parseFloat(match[1]);
|
|
68
|
+
if (Number.isFinite(value) && value > 0) {
|
|
69
|
+
return match[2].toLowerCase() === 'm' ? value / 1000 : value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Fall back to inferring from an artifact size in GB (Q4 ~ 0.6GB/B).
|
|
75
|
+
const sizeGB = deriveSizeGB(model, null);
|
|
76
|
+
if (Number.isFinite(sizeGB) && sizeGB > 0) {
|
|
77
|
+
return Math.max(0.5, Math.round((sizeGB / 0.6) * 2) / 2);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return 7; // Same neutral fallback the engines used.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function deriveSizeGB(model = {}, paramsB = null) {
|
|
84
|
+
const direct =
|
|
85
|
+
toFiniteNumber(model.sizeGB) ??
|
|
86
|
+
toFiniteNumber(model.size_gb) ??
|
|
87
|
+
toFiniteNumber(model.real_size_gb) ??
|
|
88
|
+
toFiniteNumber(model.estimated_size_gb);
|
|
89
|
+
if (Number.isFinite(direct) && direct > 0) return direct;
|
|
90
|
+
|
|
91
|
+
// "size" may be a file size like "4.4GB" (vs a parameter count like "8B").
|
|
92
|
+
if (typeof model.size === 'string') {
|
|
93
|
+
const gbMatch = model.size.match(/(\d+(?:\.\d+)?)\s*g(?:i)?b\b/i);
|
|
94
|
+
if (gbMatch) return parseFloat(gbMatch[1]);
|
|
95
|
+
}
|
|
96
|
+
if (typeof model.installedSize === 'string') {
|
|
97
|
+
const gbMatch = model.installedSize.match(/(\d+(?:\.\d+)?)\s*g(?:i)?b\b/i);
|
|
98
|
+
if (gbMatch) return parseFloat(gbMatch[1]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (Number.isFinite(paramsB) && paramsB > 0) {
|
|
102
|
+
return Math.max(0.4, Math.round((paramsB * 0.6 + 0.4) * 10) / 10);
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function deriveContext(model = {}) {
|
|
108
|
+
const candidates = [
|
|
109
|
+
model.ctxMax,
|
|
110
|
+
model.context_length,
|
|
111
|
+
model.contextLength,
|
|
112
|
+
model.context,
|
|
113
|
+
model.performance && model.performance.context_length
|
|
114
|
+
];
|
|
115
|
+
for (const candidate of candidates) {
|
|
116
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) {
|
|
117
|
+
return Math.round(candidate);
|
|
118
|
+
}
|
|
119
|
+
if (typeof candidate === 'string') {
|
|
120
|
+
const parsed = canonicalSelector.parseContextLength(candidate);
|
|
121
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return 4096;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function deriveQuant(model = {}) {
|
|
128
|
+
const candidates = [model.quant, model.quantization];
|
|
129
|
+
for (const candidate of candidates) {
|
|
130
|
+
if (typeof candidate === 'string' && candidate.trim()) {
|
|
131
|
+
return canonicalSelector.normalizeQuantization(candidate);
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(candidate) && candidate.length > 0) {
|
|
134
|
+
// ExpandedModelsDatabase stores quantization as an array of options;
|
|
135
|
+
// prefer the highest-quality option that the hierarchy knows about.
|
|
136
|
+
const normalized = candidate.map((q) => canonicalSelector.normalizeQuantization(q));
|
|
137
|
+
for (const q of canonicalSelector.quantHierarchy) {
|
|
138
|
+
if (normalized.includes(q)) return q;
|
|
139
|
+
}
|
|
140
|
+
return normalized[0];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return 'Q4_K_M';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function deriveModalities(model = {}) {
|
|
147
|
+
if (Array.isArray(model.modalities) && model.modalities.length > 0) {
|
|
148
|
+
return model.modalities.map((m) => String(m).toLowerCase());
|
|
149
|
+
}
|
|
150
|
+
const text = [
|
|
151
|
+
model.name,
|
|
152
|
+
model.model_name,
|
|
153
|
+
model.model_id,
|
|
154
|
+
model.model_identifier,
|
|
155
|
+
model.specialization,
|
|
156
|
+
model.category,
|
|
157
|
+
...(Array.isArray(model.capabilities) ? model.capabilities : [String(model.capabilities || '')]),
|
|
158
|
+
...(Array.isArray(model.input_types) ? model.input_types : [])
|
|
159
|
+
]
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.join(' ')
|
|
162
|
+
.toLowerCase();
|
|
163
|
+
const hasVision = /\b(vision|vl\b|llava|bakllava|moondream|multimodal|image)\b/.test(text);
|
|
164
|
+
return hasVision ? ['text', 'vision'] : ['text'];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function deriveTags(model = {}, modalities = ['text']) {
|
|
168
|
+
const explicit = Array.isArray(model.tags) ? model.tags.map((t) => String(t).toLowerCase()) : [];
|
|
169
|
+
const tags = new Set(explicit);
|
|
170
|
+
|
|
171
|
+
const text = [
|
|
172
|
+
model.name,
|
|
173
|
+
model.model_name,
|
|
174
|
+
model.model_id,
|
|
175
|
+
model.model_identifier,
|
|
176
|
+
model.specialization,
|
|
177
|
+
model.category,
|
|
178
|
+
model.tag,
|
|
179
|
+
...(Array.isArray(model.capabilities)
|
|
180
|
+
? model.capabilities
|
|
181
|
+
: [String(model.capabilities || '')]),
|
|
182
|
+
...(Array.isArray(model.use_cases) ? model.use_cases : [])
|
|
183
|
+
]
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.join(' ')
|
|
186
|
+
.toLowerCase();
|
|
187
|
+
|
|
188
|
+
if (/\b(coder|code|programming|deepseek-coder|starcoder|codellama|codegemma)\b/.test(text)) tags.add('coder');
|
|
189
|
+
if (/\binstruct\b/.test(text)) tags.add('instruct');
|
|
190
|
+
if (/\b(chat|assistant|conversation)\b/.test(text)) tags.add('chat');
|
|
191
|
+
if (/\bembed/.test(text)) tags.add('embedding');
|
|
192
|
+
if (/\b(reason|math|logic)\b/.test(text)) tags.add('reasoning');
|
|
193
|
+
if (modalities.includes('vision')) tags.add('vision');
|
|
194
|
+
|
|
195
|
+
// Most general/instruct models should compete in the general category even
|
|
196
|
+
// when no explicit tag exists, matching the permissive engines' behavior.
|
|
197
|
+
if (tags.size === 0) tags.add('chat');
|
|
198
|
+
|
|
199
|
+
return [...tags];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function deriveIdentifier(model = {}) {
|
|
203
|
+
return (
|
|
204
|
+
model.model_identifier ||
|
|
205
|
+
model.tag ||
|
|
206
|
+
model.model_id ||
|
|
207
|
+
model.model_name ||
|
|
208
|
+
model.name ||
|
|
209
|
+
'unknown-model'
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Convert an arbitrary model object (any of the three command shapes) into the
|
|
215
|
+
* already-normalized deterministic model shape. The original object is kept on
|
|
216
|
+
* `__source` so callers can map ranking output back to their display shape.
|
|
217
|
+
*/
|
|
218
|
+
function normalizeToDeterministic(model = {}) {
|
|
219
|
+
const paramsB = deriveParamsB(model);
|
|
220
|
+
const sizeGB = deriveSizeGB(model, paramsB) ?? Math.max(0.4, paramsB * 0.6);
|
|
221
|
+
const modalities = deriveModalities(model);
|
|
222
|
+
const tags = deriveTags(model, modalities);
|
|
223
|
+
const quant = deriveQuant(model);
|
|
224
|
+
const identifier = deriveIdentifier(model);
|
|
225
|
+
|
|
226
|
+
const moeFlag = Boolean(model.isMoE || model.is_moe);
|
|
227
|
+
const totalParamsB =
|
|
228
|
+
toFiniteNumber(model.totalParamsB) ?? toFiniteNumber(model.total_params_b) ?? null;
|
|
229
|
+
const activeParamsB =
|
|
230
|
+
toFiniteNumber(model.activeParamsB) ?? toFiniteNumber(model.active_params_b) ?? null;
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
// Canonical deterministic fields (pass `alreadyNormalized` branch).
|
|
234
|
+
name: identifier,
|
|
235
|
+
model_identifier: identifier,
|
|
236
|
+
family: model.family || canonicalSelector.extractFamily(String(identifier)),
|
|
237
|
+
paramsB,
|
|
238
|
+
ctxMax: deriveContext(model),
|
|
239
|
+
quant,
|
|
240
|
+
sizeGB,
|
|
241
|
+
modalities,
|
|
242
|
+
tags,
|
|
243
|
+
installed: Boolean(model.installed || model.isOllamaInstalled),
|
|
244
|
+
pulls: toFiniteNumber(model.pulls) ?? toFiniteNumber(model.actual_pulls) ?? 0,
|
|
245
|
+
availableQuantizations: [quant],
|
|
246
|
+
sizeByQuant: { [quant]: sizeGB },
|
|
247
|
+
// MoE passthrough so memory/speed estimation stays accurate.
|
|
248
|
+
isMoE: moeFlag,
|
|
249
|
+
is_moe: moeFlag,
|
|
250
|
+
totalParamsB,
|
|
251
|
+
activeParamsB,
|
|
252
|
+
expertCount: toFiniteNumber(model.expertCount) ?? toFiniteNumber(model.expert_count) ?? null,
|
|
253
|
+
expertsActivePerToken:
|
|
254
|
+
toFiniteNumber(model.expertsActivePerToken) ??
|
|
255
|
+
toFiniteNumber(model.experts_active_per_token) ??
|
|
256
|
+
null,
|
|
257
|
+
// Freshness defaults: neutral so we never penalize callers that lack
|
|
258
|
+
// timestamps (the per-command sources already carry these when known).
|
|
259
|
+
freshnessScore: toFiniteNumber(model.freshnessScore) ?? 55,
|
|
260
|
+
isStale: Boolean(model.isStale),
|
|
261
|
+
isDeprecated: Boolean(model.isDeprecated || model.deprecated),
|
|
262
|
+
source: model.source || 'unified_core',
|
|
263
|
+
// Back-reference for result mapping.
|
|
264
|
+
__source: model
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Canonical ranking entrypoint. Returns the deterministic selector's ranked
|
|
270
|
+
* candidates (each carries `.score`, `.components {Q,S,F,C,H}`, `.meta`, etc.)
|
|
271
|
+
* for the supplied models/hardware. Each candidate's `meta.__source` points
|
|
272
|
+
* back to the caller's original model object.
|
|
273
|
+
*
|
|
274
|
+
* @param {Array<Object>} models Arbitrary model objects (any command shape).
|
|
275
|
+
* @param {Object} hardware Hardware profile (any detector shape).
|
|
276
|
+
* @param {Object} options
|
|
277
|
+
* @param {string} [options.category='general'] Use-case / category.
|
|
278
|
+
* @param {string} [options.optimizeFor='balanced']
|
|
279
|
+
* @param {string} [options.runtime='ollama']
|
|
280
|
+
* @param {number} [options.topN] Limit (default: all candidates).
|
|
281
|
+
* @returns {Promise<{category, candidates, total_evaluated, hardware}>}
|
|
282
|
+
*/
|
|
283
|
+
async function rankModels(models, hardware, options = {}) {
|
|
284
|
+
const category = normalizeCategory(options.category || options.useCase || 'general');
|
|
285
|
+
const pool = (Array.isArray(models) ? models : [])
|
|
286
|
+
.filter(Boolean)
|
|
287
|
+
.map(normalizeToDeterministic);
|
|
288
|
+
|
|
289
|
+
const result = await canonicalSelector.selectModels(category, {
|
|
290
|
+
topN: typeof options.topN === 'number' ? options.topN : pool.length || 1,
|
|
291
|
+
enableProbe: false,
|
|
292
|
+
silent: true,
|
|
293
|
+
optimizeFor: options.optimizeFor || options.optimize || options.objective || 'balanced',
|
|
294
|
+
runtime: options.runtime || 'ollama',
|
|
295
|
+
hardware: hardware || undefined,
|
|
296
|
+
installedModels: [],
|
|
297
|
+
modelPool: pool
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Map deterministic-engine categories from the broader vocabulary the commands
|
|
305
|
+
* use (e.g. `check`/`smart-recommend` accept `chat`, `creative`, `vision`,
|
|
306
|
+
* `talking`) onto the categories the deterministic core scores.
|
|
307
|
+
*/
|
|
308
|
+
function normalizeCategory(category) {
|
|
309
|
+
const normalized = String(category || 'general').toLowerCase().trim();
|
|
310
|
+
const map = {
|
|
311
|
+
general: 'general',
|
|
312
|
+
chat: 'general',
|
|
313
|
+
talking: 'general',
|
|
314
|
+
assistant: 'general',
|
|
315
|
+
coding: 'coding',
|
|
316
|
+
code: 'coding',
|
|
317
|
+
programming: 'coding',
|
|
318
|
+
reasoning: 'reasoning',
|
|
319
|
+
math: 'reasoning',
|
|
320
|
+
multimodal: 'multimodal',
|
|
321
|
+
vision: 'multimodal',
|
|
322
|
+
embeddings: 'embeddings',
|
|
323
|
+
embedding: 'embeddings',
|
|
324
|
+
summarization: 'summarization',
|
|
325
|
+
reading: 'reading',
|
|
326
|
+
creative: 'general',
|
|
327
|
+
fast: 'general',
|
|
328
|
+
quality: 'general',
|
|
329
|
+
longctx: 'reading'
|
|
330
|
+
};
|
|
331
|
+
return map[normalized] || 'general';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
module.exports = {
|
|
335
|
+
canonicalSelector,
|
|
336
|
+
rankModels,
|
|
337
|
+
normalizeToDeterministic,
|
|
338
|
+
normalizeCategory,
|
|
339
|
+
deriveParamsB,
|
|
340
|
+
deriveSizeGB
|
|
341
|
+
};
|
|
@@ -25,6 +25,7 @@ class ScoringEngine {
|
|
|
25
25
|
// Model family quality rankings (0-100 base score)
|
|
26
26
|
this.familyQuality = {
|
|
27
27
|
// Frontier models
|
|
28
|
+
'qwen3': 95,
|
|
28
29
|
'qwen2.5': 95,
|
|
29
30
|
'qwen2': 90,
|
|
30
31
|
'llama3.3': 95,
|
|
@@ -35,12 +36,15 @@ class ScoringEngine {
|
|
|
35
36
|
'deepseek-v2.5': 94,
|
|
36
37
|
'deepseek-coder-v2': 92,
|
|
37
38
|
'deepseek-r1': 96,
|
|
39
|
+
'gemma3': 90,
|
|
38
40
|
'gemma2': 90,
|
|
39
41
|
'gemma': 82,
|
|
42
|
+
'phi4': 92,
|
|
40
43
|
'phi-4': 92,
|
|
41
44
|
'phi-3.5': 88,
|
|
42
45
|
'phi-3': 85,
|
|
43
46
|
'phi-2': 75,
|
|
47
|
+
'granite3': 80,
|
|
44
48
|
'mistral-large': 94,
|
|
45
49
|
'mistral': 85,
|
|
46
50
|
'mixtral': 88,
|
|
@@ -443,8 +447,11 @@ class ScoringEngine {
|
|
|
443
447
|
// Tight fit
|
|
444
448
|
return 70 + (1.0 - usage) / 0.15 * 20;
|
|
445
449
|
} else if (usage <= 1.2) {
|
|
446
|
-
// May work with swapping (especially on Mac)
|
|
447
|
-
|
|
450
|
+
// May work with swapping (especially on Mac). Decay continuously from
|
|
451
|
+
// 70 at usage=1.0 down to 0 at usage=1.2 — the old `50 - (usage-1)*100`
|
|
452
|
+
// dropped to ~50 the instant usage crossed 1.0, a 20-point cliff that
|
|
453
|
+
// could flip the ranking of two near-identical-size variants.
|
|
454
|
+
return 70 - (usage - 1.0) / 0.2 * 70;
|
|
448
455
|
} else {
|
|
449
456
|
// Won't fit
|
|
450
457
|
return 0;
|
|
@@ -307,10 +307,21 @@ class OllamaCapacityPlanner {
|
|
|
307
307
|
const fallbackState = this.computeLoadState(modelPool, fallbackCtx, 1, budgetGB);
|
|
308
308
|
const fallbackTotalGB = fallbackState.baseTotalGB + fallbackState.kvAtContextGB;
|
|
309
309
|
|
|
310
|
+
// Even the reduced "recommended" / "fallback" settings can exceed the budget
|
|
311
|
+
// when a single model's base memory alone is larger than the budget. Surface
|
|
312
|
+
// that explicitly so callers don't blindly apply env vars that will OOM.
|
|
313
|
+
const recommendedFits = recommendedTotalGB <= budgetGB;
|
|
314
|
+
const fallbackFits = fallbackTotalGB <= budgetGB;
|
|
315
|
+
|
|
310
316
|
const notes = [];
|
|
311
317
|
if (!requestedFits) {
|
|
312
318
|
notes.push('Requested settings exceed available memory budget; reduced settings are recommended.');
|
|
313
319
|
}
|
|
320
|
+
if (!fallbackFits) {
|
|
321
|
+
notes.push('No safe configuration fits the memory budget for this model selection; choose a smaller or more-quantized model.');
|
|
322
|
+
} else if (!recommendedFits) {
|
|
323
|
+
notes.push('Recommended settings still exceed the budget; apply the fallback settings instead.');
|
|
324
|
+
}
|
|
314
325
|
if (recommendedCtx < requestedCtx) {
|
|
315
326
|
notes.push(`Context reduced from ${requestedCtx} to ${recommendedCtx} to avoid memory pressure.`);
|
|
316
327
|
}
|
|
@@ -366,7 +377,8 @@ class OllamaCapacityPlanner {
|
|
|
366
377
|
max_loaded_models: recommendedLoaded,
|
|
367
378
|
max_queue: maxQueue,
|
|
368
379
|
keep_alive: profile.keepAlive,
|
|
369
|
-
flash_attention: flashAttention
|
|
380
|
+
flash_attention: flashAttention,
|
|
381
|
+
fits: recommendedFits
|
|
370
382
|
},
|
|
371
383
|
memory: {
|
|
372
384
|
budgetGB: Math.round(budgetGB * 100) / 100,
|
|
@@ -379,7 +391,8 @@ class OllamaCapacityPlanner {
|
|
|
379
391
|
num_ctx: fallbackCtx,
|
|
380
392
|
num_parallel: 1,
|
|
381
393
|
max_loaded_models: 1,
|
|
382
|
-
estimated_memory_gb: Math.round(fallbackTotalGB * 100) / 100
|
|
394
|
+
estimated_memory_gb: Math.round(fallbackTotalGB * 100) / 100,
|
|
395
|
+
fits: fallbackFits
|
|
383
396
|
},
|
|
384
397
|
shell: {
|
|
385
398
|
env: {
|