@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/config.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { config } from 'dotenv';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { ramPresets } from './hardware.js';
|
|
7
|
+
import { resolveHomeDir } from './home-dir.js';
|
|
8
|
+
import { getSubscriptionPlan, getValidPlanKeys, isValidPlanKey } from './pricing/plans.js';
|
|
9
|
+
import { isEntryPoint } from './utils/entry-point.js';
|
|
10
|
+
const filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const dirname = path.dirname(filename);
|
|
12
|
+
const ROOT_DIR = path.resolve(dirname, '..');
|
|
13
|
+
// Settings and data (.env, output/): the checkout for a git clone, ~/.slm-gate for an npm install,
|
|
14
|
+
// or SLM_GATE_HOME (src/home-dir.ts).
|
|
15
|
+
const HOME_DIR = resolveHomeDir({ installDir: ROOT_DIR, env: process.env, userHome: os.homedir() });
|
|
16
|
+
const OUTPUT_DIR = path.join(HOME_DIR, 'output');
|
|
17
|
+
// Load THIS INSTALL'S .env, from HOME_DIR. Deliberately not cwd-relative:
|
|
18
|
+
// MCP hosts spawn the gate with cwd set to whatever workspace the user has open, so a
|
|
19
|
+
// bare dotenv.config() silently found nothing and left LANGFUSE_*/PROVIDER undefined
|
|
20
|
+
// while LEDGER_PATH's module-relative default still resolved — a gate that looked
|
|
21
|
+
// healthy and shipped no telemetry.
|
|
22
|
+
//
|
|
23
|
+
// The caller's cwd .env is deliberately NOT loaded as a fallback: it would pull an
|
|
24
|
+
// unrelated project's PROVIDER, CLOUD_API_KEY and CLOUD_MODEL into this process.
|
|
25
|
+
// Host-supplied process.env still wins, because dotenv never overrides an existing var.
|
|
26
|
+
const envFile = config({ path: path.join(HOME_DIR, '.env') });
|
|
27
|
+
// Zod pre-processors for env strings
|
|
28
|
+
const parseInteger = (fallback) => z.string().optional().transform(v => v ? parseInt(v, 10) : fallback);
|
|
29
|
+
const parseFloatNumber = (fallback) => z.string().optional().transform(v => v ? parseFloat(v) : fallback);
|
|
30
|
+
const parseBoolean = (fallback) => z.string().optional().transform(v => {
|
|
31
|
+
if (!v)
|
|
32
|
+
return fallback;
|
|
33
|
+
return v.toLowerCase() === 'on' || v.toLowerCase() === 'true' || v === '1';
|
|
34
|
+
});
|
|
35
|
+
const parseNumberArray = (fallback) => z.string().optional().transform(v => v ? v.split(',').map(n => parseInt(n.trim(), 10)) : fallback);
|
|
36
|
+
const parseDownstreamMcp = () => z.string().optional().transform(v => {
|
|
37
|
+
if (!v)
|
|
38
|
+
return null;
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(v);
|
|
41
|
+
if (parsed.command) {
|
|
42
|
+
return { command: parsed.command, args: parsed.args, env: parsed.env };
|
|
43
|
+
}
|
|
44
|
+
if (parsed.url) {
|
|
45
|
+
return { url: parsed.url, headers: parsed.headers };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const envSchema = z.object({
|
|
54
|
+
// STEP 1
|
|
55
|
+
SLM_PROVIDER: z.enum(['ollama', 'openai']).default('ollama'),
|
|
56
|
+
OLLAMA_HOST: z.string().default('http://localhost:11434'),
|
|
57
|
+
OLLAMA_KEEP_ALIVE: z.string().default('12h'),
|
|
58
|
+
SLM_BRAIN_MODEL: z.string().optional(), // resolved below
|
|
59
|
+
SLM_GATE_MODEL: z.string().optional(), // resolved below
|
|
60
|
+
SLM_GATE_TESTING_MODEL: z.string().optional(), // resolved below, used for benchmark harness & test runs
|
|
61
|
+
NUM_CTX: parseInteger(8192),
|
|
62
|
+
TEMPERATURE: parseFloatNumber(0),
|
|
63
|
+
SLM_TIMEOUT_MS: parseInteger(120000),
|
|
64
|
+
SELF_CONSISTENCY_K: parseInteger(3),
|
|
65
|
+
SELF_CONSISTENCY_TEMP: parseFloatNumber(0.7),
|
|
66
|
+
// STEP 2
|
|
67
|
+
STRICTNESS_LEVELS: parseNumberArray([0, 1, 2, 3, 4, 5]),
|
|
68
|
+
HEADLINE_STRICTNESS: parseInteger(4),
|
|
69
|
+
// STEP 3
|
|
70
|
+
CLOUD_API_STYLE: z.enum(['openai', 'anthropic']).default('openai'),
|
|
71
|
+
CLOUD_BASE_URL: z.string().optional(),
|
|
72
|
+
CLOUD_API_KEY: z.string().optional(),
|
|
73
|
+
CLOUD_MODEL: z.string().optional(),
|
|
74
|
+
// Semantic CACHE
|
|
75
|
+
SEMCACHE: parseBoolean(false),
|
|
76
|
+
SEMCACHE_THRESHOLD: parseFloatNumber(0.95),
|
|
77
|
+
EMBED_MODEL: z.string().default('nomic-embed-text'),
|
|
78
|
+
// STEP 4
|
|
79
|
+
LLM_GATE_PORT: parseInteger(8787),
|
|
80
|
+
// When a coding tool starts slm-gate's MCP server, also start the model gate (in the background) if it
|
|
81
|
+
// is not running, and keep it running while tools are open. Off = you start it yourself (`slm-gate start`).
|
|
82
|
+
LLM_GATE_AUTOSTART: parseBoolean(true),
|
|
83
|
+
// Distil large command, search and listing tool results before requests leave the machine. Off makes
|
|
84
|
+
// the gate a plain pass-through; ongoing conversations then resend their distilled history in full
|
|
85
|
+
// once (one prompt-cache miss, and Claude drops its earlier thinking once).
|
|
86
|
+
LLM_GATE_DISTILL: parseBoolean(true),
|
|
87
|
+
// Offer the first request of a conversation to the local model (Step A). Off sends every request on.
|
|
88
|
+
LLM_GATE_LOCAL_FIRST: parseBoolean(true),
|
|
89
|
+
// How long a first request may wait for a local answer before it goes on; the attempt is then cancelled.
|
|
90
|
+
// A warm local answer (3 samples) measured 3.8 s; deciding a coding task is not eligible takes ~0.25 s.
|
|
91
|
+
LOCAL_ATTEMPT_BUDGET_MS: parseInteger(6000),
|
|
92
|
+
// Where the model gate forwards each wire format. Built in; override only when your requests
|
|
93
|
+
// must already go through a company gateway. The tool's own login is forwarded either way.
|
|
94
|
+
UPSTREAM_ANTHROPIC_URL: z.string().url().default('https://api.anthropic.com'),
|
|
95
|
+
UPSTREAM_OPENAI_URL: z.string().url().default('https://api.openai.com/v1'),
|
|
96
|
+
UPSTREAM_CHATGPT_URL: z.string().url().default('https://chatgpt.com/backend-api/codex'),
|
|
97
|
+
UPSTREAM_GEMINI_URL: z.string().url().default('https://generativelanguage.googleapis.com'),
|
|
98
|
+
DOWNSTREAM_MCP: parseDownstreamMcp(),
|
|
99
|
+
MCP_GATE_TRANSPORT: z.enum(['stdio', 'http']).default('stdio'),
|
|
100
|
+
MCP_GATE_PORT: parseInteger(8788),
|
|
101
|
+
// STEP 5
|
|
102
|
+
PROVIDER: z.enum(['gemini', 'claude', 'chatgpt']).optional(),
|
|
103
|
+
LEDGER_PATH: z.string().default(path.join(OUTPUT_DIR, 'ledger.sqlite')),
|
|
104
|
+
LANGFUSE_PUBLIC_KEY: z.string().optional(),
|
|
105
|
+
LANGFUSE_SECRET_KEY: z.string().optional(),
|
|
106
|
+
LANGFUSE_HOST: z.string().optional(),
|
|
107
|
+
// Langfuse environment dimension. Keeps benchmark/harness runs out of the real
|
|
108
|
+
// traffic view — the dashboard's Env selector filters on this. Langfuse requires
|
|
109
|
+
// lowercase alphanumeric plus - and _, and forbids the 'langfuse' prefix.
|
|
110
|
+
// Not 'default': that is where any other writer to the same Langfuse project lands
|
|
111
|
+
// when it sets no environment, so the gate's traffic would be mixed with theirs.
|
|
112
|
+
LANGFUSE_ENVIRONMENT: z.string()
|
|
113
|
+
.regex(/^(?!langfuse)[a-z0-9_-]+$/, "LANGFUSE_ENVIRONMENT must be lowercase alphanumeric (- and _ allowed) and must not start with 'langfuse'")
|
|
114
|
+
.default('slm-gate'),
|
|
115
|
+
SUBSCRIPTION_PLAN: z.string().optional(),
|
|
116
|
+
PLAN_CLAUDE: z.string().optional(),
|
|
117
|
+
PLAN_CHATGPT: z.string().optional(),
|
|
118
|
+
PLAN_GEMINI: z.string().optional(),
|
|
119
|
+
// STEP 6
|
|
120
|
+
RESOLVER_CLOUD_TIER: parseBoolean(false),
|
|
121
|
+
RESOLVER_CLOUD_BUDGET_USD: parseFloatNumber(0),
|
|
122
|
+
PROMPT_VERSION: z.string().default('v1'),
|
|
123
|
+
// The presets in src/hardware.ts ramPresets. Every value recommendPreset() can print must be accepted
|
|
124
|
+
// here, or following doctor's advice stops slm-gate from starting. slm-gate needs 16 GB of RAM or more.
|
|
125
|
+
RAM_PRESET: z.enum(['ram-16', 'ram-24', 'ram-32', 'ram-48', 'ram-64', 'ram-128', 'custom']).default('custom'),
|
|
126
|
+
TLS_ADAPTER: parseBoolean(false),
|
|
127
|
+
DISTILL_PRESERVE_PATH: z.string().optional().transform(v => v?.trim() || null),
|
|
128
|
+
DISTILL_PRESERVE_MODE: z.enum(['extend', 'replace']).default('extend'),
|
|
129
|
+
DISTILL_SKILLS: parseBoolean(false),
|
|
130
|
+
DISTILL_ADAPTIVE: parseBoolean(false),
|
|
131
|
+
DISTILL_ADAPTIVE_THRESHOLD: parseFloatNumber(0.86),
|
|
132
|
+
DISTILL_ADAPTIVE_EXPLORE_RATE: parseFloatNumber(0.15),
|
|
133
|
+
DISTILL_FEEDBACK_RETENTION_DAYS: parseInteger(180),
|
|
134
|
+
DISTILL_FEEDBACK_MAX_ROWS: parseInteger(5000),
|
|
135
|
+
DISTILL_MAX_TOKENS: parseInteger(2000),
|
|
136
|
+
DISTILL_MIN_TOKENS: parseInteger(500),
|
|
137
|
+
// How long the model gate may hold one request to distil its new tool results. Past it, a result is
|
|
138
|
+
// sent (and kept, forever) as the original. Warm local-model runs take ~0.5 s per ~600 words.
|
|
139
|
+
DISTILL_BUDGET_MS: parseInteger(3000),
|
|
140
|
+
KEEP_RECENT_TOOL_TURNS: parseInteger(2),
|
|
141
|
+
ELISION_MAX_ENTRIES: parseInteger(5000),
|
|
142
|
+
ELISION_RETENTION_DAYS: parseInteger(180),
|
|
143
|
+
ELISION_MAX_MB: parseInteger(500),
|
|
144
|
+
// How many days of data your Langfuse plan keeps (Hobby: 30). Only the dashboard text reads it: the
|
|
145
|
+
// cards cover this rolling window, while the local ledger keeps everything.
|
|
146
|
+
LANGFUSE_RETENTION_DAYS: parseInteger(30),
|
|
147
|
+
// ROUTING TUNE
|
|
148
|
+
ROUTING_TUNE: parseBoolean(false),
|
|
149
|
+
ROUTING_TUNE_WINDOW: parseInteger(20),
|
|
150
|
+
ROUTING_TUNE_MIN_SAMPLES: parseInteger(8),
|
|
151
|
+
ROUTING_TUNE_THRESHOLD: parseFloatNumber(0.5),
|
|
152
|
+
ROUTING_TUNE_EXPLORE_RATE: parseFloatNumber(0.15),
|
|
153
|
+
});
|
|
154
|
+
// A variable that is present but blank means "unset". dotenv and MCP-host env blocks both
|
|
155
|
+
// deliver `LEDGER_PATH=` (as shipped in every .env template) as '', and zod's .default() only
|
|
156
|
+
// fires on undefined — so a blank value used to defeat the absolute default and leave a
|
|
157
|
+
// cwd-relative './output/ledger.sqlite', which ENOENTs when the host's cwd does not exist
|
|
158
|
+
// (Claude Desktop). Dropping empties here fixes that for every variable in one place.
|
|
159
|
+
const envInput = Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== ''));
|
|
160
|
+
/**
|
|
161
|
+
* Every environment variable slm-gate's configuration reads. The automatically started model gate is
|
|
162
|
+
* launched without these, so it takes its settings only from slm-gate's own .env — never from the MCP env
|
|
163
|
+
* block of whichever coding tool happened to start it (src/setup/model-gate.ts).
|
|
164
|
+
*/
|
|
165
|
+
export const CONFIG_ENV_KEYS = Object.keys(envSchema.shape);
|
|
166
|
+
const parsedEnv = envSchema.parse(envInput);
|
|
167
|
+
const preset = ramPresets[parsedEnv.RAM_PRESET] || ramPresets['custom'];
|
|
168
|
+
const resolvePlan = (provider) => {
|
|
169
|
+
// 1. PLAN_<P>
|
|
170
|
+
const planProviderKey = `PLAN_${provider.toUpperCase()}`;
|
|
171
|
+
let planKey = parsedEnv[planProviderKey];
|
|
172
|
+
// 2. SUBSCRIPTION_PLAN
|
|
173
|
+
if (!planKey && parsedEnv.SUBSCRIPTION_PLAN) {
|
|
174
|
+
if (parsedEnv.SUBSCRIPTION_PLAN.startsWith(provider)) {
|
|
175
|
+
planKey = parsedEnv.SUBSCRIPTION_PLAN;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// 3. Default
|
|
179
|
+
if (!planKey) {
|
|
180
|
+
if (provider === 'claude')
|
|
181
|
+
planKey = 'claude-pro';
|
|
182
|
+
else if (provider === 'chatgpt')
|
|
183
|
+
planKey = 'chatgpt-plus';
|
|
184
|
+
else if (provider === 'gemini')
|
|
185
|
+
planKey = 'gemini-pro';
|
|
186
|
+
}
|
|
187
|
+
if (planKey && !isValidPlanKey(planKey)) {
|
|
188
|
+
throw new Error(`Invalid plan key '${planKey}' for ${provider}. Valid keys: ${getValidPlanKeys().join(', ')}`);
|
|
189
|
+
}
|
|
190
|
+
const resolved = getSubscriptionPlan(planKey);
|
|
191
|
+
return {
|
|
192
|
+
windowMinutes: resolved.windowMinutes,
|
|
193
|
+
source: resolved.source,
|
|
194
|
+
plan: planKey
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
const claudePlan = resolvePlan('claude');
|
|
198
|
+
const chatgptPlan = resolvePlan('chatgpt');
|
|
199
|
+
const geminiPlan = resolvePlan('gemini');
|
|
200
|
+
console.error(`[config] claude plan: ${claudePlan.plan} (windowMinutes: ${claudePlan.windowMinutes})`);
|
|
201
|
+
console.error(`[config] chatgpt plan: ${chatgptPlan.plan} (windowMinutes: ${chatgptPlan.windowMinutes})`);
|
|
202
|
+
console.error(`[config] gemini plan: ${geminiPlan.plan} (windowMinutes: ${geminiPlan.windowMinutes})`);
|
|
203
|
+
export const CONFIG = Object.freeze({
|
|
204
|
+
...parsedEnv,
|
|
205
|
+
ROOT_DIR,
|
|
206
|
+
HOME_DIR,
|
|
207
|
+
OUTPUT_DIR,
|
|
208
|
+
SLM_BRAIN_MODEL: parsedEnv.SLM_BRAIN_MODEL || preset.brain,
|
|
209
|
+
SLM_GATE_MODEL: parsedEnv.SLM_GATE_MODEL || preset.gate,
|
|
210
|
+
SLM_GATE_TESTING_MODEL: parsedEnv.SLM_GATE_TESTING_MODEL || parsedEnv.SLM_GATE_MODEL || preset.gate,
|
|
211
|
+
// Where every session looks for, and starts, the one shared model gate: LLM_GATE_PORT as slm-gate's own
|
|
212
|
+
// .env sets it. LLM_GATE_PORT above prefers the process environment, so a value in one coding tool's MCP
|
|
213
|
+
// env block (or the shell) would send that session to another port, where it would start a second gate.
|
|
214
|
+
MODEL_GATE_PORT: envSchema.shape.LLM_GATE_PORT.parse(envFile.parsed?.LLM_GATE_PORT || undefined),
|
|
215
|
+
RESOLVED_PLAN_CLAUDE: claudePlan,
|
|
216
|
+
RESOLVED_PLAN_CHATGPT: chatgptPlan,
|
|
217
|
+
RESOLVED_PLAN_GEMINI: geminiPlan,
|
|
218
|
+
});
|
|
219
|
+
/**
|
|
220
|
+
* Validates that specific keys exist and are non-empty. Throws actionable errors if missing.
|
|
221
|
+
* Called right before an operation that requires them, NOT on module boot.
|
|
222
|
+
*/
|
|
223
|
+
export function requireKeys(keys) {
|
|
224
|
+
const missing = keys.filter(k => !CONFIG[k]);
|
|
225
|
+
if (missing.length > 0) {
|
|
226
|
+
throw new Error(`Missing required configuration for this operation: ${missing.join(', ')}. Please update your .env file.`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Config test script executed via `pnpm run config`
|
|
230
|
+
if (isEntryPoint(import.meta.url)) {
|
|
231
|
+
console.log('=== SMALL-LANGUAGE-MODEL-GATE CONFIGURATION ===');
|
|
232
|
+
const redactedConfig = { ...CONFIG };
|
|
233
|
+
if (redactedConfig.CLOUD_API_KEY)
|
|
234
|
+
redactedConfig.CLOUD_API_KEY = '<SET>';
|
|
235
|
+
else
|
|
236
|
+
redactedConfig.CLOUD_API_KEY = '<UNSET>';
|
|
237
|
+
if (redactedConfig.LANGFUSE_SECRET_KEY)
|
|
238
|
+
redactedConfig.LANGFUSE_SECRET_KEY = '<SET>';
|
|
239
|
+
else
|
|
240
|
+
redactedConfig.LANGFUSE_SECRET_KEY = '<UNSET>';
|
|
241
|
+
if (redactedConfig.LANGFUSE_PUBLIC_KEY)
|
|
242
|
+
redactedConfig.LANGFUSE_PUBLIC_KEY = '<SET>';
|
|
243
|
+
else
|
|
244
|
+
redactedConfig.LANGFUSE_PUBLIC_KEY = '<UNSET>';
|
|
245
|
+
console.log(JSON.stringify(redactedConfig, null, 2));
|
|
246
|
+
console.log('\n--- Summary ---');
|
|
247
|
+
console.log(`brain: ${CONFIG.SLM_BRAIN_MODEL} | gate: ${CONFIG.SLM_GATE_MODEL} | test: ${CONFIG.SLM_GATE_TESTING_MODEL}`);
|
|
248
|
+
const downstream = CONFIG.DOWNSTREAM_MCP ? (CONFIG.DOWNSTREAM_MCP.command ? `command: ${CONFIG.DOWNSTREAM_MCP.command}` : `url: ${CONFIG.DOWNSTREAM_MCP.url}`) : 'standalone';
|
|
249
|
+
console.log(`downstream: ${downstream}`);
|
|
250
|
+
const sinks = ['sqlite'];
|
|
251
|
+
if (CONFIG.LANGFUSE_PUBLIC_KEY && CONFIG.LANGFUSE_SECRET_KEY && CONFIG.LANGFUSE_HOST) {
|
|
252
|
+
sinks.push('langfuse');
|
|
253
|
+
}
|
|
254
|
+
console.log(`sinks: [${sinks.join(', ')}]`);
|
|
255
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The dashboard's numbers, computed from the local ledger.
|
|
3
|
+
*
|
|
4
|
+
* One JSON document drives both servings of the dashboard page: `slm-gate dashboard`
|
|
5
|
+
* computes it fresh per request, and `dashboard:export` bakes it into a static site.
|
|
6
|
+
* Everything here is aggregate — counts, token sums, minutes, dates. No prompt text,
|
|
7
|
+
* tool names or skill names leave the machine, so an export is safe to publish.
|
|
8
|
+
*
|
|
9
|
+
* The per-cycle view is the reason this exists locally: a provider window is a rolling
|
|
10
|
+
* span opened by the first request (Claude: 5 h), so cycles can only be reconstructed
|
|
11
|
+
* by clustering events by time — which Langfuse widgets cannot do.
|
|
12
|
+
*
|
|
13
|
+
* Benchmark runs (environment 'bench') are excluded from every savings figure and from
|
|
14
|
+
* routing: they are synthetic. They appear only in the accuracy split, labelled as such.
|
|
15
|
+
*/
|
|
16
|
+
import Database from 'better-sqlite3';
|
|
17
|
+
import { CONFIG } from '../config.js';
|
|
18
|
+
import { formatEventForLangfuse, perEventBaselineTokens, perEventCycleMinutes, perEventTokensSaved, resolveProvider, routingOutcome, } from '../ledger/index.js';
|
|
19
|
+
import { getProviderRegistry } from '../pricing/providers.js';
|
|
20
|
+
const PROVIDERS = [
|
|
21
|
+
{ id: 'claude', label: 'Claude' },
|
|
22
|
+
{ id: 'chatgpt', label: 'ChatGPT' },
|
|
23
|
+
{ id: 'gemini', label: 'Gemini' },
|
|
24
|
+
];
|
|
25
|
+
function resolvedPlan(id) {
|
|
26
|
+
const plans = {
|
|
27
|
+
claude: CONFIG.RESOLVED_PLAN_CLAUDE,
|
|
28
|
+
chatgpt: CONFIG.RESOLVED_PLAN_CHATGPT,
|
|
29
|
+
gemini: CONFIG.RESOLVED_PLAN_GEMINI,
|
|
30
|
+
};
|
|
31
|
+
const resolved = plans[id];
|
|
32
|
+
return { windowMinutes: resolved.windowMinutes, plan: resolved.plan ?? id };
|
|
33
|
+
}
|
|
34
|
+
/** Monday of the event's week, UTC. */
|
|
35
|
+
function weekStartOf(ts) {
|
|
36
|
+
const date = new Date(ts);
|
|
37
|
+
date.setUTCDate(date.getUTCDate() - ((date.getUTCDay() + 6) % 7));
|
|
38
|
+
return date.toISOString().slice(0, 10);
|
|
39
|
+
}
|
|
40
|
+
/** The event's accuracy score (0–100) exactly as the Langfuse mirror would emit it, or null. */
|
|
41
|
+
function accuracyOf(e) {
|
|
42
|
+
const score = formatEventForLangfuse(e).scores?.find(s => s.name === 'accuracy_rate_pct');
|
|
43
|
+
return typeof score?.value === 'number' ? score.value : null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Clusters one provider's events into window instances: a cycle opens at its first event
|
|
47
|
+
* and spans windowMinutes; the next event past that boundary opens the next cycle. This
|
|
48
|
+
* mirrors how the providers meter — the window starts on first use, not on the clock hour.
|
|
49
|
+
*/
|
|
50
|
+
export function clusterCycles(params) {
|
|
51
|
+
const hasBudget = getProviderRegistry()[params.providerId]?.windowBudget != null;
|
|
52
|
+
const cycles = [];
|
|
53
|
+
let windowEndMs = -Infinity;
|
|
54
|
+
for (const e of params.events) {
|
|
55
|
+
const t = Date.parse(e.ts);
|
|
56
|
+
if (t >= windowEndMs) {
|
|
57
|
+
windowEndMs = t + params.windowMinutes * 60_000;
|
|
58
|
+
cycles.push({ start: new Date(t).toISOString(), events: 0, tokensSaved: 0, minutes: hasBudget ? 0 : null });
|
|
59
|
+
}
|
|
60
|
+
const cycle = cycles[cycles.length - 1];
|
|
61
|
+
cycle.events += 1;
|
|
62
|
+
cycle.tokensSaved += perEventTokensSaved(e);
|
|
63
|
+
if (cycle.minutes !== null)
|
|
64
|
+
cycle.minutes += perEventCycleMinutes(e, params.providerId) ?? 0;
|
|
65
|
+
}
|
|
66
|
+
return cycles.map(c => ({ ...c, minutes: c.minutes === null ? null : Number(c.minutes.toFixed(3)) }));
|
|
67
|
+
}
|
|
68
|
+
/** Pure aggregation over ledger events, so it is testable without a database. */
|
|
69
|
+
export function computeDashboardData(events) {
|
|
70
|
+
const sorted = [...events].sort((a, b) => a.ts.localeCompare(b.ts));
|
|
71
|
+
const real = sorted.filter(e => e.environment !== 'bench');
|
|
72
|
+
const bench = sorted.filter(e => e.environment === 'bench');
|
|
73
|
+
const routing = { resolvedLocal: 0, distilledForwarded: 0, escalatedCloud: 0 };
|
|
74
|
+
const days = new Map();
|
|
75
|
+
for (const e of real) {
|
|
76
|
+
const day = new Date(e.ts).toISOString().slice(0, 10);
|
|
77
|
+
const row = days.get(day) ?? { day, events: 0, tokensSaved: 0, baselineTokens: 0, resolvedLocal: 0, distilledForwarded: 0, escalatedCloud: 0 };
|
|
78
|
+
row.events += 1;
|
|
79
|
+
row.tokensSaved += perEventTokensSaved(e);
|
|
80
|
+
row.baselineTokens += perEventBaselineTokens(e);
|
|
81
|
+
if (e.route !== 'feedback') {
|
|
82
|
+
const outcome = routingOutcome(e) === 'resolved_local' ? 'resolvedLocal'
|
|
83
|
+
: routingOutcome(e) === 'distilled_forwarded' ? 'distilledForwarded' : 'escalatedCloud';
|
|
84
|
+
row[outcome] += 1;
|
|
85
|
+
routing[outcome] += 1;
|
|
86
|
+
}
|
|
87
|
+
days.set(day, row);
|
|
88
|
+
}
|
|
89
|
+
const accuracySlice = (slice) => {
|
|
90
|
+
const scores = slice.map(accuracyOf).filter((v) => v !== null);
|
|
91
|
+
if (scores.length === 0)
|
|
92
|
+
return { avgPct: null, n: 0 };
|
|
93
|
+
return { avgPct: Number((scores.reduce((s, v) => s + v, 0) / scores.length).toFixed(2)), n: scores.length };
|
|
94
|
+
};
|
|
95
|
+
const registry = getProviderRegistry();
|
|
96
|
+
const providers = PROVIDERS.map(({ id, label }) => {
|
|
97
|
+
const plan = resolvedPlan(id);
|
|
98
|
+
const mine = real.filter(e => resolveProvider(e) === id);
|
|
99
|
+
const cycles = clusterCycles({ events: mine, windowMinutes: plan.windowMinutes, providerId: id });
|
|
100
|
+
const budget = registry[id]?.windowBudget ?? null;
|
|
101
|
+
const weeks = new Map();
|
|
102
|
+
for (const e of mine) {
|
|
103
|
+
const weekStart = weekStartOf(e.ts);
|
|
104
|
+
const row = weeks.get(weekStart) ?? { weekStart, events: 0, tokensSaved: 0, minutes: budget === null ? null : 0 };
|
|
105
|
+
row.events += 1;
|
|
106
|
+
row.tokensSaved += perEventTokensSaved(e);
|
|
107
|
+
if (row.minutes !== null)
|
|
108
|
+
row.minutes = Number((row.minutes + (perEventCycleMinutes(e, id) ?? 0)).toFixed(3));
|
|
109
|
+
weeks.set(weekStart, row);
|
|
110
|
+
}
|
|
111
|
+
const minuteValues = cycles.map(c => c.minutes).filter((m) => m !== null).sort((a, b) => a - b);
|
|
112
|
+
const round = (n) => Number(n.toFixed(3));
|
|
113
|
+
return {
|
|
114
|
+
id,
|
|
115
|
+
label,
|
|
116
|
+
plan: plan.plan,
|
|
117
|
+
windowMinutes: plan.windowMinutes,
|
|
118
|
+
windowBudgetTokens: budget,
|
|
119
|
+
events: mine.length,
|
|
120
|
+
tokensSaved: mine.reduce((s, e) => s + perEventTokensSaved(e), 0),
|
|
121
|
+
minutesTotal: budget === null ? null : round(minuteValues.reduce((s, m) => s + m, 0)),
|
|
122
|
+
avgMinutesPerCycle: minuteValues.length === 0 ? null : round(minuteValues.reduce((s, m) => s + m, 0) / minuteValues.length),
|
|
123
|
+
medianMinutesPerCycle: minuteValues.length === 0 ? null : round(minuteValues[Math.floor(minuteValues.length / 2)]),
|
|
124
|
+
cycles,
|
|
125
|
+
weeks: [...weeks.values()].sort((a, b) => a.weekStart.localeCompare(b.weekStart)),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
generatedAt: new Date().toISOString(),
|
|
130
|
+
firstEventTs: sorted[0]?.ts ?? null,
|
|
131
|
+
lastEventTs: sorted[sorted.length - 1]?.ts ?? null,
|
|
132
|
+
realEvents: real.length,
|
|
133
|
+
benchEvents: bench.length,
|
|
134
|
+
routing,
|
|
135
|
+
accuracy: { real: accuracySlice(real), bench: accuracySlice(bench) },
|
|
136
|
+
days: [...days.values()].sort((a, b) => a.day.localeCompare(b.day)),
|
|
137
|
+
providers,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Reads the ledger read-only and aggregates it. Throws when the ledger does not exist yet. */
|
|
141
|
+
export function loadDashboardData() {
|
|
142
|
+
const db = new Database(CONFIG.LEDGER_PATH, { readonly: true, fileMustExist: true });
|
|
143
|
+
try {
|
|
144
|
+
return computeDashboardData(db.prepare('SELECT * FROM events').all());
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
db.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Bakes the dashboard into a static site for free hosting (GitHub Pages).
|
|
3
|
+
*
|
|
4
|
+
* Writes <out>/index.html (the same page the local server serves), <out>/data.json
|
|
5
|
+
* (aggregates only: counts, token sums, minutes, dates — no prompts, tool names or
|
|
6
|
+
* skill names, so it is safe to publish; the timestamps do reveal when you work),
|
|
7
|
+
* and <out>/.nojekyll.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* pnpm run dashboard:export [-- --out site]
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { CONFIG } from '../config.js';
|
|
15
|
+
import { isEntryPoint } from '../utils/entry-point.js';
|
|
16
|
+
import { loadDashboardData } from './data.js';
|
|
17
|
+
function main() {
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const outIdx = args.indexOf('--out');
|
|
20
|
+
const outDir = path.resolve(CONFIG.ROOT_DIR, outIdx >= 0 ? args[outIdx + 1] : 'site');
|
|
21
|
+
const data = loadDashboardData();
|
|
22
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
23
|
+
fs.copyFileSync(path.join(CONFIG.ROOT_DIR, 'src', 'dashboard', 'index.html'), path.join(outDir, 'index.html'));
|
|
24
|
+
fs.writeFileSync(path.join(outDir, 'data.json'), JSON.stringify(data, null, 2));
|
|
25
|
+
fs.writeFileSync(path.join(outDir, '.nojekyll'), '');
|
|
26
|
+
console.log(`Exported ${data.realEvents + data.benchEvents} events of aggregates to ${outDir}/`);
|
|
27
|
+
console.log('Contains numbers and dates only — no prompts, tool names or skill names.');
|
|
28
|
+
console.log('\nTo publish on GitHub Pages:');
|
|
29
|
+
console.log(' 1. git add site && commit && push (the Pages workflow deploys site/ from main)');
|
|
30
|
+
console.log(' 2. Once, in the repository: Settings → Pages → Source: GitHub Actions');
|
|
31
|
+
console.log(' 3. Your dashboard: https://<owner>.github.io/<repo>/');
|
|
32
|
+
console.log('\nOr view without hosting anything: open the page anywhere and drop this data.json on it.');
|
|
33
|
+
}
|
|
34
|
+
if (isEntryPoint(import.meta.url)) {
|
|
35
|
+
try {
|
|
36
|
+
main();
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
console.error('dashboard:export failed:', err instanceof Error ? err.message : err);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Serves the metrics dashboard locally, live from the ledger.
|
|
3
|
+
*
|
|
4
|
+
* Read-only: the ledger is opened read-only per request and nothing is written anywhere.
|
|
5
|
+
* Local-only via the same rule as the gate's other servers (listenOnThisComputer).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* pnpm run dashboard [-- --port 8790]
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { CONFIG } from '../config.js';
|
|
13
|
+
import { isEntryPoint } from '../utils/entry-point.js';
|
|
14
|
+
import { isLocalRequest, listenOnThisComputer } from '../utils/local-only.js';
|
|
15
|
+
import { loadDashboardData } from './data.js';
|
|
16
|
+
const PAGE_PATH = path.join(CONFIG.ROOT_DIR, 'src', 'dashboard', 'index.html');
|
|
17
|
+
function handler(req, res) {
|
|
18
|
+
if (!isLocalRequest(req.headers)) {
|
|
19
|
+
res.writeHead(403).end('local requests only');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const url = (req.url ?? '/').split('?')[0];
|
|
23
|
+
try {
|
|
24
|
+
if (url === '/' || url === '/index.html') {
|
|
25
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(fs.readFileSync(PAGE_PATH));
|
|
26
|
+
}
|
|
27
|
+
else if (url === '/data.json') {
|
|
28
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(loadDashboardData()));
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
res.writeHead(404).end('not found');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
36
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' }).end(/(ENOENT|does not exist)/i.test(message)
|
|
37
|
+
? `No ledger at ${CONFIG.LEDGER_PATH} yet — it is created when the gate handles its first request.`
|
|
38
|
+
: message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function main() {
|
|
42
|
+
const args = process.argv.slice(2);
|
|
43
|
+
const portIdx = args.indexOf('--port');
|
|
44
|
+
const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 8790;
|
|
45
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
46
|
+
console.error('--port takes a port number.');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
await listenOnThisComputer({
|
|
50
|
+
handler,
|
|
51
|
+
port,
|
|
52
|
+
onListening: () => {
|
|
53
|
+
console.log(`SLM Gate metrics dashboard: http://localhost:${port}`);
|
|
54
|
+
console.log(`Reading ${CONFIG.LEDGER_PATH} (read-only, computed fresh on every reload). Ctrl-C to stop.`);
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (isEntryPoint(import.meta.url)) {
|
|
59
|
+
main().catch(err => {
|
|
60
|
+
console.error('dashboard failed to start:', err instanceof Error ? err.message : err);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
});
|
|
63
|
+
}
|