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.
@@ -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
- const normalized = sizeString.toLowerCase().replace(/[^0-9.kmb]/g, '');
138
-
139
- if (normalized.includes('k')) {
140
- return parseFloat(normalized.replace('k', '')) / 1000;
141
- } else if (normalized.includes('m')) {
142
- return parseFloat(normalized.replace('m', '')) / 1000;
143
- } else if (normalized.includes('b')) {
144
- return parseFloat(normalized.replace('b', ''));
145
- } else {
146
- return parseFloat(normalized);
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
- return 50 - (usage - 1.0) * 100;
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: {