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.
@@ -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 (Ollama by default). Explicit local_backend=omlx uses authenticated " +
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: "Max output tokens (default 1024, hard cap 8192).",
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: "Default Ollama; explicit oMLX is text-only code mode, no backend substitution.",
674
+ description: "Override coding backend; oMLX is text-only. No substitution.",
668
675
  },
669
676
  local_model: {
670
677
  type: "string",
671
- description: "Explicit oMLX only: exact advertised model ID; otherwise use trusted local configuration.",
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 workload hint prism_infer (not the task router) uses to pick the initial " +
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: "Project whose dashboard-depth handoff/recent session memory is historical worker context.",
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: "Timeout override. Ollama: 27B 120s, 9B 60s, 4B 20s, 2B 15s. oMLX generation/check budget: 300s.",
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.local_model !== undefined && (a.local_backend !== "omlx" || typeof a.local_model !== "string" || !a.local_model.trim()))
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
- async function strictLoaded(url) {
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
- if (omlxTarget && installed === null)
2244
- throw new Error("prism_infer: omlx_classifier_unavailable");
2245
- // Explicit oMLX: timeout_ms bounds the whole call, the Ollama unload and
2246
- // classification included.
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
- // Ollama is unloaded during an explicit oMLX call (owner, 2026-10-15; this
2249
- // replaces plan section 4's "never evict another Ollama user's weights").
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
- const initial = new Set(await omlxRuntime.listLoaded());
2259
- for (const model of initial)
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
+ }