@zenithfoundry/slm-gate 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +669 -0
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/configs/antigravity/.env.16gb.example +674 -0
- package/configs/antigravity/.env.24gb.example +674 -0
- package/configs/antigravity/.env.32gb.example +674 -0
- package/configs/antigravity/README.md +109 -0
- package/configs/claude-code/.env.16gb.example +674 -0
- package/configs/claude-code/.env.24gb.example +674 -0
- package/configs/claude-code/.env.32gb.example +674 -0
- package/configs/claude-code/README.md +52 -0
- package/configs/claude-desktop/.env.16gb.example +674 -0
- package/configs/claude-desktop/.env.24gb.example +674 -0
- package/configs/claude-desktop/.env.32gb.example +674 -0
- package/configs/claude-desktop/README.md +37 -0
- package/configs/cline-continue-opencode/.env.16gb.example +674 -0
- package/configs/cline-continue-opencode/.env.24gb.example +674 -0
- package/configs/cline-continue-opencode/.env.32gb.example +674 -0
- package/configs/cline-continue-opencode/README.md +34 -0
- package/configs/cursor/.env.16gb.example +674 -0
- package/configs/cursor/.env.24gb.example +674 -0
- package/configs/cursor/.env.32gb.example +674 -0
- package/configs/cursor/README.md +26 -0
- package/configs/generic-http/.env.16gb.example +674 -0
- package/configs/generic-http/.env.24gb.example +674 -0
- package/configs/generic-http/.env.32gb.example +674 -0
- package/configs/generic-http/README.md +20 -0
- package/configs/generic-stdio/.env.16gb.example +674 -0
- package/configs/generic-stdio/.env.24gb.example +674 -0
- package/configs/generic-stdio/.env.32gb.example +674 -0
- package/configs/generic-stdio/README.md +24 -0
- package/configs/preserve/README.md +26 -0
- package/configs/preserve/tls.json +61 -0
- package/dist/adapters/tech-lead-stack.js +38 -0
- package/dist/cache/index.js +173 -0
- package/dist/cli.js +256 -0
- package/dist/config.js +255 -0
- package/dist/dashboard/data.js +149 -0
- package/dist/dashboard/export.js +42 -0
- package/dist/dashboard/serve.js +63 -0
- package/dist/doctor.js +338 -0
- package/dist/hardware.js +126 -0
- package/dist/home-dir.js +39 -0
- package/dist/ledger/flush-lifecycle.js +50 -0
- package/dist/ledger/index.js +946 -0
- package/dist/ledger/report.js +69 -0
- package/dist/ledger/setup-dashboard.js +456 -0
- package/dist/ledger/smoke.js +37 -0
- package/dist/ledger/sync-config.js +177 -0
- package/dist/ledger/sync.js +307 -0
- package/dist/ledger/verify.js +185 -0
- package/dist/ledger/wipe-langfuse.js +130 -0
- package/dist/llm-gate/distill.js +239 -0
- package/dist/llm-gate/formats/anthropic.js +185 -0
- package/dist/llm-gate/formats/chat-completions.js +103 -0
- package/dist/llm-gate/formats/contract.js +29 -0
- package/dist/llm-gate/formats/gemini.js +84 -0
- package/dist/llm-gate/formats/internal.js +1 -0
- package/dist/llm-gate/formats/openai.js +77 -0
- package/dist/llm-gate/formats/responses.js +146 -0
- package/dist/llm-gate/forward.js +150 -0
- package/dist/llm-gate/index.js +40 -0
- package/dist/llm-gate/local-first.js +217 -0
- package/dist/llm-gate/pipeline.js +267 -0
- package/dist/llm-gate/server.js +289 -0
- package/dist/mcp-gate/ground.js +64 -0
- package/dist/mcp-gate/index.js +57 -0
- package/dist/mcp-gate/pipeline.js +252 -0
- package/dist/mcp-gate/server.js +302 -0
- package/dist/mcp-gate/tool-names.js +57 -0
- package/dist/models/check.js +26 -0
- package/dist/models/footprint.js +137 -0
- package/dist/models/helpers.js +91 -0
- package/dist/models/index.js +5 -0
- package/dist/models/reasoning.js +91 -0
- package/dist/models/roles.js +9 -0
- package/dist/models/slm.js +243 -0
- package/dist/models/types.js +1 -0
- package/dist/pricing/index.js +115 -0
- package/dist/pricing/plans.js +54 -0
- package/dist/pricing/providers.js +172 -0
- package/dist/resolver/index.js +277 -0
- package/dist/resolver/types.js +1 -0
- package/dist/setup/claim.js +41 -0
- package/dist/setup/gate-command.js +41 -0
- package/dist/setup/init.js +92 -0
- package/dist/setup/local-models.js +123 -0
- package/dist/setup/model-gate.js +220 -0
- package/dist/setup/notify.js +45 -0
- package/dist/setup/ollama-install.js +53 -0
- package/dist/setup/parent-watch.js +84 -0
- package/dist/setup/required-models.js +20 -0
- package/dist/setup/startup.js +132 -0
- package/dist/setup/tool-settings.js +101 -0
- package/dist/utils/backoff.js +47 -0
- package/dist/utils/compression.js +145 -0
- package/dist/utils/constants.js +22 -0
- package/dist/utils/duration.js +43 -0
- package/dist/utils/elision.js +556 -0
- package/dist/utils/embedding.js +32 -0
- package/dist/utils/entry-point.js +23 -0
- package/dist/utils/local-only.js +82 -0
- package/dist/utils/preserve-patterns.js +115 -0
- package/dist/utils/safety.js +30 -0
- package/dist/verifier/index.js +67 -0
- package/package.json +121 -0
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight Check (Doctor) Script
|
|
3
|
+
*
|
|
4
|
+
* Why it is written this way:
|
|
5
|
+
* 1. Fail-Fast Diagnostics: Instead of crashing deep within the application logic when a required
|
|
6
|
+
* resource is missing (e.g., Ollama is down, port is bound), the `doctor` command acts as a
|
|
7
|
+
* proactive health-check.
|
|
8
|
+
* 2. Actionable Feedback: Every check strictly reports a human-readable success/failure message
|
|
9
|
+
* alongside an actionable `Fix` instruction.
|
|
10
|
+
* 3. Graceful Network Tolerance: It uses non-throwing network checks (catching fetch/net errors)
|
|
11
|
+
* so the diagnostic tool itself doesn't crash if the environment is heavily misconfigured.
|
|
12
|
+
*/
|
|
13
|
+
import Database from 'better-sqlite3';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { CONFIG } from './config.js';
|
|
17
|
+
import { detectHardware, recommendPreset, recommendNumCtx, getPresetRank, MIN_RAM_GB, modelsForRam, ramPresets } from './hardware.js';
|
|
18
|
+
import { isPackageInstall } from './home-dir.js';
|
|
19
|
+
import { getModelsFootprint } from './models/footprint.js';
|
|
20
|
+
import { getProviderRegistry } from './pricing/providers.js';
|
|
21
|
+
import { checkLocalModels } from './setup/local-models.js';
|
|
22
|
+
import { cliCommand, GATE_LOG_FILE, isStoppedByUser, portOwner, probeGate } from './setup/model-gate.js';
|
|
23
|
+
import { findStrandedServers } from './setup/parent-watch.js';
|
|
24
|
+
import { toolSettings, UNROUTABLE_TOOLS } from './setup/tool-settings.js';
|
|
25
|
+
import { listenOnThisComputer } from './utils/local-only.js';
|
|
26
|
+
/**
|
|
27
|
+
* Checks whether slm-gate's HTTP server could start on a port, by making exactly the binds it makes
|
|
28
|
+
* (127.0.0.1 and ::1) and closing them again. A plain `listen(port)` would bind every interface, which
|
|
29
|
+
* macOS allows next to a server bound to 127.0.0.1 only, so a busy port would look free.
|
|
30
|
+
*
|
|
31
|
+
* @param port - The network port number to check (e.g., 8788).
|
|
32
|
+
* @returns A promise resolving to true if the port is free, false otherwise.
|
|
33
|
+
*/
|
|
34
|
+
async function checkPortFree(port) {
|
|
35
|
+
try {
|
|
36
|
+
const servers = await listenOnThisComputer({ handler: () => { }, port });
|
|
37
|
+
await Promise.all(servers.map(server => new Promise(resolve => server.close(resolve))));
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Days without a model-gate event before doctor warns that per-prompt savings are not measured. */
|
|
45
|
+
const MODEL_GATE_QUIET_DAYS = 7;
|
|
46
|
+
/**
|
|
47
|
+
* The newest model-gate event in the ledger, benchmark runs excluded (they are not real traffic).
|
|
48
|
+
* Opens the ledger read-only: doctor must not create or migrate it, which getDb() would.
|
|
49
|
+
*
|
|
50
|
+
* @returns The event's timestamp, null when there is none, or the error that stopped the read.
|
|
51
|
+
*/
|
|
52
|
+
function lastModelGateEventTs() {
|
|
53
|
+
if (!fs.existsSync(CONFIG.LEDGER_PATH))
|
|
54
|
+
return { ts: null };
|
|
55
|
+
let db = null;
|
|
56
|
+
try {
|
|
57
|
+
db = new Database(CONFIG.LEDGER_PATH, { readonly: true });
|
|
58
|
+
const row = db.prepare("SELECT MAX(ts) AS ts FROM events WHERE layer = 'llm' AND (environment IS NULL OR environment != 'bench')").get();
|
|
59
|
+
return { ts: row.ts };
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
63
|
+
// A ledger that has never recorded an event has no table yet.
|
|
64
|
+
return /no such table/i.test(message) ? { ts: null } : { ts: null, error: message };
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
db?.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The Langfuse environments the gate's real traffic sits in: every environment in the ledger except
|
|
72
|
+
* the benchmark's, plus the one new events go to. History can sit apart from new traffic — events
|
|
73
|
+
* written before LANGFUSE_ENVIRONMENT defaulted to 'slm-gate' stay in 'default', because Langfuse
|
|
74
|
+
* never moves a score to a new environment.
|
|
75
|
+
*/
|
|
76
|
+
function dashboardEnvironments() {
|
|
77
|
+
const environments = new Set([CONFIG.LANGFUSE_ENVIRONMENT]);
|
|
78
|
+
if (!fs.existsSync(CONFIG.LEDGER_PATH))
|
|
79
|
+
return { environments: [...environments] };
|
|
80
|
+
let db = null;
|
|
81
|
+
try {
|
|
82
|
+
db = new Database(CONFIG.LEDGER_PATH, { readonly: true });
|
|
83
|
+
const rows = db.prepare("SELECT DISTINCT environment FROM events WHERE environment IS NOT NULL AND environment != 'bench'").all();
|
|
84
|
+
for (const row of rows)
|
|
85
|
+
environments.add(row.environment);
|
|
86
|
+
return { environments: [...environments].sort() };
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
+
return /no such table/i.test(message) ? { environments: [...environments] } : { environments: [...environments], error: message };
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
db?.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Main execution flow for the doctor command.
|
|
98
|
+
* Sequentially tests critical dependencies: Hardware capabilities, Node version, Environment vars, Ollama and
|
|
99
|
+
* the configured local models, Downstream MCP config, Ledger write-permissions, and the model gate (running,
|
|
100
|
+
* or what holds its port). Then prints the setting that points each coding tool at the gate.
|
|
101
|
+
*
|
|
102
|
+
* This function will force a process.exit(1) if any issues are detected, preventing the application
|
|
103
|
+
* from starting in a broken state.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* // Usually invoked via CLI
|
|
107
|
+
* // $ node dist/cli.js doctor
|
|
108
|
+
* run().catch(err => console.error(err));
|
|
109
|
+
*/
|
|
110
|
+
async function run() {
|
|
111
|
+
console.log('=== SMALL-LANGUAGE-MODEL-GATE DOCTOR ===\n');
|
|
112
|
+
let issues = 0;
|
|
113
|
+
/**
|
|
114
|
+
* Helper to format and track the result of a single check.
|
|
115
|
+
*/
|
|
116
|
+
function report(success, msg, fix) {
|
|
117
|
+
if (success) {
|
|
118
|
+
console.log(`✓ ${msg}`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
console.log(`✗ ${msg}`);
|
|
122
|
+
if (fix)
|
|
123
|
+
console.log(` Fix: ${fix}`);
|
|
124
|
+
issues++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// 0. Hardware Check
|
|
128
|
+
const hw = detectHardware();
|
|
129
|
+
report(true, `Hardware: ${hw.totalRamGB}GB RAM, ${hw.arch}, ${hw.accelerator} accelerator${hw.unifiedMemory ? ' (Unified Memory)' : ''}`);
|
|
130
|
+
report(hw.totalRamGB >= MIN_RAM_GB, `At least ${MIN_RAM_GB} GB of RAM (found ${hw.totalRamGB} GB)`, `slm-gate needs ${MIN_RAM_GB} GB of RAM or more, to keep both local models loaded next to your other apps.`);
|
|
131
|
+
const recPreset = recommendPreset(hw.totalRamGB);
|
|
132
|
+
const recNumCtx = recommendNumCtx(hw.totalRamGB);
|
|
133
|
+
report(true, `Recommended settings: RAM_PRESET=${recPreset}, NUM_CTX=${recNumCtx}`);
|
|
134
|
+
// Compare the user's specifically configured models against the defaults for their preset
|
|
135
|
+
// This highlights potential mismatches where a user expects the performance of a preset but has overridden the models
|
|
136
|
+
const defaultModelsForPreset = ramPresets[CONFIG.RAM_PRESET] || ramPresets['custom'];
|
|
137
|
+
if (CONFIG.SLM_BRAIN_MODEL !== defaultModelsForPreset.brain || CONFIG.SLM_GATE_MODEL !== defaultModelsForPreset.gate) {
|
|
138
|
+
console.log(` Note: Configured models (${CONFIG.SLM_BRAIN_MODEL} + ${CONFIG.SLM_GATE_MODEL}) differ from preset defaults (${defaultModelsForPreset.brain} + ${defaultModelsForPreset.gate})`);
|
|
139
|
+
}
|
|
140
|
+
let memoryWarning = false;
|
|
141
|
+
// Above 128 GB the recommendation is `custom`, and every preset fits.
|
|
142
|
+
const recRank = recPreset === 'custom' ? Infinity : getPresetRank(recPreset);
|
|
143
|
+
const curRank = getPresetRank(CONFIG.RAM_PRESET);
|
|
144
|
+
if (recRank < curRank) {
|
|
145
|
+
memoryWarning = true;
|
|
146
|
+
}
|
|
147
|
+
if (!memoryWarning && CONFIG.SLM_PROVIDER === 'ollama') {
|
|
148
|
+
try {
|
|
149
|
+
const footprint = await getModelsFootprint([CONFIG.SLM_BRAIN_MODEL, CONFIG.SLM_GATE_MODEL]);
|
|
150
|
+
const totalBytes = Object.values(footprint).reduce((a, b) => a + b, 0);
|
|
151
|
+
let totalGB = totalBytes / (1024 * 1024 * 1024);
|
|
152
|
+
// If footprint is 0 (Ollama down or models not pulled yet), estimate sizes from the model name's parameter count
|
|
153
|
+
if (totalGB === 0) {
|
|
154
|
+
// Heuristic: ~0.7GB per billion parameters (typical for 4-bit/5-bit quants)
|
|
155
|
+
const est = (m) => { const match = m.match(/(\d+(?:\.\d+)?)b/i); return match ? parseFloat(match[1]) * 0.7 : 0; };
|
|
156
|
+
totalGB = est(CONFIG.SLM_BRAIN_MODEL) + est(CONFIG.SLM_GATE_MODEL);
|
|
157
|
+
}
|
|
158
|
+
// Warn if the combined model sizes exceed 70% of total physical RAM
|
|
159
|
+
if (totalGB > hw.totalRamGB * 0.7) {
|
|
160
|
+
memoryWarning = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// ignore
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// If a memory constraint is detected, actively intervene by writing a known-safe fallback configuration
|
|
168
|
+
// The system or user can then choose to load this fallback instead of crashing
|
|
169
|
+
if (memoryWarning) {
|
|
170
|
+
const fallbackModels = modelsForRam(hw.totalRamGB);
|
|
171
|
+
const fallbackConfig = {
|
|
172
|
+
RAM_PRESET: recPreset,
|
|
173
|
+
SLM_BRAIN_MODEL: fallbackModels.brain,
|
|
174
|
+
SLM_GATE_MODEL: fallbackModels.gate,
|
|
175
|
+
NUM_CTX: recNumCtx
|
|
176
|
+
};
|
|
177
|
+
const fallbackPath = path.join(CONFIG.HOME_DIR, '.slm-gate-fallback.json');
|
|
178
|
+
fs.writeFileSync(fallbackPath, JSON.stringify(fallbackConfig, null, 2));
|
|
179
|
+
report(false, `Memory constraint: Current models/preset likely exceed available memory (eviction/thrash risk).`, `A safe fallback config was written to .slm-gate-fallback.json`);
|
|
180
|
+
}
|
|
181
|
+
// 1. Node version check
|
|
182
|
+
// Node 22 is the floor set by package.json `engines`: better-sqlite3 >= 13 (N-API prebuilds)
|
|
183
|
+
// requires it, and it is the oldest release still receiving prebuilt binaries.
|
|
184
|
+
const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
|
|
185
|
+
report(nodeMajor >= 22, `Node version ≥ 22 (found v${process.versions.node})`, 'Upgrade Node.js to v22 or later.');
|
|
186
|
+
// 2. .env presence and CONFIG parsing
|
|
187
|
+
// Validates that the configuration template has been implemented by the user.
|
|
188
|
+
const envPath = path.join(CONFIG.HOME_DIR, '.env');
|
|
189
|
+
const envExists = fs.existsSync(envPath);
|
|
190
|
+
report(envExists, '.env file is present', 'Copy .env.example to .env and configure it.');
|
|
191
|
+
report(true, 'Configuration parses successfully'); // If we reached here without throwing, CONFIG parsed correctly.
|
|
192
|
+
// 3. Ollama and the local models the settings name (the same check the MCP server runs at start-up).
|
|
193
|
+
if (CONFIG.SLM_PROVIDER === 'ollama') {
|
|
194
|
+
const models = await checkLocalModels();
|
|
195
|
+
for (const problem of models.problems)
|
|
196
|
+
report(false, problem.message, problem.fix);
|
|
197
|
+
if (models.problems.length === 0) {
|
|
198
|
+
report(true, `Ollama is running at ${CONFIG.OLLAMA_HOST} and has every local model the settings name`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
report(true, `SLM_PROVIDER is openai; assuming SLM endpoint is reachable.`);
|
|
203
|
+
}
|
|
204
|
+
// No cloud key is checked: the model gate forwards each coding tool's own login. CLOUD_* is read only by
|
|
205
|
+
// the benchmark, the optional resolver cloud tier and an OpenAI-compatible SLM_PROVIDER.
|
|
206
|
+
// 6 & 7. Downstream MCP Configuration & TLS Adapter rules
|
|
207
|
+
// Ensures that if the user explicitly enabled the TLS adapter, the downstream MCP target is physically present on disk.
|
|
208
|
+
if (CONFIG.DOWNSTREAM_MCP) {
|
|
209
|
+
report(true, 'DOWNSTREAM_MCP is configured');
|
|
210
|
+
if (CONFIG.DOWNSTREAM_MCP.command) {
|
|
211
|
+
// For Stdio MCP servers, the first argument is conventionally the target script.
|
|
212
|
+
const cmdArgs = CONFIG.DOWNSTREAM_MCP.args || [];
|
|
213
|
+
const targetFile = cmdArgs[0];
|
|
214
|
+
if (targetFile) {
|
|
215
|
+
const resolvedTarget = path.resolve(targetFile);
|
|
216
|
+
const targetExists = fs.existsSync(resolvedTarget);
|
|
217
|
+
let fixMsg = `Ensure the file exists at ${resolvedTarget}`;
|
|
218
|
+
if (!targetExists && resolvedTarget.includes('tech-lead-stack') && resolvedTarget.includes('mcp-server.mjs')) {
|
|
219
|
+
const tlsDir = resolvedTarget.split('/dist/')[0];
|
|
220
|
+
fixMsg = `build it: cd ${tlsDir} && pnpm run mcp:build`;
|
|
221
|
+
}
|
|
222
|
+
report(targetExists, `DOWNSTREAM_MCP target file exists (${resolvedTarget})`, fixMsg);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
report(!CONFIG.TLS_ADAPTER, 'DOWNSTREAM_MCP is blank (standalone mode)', 'TLS_ADAPTER=on requires DOWNSTREAM_MCP to be set');
|
|
228
|
+
}
|
|
229
|
+
// 8. SQLite Ledger path & permissions
|
|
230
|
+
// MCP hosts (Claude Desktop among them) spawn the gate with a working directory that may not
|
|
231
|
+
// exist. A cwd-relative LEDGER_PATH therefore passes here — doctor runs from the repo — and
|
|
232
|
+
// ENOENTs inside the host, so only an absolute path is accepted.
|
|
233
|
+
if (!path.isAbsolute(CONFIG.LEDGER_PATH)) {
|
|
234
|
+
report(false, `Ledger path is a full path (${CONFIG.LEDGER_PATH})`, `Set LEDGER_PATH to a full path on this machine, e.g. ${path.join(CONFIG.OUTPUT_DIR, 'ledger.sqlite')} — or leave it blank to use that default.`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const ledgerDir = path.dirname(CONFIG.LEDGER_PATH);
|
|
238
|
+
try {
|
|
239
|
+
if (!fs.existsSync(ledgerDir)) {
|
|
240
|
+
fs.mkdirSync(ledgerDir, { recursive: true });
|
|
241
|
+
}
|
|
242
|
+
fs.accessSync(ledgerDir, fs.constants.W_OK);
|
|
243
|
+
report(true, `Ledger path is writable (${CONFIG.LEDGER_PATH})`);
|
|
244
|
+
}
|
|
245
|
+
catch (e) {
|
|
246
|
+
report(false, `Ledger path is not writable (${CONFIG.LEDGER_PATH})`, 'Fix permissions for the output directory.');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// 8b. Window budgets. Without one, that provider's cycle cards never fill in.
|
|
250
|
+
// A note, not a failure: the gate itself works fine without them.
|
|
251
|
+
const cycleCards = { claude: 'Claude', chatgpt: 'ChatGPT', gemini: 'Gemini' };
|
|
252
|
+
const registry = getProviderRegistry();
|
|
253
|
+
for (const [id, label] of Object.entries(cycleCards)) {
|
|
254
|
+
if (!registry[id]?.windowBudget) {
|
|
255
|
+
console.log(` Note: ${id.toUpperCase()}_WINDOW_BUDGET is not set, so both "${label} Cycle" cards (Est. Seconds Saved / Est. Minutes Saved) will stay empty.`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// 9. The model gate: every coding tool pointed at it fails while it is down, so this warns loudly.
|
|
259
|
+
const gatePort = CONFIG.MODEL_GATE_PORT;
|
|
260
|
+
const gateAddress = `http://localhost:${gatePort}`;
|
|
261
|
+
const toolsFail = `EVERY CODING TOOL POINTED AT ${gateAddress} CANNOT REACH ITS AI PROVIDER.`;
|
|
262
|
+
const [start, restart, doctor] = [cliCommand('start'), cliCommand('restart'), cliCommand('doctor')];
|
|
263
|
+
const repair = isPackageInstall(CONFIG.ROOT_DIR)
|
|
264
|
+
? '`npm install -g @zenithfoundry/slm-gate@latest`'
|
|
265
|
+
: `\`cd ${CONFIG.ROOT_DIR} && pnpm install && pnpm run build\``;
|
|
266
|
+
const reinstall = `If it still does not start, see ${GATE_LOG_FILE}; a broken install is repaired with ${repair}, then \`${restart}\`.`;
|
|
267
|
+
const gate = await probeGate({ port: gatePort });
|
|
268
|
+
if (gate.kind === 'slm-gate') {
|
|
269
|
+
report(true, `Model gate is running on ${gateAddress} (pid ${gate.health.pid}, started ${gate.health.startedAt})`);
|
|
270
|
+
if (gate.stale) {
|
|
271
|
+
report(false, 'The running model gate is an older slm-gate build than the one installed', `\`${restart}\` when no coding tool is in the middle of an answer.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
else if (gate.kind === 'other') {
|
|
275
|
+
const owner = portOwner(gatePort) ?? 'another program';
|
|
276
|
+
report(false, `MODEL GATE CANNOT RUN: port ${gatePort} is taken by ${owner}. ${toolsFail}`, `Either quit ${owner}, then run \`${start}\`. Or move the gate to a free port: set LLM_GATE_PORT=<new port> in ${path.join(CONFIG.HOME_DIR, '.env')}, run \`${restart}\`, then run \`${doctor}\` again, paste the new lines below into each coding tool and restart them.`);
|
|
277
|
+
}
|
|
278
|
+
else if (isStoppedByUser()) {
|
|
279
|
+
report(false, `MODEL GATE IS STOPPED (you ran \`slm-gate stop\`). ${toolsFail}`, `\`${start}\` (it also starts again by itself after a reboot). ${reinstall}`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
const startsItself = CONFIG.LLM_GATE_AUTOSTART
|
|
283
|
+
? `It starts by itself when a coding tool starts slm-gate's MCP server; to start it now run \`${start}\`.`
|
|
284
|
+
: `LLM_GATE_AUTOSTART is off, so nothing starts it for you: run \`${start}\`.`;
|
|
285
|
+
report(false, `MODEL GATE IS NOT RUNNING on ${gateAddress}. ${toolsFail}`, `${startsItself} ${reinstall}`);
|
|
286
|
+
}
|
|
287
|
+
if (CONFIG.MCP_GATE_TRANSPORT === 'http') {
|
|
288
|
+
const mcpPortFree = await checkPortFree(CONFIG.MCP_GATE_PORT);
|
|
289
|
+
report(mcpPortFree, `MCP_GATE_PORT (${CONFIG.MCP_GATE_PORT}) is free`, `Kill the process using port ${CONFIG.MCP_GATE_PORT}`);
|
|
290
|
+
}
|
|
291
|
+
// 9b. MCP servers whose coding tool went away. Current builds stop themselves; ones started by an
|
|
292
|
+
// older build cannot, and go on checking Ollama and showing notifications until the next reboot.
|
|
293
|
+
const stranded = findStrandedServers();
|
|
294
|
+
report(stranded.length === 0, 'No slm-gate MCP servers are left over from closed coding tools', `${stranded.length} slm-gate MCP server${stranded.length === 1 ? '' : 's'} still running with no coding tool attached (pid ${stranded.join(', ')}). ` +
|
|
295
|
+
`Each keeps checking Ollama and can show notifications from the build it was started with. Stop them with: kill ${stranded.join(' ')}`);
|
|
296
|
+
// 9c. Per-prompt savings (local answers, accuracy, window time per provider) are only measured on
|
|
297
|
+
// model-gate traffic. MCP tool calls alone keep the ledger busy while those cards stay empty, so the
|
|
298
|
+
// gap is called out here. A warning, not an issue: the gate itself works.
|
|
299
|
+
const lastModelGateEvent = lastModelGateEventTs();
|
|
300
|
+
if (lastModelGateEvent.error) {
|
|
301
|
+
console.log(` Note: could not read the ledger to check for model-gate traffic: ${lastModelGateEvent.error}`);
|
|
302
|
+
}
|
|
303
|
+
else if (!lastModelGateEvent.ts || Date.now() - Date.parse(lastModelGateEvent.ts) > MODEL_GATE_QUIET_DAYS * 86_400_000) {
|
|
304
|
+
console.log(`⚠ No model request has gone through the model gate in the last ${MODEL_GATE_QUIET_DAYS} days (last: ${lastModelGateEvent.ts ?? 'never'}). ` +
|
|
305
|
+
'Per-prompt savings (local answers, SLM accuracy, window time saved) are not being measured; only MCP tool calls are reaching slm-gate.');
|
|
306
|
+
console.log(` Fix: point a coding tool at ${gateAddress} with the settings printed below.`);
|
|
307
|
+
}
|
|
308
|
+
// 9d. Langfuse's dashboard shows one Env selection at a time; set to the wrong one it looks empty.
|
|
309
|
+
if (CONFIG.LANGFUSE_PUBLIC_KEY && CONFIG.LANGFUSE_SECRET_KEY && CONFIG.LANGFUSE_HOST) {
|
|
310
|
+
const dashboard = dashboardEnvironments();
|
|
311
|
+
if (dashboard.error)
|
|
312
|
+
console.log(` Note: could not read the ledger's environments: ${dashboard.error}`);
|
|
313
|
+
console.log(` Langfuse dashboard: set the Env selector to ${dashboard.environments.map(e => `"${e}"`).join(' and ')}` +
|
|
314
|
+
` (new traffic goes to "${CONFIG.LANGFUSE_ENVIRONMENT}"; leave "bench" out unless you want benchmark runs).`);
|
|
315
|
+
}
|
|
316
|
+
// 10. The setting that sends each coding tool's model requests through the gate (on the current port).
|
|
317
|
+
console.log(`\n--- Coding tool settings: paste these to send a tool's model requests through ${gateAddress} ---`);
|
|
318
|
+
for (const setting of toolSettings(gatePort)) {
|
|
319
|
+
console.log(`\n${setting.tool} (works with: ${setting.login})`);
|
|
320
|
+
for (const line of setting.lines)
|
|
321
|
+
console.log(` ${line}`);
|
|
322
|
+
}
|
|
323
|
+
console.log('\nThese cannot send their model requests through the gate (slm-gate\'s MCP tools still work in them):');
|
|
324
|
+
for (const unroutable of UNROUTABLE_TOOLS)
|
|
325
|
+
console.log(` ${unroutable.tool}: ${unroutable.reason}`);
|
|
326
|
+
console.log('\n=============================================');
|
|
327
|
+
if (issues === 0) {
|
|
328
|
+
console.log('READY');
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
console.log(`${issues} issue(s) to fix`);
|
|
332
|
+
process.exit(1); // Fail the script execution so CI pipelines or scripts can catch it
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
run().catch(err => {
|
|
336
|
+
console.error('Doctor check failed unexpectedly:', err);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
});
|
package/dist/hardware.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
/**
|
|
4
|
+
* Detects the host machine's hardware capabilities.
|
|
5
|
+
* Determines total RAM, OS platform, and available ML accelerators.
|
|
6
|
+
*
|
|
7
|
+
* @param mockOs - Optional mock OS module for unit testing.
|
|
8
|
+
* @returns {HardwareInfo} An object detailing the host's hardware profile.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const hw = detectHardware();
|
|
12
|
+
* if (hw.accelerator === 'cuda') {
|
|
13
|
+
* console.log('NVIDIA GPU detected!');
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
export function detectHardware(mockOs) {
|
|
17
|
+
const osModule = mockOs || os;
|
|
18
|
+
const totalRamGB = Math.round(osModule.totalmem() / (1024 * 1024 * 1024));
|
|
19
|
+
const arch = osModule.arch();
|
|
20
|
+
const platform = osModule.platform();
|
|
21
|
+
const isAppleSilicon = platform === 'darwin' && arch === 'arm64';
|
|
22
|
+
let accelerator = 'cpu';
|
|
23
|
+
// Apple Silicon inherently supports Metal
|
|
24
|
+
if (isAppleSilicon) {
|
|
25
|
+
accelerator = 'metal';
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
// Attempt to detect NVIDIA GPUs by running nvidia-smi
|
|
29
|
+
try {
|
|
30
|
+
execSync('nvidia-smi', { stdio: 'ignore' });
|
|
31
|
+
accelerator = 'cuda';
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Ignore errors (e.g., command not found or no GPU), fallback to 'cpu' remains
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Apple Silicon uses a unified memory architecture where CPU and GPU share the same RAM pool
|
|
38
|
+
const unifiedMemory = isAppleSilicon;
|
|
39
|
+
return { totalRamGB, arch, platform, isAppleSilicon, accelerator, unifiedMemory };
|
|
40
|
+
}
|
|
41
|
+
/** slm-gate needs at least this much RAM: both local models, their context, and room for your other apps. */
|
|
42
|
+
export const MIN_RAM_GB = 16;
|
|
43
|
+
/**
|
|
44
|
+
* Recommends a RAM preset for a computer with MIN_RAM_GB of RAM or more. A size between two presets gets
|
|
45
|
+
* the smaller one (48–63 GB → ram-48). More than 128 GB gets `custom`: start from the 128 GB models
|
|
46
|
+
* (modelsForRam) and choose bigger ones yourself, e.g. with llmfit (https://github.com/AlexsJones/llmfit).
|
|
47
|
+
* `slm-gate init` and `slm-gate doctor` both use this.
|
|
48
|
+
*
|
|
49
|
+
* @param totalRamGB - The total physical RAM in gigabytes.
|
|
50
|
+
* @returns {string} The recommended RAM preset key (e.g., 'ram-16').
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* const preset = recommendPreset(48);
|
|
54
|
+
* // returns 'ram-48'
|
|
55
|
+
*/
|
|
56
|
+
export function recommendPreset(totalRamGB) {
|
|
57
|
+
if (totalRamGB > 128)
|
|
58
|
+
return 'custom';
|
|
59
|
+
if (totalRamGB >= 128)
|
|
60
|
+
return 'ram-128';
|
|
61
|
+
if (totalRamGB >= 64)
|
|
62
|
+
return 'ram-64';
|
|
63
|
+
if (totalRamGB >= 48)
|
|
64
|
+
return 'ram-48';
|
|
65
|
+
if (totalRamGB >= 32)
|
|
66
|
+
return 'ram-32';
|
|
67
|
+
if (totalRamGB >= 24)
|
|
68
|
+
return 'ram-24';
|
|
69
|
+
return 'ram-16';
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Recommends a safe context window size (NUM_CTX) based on available RAM.
|
|
73
|
+
* Ensures that there is enough memory to hold KV caches for both SLM models if running in dual-model mode.
|
|
74
|
+
*
|
|
75
|
+
* @param totalRamGB - The total physical RAM in gigabytes.
|
|
76
|
+
* @param dualModel - Whether the system is running both a brain and a gate model concurrently (defaults to true).
|
|
77
|
+
* @returns {number} The recommended NUM_CTX context length.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* const numCtx = recommendNumCtx(16);
|
|
81
|
+
* // returns 4096
|
|
82
|
+
*/
|
|
83
|
+
export function recommendNumCtx(totalRamGB, dualModel = true) {
|
|
84
|
+
return totalRamGB >= 24 ? 8192 : 4096;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Pre-defined model pairs tailored to different RAM tiers: the one table config.ts, doctor, init and
|
|
88
|
+
* `models:check` all read. Each preset specifies a 'brain' model (for complex reasoning) and a 'gate' model
|
|
89
|
+
* (for fast routing/classification); every name must exist in Ollama's library. `custom` is the pair used
|
|
90
|
+
* when RAM_PRESET=custom and no SLM_*_MODEL is set.
|
|
91
|
+
*/
|
|
92
|
+
export const ramPresets = {
|
|
93
|
+
'ram-16': { brain: 'qwen2.5-coder:3b', gate: 'qwen2.5-coder:0.5b' },
|
|
94
|
+
'ram-24': { brain: 'qwen3.5:4b', gate: 'qwen2.5-coder:3b' },
|
|
95
|
+
'ram-32': { brain: 'qwen2.5:7b', gate: 'qwen2.5-coder:3b' },
|
|
96
|
+
'ram-48': { brain: 'qwen3.5:9b', gate: 'qwen2.5-coder:3b' },
|
|
97
|
+
'ram-64': { brain: 'qwen3.5:9b', gate: 'qwen3.5:4b' },
|
|
98
|
+
'ram-128': { brain: 'qwen3:14b', gate: 'qwen3:8b' },
|
|
99
|
+
'custom': { brain: 'qwen3.5:4b', gate: 'qwen2.5-coder:3b' },
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* The models for a computer with this much RAM: its preset's pair, or above 128 GB (preset `custom`) the
|
|
103
|
+
* 128 GB pair, which certainly fits and is the starting point for choosing bigger models.
|
|
104
|
+
*/
|
|
105
|
+
export function modelsForRam(totalRamGB) {
|
|
106
|
+
const preset = recommendPreset(totalRamGB);
|
|
107
|
+
return ramPresets[preset === 'custom' ? 'ram-128' : preset];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Parses a RAM preset string to extract its numeric ranking/size in GB.
|
|
111
|
+
* This is used to logically compare if a user's configured preset is too demanding for their hardware.
|
|
112
|
+
*
|
|
113
|
+
* @param preset - The RAM preset string (e.g., 'ram-16' or 'custom').
|
|
114
|
+
* @returns {number} The numeric equivalent of the preset in GB.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* const rank = getPresetRank('ram-16');
|
|
118
|
+
* // returns 16
|
|
119
|
+
*/
|
|
120
|
+
export function getPresetRank(preset) {
|
|
121
|
+
const match = preset.match(/^ram-(\d+)$/);
|
|
122
|
+
if (match)
|
|
123
|
+
return parseInt(match[1], 10);
|
|
124
|
+
// `custom` without model names uses the custom pair (qwen3.5:4b + qwen2.5-coder:3b), a ram-24 class load
|
|
125
|
+
return 24;
|
|
126
|
+
}
|
package/dist/home-dir.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Where an install keeps its settings and data: `.env`, and `output/` (the ledger, logs,
|
|
3
|
+
* markers and notices). Code and the shipped templates (`dist/`, `configs/`) stay in the install folder.
|
|
4
|
+
*
|
|
5
|
+
* - A git checkout keeps them in the checkout, as it always has.
|
|
6
|
+
* - An npm install lives inside `node_modules`, which every upgrade replaces, so it uses `~/.slm-gate`.
|
|
7
|
+
* - `SLM_GATE_HOME` overrides both. It says where `.env` is, so it comes from the environment (the shell,
|
|
8
|
+
* or a coding tool's MCP env block), not from `.env`. It is not a configuration key, so the
|
|
9
|
+
* automatically started model gate inherits it (src/setup/model-gate.ts `gateEnvironment`) and reads
|
|
10
|
+
* the same `.env`.
|
|
11
|
+
*
|
|
12
|
+
* No side effects: cli.ts imports this without loading the configuration.
|
|
13
|
+
*/
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
/**
|
|
16
|
+
* @param params.installDir The install folder, where `dist/` and `configs/` are
|
|
17
|
+
* @param params.env The process environment; only SLM_GATE_HOME is read, and blank counts as unset
|
|
18
|
+
* @param params.userHome The user's home folder
|
|
19
|
+
* @returns The absolute folder for `.env` and `output/`
|
|
20
|
+
* @throws When SLM_GATE_HOME is not an absolute path: coding tools start slm-gate from any folder, so a
|
|
21
|
+
* relative one would point somewhere different each time
|
|
22
|
+
*/
|
|
23
|
+
export function resolveHomeDir(params) {
|
|
24
|
+
const explicit = params.env.SLM_GATE_HOME;
|
|
25
|
+
if (explicit) {
|
|
26
|
+
if (!path.isAbsolute(explicit)) {
|
|
27
|
+
throw new Error(`SLM_GATE_HOME must be an absolute path, because coding tools start slm-gate from any folder; got "${explicit}".`);
|
|
28
|
+
}
|
|
29
|
+
return path.resolve(explicit);
|
|
30
|
+
}
|
|
31
|
+
return isPackageInstall(params.installDir) ? path.join(params.userHome, '.slm-gate') : params.installDir;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* @param installDir The install folder
|
|
35
|
+
* @returns True for a copy installed by npm (it lives inside node_modules), false for a git checkout
|
|
36
|
+
*/
|
|
37
|
+
export function isPackageInstall(installDir) {
|
|
38
|
+
return installDir.split(path.sep).includes('node_modules');
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Langfuse flush lifecycle, shared by every long-lived gate process.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the previous bare 5-minute `setInterval`. That design meant a gate which
|
|
5
|
+
* answered a few tool calls and exited inside one interval shipped nothing at all, and a
|
|
6
|
+
* backlog drained at only 50 events per 5 minutes — which is why the dashboard appeared
|
|
7
|
+
* empty and then jumped.
|
|
8
|
+
*/
|
|
9
|
+
import { LangfuseSink } from './index.js';
|
|
10
|
+
/** Backstop poll. Short enough that data is reviewable almost immediately. */
|
|
11
|
+
const INTERVAL_MS = 15_000;
|
|
12
|
+
/**
|
|
13
|
+
* Wall-clock budget for the shutdown drain. MCP hosts send SIGTERM and then force-kill
|
|
14
|
+
* after a short grace period, so an unbounded network drain would just be killed mid-flight.
|
|
15
|
+
*/
|
|
16
|
+
const SHUTDOWN_BUDGET_MS = 4_000;
|
|
17
|
+
/**
|
|
18
|
+
* Installs startup drain, periodic backstop, and a bounded shutdown drain.
|
|
19
|
+
*
|
|
20
|
+
* @param layer Log prefix identifying the calling gate (e.g. 'mcp-gate', 'llm-gate').
|
|
21
|
+
* @returns A `flushSoon` function callers may invoke after writing an event to ship it
|
|
22
|
+
* near-immediately, debounced so a burst of events still results in one request.
|
|
23
|
+
*/
|
|
24
|
+
export function installLangfuseFlushLifecycle(layer) {
|
|
25
|
+
const flush = (why, options) => LangfuseSink.flushQueue(options).catch(err => console.error(`[${layer}] Langfuse flush error (${why}):`, err));
|
|
26
|
+
// Drain whatever a previous process left behind.
|
|
27
|
+
void flush('startup');
|
|
28
|
+
// unref() so this timer never keeps the process alive on its own.
|
|
29
|
+
setInterval(() => { void flush('interval'); }, INTERVAL_MS).unref();
|
|
30
|
+
let shuttingDown = false;
|
|
31
|
+
const drainAndExit = async (signal) => {
|
|
32
|
+
if (shuttingDown)
|
|
33
|
+
return;
|
|
34
|
+
shuttingDown = true;
|
|
35
|
+
await flush(signal, { deadlineMs: SHUTDOWN_BUDGET_MS });
|
|
36
|
+
process.exit(0);
|
|
37
|
+
};
|
|
38
|
+
process.once('SIGINT', () => void drainAndExit('SIGINT'));
|
|
39
|
+
process.once('SIGTERM', () => void drainAndExit('SIGTERM'));
|
|
40
|
+
let debounce = null;
|
|
41
|
+
return function flushSoon() {
|
|
42
|
+
if (debounce)
|
|
43
|
+
return;
|
|
44
|
+
debounce = setTimeout(() => {
|
|
45
|
+
debounce = null;
|
|
46
|
+
void flush('debounced');
|
|
47
|
+
}, 2_000);
|
|
48
|
+
debounce.unref();
|
|
49
|
+
};
|
|
50
|
+
}
|