prism-mcp-server 20.22.2 → 20.22.4
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 +94 -70
- package/dist/cli.js +42 -0
- package/dist/config.js +4 -3
- package/dist/dashboard/localCodingRouter.js +120 -0
- package/dist/dashboard/localCodingUI.js +171 -0
- package/dist/dashboard/server.js +3 -0
- package/dist/dashboard/ui.js +10 -1
- package/dist/storage/configStorage.js +2 -2
- package/dist/tools/prismInferHandler.js +115 -48
- package/dist/utils/localCodingPreference.js +24 -0
- package/dist/utils/localCodingSettings.js +55 -0
- package/dist/utils/ollamaCodingModels.js +93 -0
- package/dist/utils/omlxProvider.js +79 -14
- package/dist/utils/omlxSetup.js +432 -0
- package/dist/utils/omlxSetupWorker.js +23 -0
- package/package.json +3 -2
package/dist/dashboard/ui.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { ANALYTICS_DATE_PATTERN, ANALYTICS_METRIC_IDS, DASHBOARD_READ_MESSAGES } from './readMessages.js';
|
|
17
17
|
import { DARK_FACTORY_SETTINGS_UNAVAILABLE } from '../darkfactory/enablement.js';
|
|
18
18
|
import { ACTR_SETTINGS_UNAVAILABLE, ACTR_UI } from '../utils/actrEnablement.js';
|
|
19
|
+
import { localCodingPanelHTML, localCodingScript } from './localCodingUI.js';
|
|
19
20
|
export function renderDashboardLocalOpenHTML() {
|
|
20
21
|
return `<!DOCTYPE html>
|
|
21
22
|
<html lang="en">
|
|
@@ -481,6 +482,10 @@ export function renderDashboardHTML(version) {
|
|
|
481
482
|
.skill-save-btn { background: var(--accent-purple); color: #fff; border: none; border-radius: var(--radius-sm);
|
|
482
483
|
padding: 0.45rem 1rem; font-size: 0.82rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
|
|
483
484
|
.skill-save-btn:hover { opacity: 0.85; }
|
|
485
|
+
#spanel-local .setting-desc { font-size: 0.8rem; line-height: 1.55; }
|
|
486
|
+
#spanel-local a { color: var(--accent-purple); text-underline-offset: 3px; }
|
|
487
|
+
#spanel-local button, #spanel-local select, #spanel-local input { min-height: 40px; }
|
|
488
|
+
#spanel-local button:disabled { opacity: 0.5; cursor: default; }
|
|
484
489
|
.skill-upload-btn { background: none; border: 1px solid var(--border-glass); color: var(--text-secondary);
|
|
485
490
|
border-radius: var(--radius-sm); padding: 0.45rem 0.85rem; font-size: 0.82rem; cursor: pointer; transition: all 0.2s; }
|
|
486
491
|
.skill-upload-btn:hover { border-color: var(--accent-purple); color: var(--accent-purple); }
|
|
@@ -1239,6 +1244,7 @@ export function renderDashboardHTML(version) {
|
|
|
1239
1244
|
<div class="settings-tabs">
|
|
1240
1245
|
<button class="s-tab active" id="stab-account" onclick="switchSettingsTab('account')">👤 Account</button>
|
|
1241
1246
|
<button class="s-tab" id="stab-settings" onclick="switchSettingsTab('settings')">⚙️ Preferences</button>
|
|
1247
|
+
<button class="s-tab" id="stab-local" onclick="switchSettingsTab('local')">Local Coding</button>
|
|
1242
1248
|
<button class="s-tab" id="stab-skills" onclick="switchSettingsTab('skills')">📜 Skills</button>
|
|
1243
1249
|
<button class="s-tab" id="stab-providers" onclick="switchSettingsTab('providers')">🤖 AI Providers</button>
|
|
1244
1250
|
<button class="s-tab" id="stab-observability" onclick="switchSettingsTab('observability')">🔭 Observability</button>
|
|
@@ -1251,6 +1257,7 @@ export function renderDashboardHTML(version) {
|
|
|
1251
1257
|
</div>
|
|
1252
1258
|
</div>
|
|
1253
1259
|
|
|
1260
|
+
${localCodingPanelHTML()}
|
|
1254
1261
|
<!-- Settings panel (existing content) -->
|
|
1255
1262
|
<div class="s-tab-panel" id="spanel-settings">
|
|
1256
1263
|
|
|
@@ -3660,13 +3667,14 @@ document.getElementById('settingsModal').addEventListener('click', function (e)
|
|
|
3660
3667
|
// ─── Skills Tab JS ───────────────────────────────────────────
|
|
3661
3668
|
var _skillsCache = {}; // role → content cache
|
|
3662
3669
|
function switchSettingsTab(tab) {
|
|
3663
|
-
['account', 'settings', 'skills', 'providers', 'observability'].forEach(function (t) {
|
|
3670
|
+
['account', 'settings', 'local', 'skills', 'providers', 'observability'].forEach(function (t) {
|
|
3664
3671
|
document.getElementById('stab-' + t).classList.toggle('active', t === tab);
|
|
3665
3672
|
document.getElementById('spanel-' + t).classList.toggle('active', t === tab);
|
|
3666
3673
|
});
|
|
3667
3674
|
if (tab === 'account') {
|
|
3668
3675
|
loadIdentityChip();
|
|
3669
3676
|
}
|
|
3677
|
+
if (tab === 'local') loadLocalCoding(true);
|
|
3670
3678
|
if (tab === 'skills') {
|
|
3671
3679
|
var role = document.getElementById('skillRoleSelect').value;
|
|
3672
3680
|
loadSkillForRole(role);
|
|
@@ -4848,6 +4856,7 @@ function renderIntentHealthCard(data) {
|
|
|
4848
4856
|
container.innerHTML = html;
|
|
4849
4857
|
}
|
|
4850
4858
|
|
|
4859
|
+
${localCodingScript()}
|
|
4851
4860
|
</script>
|
|
4852
4861
|
</body>
|
|
4853
4862
|
</html>`;
|
|
@@ -325,10 +325,10 @@ export async function getSessionContextReceipt(conversationHash, projectHash) {
|
|
|
325
325
|
return null;
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
|
-
export async function getSetting(key, defaultValue = "") {
|
|
328
|
+
export async function getSetting(key, defaultValue = "", options = {}) {
|
|
329
329
|
await initConfigStorage();
|
|
330
330
|
// Serve from cache when warm (the common case after startup).
|
|
331
|
-
if (settingsCache && key in settingsCache) {
|
|
331
|
+
if (!options.fresh && settingsCache && key in settingsCache) {
|
|
332
332
|
return settingsCache[key];
|
|
333
333
|
}
|
|
334
334
|
const client = getClient();
|
|
@@ -45,6 +45,9 @@ import { recordInference, recordThinkOnlyRetry, formatInferenceMetrics, estimate
|
|
|
45
45
|
import { appendInferMetric } from "../storage/inferMetricsLedger.js";
|
|
46
46
|
import { getStorage } from "../storage/index.js";
|
|
47
47
|
import { getSetting } from "../storage/configStorage.js";
|
|
48
|
+
import { localCodingPreference, resolveLocalCodingBackend } from "../utils/localCodingPreference.js";
|
|
49
|
+
import { isLocalCodingModelId, resolveLocalCodingModel } from "../utils/localCodingSettings.js";
|
|
50
|
+
import { selectedOllamaCodingModel } from "../utils/ollamaCodingModels.js";
|
|
48
51
|
import { DEFAULT_PRISM_ROUTE_TOOLS, applyLocalRouteContract, isRouteToolName, parseRouteOutput, routeServesProse, validatePortalRouteGuardOutcome, } from "../utils/routeContract.js";
|
|
49
52
|
const INFER_CONTEXT_DEPTHS = new Set(["quick", "standard", "deep"]);
|
|
50
53
|
const LOCAL_WORKER_MEMORY_INSTRUCTION = "You are a bounded local Prism worker. Complete only the requested subtask. " +
|
|
@@ -75,6 +78,10 @@ const FAST_TASK_COMPLEXITY_MAX = 3;
|
|
|
75
78
|
const BALANCED_TASK_COMPLEXITY_MAX = 6;
|
|
76
79
|
const MAX_CODING_REPAIR_ATTEMPTS = 2;
|
|
77
80
|
const MAX_ROUTE_TOOLS = 64;
|
|
81
|
+
const CODING_ROLE_DEFAULTS = {
|
|
82
|
+
omlx: { maxTokens: 4096, timeoutMs: 600_000 },
|
|
83
|
+
ollama: { maxTokens: 1024, timeoutMs: 300_000 },
|
|
84
|
+
};
|
|
78
85
|
// ─── Tool Definition ────────────────────────────────────────────
|
|
79
86
|
/**
|
|
80
87
|
* Default system prompt for image requests, applied only when the caller
|
|
@@ -598,7 +605,7 @@ export const PRISM_INFER_TOOL = {
|
|
|
598
605
|
idempotentHint: false,
|
|
599
606
|
openWorldHint: true,
|
|
600
607
|
},
|
|
601
|
-
description: "Run local inference
|
|
608
|
+
description: "Run local inference using the saved coding backend preference. Explicit local_backend overrides it. oMLX uses authenticated " +
|
|
602
609
|
"loopback oMLX for text-only code/reviewer drafts, without Ollama/cloud substitution or final sign-off. " +
|
|
603
610
|
"Owns model selection across 27B / 9B / 4B / 2B using an explicit `model_ceiling` or " +
|
|
604
611
|
"the caller's `task_complexity`, then validates loaded memory size, model context, " +
|
|
@@ -654,7 +661,7 @@ export const PRISM_INFER_TOOL = {
|
|
|
654
661
|
},
|
|
655
662
|
max_tokens: {
|
|
656
663
|
type: "number",
|
|
657
|
-
description: "
|
|
664
|
+
description: "Output cap 8192, default 1024. With local_role: oMLX defaults 4096.",
|
|
658
665
|
default: 1024,
|
|
659
666
|
},
|
|
660
667
|
temperature: {
|
|
@@ -664,12 +671,13 @@ export const PRISM_INFER_TOOL = {
|
|
|
664
671
|
},
|
|
665
672
|
local_backend: {
|
|
666
673
|
type: "string", enum: ["ollama", "omlx"],
|
|
667
|
-
description: "
|
|
674
|
+
description: "Override coding backend; oMLX is text-only. No substitution.",
|
|
668
675
|
},
|
|
669
676
|
local_model: {
|
|
670
677
|
type: "string",
|
|
671
|
-
description: "
|
|
678
|
+
description: "Installed code model override; unavailable models refuse.",
|
|
672
679
|
},
|
|
680
|
+
local_role: { type: "string", enum: ["coder", "reviewer"], description: "Coding role; default coder." },
|
|
673
681
|
model_ceiling: {
|
|
674
682
|
type: "string",
|
|
675
683
|
enum: ["27b", "9b", "4b", "2b"],
|
|
@@ -679,12 +687,11 @@ export const PRISM_INFER_TOOL = {
|
|
|
679
687
|
type: "number",
|
|
680
688
|
minimum: 1,
|
|
681
689
|
maximum: 10,
|
|
682
|
-
description: "1-10
|
|
683
|
-
"local tier and thinking mode; explicit model_ceiling/think win.",
|
|
690
|
+
description: "1-10 tier/thinking hint for prism_infer; explicit model_ceiling/think win.",
|
|
684
691
|
},
|
|
685
692
|
project: {
|
|
686
693
|
type: "string",
|
|
687
|
-
description: "
|
|
694
|
+
description: "Historical project memory; dashboard depth.",
|
|
688
695
|
},
|
|
689
696
|
context_depth: {
|
|
690
697
|
type: "string",
|
|
@@ -701,7 +708,7 @@ export const PRISM_INFER_TOOL = {
|
|
|
701
708
|
},
|
|
702
709
|
timeout_ms: {
|
|
703
710
|
type: "number",
|
|
704
|
-
description: "
|
|
711
|
+
description: "Milliseconds. Explicit local_role: oMLX 600s/Ollama 300s; otherwise legacy timeouts.",
|
|
705
712
|
},
|
|
706
713
|
evidence: {
|
|
707
714
|
type: "array",
|
|
@@ -863,7 +870,9 @@ export function isPrismInferArgs(args) {
|
|
|
863
870
|
return false;
|
|
864
871
|
if (a.local_backend !== undefined && a.local_backend !== "ollama" && a.local_backend !== "omlx")
|
|
865
872
|
return false;
|
|
866
|
-
if (a.
|
|
873
|
+
if (a.local_role !== undefined && (a.mode !== "code" || (a.local_role !== "coder" && a.local_role !== "reviewer") || (Array.isArray(a.images) && a.images.length > 0)))
|
|
874
|
+
return false;
|
|
875
|
+
if (a.local_model !== undefined && (a.mode !== "code" || !isLocalCodingModelId(a.local_model) || (Array.isArray(a.images) && a.images.length > 0)))
|
|
867
876
|
return false;
|
|
868
877
|
if (a.local_backend === "omlx" && (a.mode !== "code" || (Array.isArray(a.images) && a.images.length > 0)))
|
|
869
878
|
return false;
|
|
@@ -1099,9 +1108,28 @@ export async function waitForOllamaUnloadDrain(listLoaded, initial, timeoutMs =
|
|
|
1099
1108
|
}
|
|
1100
1109
|
throw new OmlxError("omlx_ollama_unload_incomplete");
|
|
1101
1110
|
}
|
|
1102
|
-
|
|
1111
|
+
export function isOllamaConnectionRefused(error) {
|
|
1112
|
+
if (!(error instanceof Error))
|
|
1113
|
+
return false;
|
|
1114
|
+
if (error.code === "ECONNREFUSED")
|
|
1115
|
+
return true;
|
|
1116
|
+
if (error instanceof AggregateError)
|
|
1117
|
+
return error.errors.length > 0 && error.errors.every(isOllamaConnectionRefused);
|
|
1118
|
+
return error.cause !== undefined && isOllamaConnectionRefused(error.cause);
|
|
1119
|
+
}
|
|
1120
|
+
export async function strictLoaded(url, fetchImpl = fetch) {
|
|
1121
|
+
let response;
|
|
1122
|
+
try {
|
|
1123
|
+
response = await fetchImpl(`${url}/api/ps`, { signal: AbortSignal.timeout(3_000), redirect: "error" });
|
|
1124
|
+
}
|
|
1125
|
+
catch (error) {
|
|
1126
|
+
// A closed local listener needs no daemon startup. Timeouts, DNS errors,
|
|
1127
|
+
// HTTP errors and malformed state are unknown and still refuse.
|
|
1128
|
+
if (isOllamaConnectionRefused(error))
|
|
1129
|
+
return new Set();
|
|
1130
|
+
throw new OmlxError("omlx_ollama_state_unavailable");
|
|
1131
|
+
}
|
|
1103
1132
|
try {
|
|
1104
|
-
const response = await fetch(`${url}/api/ps`, { signal: AbortSignal.timeout(3_000), redirect: "error" });
|
|
1105
1133
|
if (!response.ok)
|
|
1106
1134
|
throw new Error();
|
|
1107
1135
|
const data = await response.json();
|
|
@@ -1230,8 +1258,8 @@ const templateOverheadCache = new Map();
|
|
|
1230
1258
|
* Returns null when unprobeable, and the caller keeps the old literal — an
|
|
1231
1259
|
* unmeasurable overhead must not become a zero.
|
|
1232
1260
|
*/
|
|
1233
|
-
export async function probeTemplateOverhead(url, model, hasSystem) {
|
|
1234
|
-
const key = `${model}::${hasSystem}`;
|
|
1261
|
+
export async function probeTemplateOverhead(url, model, hasSystem, numCtx) {
|
|
1262
|
+
const key = `${url}::${model}::${hasSystem}::${numCtx ?? "default"}`;
|
|
1235
1263
|
if (templateOverheadCache.has(key))
|
|
1236
1264
|
return templateOverheadCache.get(key) ?? null;
|
|
1237
1265
|
try {
|
|
@@ -1246,7 +1274,7 @@ export async function probeTemplateOverhead(url, model, hasSystem) {
|
|
|
1246
1274
|
headers: { "Content-Type": "application/json" },
|
|
1247
1275
|
body: JSON.stringify({
|
|
1248
1276
|
model, messages, stream: false, think: false,
|
|
1249
|
-
options: { num_predict: 1, temperature: 0 },
|
|
1277
|
+
options: { num_predict: 1, temperature: 0, ...(numCtx === undefined ? {} : { num_ctx: numCtx }) },
|
|
1250
1278
|
}),
|
|
1251
1279
|
// Short, because this must never gate a request: a live ollama
|
|
1252
1280
|
// answers a one-character prompt in milliseconds, and anything
|
|
@@ -1360,7 +1388,7 @@ export async function groundAnswer(o) {
|
|
|
1360
1388
|
messages,
|
|
1361
1389
|
stream: false,
|
|
1362
1390
|
think: withThinking,
|
|
1363
|
-
options: { num_predict: tokens, temperature: 0 },
|
|
1391
|
+
options: { num_predict: tokens, temperature: 0, ...(o.numCtx === undefined ? {} : { num_ctx: o.numCtx }) },
|
|
1364
1392
|
}),
|
|
1365
1393
|
signal: AbortSignal.timeout(timeoutMs),
|
|
1366
1394
|
// The conversation must not follow a redirect off this machine.
|
|
@@ -1486,7 +1514,7 @@ export async function probeVision(url, model) {
|
|
|
1486
1514
|
* that copied one layer of production and assumed the rest; measuring against a
|
|
1487
1515
|
* hand-rolled copy of this function would repeat that.
|
|
1488
1516
|
*/
|
|
1489
|
-
export async function callOllamaGenerate(url, model, prompt, system, maxTokens, temperature, timeoutMs, think, images, history) {
|
|
1517
|
+
export async function callOllamaGenerate(url, model, prompt, system, maxTokens, temperature, timeoutMs, think, images, history, contextTokens) {
|
|
1490
1518
|
try {
|
|
1491
1519
|
// The caller's system message, or — when there is none — an empty one
|
|
1492
1520
|
// for a model whose Modelfile bakes a SYSTEM, so the baked tool-routing
|
|
@@ -1505,7 +1533,7 @@ export async function callOllamaGenerate(url, model, prompt, system, maxTokens,
|
|
|
1505
1533
|
messages,
|
|
1506
1534
|
stream: false,
|
|
1507
1535
|
...(think !== undefined ? { think } : {}),
|
|
1508
|
-
options: { num_predict: maxTokens, temperature },
|
|
1536
|
+
options: { num_predict: maxTokens, temperature, ...(contextTokens === undefined ? {} : { num_ctx: contextTokens }) },
|
|
1509
1537
|
};
|
|
1510
1538
|
const res = await fetch(`${url}/api/chat`, {
|
|
1511
1539
|
method: "POST",
|
|
@@ -1928,6 +1956,37 @@ const _evictionMutex = (() => {
|
|
|
1928
1956
|
})();
|
|
1929
1957
|
export async function runInfer(args, deps) {
|
|
1930
1958
|
const started = Date.now();
|
|
1959
|
+
// Backend and model are independent overrides. Resolve before taking a
|
|
1960
|
+
// device lease; never infer the reviewer role from prose or system text.
|
|
1961
|
+
const intercepted = [...(args.messages ?? []).filter(turn => turn.role === "user").map(turn => turn.content), args.prompt].some(text => Boolean(checkInputSafety(text)));
|
|
1962
|
+
if (!intercepted && args.mode === "code" && !args.images?.length && deps.codingPreference && !(args.local_backend && args.local_model)) {
|
|
1963
|
+
let preference;
|
|
1964
|
+
try {
|
|
1965
|
+
preference = await deps.codingPreference(args.local_role ?? "coder", args.local_backend);
|
|
1966
|
+
}
|
|
1967
|
+
catch {
|
|
1968
|
+
const reason = "local_coding_preference_invalid";
|
|
1969
|
+
if (args.escalation === "report")
|
|
1970
|
+
return { output: "", backend: "refused", model_picked: null,
|
|
1971
|
+
ram_free_mb: Math.round(deps.freemem() / 1024 ** 2), latency_ms: Date.now() - started, used_cloud: false,
|
|
1972
|
+
attempts: [{ tier: "preference", reason }], gate_outcome: { status: "refused", reason, served_anyway: false } };
|
|
1973
|
+
throw new Error(`prism_infer: ${reason}`);
|
|
1974
|
+
}
|
|
1975
|
+
// Auto eligibility uses the same plan snapshot as the later gate,
|
|
1976
|
+
// before choosing a device lease. Explicit selections remain strict.
|
|
1977
|
+
if (preference.effective === "auto")
|
|
1978
|
+
deps = { ...deps, entitlements: deps.entitlements ?? await getEntitlements() };
|
|
1979
|
+
const selectionArgs = preference.effective === "auto"
|
|
1980
|
+
? { ...args, model_ceiling: clampCeiling(args.model_ceiling ?? "27b", deps.entitlements.model_ceiling) } : args;
|
|
1981
|
+
const backend = resolveLocalCodingBackend(selectionArgs, preference);
|
|
1982
|
+
const model = resolveLocalCodingModel(preference, backend, args.local_role ?? "coder", args.local_model);
|
|
1983
|
+
args = { ...args, local_backend: backend,
|
|
1984
|
+
...(model ? { local_model: model } : {}) };
|
|
1985
|
+
}
|
|
1986
|
+
if (args.local_role) {
|
|
1987
|
+
const defaults = CODING_ROLE_DEFAULTS[args.local_backend === "omlx" ? "omlx" : "ollama"];
|
|
1988
|
+
args = { ...args, max_tokens: args.max_tokens ?? defaults.maxTokens, timeout_ms: args.timeout_ms ?? defaults.timeoutMs };
|
|
1989
|
+
}
|
|
1931
1990
|
return withInferenceResourceLease(args.local_backend === "omlx", async () => {
|
|
1932
1991
|
let releaseDevice;
|
|
1933
1992
|
const runtime = args.local_backend === "omlx" ? deps.omlx : undefined;
|
|
@@ -2238,30 +2297,36 @@ async function runInferAfterInputSafety(args, deps, t0, temperature) {
|
|
|
2238
2297
|
}
|
|
2239
2298
|
attempts.push({ tier: "omlx", reason: "authenticated_model_detected" });
|
|
2240
2299
|
}
|
|
2241
|
-
const installed = await deps.listTags();
|
|
2242
|
-
const loaded = await deps.listLoaded();
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2300
|
+
const installed = omlxTarget ? new Set() : await deps.listTags();
|
|
2301
|
+
const loaded = omlxTarget ? new Set() : await deps.listLoaded();
|
|
2302
|
+
let selectedOllama;
|
|
2303
|
+
if (!omlxTarget && args.local_model) {
|
|
2304
|
+
if (mode !== "code" || args.images?.length)
|
|
2305
|
+
throw new Error("prism_infer: selected models require text-only code mode");
|
|
2306
|
+
try {
|
|
2307
|
+
if (installed === null)
|
|
2308
|
+
throw new Error("ollama_model_inventory_unavailable");
|
|
2309
|
+
if (!installed.has(args.local_model))
|
|
2310
|
+
throw new Error("ollama_model_not_installed");
|
|
2311
|
+
selectedOllama = await (deps.selectedOllamaModel ?? selectedOllamaCodingModel)(deps.ollamaUrl, args.local_model, clampCeiling(args.model_ceiling ?? "27b", ent.model_ceiling));
|
|
2312
|
+
}
|
|
2313
|
+
catch (error) {
|
|
2314
|
+
const reason = error instanceof Error && /^ollama_model_[a-z_]+$/.test(error.message) ? error.message : "ollama_model_metadata_unavailable";
|
|
2315
|
+
attempts.push({ tier: args.local_model, reason });
|
|
2316
|
+
if (wantReport)
|
|
2317
|
+
return refusedResult(reason);
|
|
2318
|
+
throw new Error(`prism_infer: ${reason}; selected coding model will not be substituted`);
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
// oMLX classification and generation share one bounded deadline.
|
|
2247
2322
|
const omlxDeadline = Date.now() + (args.timeout_ms ?? 300_000);
|
|
2248
|
-
//
|
|
2249
|
-
//
|
|
2250
|
-
// Under the device lease, before classification: unload every resident
|
|
2251
|
-
// model reversibly (the weights stay on disk and reload on the next Ollama
|
|
2252
|
-
// call) and wait until Ollama reports none. A failed unload, a model that
|
|
2253
|
-
// does not drain, or a newcomer loading meanwhile refuses. After this, a
|
|
2254
|
-
// model that appears is refused per read by guardedOmlxGenerate.
|
|
2323
|
+
// Observe contention without starting Ollama or evicting another job.
|
|
2324
|
+
// Guarded reads keep monitoring for newcomers throughout generation.
|
|
2255
2325
|
if (omlxTarget && omlxRuntime) {
|
|
2256
2326
|
let residency = null;
|
|
2257
2327
|
try {
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
await omlxRuntime.unload(model);
|
|
2261
|
-
if (initial.size > 0) {
|
|
2262
|
-
await waitForOllamaUnloadDrain(() => omlxRuntime.listLoaded(), initial);
|
|
2263
|
-
attempts.push({ tier: "omlx", reason: `omlx_ollama_unloaded:${initial.size}` });
|
|
2264
|
-
}
|
|
2328
|
+
if ((await omlxRuntime.listLoaded()).size > 0)
|
|
2329
|
+
throw new OmlxError("omlx_memory_contention");
|
|
2265
2330
|
}
|
|
2266
2331
|
catch (error) {
|
|
2267
2332
|
residency = error instanceof OmlxError ? error.reason : "omlx_ollama_state_unavailable";
|
|
@@ -2731,7 +2796,8 @@ async function runInferAfterInputSafety(args, deps, t0, temperature) {
|
|
|
2731
2796
|
...(history ?? []), { role: "user", content: prompt }];
|
|
2732
2797
|
return guardedOmlxGenerate(omlxRuntime, omlxTarget, { messages, maxTokens, temperature: args.temperature, timeoutMs: Math.min(timeoutMs, omlxDeadline - Date.now()), think }, deps.freemem);
|
|
2733
2798
|
}
|
|
2734
|
-
: deps.callLocal
|
|
2799
|
+
: selectedOllama ? (url, model, prompt, system, maxTokens, temperature, timeoutMs, think, images, history) => deps.callLocal(url, model, prompt, system, maxTokens, temperature, timeoutMs, think, images, history, selectedOllama.ctxTokens)
|
|
2800
|
+
: deps.callLocal;
|
|
2735
2801
|
// A second read ran the 9b after free RAM and the warm set were read
|
|
2736
2802
|
// above (and may have loaded it, or found it unloaded since); eviction and
|
|
2737
2803
|
// the tier RAM gate below must see the machine as it is now. Every other
|
|
@@ -2749,20 +2815,20 @@ async function runInferAfterInputSafety(args, deps, t0, temperature) {
|
|
|
2749
2815
|
// A local answer to a conversation the answer check rejected or did not check: never served as answer text.
|
|
2750
2816
|
let withheldReason = null;
|
|
2751
2817
|
if (installed) {
|
|
2752
|
-
const servingTiers = omlxTarget ? [{ tag: omlxTarget.model, weightsGb: 0, minFreeGb: OMLX_MIN_AVAILABLE_BYTES / 1024 ** 3, ctxTokens: omlxTarget.contextTokens }] : MODEL_TIERS;
|
|
2818
|
+
const servingTiers = omlxTarget ? [{ tag: omlxTarget.model, weightsGb: 0, minFreeGb: OMLX_MIN_AVAILABLE_BYTES / 1024 ** 3, ctxTokens: omlxTarget.contextTokens }] : selectedOllama ? [selectedOllama] : MODEL_TIERS;
|
|
2753
2819
|
// F4 fix: guard ceiling-not-found — Math.max(0,-1) silently targets tier 0 (27b).
|
|
2754
2820
|
// Instead of defaulting to the largest tier, treat not-found as "no ceiling" (start=0).
|
|
2755
2821
|
const ceilIdx = effectiveCeiling
|
|
2756
2822
|
? MODEL_TIERS.findIndex(t => t.tag.endsWith(`:${effectiveCeiling}`))
|
|
2757
2823
|
: -1;
|
|
2758
|
-
const ceilStart = omlxTarget ? 0 : ceilIdx >= 0 ? ceilIdx : 0;
|
|
2824
|
+
const ceilStart = omlxTarget || selectedOllama ? 0 : ceilIdx >= 0 ? ceilIdx : 0;
|
|
2759
2825
|
// Auto-evict: if the ceiling tier is installed but not warm and prism's
|
|
2760
2826
|
// own smaller tier models are warm, unload them to make room.
|
|
2761
2827
|
// Operates only on prism tier models — never evicts arbitrary Ollama models
|
|
2762
2828
|
// the caller doesn't own (F1). Uses an in-process mutex to prevent a
|
|
2763
2829
|
// concurrent request from evicting a model mid-inference (F3).
|
|
2764
|
-
let freeAfterEvict = omlxTarget ? deps.freemem() : genFreeBytes;
|
|
2765
|
-
if (!omlxTarget && genLoaded && genLoaded.size > 0) {
|
|
2830
|
+
let freeAfterEvict = omlxTarget || selectedOllama ? deps.freemem() : genFreeBytes;
|
|
2831
|
+
if (!omlxTarget && !selectedOllama && genLoaded && genLoaded.size > 0) {
|
|
2766
2832
|
const ceilTier = MODEL_TIERS[ceilIdx >= 0 ? ceilIdx : 0];
|
|
2767
2833
|
const ceilName = ceilTier ? resolveOllamaName(ceilTier.tag, installed) : null;
|
|
2768
2834
|
const ceilInstalled = ceilName ? installed.has(ceilName) : false;
|
|
@@ -2908,14 +2974,14 @@ async function runInferAfterInputSafety(args, deps, t0, temperature) {
|
|
|
2908
2974
|
// or namespaced (`dcostenco/prism-coder:27b`, the form `ollama pull`
|
|
2909
2975
|
// produces from a HF repo). resolveOllamaName returns the actual
|
|
2910
2976
|
// name Ollama knows so /api/generate finds the model.
|
|
2911
|
-
const ollamaName = omlxTarget ? omlxTarget.model : resolveOllamaName(tier.tag, installed);
|
|
2977
|
+
const ollamaName = omlxTarget ? omlxTarget.model : selectedOllama ? selectedOllama.tag : resolveOllamaName(tier.tag, installed);
|
|
2912
2978
|
if (!omlxTarget && !installed.has(ollamaName)) {
|
|
2913
2979
|
attempts.push({ tier: tier.tag, reason: "not_pulled" });
|
|
2914
2980
|
continue;
|
|
2915
2981
|
}
|
|
2916
2982
|
// RAM gate — but skip the check if the tier is already warm in
|
|
2917
2983
|
// Ollama. Reused models don't reallocate weight buffers.
|
|
2918
|
-
const isWarm = genLoaded.has(ollamaName);
|
|
2984
|
+
const isWarm = !selectedOllama && genLoaded.has(ollamaName);
|
|
2919
2985
|
if (!isWarm && freeAfterEvict < tier.minFreeGb * (1024 ** 3)) {
|
|
2920
2986
|
attempts.push({ tier: tier.tag, reason: "ram_insufficient" });
|
|
2921
2987
|
continue;
|
|
@@ -2946,7 +3012,7 @@ async function runInferAfterInputSafety(args, deps, t0, temperature) {
|
|
|
2946
3012
|
// unprobeable means fall back to the table, never unlock.
|
|
2947
3013
|
let liveCtx = null;
|
|
2948
3014
|
try {
|
|
2949
|
-
liveCtx = omlxTarget ? omlxTarget.contextTokens : await (deps.probeNumCtx ?? probeNumCtx)(deps.ollamaUrl, ollamaName);
|
|
3015
|
+
liveCtx = omlxTarget ? omlxTarget.contextTokens : selectedOllama ? selectedOllama.ctxTokens : await (deps.probeNumCtx ?? probeNumCtx)(deps.ollamaUrl, ollamaName);
|
|
2950
3016
|
}
|
|
2951
3017
|
catch {
|
|
2952
3018
|
liveCtx = null;
|
|
@@ -2966,7 +3032,7 @@ async function runInferAfterInputSafety(args, deps, t0, temperature) {
|
|
|
2966
3032
|
const MAX_TEMPLATE_OVERHEAD = 1_300;
|
|
2967
3033
|
let CTX_TEMPLATE_MARGIN = 64;
|
|
2968
3034
|
if (!omlxTarget && promptBodyEst + MAX_TEMPLATE_OVERHEAD > effectiveCtx) {
|
|
2969
|
-
const probed = await (deps.probeTemplateOverhead ?? probeTemplateOverhead)(deps.ollamaUrl, ollamaName, effectiveSystem != null);
|
|
3035
|
+
const probed = await (deps.probeTemplateOverhead ?? probeTemplateOverhead)(deps.ollamaUrl, ollamaName, effectiveSystem != null, ...(selectedOllama ? [selectedOllama.ctxTokens] : []));
|
|
2970
3036
|
if (probed != null)
|
|
2971
3037
|
CTX_TEMPLATE_MARGIN = probed;
|
|
2972
3038
|
}
|
|
@@ -3248,9 +3314,9 @@ async function runInferAfterInputSafety(args, deps, t0, temperature) {
|
|
|
3248
3314
|
// overhead, the reasoning budget and the one follow-up turn: a
|
|
3249
3315
|
// request Ollama truncates would let the check pass an answer
|
|
3250
3316
|
// against a conversation it only partly read.
|
|
3251
|
-
const runtimeCtx = omlxTarget ? omlxTarget.contextTokens : await (deps.probeLoadedContext ?? probeLoadedContext)(deps.ollamaUrl, ollamaName).catch(() => null);
|
|
3317
|
+
const runtimeCtx = omlxTarget ? omlxTarget.contextTokens : selectedOllama ? selectedOllama.ctxTokens : await (deps.probeLoadedContext ?? probeLoadedContext)(deps.ollamaUrl, ollamaName).catch(() => null);
|
|
3252
3318
|
const checkCtx = runtimeCtx ?? effectiveCtx;
|
|
3253
|
-
const overhead = omlxTarget ? MAX_TEMPLATE_OVERHEAD : (await Promise.resolve((deps.probeTemplateOverhead ?? probeTemplateOverhead)(deps.ollamaUrl, ollamaName, true)).catch(() => null)) ?? MAX_TEMPLATE_OVERHEAD;
|
|
3319
|
+
const overhead = omlxTarget ? MAX_TEMPLATE_OVERHEAD : (await Promise.resolve((deps.probeTemplateOverhead ?? probeTemplateOverhead)(deps.ollamaUrl, ollamaName, true, ...(selectedOllama ? [selectedOllama.ctxTokens] : []))).catch(() => null)) ?? MAX_TEMPLATE_OVERHEAD;
|
|
3254
3320
|
const need = answerGroundingBytes(policy, args.messages ?? [], args.prompt, output)
|
|
3255
3321
|
+ estimateImageTokens(resolvedImages?.length ?? 0) + overhead
|
|
3256
3322
|
+ (ANSWER_GROUNDING_THINK ? ANSWER_GROUNDING_THINK_TOKENS : ANSWER_GROUNDING_OUTPUT_TOKENS)
|
|
@@ -3547,6 +3613,7 @@ async function applyVerification(draft, args, deps, partial) {
|
|
|
3547
3613
|
*/
|
|
3548
3614
|
export function productionInferDeps() {
|
|
3549
3615
|
return {
|
|
3616
|
+
codingPreference: localCodingPreference,
|
|
3550
3617
|
omlx: createOmlxRuntime({
|
|
3551
3618
|
listLoaded: () => strictLoaded(PRISM_LOCAL_LLM_URL),
|
|
3552
3619
|
unload: model => unloadOllamaModel(PRISM_LOCAL_LLM_URL, model),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { configuredOmlxModel } from "./omlxProvider.js";
|
|
2
|
+
import { isLocalCodingBackend, readLocalCodingSettings, resolveLocalCodingModel } from "./localCodingSettings.js";
|
|
3
|
+
export { LOCAL_CODING_BACKENDS, isLocalCodingBackend } from "./localCodingSettings.js";
|
|
4
|
+
/** Read on each task so a dashboard save applies without restarting a host. */
|
|
5
|
+
export async function localCodingPreference(role = "coder", explicitBackend) {
|
|
6
|
+
const settings = await readLocalCodingSettings(), stored = settings.backend;
|
|
7
|
+
const override = explicitBackend ? undefined : process.env.PRISM_LOCAL_CODING_BACKEND;
|
|
8
|
+
if (override !== undefined && !isLocalCodingBackend(override))
|
|
9
|
+
throw new Error("local_coding_override_invalid");
|
|
10
|
+
const model = resolveLocalCodingModel(settings, "omlx", "coder");
|
|
11
|
+
const effective = explicitBackend ?? override ?? stored;
|
|
12
|
+
// A broken configured server is an error, not permission to change backend.
|
|
13
|
+
const configured = effective === "auto" ? Boolean(await configuredOmlxModel(resolveLocalCodingModel(settings, "omlx", role))) : false;
|
|
14
|
+
return { saved: stored, effective, envOverride: override ?? null, model: model || undefined, models: settings.models, configured };
|
|
15
|
+
}
|
|
16
|
+
export function resolveLocalCodingBackend(args, preference) {
|
|
17
|
+
if (args.local_backend)
|
|
18
|
+
return args.local_backend;
|
|
19
|
+
if (args.mode !== "code" || args.images?.length)
|
|
20
|
+
return "ollama";
|
|
21
|
+
if (preference.effective === "auto" && args.model_ceiling && args.model_ceiling !== "27b")
|
|
22
|
+
return "ollama";
|
|
23
|
+
return preference.effective === "omlx" || (preference.effective === "auto" && preference.configured) ? "omlx" : "ollama";
|
|
24
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { getSetting, setSetting } from "../storage/configStorage.js";
|
|
2
|
+
export const LOCAL_CODING_BACKENDS = ["auto", "omlx", "ollama"];
|
|
3
|
+
export const LOCAL_CODING_ROLES = ["coder", "reviewer"];
|
|
4
|
+
// Older Prism versions reject unknown keys. A single versioned record keeps
|
|
5
|
+
// new saves atomic without corrupting their legacy config or rollback state.
|
|
6
|
+
const KEY = "local_coding_config_v2";
|
|
7
|
+
const LEGACY_KEY = "local_coding_config";
|
|
8
|
+
export function isLocalCodingModelId(value) {
|
|
9
|
+
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value);
|
|
10
|
+
}
|
|
11
|
+
function record(value) {
|
|
12
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
13
|
+
throw new Error("local_coding_preference_invalid");
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
function parseModels(value) {
|
|
17
|
+
const input = record(value), models = {};
|
|
18
|
+
for (const backend of Object.keys(input)) {
|
|
19
|
+
if (backend !== "omlx" && backend !== "ollama")
|
|
20
|
+
throw new Error("local_coding_preference_invalid");
|
|
21
|
+
const roles = record(input[backend]);
|
|
22
|
+
models[backend] = {};
|
|
23
|
+
for (const role of Object.keys(roles)) {
|
|
24
|
+
if ((role !== "coder" && role !== "reviewer") || !isLocalCodingModelId(roles[role]))
|
|
25
|
+
throw new Error("local_coding_preference_invalid");
|
|
26
|
+
models[backend][role] = roles[role];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return models;
|
|
30
|
+
}
|
|
31
|
+
export function isLocalCodingBackend(value) {
|
|
32
|
+
return LOCAL_CODING_BACKENDS.some(backend => backend === value);
|
|
33
|
+
}
|
|
34
|
+
export function parseLocalCodingSettings(value) {
|
|
35
|
+
const input = record(value);
|
|
36
|
+
if (!Object.hasOwn(input, "backend") || !Object.hasOwn(input, "source")
|
|
37
|
+
|| Object.keys(input).some(key => !["backend", "source", "model", "models"].includes(key))
|
|
38
|
+
|| !isLocalCodingBackend(input.backend) || (input.source !== "existing" && input.source !== "managed")
|
|
39
|
+
|| (Object.hasOwn(input, "model") && !isLocalCodingModelId(input.model)))
|
|
40
|
+
throw new Error("local_coding_preference_invalid");
|
|
41
|
+
return { backend: input.backend, source: input.source,
|
|
42
|
+
...(Object.hasOwn(input, "model") ? { model: input.model } : {}),
|
|
43
|
+
...(Object.hasOwn(input, "models") ? { models: parseModels(input.models) } : {}) };
|
|
44
|
+
}
|
|
45
|
+
/** Exact assignments: sharing an ID is allowed, never inferred across roles. */
|
|
46
|
+
export function resolveLocalCodingModel(settings, backend, role, explicitModel) {
|
|
47
|
+
return explicitModel ?? settings.models?.[backend]?.[role] ?? (backend === "omlx" && role === "coder" ? settings.model : undefined);
|
|
48
|
+
}
|
|
49
|
+
export async function readLocalCodingSettings() {
|
|
50
|
+
const raw = await getSetting(KEY, "", { fresh: true }) || await getSetting(LEGACY_KEY, "", { fresh: true });
|
|
51
|
+
return raw ? parseLocalCodingSettings(JSON.parse(raw)) : { backend: "auto", source: "existing" };
|
|
52
|
+
}
|
|
53
|
+
export async function saveLocalCodingSettings(value) {
|
|
54
|
+
await setSetting(KEY, JSON.stringify(parseLocalCodingSettings(value)));
|
|
55
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { PRISM_LOCAL_LLM_URL } from "../config.js";
|
|
2
|
+
import { isLocalCodingModelId } from "./localCodingSettings.js";
|
|
3
|
+
const GIB = 1024 ** 3;
|
|
4
|
+
const MAX_MODELS = 1024;
|
|
5
|
+
const MAX_METADATA_BYTES = 1024 * 1024;
|
|
6
|
+
const CODING_CONTEXT_LIMIT = 4096;
|
|
7
|
+
const positive = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
8
|
+
/** Read-only metadata probes; never pulls or loads a model. */
|
|
9
|
+
async function metadata(url, fetchImpl, model) {
|
|
10
|
+
const response = await fetchImpl(url, { ...(model === undefined ? {} : {
|
|
11
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model }),
|
|
12
|
+
}), redirect: "error", signal: AbortSignal.timeout(3000) });
|
|
13
|
+
if (!response.ok || !response.body)
|
|
14
|
+
throw new Error("ollama_model_metadata_unavailable");
|
|
15
|
+
const reader = response.body.getReader();
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let size = 0;
|
|
18
|
+
try {
|
|
19
|
+
for (;;) {
|
|
20
|
+
const part = await reader.read();
|
|
21
|
+
if (part.done)
|
|
22
|
+
break;
|
|
23
|
+
size += part.value.byteLength;
|
|
24
|
+
if (size > MAX_METADATA_BYTES)
|
|
25
|
+
throw new Error("ollama_model_metadata_invalid");
|
|
26
|
+
chunks.push(part.value);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
void reader.cancel().catch(() => { });
|
|
31
|
+
}
|
|
32
|
+
const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
33
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
34
|
+
throw new Error("ollama_model_metadata_invalid");
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
export async function inspectOllamaCodingModels(url = PRISM_LOCAL_LLM_URL, fetchImpl = fetch) {
|
|
38
|
+
const [tags, ps] = await Promise.all([metadata(`${url}/api/tags`, fetchImpl), metadata(`${url}/api/ps`, fetchImpl)]);
|
|
39
|
+
if (!Array.isArray(tags.models) || tags.models.length > MAX_MODELS || !Array.isArray(ps.models) || ps.models.length > MAX_MODELS)
|
|
40
|
+
throw new Error("ollama_model_inventory_invalid");
|
|
41
|
+
const ids = new Set();
|
|
42
|
+
return tags.models.map((model) => {
|
|
43
|
+
if (!model || !isLocalCodingModelId(model.name) || ids.has(model.name))
|
|
44
|
+
throw new Error("ollama_model_inventory_invalid");
|
|
45
|
+
ids.add(model.name);
|
|
46
|
+
const loaded = ps.models.find((entry) => entry && (entry.name === model.name || entry.model === model.name));
|
|
47
|
+
return { id: model.name, loaded: Boolean(loaded),
|
|
48
|
+
...(positive(model.size) ? { weightBytes: model.size } : {}),
|
|
49
|
+
...(positive(loaded?.size_vram) ? { loadedBytes: loaded.size_vram } : {}),
|
|
50
|
+
...(positive(loaded?.context_length) ? { contextTokens: loaded.context_length } : {}) };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** A selected model gets one exact attempt, not a walk through named tiers.
|
|
54
|
+
* Sizes plus overhead are an estimate, not a guarantee about peak RAM. */
|
|
55
|
+
export async function selectedOllamaCodingModel(url, id, ceiling, fetchImpl = fetch) {
|
|
56
|
+
if (!isLocalCodingModelId(id))
|
|
57
|
+
throw new Error("ollama_model_id_invalid");
|
|
58
|
+
const models = await inspectOllamaCodingModels(url, fetchImpl);
|
|
59
|
+
const selected = models.find(model => model.id === id);
|
|
60
|
+
if (!selected)
|
|
61
|
+
throw new Error("ollama_model_not_installed");
|
|
62
|
+
const show = await metadata(`${url}/api/show`, fetchImpl, id);
|
|
63
|
+
const info = show.model_info;
|
|
64
|
+
const params = info?.["general.parameter_count"];
|
|
65
|
+
const architecture = info?.["general.architecture"];
|
|
66
|
+
const nativeContext = typeof architecture === "string" ? info?.[`${architecture}.context_length`] : undefined;
|
|
67
|
+
if (!selected.weightBytes || !positive(params) || !positive(nativeContext)
|
|
68
|
+
|| !Array.isArray(show.capabilities) || !show.capabilities.includes("completion"))
|
|
69
|
+
throw new Error("ollama_model_metadata_incomplete");
|
|
70
|
+
// Entitlement tiers name nominal billions, not exact tensor counts.
|
|
71
|
+
// Rounding admits e.g. 27.4B as 27B without making the top tier unlimited.
|
|
72
|
+
const maxBillions = /^(2|4|9|27)b$/.test(ceiling) ? Number(ceiling.slice(0, -1)) : 0;
|
|
73
|
+
if (!maxBillions || Math.round(params / 1e9) > maxBillions)
|
|
74
|
+
throw new Error("ollama_model_ceiling");
|
|
75
|
+
const ctxTokens = Math.min(nativeContext, CODING_CONTEXT_LIMIT);
|
|
76
|
+
const layers = info[`${architecture}.block_count`], heads = info[`${architecture}.attention.head_count_kv`];
|
|
77
|
+
const embedding = info[`${architecture}.embedding_length`], attentionHeads = info[`${architecture}.attention.head_count`];
|
|
78
|
+
if (![layers, heads, embedding, attentionHeads].every(positive))
|
|
79
|
+
throw new Error("ollama_model_memory_unknown");
|
|
80
|
+
const rawKeyLength = info[`${architecture}.attention.key_length`], rawValueLength = info[`${architecture}.attention.value_length`];
|
|
81
|
+
if ((rawKeyLength !== undefined && !positive(rawKeyLength)) || (rawValueLength !== undefined && !positive(rawValueLength)))
|
|
82
|
+
throw new Error("ollama_model_memory_unknown");
|
|
83
|
+
// Some architectures declare independent K/V dimensions. Never shrink the
|
|
84
|
+
// fp16 estimate below either the declared or the derived head dimension.
|
|
85
|
+
const derivedHead = embedding / attentionHeads;
|
|
86
|
+
const keyLength = Math.max(derivedHead, rawKeyLength ?? derivedHead), valueLength = Math.max(derivedHead, rawValueLength ?? derivedHead);
|
|
87
|
+
const kvBytes = layers * heads * (keyLength + valueLength) * ctxTokens * 2;
|
|
88
|
+
const requiredBytes = Math.max(selected.weightBytes * 1.5, selected.weightBytes + kvBytes + 2 * GIB);
|
|
89
|
+
if (!Number.isFinite(requiredBytes) || requiredBytes <= 0)
|
|
90
|
+
throw new Error("ollama_model_memory_unknown");
|
|
91
|
+
return { tag: id, weightsGb: selected.weightBytes / GIB, minFreeGb: requiredBytes / GIB, ctxTokens,
|
|
92
|
+
prefersThinking: show.capabilities.includes("thinking") };
|
|
93
|
+
}
|