@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
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { CONFIG } from '../config.js';
|
|
4
|
+
import { waitWithBackoff } from '../utils/backoff.js';
|
|
5
|
+
import { getProviderRegistry, minutesFreed, providerFromAgentName, providerFromModelId, } from '../pricing/providers.js';
|
|
6
|
+
let db = null;
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
export function getDb() {
|
|
10
|
+
if (!db) {
|
|
11
|
+
// CONFIG.LEDGER_PATH always resolves (a blank env value is treated as unset and takes the
|
|
12
|
+
// absolute default under OUTPUT_DIR), so there is deliberately no cwd-relative fallback:
|
|
13
|
+
// MCP hosts spawn this process with a working directory that may not exist.
|
|
14
|
+
const ledgerPath = CONFIG.LEDGER_PATH;
|
|
15
|
+
const dir = path.dirname(ledgerPath);
|
|
16
|
+
if (!fs.existsSync(dir)) {
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
db = new Database(ledgerPath);
|
|
20
|
+
db.pragma('journal_mode = WAL');
|
|
21
|
+
db.exec(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS distill_policy (
|
|
23
|
+
tool_pattern TEXT PRIMARY KEY,
|
|
24
|
+
fidelity TEXT CHECK (fidelity IN ('verbatim','structural','summarize')),
|
|
25
|
+
priority INTEGER,
|
|
26
|
+
updated_at TEXT
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS distill_feedback (
|
|
30
|
+
id TEXT PRIMARY KEY,
|
|
31
|
+
tool_name TEXT,
|
|
32
|
+
skill TEXT,
|
|
33
|
+
content_hash TEXT,
|
|
34
|
+
region_text TEXT,
|
|
35
|
+
embedding_blob BLOB,
|
|
36
|
+
signal INTEGER,
|
|
37
|
+
created_at TEXT
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_distill_feedback_tool ON distill_feedback(tool_name);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_distill_feedback_created ON distill_feedback(created_at);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
44
|
+
ts TEXT,
|
|
45
|
+
layer TEXT,
|
|
46
|
+
request_id TEXT UNIQUE PRIMARY KEY,
|
|
47
|
+
session_id TEXT,
|
|
48
|
+
skill TEXT,
|
|
49
|
+
route TEXT,
|
|
50
|
+
is_local_call INTEGER,
|
|
51
|
+
slm_model TEXT,
|
|
52
|
+
api_model TEXT,
|
|
53
|
+
in_tok INTEGER,
|
|
54
|
+
out_tok INTEGER,
|
|
55
|
+
api_in_tok INTEGER,
|
|
56
|
+
api_out_tok INTEGER,
|
|
57
|
+
cost_usd REAL,
|
|
58
|
+
slm_latency_s REAL,
|
|
59
|
+
api_latency_s REAL,
|
|
60
|
+
verifier_flags TEXT,
|
|
61
|
+
quality_score REAL,
|
|
62
|
+
slm_gate TEXT,
|
|
63
|
+
meta TEXT,
|
|
64
|
+
provider TEXT,
|
|
65
|
+
agent TEXT,
|
|
66
|
+
environment TEXT
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
70
|
+
key TEXT PRIMARY KEY,
|
|
71
|
+
value TEXT,
|
|
72
|
+
ts TEXT
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
-- The model gate's distillation decisions: for each original tool result (by key), the exact
|
|
76
|
+
-- text the gate sent. The first decision wins and rows are never pruned automatically: every
|
|
77
|
+
-- later request must resend the same bytes, or provider caches and Claude's thinking break.
|
|
78
|
+
CREATE TABLE IF NOT EXISTS llm_distilled (
|
|
79
|
+
key TEXT PRIMARY KEY,
|
|
80
|
+
text TEXT NOT NULL,
|
|
81
|
+
outcome TEXT NOT NULL,
|
|
82
|
+
created_at TEXT NOT NULL
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE IF NOT EXISTS langfuse_queue (
|
|
86
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
+
payload TEXT,
|
|
88
|
+
synced INTEGER DEFAULT 0,
|
|
89
|
+
attempts INTEGER DEFAULT 0
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
-- Queue rows Langfuse kept rejecting item by item. Parked here, never deleted, so a
|
|
93
|
+
-- malformed row cannot block the queue and its data is still there to inspect or resend.
|
|
94
|
+
CREATE TABLE IF NOT EXISTS langfuse_dead_letter (
|
|
95
|
+
id INTEGER PRIMARY KEY,
|
|
96
|
+
payload TEXT,
|
|
97
|
+
attempts INTEGER,
|
|
98
|
+
error TEXT,
|
|
99
|
+
dead_at TEXT
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
CREATE TABLE IF NOT EXISTS elision_cache (
|
|
103
|
+
id TEXT PRIMARY KEY,
|
|
104
|
+
tool_name TEXT,
|
|
105
|
+
args TEXT,
|
|
106
|
+
original_text TEXT,
|
|
107
|
+
ranges TEXT,
|
|
108
|
+
content_hash TEXT,
|
|
109
|
+
created_at TEXT,
|
|
110
|
+
last_accessed_at TEXT,
|
|
111
|
+
size_bytes INTEGER
|
|
112
|
+
);
|
|
113
|
+
`);
|
|
114
|
+
// Additive, idempotent column migrations. CREATE TABLE IF NOT EXISTS above only covers
|
|
115
|
+
// fresh databases, and this process runs headless inside MCP hosts where nobody will
|
|
116
|
+
// remember to run scripts/migrations/*. Every column here must be nullable.
|
|
117
|
+
for (const [table, column, ddl] of [
|
|
118
|
+
['events', 'provider', 'ALTER TABLE events ADD COLUMN provider TEXT'],
|
|
119
|
+
['events', 'agent', 'ALTER TABLE events ADD COLUMN agent TEXT'],
|
|
120
|
+
['events', 'environment', 'ALTER TABLE events ADD COLUMN environment TEXT'],
|
|
121
|
+
['langfuse_queue', 'attempts', 'ALTER TABLE langfuse_queue ADD COLUMN attempts INTEGER DEFAULT 0'],
|
|
122
|
+
]) {
|
|
123
|
+
const tableInfo = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
124
|
+
if (!Array.isArray(tableInfo) || tableInfo.some(c => c.name === column))
|
|
125
|
+
continue;
|
|
126
|
+
try {
|
|
127
|
+
db.exec(ddl);
|
|
128
|
+
console.error(`[ledger] Migrated: added ${table}.${column}`);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
// "duplicate column name" means another process won the race — benign.
|
|
132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
133
|
+
if (!/duplicate column name/i.test(message))
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const policyCount = db.prepare('SELECT count(*) as c FROM distill_policy').get();
|
|
138
|
+
if (!policyCount || policyCount.c === 0) {
|
|
139
|
+
const stmt = db.prepare('INSERT INTO distill_policy (tool_pattern, fidelity, priority, updated_at) VALUES (?, ?, ?, ?)');
|
|
140
|
+
const now = new Date().toISOString();
|
|
141
|
+
stmt.run('%skill%', 'verbatim', 10, now);
|
|
142
|
+
stmt.run('get_skill', 'verbatim', 10, now);
|
|
143
|
+
stmt.run('read_file', 'structural', 5, now);
|
|
144
|
+
stmt.run('view_file', 'structural', 5, now);
|
|
145
|
+
stmt.run('run_command', 'summarize', 0, now);
|
|
146
|
+
stmt.run('get_logs', 'summarize', 0, now);
|
|
147
|
+
stmt.run('grep_search', 'summarize', 0, now);
|
|
148
|
+
stmt.run('list_dir', 'summarize', 0, now);
|
|
149
|
+
stmt.run('*', 'summarize', -1, now);
|
|
150
|
+
}
|
|
151
|
+
// Automatic cleanup (startup sweep)
|
|
152
|
+
const retentionDays = CONFIG.ELISION_RETENTION_DAYS ?? 180;
|
|
153
|
+
const cutoffDate = new Date();
|
|
154
|
+
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
|
155
|
+
db.prepare(`DELETE FROM elision_cache WHERE created_at < ?`).run(cutoffDate.toISOString());
|
|
156
|
+
}
|
|
157
|
+
return db;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Log the resolved ledger database path and event count to stderr.
|
|
161
|
+
* Call at startup from each layer entry point so divergent paths are immediately obvious.
|
|
162
|
+
*
|
|
163
|
+
* @param layer - Identifier for the calling layer (e.g. 'mcp-gate', 'llm-gate', 'sync')
|
|
164
|
+
*/
|
|
165
|
+
export function logLedgerInfo(layer) {
|
|
166
|
+
const database = getDb();
|
|
167
|
+
const count = database.prepare('SELECT count(*) as c FROM events').get()?.c ?? 0;
|
|
168
|
+
const resolved = path.resolve(CONFIG.LEDGER_PATH);
|
|
169
|
+
console.error(`[${layer}] Using database: ${resolved} (${count} events)`);
|
|
170
|
+
}
|
|
171
|
+
export function writeElision(record) {
|
|
172
|
+
const ts = new Date().toISOString();
|
|
173
|
+
// Size cap constraint
|
|
174
|
+
const maxMb = CONFIG.ELISION_MAX_MB ?? 500;
|
|
175
|
+
const maxBytes = maxMb * 1024 * 1024;
|
|
176
|
+
const db = getDb();
|
|
177
|
+
// Start a transaction for the write + eviction
|
|
178
|
+
const transaction = db.transaction(() => {
|
|
179
|
+
const insertStmt = db.prepare(`
|
|
180
|
+
INSERT OR REPLACE INTO elision_cache (
|
|
181
|
+
id, tool_name, args, original_text, ranges, content_hash, created_at, last_accessed_at, size_bytes
|
|
182
|
+
) VALUES (
|
|
183
|
+
@id, @tool_name, @args, @original_text, @ranges, @content_hash, @created_at, @last_accessed_at, @size_bytes
|
|
184
|
+
)
|
|
185
|
+
`);
|
|
186
|
+
insertStmt.run({
|
|
187
|
+
...record,
|
|
188
|
+
created_at: ts,
|
|
189
|
+
last_accessed_at: ts
|
|
190
|
+
});
|
|
191
|
+
// Evict oldest by last_accessed_at if we exceed max size
|
|
192
|
+
db.prepare(`
|
|
193
|
+
DELETE FROM elision_cache
|
|
194
|
+
WHERE id IN (
|
|
195
|
+
SELECT id FROM (
|
|
196
|
+
SELECT id, sum(size_bytes) OVER (ORDER BY last_accessed_at DESC) as running_total
|
|
197
|
+
FROM elision_cache
|
|
198
|
+
) WHERE running_total > ?
|
|
199
|
+
)
|
|
200
|
+
`).run(maxBytes);
|
|
201
|
+
});
|
|
202
|
+
transaction();
|
|
203
|
+
}
|
|
204
|
+
export function getElision(id) {
|
|
205
|
+
const db = getDb();
|
|
206
|
+
const row = db.prepare(`SELECT * FROM elision_cache WHERE id = ?`).get(id);
|
|
207
|
+
if (row) {
|
|
208
|
+
// Lazy expiry check
|
|
209
|
+
const retentionDays = CONFIG.ELISION_RETENTION_DAYS ?? 180;
|
|
210
|
+
const cutoffDate = new Date();
|
|
211
|
+
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
|
212
|
+
if (new Date(row.created_at) < cutoffDate) {
|
|
213
|
+
db.prepare(`DELETE FROM elision_cache WHERE id = ?`).run(id);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
db.prepare(`UPDATE elision_cache SET last_accessed_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
|
|
217
|
+
return row;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
export function isLocalEvent(e) {
|
|
222
|
+
return e.route === 'defer_local' || (!!e.verifier_flags && !e.verifier_flags.includes('escalate'));
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Classifies what actually happened to a request.
|
|
226
|
+
*
|
|
227
|
+
* The previous binary (isLocalEvent) had no slot for `condition`, so every distilled MCP
|
|
228
|
+
* tool result was labelled "Escalated (Cloud)" in the `verified` pie while the same trace
|
|
229
|
+
* carried a `call:local` tag — the two widgets flatly contradicted each other. Distillation
|
|
230
|
+
* is a third outcome: real local work was done, AND the payload still went to the cloud.
|
|
231
|
+
*
|
|
232
|
+
* @param e The ledger event to classify
|
|
233
|
+
* @returns Which of the three routing outcomes occurred
|
|
234
|
+
*/
|
|
235
|
+
export function routingOutcome(e) {
|
|
236
|
+
if (isLocalEvent(e))
|
|
237
|
+
return 'resolved_local';
|
|
238
|
+
if (e.route === 'condition' || e.route === 'forward_compressed')
|
|
239
|
+
return 'distilled_forwarded';
|
|
240
|
+
return 'escalated_cloud';
|
|
241
|
+
}
|
|
242
|
+
// Detection is delegated to the data-driven provider registry so a new host or vendor is
|
|
243
|
+
// configuration (PROVIDER_REGISTRY_PATH), not a code change and a release.
|
|
244
|
+
export function providerFromModel(model) {
|
|
245
|
+
return providerFromModelId(model);
|
|
246
|
+
}
|
|
247
|
+
export function providerFromAgent(agent) {
|
|
248
|
+
return providerFromAgentName(agent);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Resolves the provider for an event using every available signal, in priority order.
|
|
252
|
+
*
|
|
253
|
+
* @param e The ledger event
|
|
254
|
+
* @returns Provider id, or null when the event cannot be attributed.
|
|
255
|
+
*/
|
|
256
|
+
export function resolveProvider(e) {
|
|
257
|
+
return e.provider ?? providerFromModelId(e.api_model) ?? providerFromAgentName(e.agent) ?? CONFIG.PROVIDER ?? null;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Units of a provider's window that this single event frees.
|
|
261
|
+
*
|
|
262
|
+
* The unit depends on the metering model: requests never sent for 'message' providers,
|
|
263
|
+
* tokens never sent for 'compute' providers. Returning units (not minutes) keeps this
|
|
264
|
+
* honest — conversion to minutes requires a window budget the gate cannot observe.
|
|
265
|
+
*
|
|
266
|
+
* @param e The ledger event
|
|
267
|
+
* @param metering How the provider meters its window
|
|
268
|
+
* @returns Units saved, never negative.
|
|
269
|
+
*/
|
|
270
|
+
export function perEventUnitsSaved(e, metering) {
|
|
271
|
+
if (metering === 'message') {
|
|
272
|
+
// Only a prompt answered entirely locally avoids a request. A distilled-but-forwarded
|
|
273
|
+
// payload still costs one message, no matter how much it was compressed.
|
|
274
|
+
return e.route === 'defer_local' ? 1 : 0;
|
|
275
|
+
}
|
|
276
|
+
return perEventTokensSaved(e);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Minutes of a provider's rolling window freed by a single event.
|
|
280
|
+
*
|
|
281
|
+
* @param e The ledger event
|
|
282
|
+
* @param providerId Provider id, as resolved by resolveProvider()
|
|
283
|
+
* @returns Minutes freed, clamped to [0, windowMinutes], or null when the provider is
|
|
284
|
+
* unknown or its window budget has not been configured.
|
|
285
|
+
*/
|
|
286
|
+
export function perEventCycleMinutes(e, providerId) {
|
|
287
|
+
const profile = getProviderRegistry()[providerId];
|
|
288
|
+
if (!profile)
|
|
289
|
+
return null;
|
|
290
|
+
// No budget means no denominator. Emitting a number here is what produced the old
|
|
291
|
+
// nonsense figures, so minutesFreed returns null instead.
|
|
292
|
+
return minutesFreed(profile, perEventUnitsSaved(e, profile.metering));
|
|
293
|
+
}
|
|
294
|
+
export function perEventTokensSaved(e) {
|
|
295
|
+
// 'feedback' rows record a user action (an elision expansion), not model work.
|
|
296
|
+
if (e.route === 'feedback')
|
|
297
|
+
return 0;
|
|
298
|
+
const parsedMeta = e.meta ? (() => { try {
|
|
299
|
+
return JSON.parse(e.meta);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return {};
|
|
303
|
+
} })() : {};
|
|
304
|
+
if (e.route === 'defer_local') {
|
|
305
|
+
return (e.in_tok || 0) + (e.out_tok || 0);
|
|
306
|
+
}
|
|
307
|
+
else if (e.route === 'forward_compressed') {
|
|
308
|
+
const rawInTok = typeof parsedMeta.raw_in_tok === 'number' ? parsedMeta.raw_in_tok : (e.api_in_tok > 0 ? Math.round(e.api_in_tok * 1.5) : e.in_tok);
|
|
309
|
+
const baselineTokens = rawInTok + (e.api_out_tok || e.out_tok || 0);
|
|
310
|
+
const actualTokens = (e.api_in_tok || 0) + (e.api_out_tok || 0);
|
|
311
|
+
return Math.max(0, baselineTokens - actualTokens);
|
|
312
|
+
}
|
|
313
|
+
else if (e.route === 'condition') {
|
|
314
|
+
const baselineTokens = e.in_tok || 0;
|
|
315
|
+
const actualTokens = e.out_tok || 0;
|
|
316
|
+
return Math.max(0, baselineTokens - actualTokens);
|
|
317
|
+
}
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
export function perEventBaselineTokens(e) {
|
|
321
|
+
if (e.route === 'feedback')
|
|
322
|
+
return 0;
|
|
323
|
+
const parsedMeta = e.meta ? (() => { try {
|
|
324
|
+
return JSON.parse(e.meta);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return {};
|
|
328
|
+
} })() : {};
|
|
329
|
+
if (e.route === 'defer_local') {
|
|
330
|
+
return (e.in_tok || 0) + (e.out_tok || 0);
|
|
331
|
+
}
|
|
332
|
+
else if (e.route === 'forward_compressed') {
|
|
333
|
+
const rawInTok = typeof parsedMeta.raw_in_tok === 'number' ? parsedMeta.raw_in_tok : (e.api_in_tok > 0 ? Math.round(e.api_in_tok * 1.5) : e.in_tok);
|
|
334
|
+
return rawInTok + (e.api_out_tok || e.out_tok || 0);
|
|
335
|
+
}
|
|
336
|
+
else if (e.route === 'condition') {
|
|
337
|
+
return e.in_tok || 0;
|
|
338
|
+
}
|
|
339
|
+
return (e.api_in_tok || e.in_tok || 0) + (e.api_out_tok || e.out_tok || 0);
|
|
340
|
+
}
|
|
341
|
+
export function computeTotals(rows) {
|
|
342
|
+
let tokensSaved = 0;
|
|
343
|
+
let baselineTokens = 0;
|
|
344
|
+
for (const r of rows) {
|
|
345
|
+
baselineTokens += perEventBaselineTokens(r);
|
|
346
|
+
tokensSaved += perEventTokensSaved(r);
|
|
347
|
+
}
|
|
348
|
+
return { tokensSaved, baselineTokens };
|
|
349
|
+
}
|
|
350
|
+
export function computeTotalsByProvider(rows, fallbackProvider = CONFIG.PROVIDER ?? null) {
|
|
351
|
+
const stats = {
|
|
352
|
+
claude: { tokensSaved: 0, baselineTokens: 0 },
|
|
353
|
+
chatgpt: { tokensSaved: 0, baselineTokens: 0 },
|
|
354
|
+
gemini: { tokensSaved: 0, baselineTokens: 0 },
|
|
355
|
+
};
|
|
356
|
+
for (const r of rows) {
|
|
357
|
+
const p = r.provider || providerFromModel(r.api_model) || providerFromAgent(r.agent) || fallbackProvider || null;
|
|
358
|
+
if (p && stats[p]) {
|
|
359
|
+
stats[p].baselineTokens += perEventBaselineTokens(r);
|
|
360
|
+
stats[p].tokensSaved += perEventTokensSaved(r);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return stats;
|
|
364
|
+
}
|
|
365
|
+
export function computeCycleRates(rows,
|
|
366
|
+
// Same default as computeCycleRateAvg. Without it, an event carrying no provider was
|
|
367
|
+
// attributed by the average but not by the total, so the two disagreed on the same data.
|
|
368
|
+
fallbackProvider = CONFIG.PROVIDER ?? null) {
|
|
369
|
+
// Aggregate minutes freed across all of a provider's traffic. Uses the same rate-based
|
|
370
|
+
// definition as the per-event score, so the two can never disagree.
|
|
371
|
+
const out = { claude: 0, chatgpt: 0, gemini: 0 };
|
|
372
|
+
for (const r of rows) {
|
|
373
|
+
const p = (r.provider || providerFromModel(r.api_model) || providerFromAgent(r.agent) || fallbackProvider || null);
|
|
374
|
+
if (p && p in out) {
|
|
375
|
+
out[p] += perEventCycleMinutes(r, p) ?? 0;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
for (const p of Object.keys(out))
|
|
379
|
+
out[p] = Number(out[p].toFixed(2));
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
export function computeCycleRateAvg(rows, fallbackProvider = CONFIG.PROVIDER ?? null) {
|
|
383
|
+
const counts = { claude: 0, chatgpt: 0, gemini: 0 };
|
|
384
|
+
const sums = { claude: 0, chatgpt: 0, gemini: 0 };
|
|
385
|
+
for (const r of rows) {
|
|
386
|
+
const p = (r.provider || providerFromModel(r.api_model) || providerFromAgent(r.agent) || fallbackProvider || null);
|
|
387
|
+
if (p && (p === 'claude' || p === 'chatgpt' || p === 'gemini')) {
|
|
388
|
+
// null means the provider has no configured window budget, so this event contributes
|
|
389
|
+
// nothing and is excluded from the denominator too — an unmeasurable event must not
|
|
390
|
+
// be averaged in as a zero.
|
|
391
|
+
const minutes = perEventCycleMinutes(r, p);
|
|
392
|
+
if (minutes !== null) {
|
|
393
|
+
sums[p] += minutes;
|
|
394
|
+
counts[p] += 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
claude: counts.claude > 0 ? sums.claude / counts.claude : null,
|
|
400
|
+
chatgpt: counts.chatgpt > 0 ? sums.chatgpt / counts.chatgpt : null,
|
|
401
|
+
gemini: counts.gemini > 0 ? sums.gemini / counts.gemini : null,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
export function writeEvent(e) {
|
|
405
|
+
// Resolve derived fields ONCE and carry them on a single enriched object, so SQLite and
|
|
406
|
+
// Langfuse can never disagree. Previously `provider` was computed into a local, written
|
|
407
|
+
// to SQLite, and then the *un-enriched* `e` was mirrored — leaving Langfuse to re-derive
|
|
408
|
+
// it through a chain ending in CONFIG.PROVIDER, which is unset when .env fails to load.
|
|
409
|
+
const event = {
|
|
410
|
+
...e,
|
|
411
|
+
provider: e.provider ?? providerFromModel(e.api_model) ?? providerFromAgent(e.agent) ?? CONFIG.PROVIDER ?? null,
|
|
412
|
+
environment: e.environment ?? CONFIG.LANGFUSE_ENVIRONMENT,
|
|
413
|
+
};
|
|
414
|
+
const statement = getDb().prepare(`
|
|
415
|
+
INSERT OR REPLACE INTO events (
|
|
416
|
+
ts, layer, request_id, session_id, skill, route, is_local_call, slm_model, api_model,
|
|
417
|
+
in_tok, out_tok, api_in_tok, api_out_tok, cost_usd, slm_latency_s, api_latency_s,
|
|
418
|
+
verifier_flags, quality_score, slm_gate, meta, provider, agent, environment
|
|
419
|
+
) VALUES (
|
|
420
|
+
@ts, @layer, @request_id, @session_id, @skill, @route, @is_local_call, @slm_model, @api_model,
|
|
421
|
+
@in_tok, @out_tok, @api_in_tok, @api_out_tok, @cost_usd, @slm_latency_s, @api_latency_s,
|
|
422
|
+
@verifier_flags, @quality_score, @slm_gate, @meta, @provider, @agent, @environment
|
|
423
|
+
)
|
|
424
|
+
`);
|
|
425
|
+
// better-sqlite3 strictly requires all named parameters to exist on the object,
|
|
426
|
+
// so we must coalesce any undefined optional properties to null.
|
|
427
|
+
statement.run({
|
|
428
|
+
ts: event.ts,
|
|
429
|
+
layer: event.layer,
|
|
430
|
+
request_id: event.request_id,
|
|
431
|
+
session_id: event.session_id ?? null,
|
|
432
|
+
skill: event.skill ?? null,
|
|
433
|
+
route: event.route,
|
|
434
|
+
is_local_call: event.is_local_call,
|
|
435
|
+
slm_model: event.slm_model ?? null,
|
|
436
|
+
api_model: event.api_model ?? null,
|
|
437
|
+
in_tok: event.in_tok,
|
|
438
|
+
out_tok: event.out_tok,
|
|
439
|
+
api_in_tok: event.api_in_tok,
|
|
440
|
+
api_out_tok: event.api_out_tok,
|
|
441
|
+
cost_usd: event.cost_usd,
|
|
442
|
+
slm_latency_s: event.slm_latency_s,
|
|
443
|
+
api_latency_s: event.api_latency_s,
|
|
444
|
+
verifier_flags: event.verifier_flags ?? null,
|
|
445
|
+
quality_score: event.quality_score ?? null,
|
|
446
|
+
slm_gate: event.slm_gate,
|
|
447
|
+
meta: event.meta ?? null,
|
|
448
|
+
provider: event.provider,
|
|
449
|
+
agent: event.agent ?? null,
|
|
450
|
+
environment: event.environment ?? null
|
|
451
|
+
});
|
|
452
|
+
// Mirror to Langfuse if enabled
|
|
453
|
+
LangfuseSink.mirrorEvent(event);
|
|
454
|
+
}
|
|
455
|
+
export function cacheGet(key) {
|
|
456
|
+
const statement = getDb().prepare('SELECT value FROM cache WHERE key = ?');
|
|
457
|
+
const row = statement.get(key);
|
|
458
|
+
return row ? row.value : null;
|
|
459
|
+
}
|
|
460
|
+
export function cacheSet(key, value) {
|
|
461
|
+
const statement = getDb().prepare('INSERT OR REPLACE INTO cache (key, value, ts) VALUES (?, ?, ?)');
|
|
462
|
+
statement.run(key, value, new Date().toISOString());
|
|
463
|
+
}
|
|
464
|
+
/** The model gate's stored decision for a tool result, or null when it has none. Throws on database errors. */
|
|
465
|
+
export function getDistillDecision(key) {
|
|
466
|
+
const row = getDb().prepare('SELECT text, outcome FROM llm_distilled WHERE key = ?').get(key);
|
|
467
|
+
return row ?? null;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Stores a decision unless one already exists and returns the one that stands; the caller must send
|
|
471
|
+
* exactly that text. Insert and read-back share one transaction, so a decision is never on disk
|
|
472
|
+
* without the caller knowing it. Throws on database errors (e.g. the gate's short busy timeout), in
|
|
473
|
+
* which case nothing was stored.
|
|
474
|
+
*/
|
|
475
|
+
export function recordDistillDecision(params) {
|
|
476
|
+
const db = getDb();
|
|
477
|
+
return db.transaction(() => {
|
|
478
|
+
db.prepare('INSERT OR IGNORE INTO llm_distilled (key, text, outcome, created_at) VALUES (?, ?, ?, ?)')
|
|
479
|
+
.run(params.key, params.text, params.outcome, new Date().toISOString());
|
|
480
|
+
return db.prepare('SELECT text, outcome FROM llm_distilled WHERE key = ?').get(params.key);
|
|
481
|
+
})();
|
|
482
|
+
}
|
|
483
|
+
import { safeCalculateCostUsd } from '../pricing/index.js';
|
|
484
|
+
/**
|
|
485
|
+
* Carried by every trace the gate writes. Other programs may write to the same Langfuse
|
|
486
|
+
* project, so ledger:verify counts and langfuse:wipe deletes only traces with this tag. The
|
|
487
|
+
* dashboard cards do not use it (see setup-dashboard.ts): they filter on the gate's score names.
|
|
488
|
+
*/
|
|
489
|
+
export const SLM_GATE_SOURCE_TAG = 'source:slm-gate';
|
|
490
|
+
export function formatEventForLangfuse(e) {
|
|
491
|
+
// Price against the model that actually served (or would have served) this event.
|
|
492
|
+
// Previously every defer_local/condition event was priced at CONFIG.CLOUD_MODEL, so
|
|
493
|
+
// Claude traffic was costed at Gemini rates — and because CLOUD_MODEL comes from .env,
|
|
494
|
+
// the figure silently changed depending on which directory the gate was spawned in.
|
|
495
|
+
const referenceCloudModel = e.api_model || CONFIG.CLOUD_MODEL || 'gemini-2.5-flash';
|
|
496
|
+
const baselineTokens = perEventBaselineTokens(e);
|
|
497
|
+
const tokensSaved = perEventTokensSaved(e);
|
|
498
|
+
let baselineCostUsd = 0;
|
|
499
|
+
let costSavedUsd = 0;
|
|
500
|
+
const parsedMeta = e.meta ? (() => { try {
|
|
501
|
+
return JSON.parse(e.meta);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return {};
|
|
505
|
+
} })() : {};
|
|
506
|
+
if (e.route === 'defer_local') {
|
|
507
|
+
baselineCostUsd = safeCalculateCostUsd(referenceCloudModel, e.in_tok, e.out_tok);
|
|
508
|
+
costSavedUsd = baselineCostUsd;
|
|
509
|
+
}
|
|
510
|
+
else if (e.route === 'forward_compressed') {
|
|
511
|
+
const rawInTok = typeof parsedMeta.raw_in_tok === 'number' ? parsedMeta.raw_in_tok : (e.api_in_tok > 0 ? Math.round(e.api_in_tok * 1.5) : e.in_tok);
|
|
512
|
+
baselineCostUsd = safeCalculateCostUsd(referenceCloudModel, rawInTok, e.api_out_tok || e.out_tok || 0);
|
|
513
|
+
costSavedUsd = Math.max(0, baselineCostUsd - (e.cost_usd || 0));
|
|
514
|
+
}
|
|
515
|
+
else if (e.route === 'condition') {
|
|
516
|
+
baselineCostUsd = safeCalculateCostUsd(referenceCloudModel, baselineTokens, 0);
|
|
517
|
+
const conditionedCostUsd = safeCalculateCostUsd(referenceCloudModel, e.out_tok || 0, 0);
|
|
518
|
+
costSavedUsd = Math.max(0, baselineCostUsd - conditionedCostUsd);
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
// forward_raw or escalate
|
|
522
|
+
baselineCostUsd = e.cost_usd || 0;
|
|
523
|
+
costSavedUsd = 0;
|
|
524
|
+
}
|
|
525
|
+
const traceName = e.skill
|
|
526
|
+
? e.skill
|
|
527
|
+
: (e.layer === 'mcp' ? `[mcp] ${e.route}` : `[llm] ${e.route}`);
|
|
528
|
+
const tags = [
|
|
529
|
+
SLM_GATE_SOURCE_TAG,
|
|
530
|
+
e.slm_gate === 'on' ? 'slm_gate=on' : 'slm_gate=off',
|
|
531
|
+
`route:${e.route}`,
|
|
532
|
+
`layer:${e.layer}`,
|
|
533
|
+
`call:${routingOutcome(e) === 'resolved_local' ? 'local' : 'cloud'}`,
|
|
534
|
+
`outcome:${routingOutcome(e)}`,
|
|
535
|
+
`model:${e.api_model || e.slm_model || 'unknown'}`
|
|
536
|
+
];
|
|
537
|
+
const metadata = {
|
|
538
|
+
...parsedMeta,
|
|
539
|
+
route: e.route,
|
|
540
|
+
layer: e.layer,
|
|
541
|
+
is_local_call: Boolean(e.is_local_call),
|
|
542
|
+
slm_model: e.slm_model ?? undefined,
|
|
543
|
+
api_model: e.api_model ?? undefined,
|
|
544
|
+
slm_latency_s: e.slm_latency_s,
|
|
545
|
+
api_latency_s: e.api_latency_s,
|
|
546
|
+
cost_usd: e.cost_usd,
|
|
547
|
+
baseline_tokens: baselineTokens,
|
|
548
|
+
baseline_cost_usd: Number(baselineCostUsd.toFixed(6)),
|
|
549
|
+
tokens_saved: tokensSaved,
|
|
550
|
+
cost_saved_usd: Number(costSavedUsd.toFixed(6)),
|
|
551
|
+
verifier_flags: e.verifier_flags ? (() => { try {
|
|
552
|
+
return JSON.parse(e.verifier_flags);
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
return e.verifier_flags;
|
|
556
|
+
} })() : undefined,
|
|
557
|
+
};
|
|
558
|
+
const generations = [];
|
|
559
|
+
// Cloud generation
|
|
560
|
+
if (e.api_model && (e.api_in_tok > 0 || e.api_out_tok > 0)) {
|
|
561
|
+
const apiLatency = e.api_latency_s > 0 ? e.api_latency_s : 0.05;
|
|
562
|
+
generations.push({
|
|
563
|
+
id: `${e.request_id}_gen_cloud`,
|
|
564
|
+
name: 'cloud_api_call',
|
|
565
|
+
model: e.api_model,
|
|
566
|
+
usageDetails: {
|
|
567
|
+
input: e.api_in_tok,
|
|
568
|
+
output: e.api_out_tok,
|
|
569
|
+
total: e.api_in_tok + e.api_out_tok,
|
|
570
|
+
},
|
|
571
|
+
costDetails: {
|
|
572
|
+
total: e.cost_usd,
|
|
573
|
+
},
|
|
574
|
+
startTime: new Date(new Date(e.ts).getTime() - apiLatency * 1000).toISOString(),
|
|
575
|
+
endTime: new Date(e.ts).toISOString(),
|
|
576
|
+
metadata: {
|
|
577
|
+
cost_usd: e.cost_usd,
|
|
578
|
+
route: e.route,
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
// Local SLM generation (logged as generation so Langfuse aggregates local token throughput at $0)
|
|
583
|
+
if (e.slm_model && (e.in_tok > 0 || e.out_tok > 0)) {
|
|
584
|
+
const slmLatency = e.slm_latency_s > 0 ? e.slm_latency_s : 0.05;
|
|
585
|
+
const apiLatency = e.api_latency_s || 0;
|
|
586
|
+
generations.push({
|
|
587
|
+
id: `${e.request_id}_gen_local`,
|
|
588
|
+
name: 'local_slm_generation',
|
|
589
|
+
model: e.slm_model,
|
|
590
|
+
usageDetails: {
|
|
591
|
+
input: e.in_tok,
|
|
592
|
+
output: e.out_tok,
|
|
593
|
+
total: e.in_tok + e.out_tok,
|
|
594
|
+
},
|
|
595
|
+
costDetails: {
|
|
596
|
+
total: 0,
|
|
597
|
+
},
|
|
598
|
+
startTime: new Date(new Date(e.ts).getTime() - (apiLatency + slmLatency) * 1000).toISOString(),
|
|
599
|
+
endTime: new Date(new Date(e.ts).getTime() - apiLatency * 1000).toISOString(),
|
|
600
|
+
metadata: {
|
|
601
|
+
cost_usd: 0,
|
|
602
|
+
route: e.route,
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
const scores = [
|
|
607
|
+
{ id: `${e.request_id}_score_cost_saved`, name: 'cost_saved_cents', value: Number((costSavedUsd * 100).toFixed(6)), dataType: 'NUMERIC' },
|
|
608
|
+
{ id: `${e.request_id}_score_tokens_saved`, name: 'tokens_saved', value: tokensSaved, dataType: 'NUMERIC' },
|
|
609
|
+
];
|
|
610
|
+
// Accuracy rule
|
|
611
|
+
if (typeof e.quality_score === 'number') {
|
|
612
|
+
scores.push({ id: `${e.request_id}_score_accuracy_rate_pct`, name: 'accuracy_rate_pct', value: Number((e.quality_score * 100).toFixed(2)), dataType: 'NUMERIC' });
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
// Only score accuracy when a verifier ACTUALLY RAN. Previously `route === 'condition'`
|
|
616
|
+
// and `is_local_call === 1` both forced isAccepted true, so every MCP event scored a
|
|
617
|
+
// free 100 and the "SLM Accuracy Rate" widget measured nothing at all. The MCP path
|
|
618
|
+
// never invokes the verifier (src/verifier is only called from llm-gate/pipeline.ts),
|
|
619
|
+
// so those events must now produce NO accuracy score rather than a fake perfect one.
|
|
620
|
+
const localAttempted = parsedMeta.local_attempted === 1;
|
|
621
|
+
if (localAttempted) {
|
|
622
|
+
let hasFailureFlag = false;
|
|
623
|
+
if (e.verifier_flags) {
|
|
624
|
+
try {
|
|
625
|
+
const flags = JSON.parse(e.verifier_flags);
|
|
626
|
+
hasFailureFlag = Array.isArray(flags) && flags.length > 0;
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
hasFailureFlag = Boolean(e.verifier_flags);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const isAccepted = parsedMeta.local_accepted === 1 || !hasFailureFlag;
|
|
633
|
+
scores.push({ id: `${e.request_id}_score_accuracy_rate_pct`, name: 'accuracy_rate_pct', value: isAccepted ? 100 : 0, dataType: 'NUMERIC' });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
const outcome = routingOutcome(e);
|
|
637
|
+
const verifiedLabels = {
|
|
638
|
+
resolved_local: {
|
|
639
|
+
label: 'Passed (Local SLM)',
|
|
640
|
+
comment: 'Handled 100% locally by Small Language Model ($0 cloud cost)'
|
|
641
|
+
},
|
|
642
|
+
distilled_forwarded: {
|
|
643
|
+
label: 'Distilled (Forwarded)',
|
|
644
|
+
comment: 'Compressed locally by the SLM, then forwarded to the cloud model'
|
|
645
|
+
},
|
|
646
|
+
escalated_cloud: {
|
|
647
|
+
label: 'Escalated (Cloud)',
|
|
648
|
+
comment: 'Sent to the cloud model without local resolution'
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
scores.push({
|
|
652
|
+
id: `${e.request_id}_score_verified`,
|
|
653
|
+
name: 'verified',
|
|
654
|
+
value: verifiedLabels[outcome].label,
|
|
655
|
+
dataType: 'CATEGORICAL',
|
|
656
|
+
comment: verifiedLabels[outcome].comment
|
|
657
|
+
});
|
|
658
|
+
const resolvedProvider = resolveProvider(e);
|
|
659
|
+
if (resolvedProvider) {
|
|
660
|
+
const cycleMinutes = perEventCycleMinutes(e, resolvedProvider);
|
|
661
|
+
// Emitted only when the provider's window budget is configured. Without a denominator
|
|
662
|
+
// there is no honest way to express savings as minutes, so we stay silent rather than
|
|
663
|
+
// publishing the old ratio-times-window figure.
|
|
664
|
+
if (cycleMinutes !== null) {
|
|
665
|
+
// The same quantity in two units. A Langfuse widget picks a measure and an
|
|
666
|
+
// aggregation and cannot convert, so a card that reads in minutes needs a score in
|
|
667
|
+
// minutes. Minutes carry the raw value and are summed into a per-range total;
|
|
668
|
+
// seconds are averaged into a per-prompt figure, where minutes render as an
|
|
669
|
+
// unreadable 0.18934.
|
|
670
|
+
scores.push({
|
|
671
|
+
id: `${e.request_id}_score_cycle_min_${resolvedProvider}`,
|
|
672
|
+
name: `cycle_extended_minutes_${resolvedProvider}`,
|
|
673
|
+
value: cycleMinutes,
|
|
674
|
+
dataType: 'NUMERIC'
|
|
675
|
+
});
|
|
676
|
+
scores.push({
|
|
677
|
+
id: `${e.request_id}_score_cycle_sec_${resolvedProvider}`,
|
|
678
|
+
name: `cycle_extended_seconds_${resolvedProvider}`,
|
|
679
|
+
value: Number((cycleMinutes * 60).toFixed(1)),
|
|
680
|
+
dataType: 'NUMERIC'
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const environment = e.environment ?? CONFIG.LANGFUSE_ENVIRONMENT;
|
|
685
|
+
const eventTs = new Date(e.ts).toISOString();
|
|
686
|
+
return {
|
|
687
|
+
eventTs,
|
|
688
|
+
environment,
|
|
689
|
+
trace: {
|
|
690
|
+
id: e.request_id,
|
|
691
|
+
// TraceBody supports `timestamp` directly; ScoreBody does not, so scores rely on the
|
|
692
|
+
// ingestion envelope instead (see flushQueue / sync.ts).
|
|
693
|
+
timestamp: eventTs,
|
|
694
|
+
environment,
|
|
695
|
+
sessionId: e.session_id,
|
|
696
|
+
tags,
|
|
697
|
+
metadata,
|
|
698
|
+
name: traceName,
|
|
699
|
+
},
|
|
700
|
+
generations: generations.map(g => ({ ...g, environment })),
|
|
701
|
+
scores: scores.map(s => ({ ...s, environment })),
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/** Flushes a queue row may be rejected before it is parked in langfuse_dead_letter. */
|
|
705
|
+
const MAX_ROW_ATTEMPTS = 5;
|
|
706
|
+
function describeItemError(e) {
|
|
707
|
+
const detail = e.message ?? (typeof e.error === 'string' ? e.error : e.error === undefined ? '' : JSON.stringify(e.error));
|
|
708
|
+
return `status=${e.status ?? 'unknown'} ${detail}`.trim();
|
|
709
|
+
}
|
|
710
|
+
export class LangfuseSink {
|
|
711
|
+
static _warnedMissingKeys = false;
|
|
712
|
+
static hasValidConfig() {
|
|
713
|
+
const hasKeys = CONFIG.LANGFUSE_PUBLIC_KEY || CONFIG.LANGFUSE_SECRET_KEY || CONFIG.LANGFUSE_HOST;
|
|
714
|
+
const hasAllKeys = CONFIG.LANGFUSE_PUBLIC_KEY && CONFIG.LANGFUSE_SECRET_KEY && CONFIG.LANGFUSE_HOST;
|
|
715
|
+
if (hasAllKeys) {
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
else if (hasKeys && !this._warnedMissingKeys) {
|
|
719
|
+
console.error('Langfuse needs LANGFUSE_PUBLIC_KEY + SECRET_KEY + HOST — running ledger-only');
|
|
720
|
+
this._warnedMissingKeys = true;
|
|
721
|
+
}
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
static mirrorEvent(e) {
|
|
725
|
+
try {
|
|
726
|
+
const payload = formatEventForLangfuse(e);
|
|
727
|
+
const statement = getDb().prepare('INSERT INTO langfuse_queue (payload) VALUES (?)');
|
|
728
|
+
statement.run(JSON.stringify(payload));
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
console.error('Failed to queue langfuse event:', err);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Ship queued Langfuse payloads.
|
|
736
|
+
*
|
|
737
|
+
* Drains to empty rather than a single fixed-size page: the previous `LIMIT 50` with no
|
|
738
|
+
* loop meant throughput was capped at 50 events per invocation, so a backlog could never
|
|
739
|
+
* catch up. Rows are only deleted after the server accepts them, so a failure leaves the
|
|
740
|
+
* queue intact and the offline contract holds.
|
|
741
|
+
*
|
|
742
|
+
* Langfuse answers 207 when it takes the batch but rejects single items. A row is deleted
|
|
743
|
+
* only when every one of its items was accepted; a rejected row stays queued with its
|
|
744
|
+
* attempt count raised, and after MAX_ROW_ATTEMPTS it moves to langfuse_dead_letter so it
|
|
745
|
+
* cannot block the queue. Each row is sent at most once per call, so a rejection costs one
|
|
746
|
+
* attempt per flush, not one per batch.
|
|
747
|
+
*
|
|
748
|
+
* @param options.maxBatches Safety valve so a pathological queue cannot spin forever.
|
|
749
|
+
* @param options.deadlineMs Wall-clock budget; used by the shutdown drain, where MCP hosts
|
|
750
|
+
* force-kill after a short grace period.
|
|
751
|
+
* @returns Number of queue rows successfully shipped.
|
|
752
|
+
*/
|
|
753
|
+
static async flushQueue(options = {}) {
|
|
754
|
+
if (!this.hasValidConfig())
|
|
755
|
+
return 0;
|
|
756
|
+
const { maxBatches = 100, deadlineMs } = options;
|
|
757
|
+
const startedAt = Date.now();
|
|
758
|
+
const db = getDb();
|
|
759
|
+
const MAX_ATTEMPTS = 3;
|
|
760
|
+
let shipped = 0;
|
|
761
|
+
// Rows at or below this id were already sent during this call.
|
|
762
|
+
let lastId = 0;
|
|
763
|
+
for (let batchNo = 0; batchNo < maxBatches; batchNo++) {
|
|
764
|
+
if (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs) {
|
|
765
|
+
console.error('[ledger] Langfuse flush hit its time budget; remaining rows stay queued.');
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
// ORDER BY id so the oldest events drain first and ordering is deterministic.
|
|
769
|
+
const rows = db
|
|
770
|
+
.prepare('SELECT id, payload, attempts FROM langfuse_queue WHERE synced = 0 AND id > ? ORDER BY id ASC LIMIT 50')
|
|
771
|
+
.all(lastId);
|
|
772
|
+
if (rows.length === 0)
|
|
773
|
+
break;
|
|
774
|
+
lastId = rows[rows.length - 1].id;
|
|
775
|
+
const batch = [];
|
|
776
|
+
const rowIds = [];
|
|
777
|
+
// Each batch item's id → the queue row it came from, so a per-item error in a 207
|
|
778
|
+
// response can be traced back to the one row that must stay queued.
|
|
779
|
+
const rowIdByItemId = new Map();
|
|
780
|
+
for (const row of rows) {
|
|
781
|
+
rowIds.push(row.id);
|
|
782
|
+
const payload = JSON.parse(row.payload);
|
|
783
|
+
// Langfuse stamps the ingestion ENVELOPE, not the body. ScoreBody has no timestamp
|
|
784
|
+
// field at all, so without this every score lands at flush time and the whole time
|
|
785
|
+
// series collapses onto a few instants — which is what broke the date filters.
|
|
786
|
+
const envelopeTs = payload.eventTs ?? payload.trace?.timestamp ?? new Date().toISOString();
|
|
787
|
+
const add = (item) => {
|
|
788
|
+
const id = crypto.randomUUID();
|
|
789
|
+
rowIdByItemId.set(id, row.id);
|
|
790
|
+
batch.push({ id, type: item.type, timestamp: envelopeTs, body: item.body });
|
|
791
|
+
};
|
|
792
|
+
add({ type: 'trace-create', body: payload.trace });
|
|
793
|
+
const generations = payload.generations || (payload.generation ? [payload.generation] : []);
|
|
794
|
+
for (const gen of generations) {
|
|
795
|
+
add({ type: 'generation-create', body: { ...gen, traceId: payload.trace.id } });
|
|
796
|
+
}
|
|
797
|
+
if (payload.scores && Array.isArray(payload.scores)) {
|
|
798
|
+
for (const score of payload.scores) {
|
|
799
|
+
add({ type: 'score-create', body: { ...score, traceId: payload.trace.id } });
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (payload.span) {
|
|
803
|
+
add({ type: 'span-create', body: { ...payload.span, traceId: payload.trace.id } });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
let accepted = false;
|
|
807
|
+
let itemErrors = [];
|
|
808
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS && !accepted; attempt++) {
|
|
809
|
+
try {
|
|
810
|
+
const auth = Buffer.from(`${CONFIG.LANGFUSE_PUBLIC_KEY}:${CONFIG.LANGFUSE_SECRET_KEY}`).toString('base64');
|
|
811
|
+
const res = await fetch(`${CONFIG.LANGFUSE_HOST}/api/public/ingestion`, {
|
|
812
|
+
method: 'POST',
|
|
813
|
+
headers: {
|
|
814
|
+
'Authorization': `Basic ${auth}`,
|
|
815
|
+
'Content-Type': 'application/json'
|
|
816
|
+
},
|
|
817
|
+
body: JSON.stringify({ batch })
|
|
818
|
+
});
|
|
819
|
+
let body = null;
|
|
820
|
+
try {
|
|
821
|
+
body = await res.json();
|
|
822
|
+
}
|
|
823
|
+
catch { /* not JSON */ }
|
|
824
|
+
if (body && Array.isArray(body.errors) && body.errors.length > 0) {
|
|
825
|
+
for (const e of body.errors) {
|
|
826
|
+
console.error(`[ledger] Langfuse per-item error: id=${e.id ?? 'unknown'} ${describeItemError(e)}`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (res.ok) {
|
|
830
|
+
accepted = true;
|
|
831
|
+
itemErrors = body && Array.isArray(body.errors) ? body.errors : [];
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
// 429 and 5xx are transient: back off and retry the same batch. Anything else is
|
|
835
|
+
// a permanent rejection (bad payload, bad auth) that retrying cannot fix.
|
|
836
|
+
const isTransient = res.status === 429 || res.status >= 500;
|
|
837
|
+
const errText = body ? JSON.stringify(body) : await res.text().catch(() => '(no body)');
|
|
838
|
+
if (!isTransient) {
|
|
839
|
+
console.warn(`[ledger] Warning: Langfuse ingestion rejected (${res.status}): ${errText}`);
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
if (attempt < MAX_ATTEMPTS - 1) {
|
|
843
|
+
await waitWithBackoff(attempt, MAX_ATTEMPTS, `Langfuse ingestion ${res.status}`, res.headers.get('retry-after'), 'ledger');
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
console.warn(`[ledger] Warning: Langfuse ingestion failed after ${MAX_ATTEMPTS} attempts (${res.status}): ${errText}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
catch (err) {
|
|
850
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
851
|
+
if (attempt < MAX_ATTEMPTS - 1) {
|
|
852
|
+
await waitWithBackoff(attempt, MAX_ATTEMPTS, `Langfuse network error (${message})`, null, 'ledger');
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
console.warn(`[ledger] Warning: Langfuse network flush failed: ${message}`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (!accepted)
|
|
860
|
+
break; // leave rows queued for the next attempt
|
|
861
|
+
// An error that names no item of this batch cannot be pinned to a row, so every row
|
|
862
|
+
// in the batch is treated as rejected: resending is harmless (every id is an upsert),
|
|
863
|
+
// deleting an unaccepted row is silent data loss.
|
|
864
|
+
const rejections = new Map();
|
|
865
|
+
for (const e of itemErrors) {
|
|
866
|
+
const rowId = e.id === undefined ? undefined : rowIdByItemId.get(e.id);
|
|
867
|
+
for (const id of rowId === undefined ? rowIds : [rowId]) {
|
|
868
|
+
rejections.set(id, [...(rejections.get(id) ?? []), describeItemError(e)]);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const acceptedIds = rowIds.filter(id => !rejections.has(id));
|
|
872
|
+
db.transaction(() => {
|
|
873
|
+
if (acceptedIds.length > 0) {
|
|
874
|
+
const placeholders = acceptedIds.map(() => '?').join(',');
|
|
875
|
+
db.prepare(`DELETE FROM langfuse_queue WHERE id IN (${placeholders})`).run(...acceptedIds);
|
|
876
|
+
}
|
|
877
|
+
for (const row of rows) {
|
|
878
|
+
const errors = rejections.get(row.id);
|
|
879
|
+
if (!errors)
|
|
880
|
+
continue;
|
|
881
|
+
const attempts = (row.attempts ?? 0) + 1;
|
|
882
|
+
if (attempts < MAX_ROW_ATTEMPTS) {
|
|
883
|
+
db.prepare('UPDATE langfuse_queue SET attempts = ? WHERE id = ?').run(attempts, row.id);
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
db.prepare('INSERT OR REPLACE INTO langfuse_dead_letter (id, payload, attempts, error, dead_at) VALUES (?, ?, ?, ?, ?)')
|
|
887
|
+
.run(row.id, row.payload, attempts, errors.join('\n'), new Date().toISOString());
|
|
888
|
+
db.prepare('DELETE FROM langfuse_queue WHERE id = ?').run(row.id);
|
|
889
|
+
console.warn(`[ledger] Warning: Langfuse rejected queue row ${row.id} ${attempts} times; moved it to langfuse_dead_letter. Last error: ${errors[0]}`);
|
|
890
|
+
}
|
|
891
|
+
})();
|
|
892
|
+
shipped += acceptedIds.length;
|
|
893
|
+
}
|
|
894
|
+
return shipped;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Test-only utility to reset the internal client state.
|
|
898
|
+
*/
|
|
899
|
+
static __resetForTests() {
|
|
900
|
+
db = null;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
export function writeDistillFeedback(row) {
|
|
904
|
+
const db = getDb();
|
|
905
|
+
if (!db)
|
|
906
|
+
return;
|
|
907
|
+
try {
|
|
908
|
+
const stmt = db.prepare(`
|
|
909
|
+
INSERT INTO distill_feedback (id, tool_name, skill, content_hash, region_text, embedding_blob, signal, created_at)
|
|
910
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
911
|
+
`);
|
|
912
|
+
stmt.run(row.id, row.tool_name, row.skill, row.content_hash, row.region_text, row.embedding_blob, row.signal, new Date().toISOString());
|
|
913
|
+
}
|
|
914
|
+
catch (e) {
|
|
915
|
+
console.error('Failed to write distill feedback', e);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
export function getDistillFeedback(toolName, limit = 50) {
|
|
919
|
+
const db = getDb();
|
|
920
|
+
if (!db)
|
|
921
|
+
return [];
|
|
922
|
+
try {
|
|
923
|
+
return db.prepare('SELECT * FROM distill_feedback WHERE tool_name = ? ORDER BY created_at DESC LIMIT ?')
|
|
924
|
+
.all(toolName, limit);
|
|
925
|
+
}
|
|
926
|
+
catch (e) {
|
|
927
|
+
return [];
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
export function getDistillPolicy(toolName) {
|
|
931
|
+
const db = getDb();
|
|
932
|
+
if (!db)
|
|
933
|
+
return 'summarize';
|
|
934
|
+
try {
|
|
935
|
+
const rows = db.prepare('SELECT tool_pattern, fidelity FROM distill_policy ORDER BY priority DESC').all();
|
|
936
|
+
for (const r of rows) {
|
|
937
|
+
if (r.tool_pattern === '*')
|
|
938
|
+
return r.fidelity;
|
|
939
|
+
const regexStr = r.tool_pattern.replace(/%/g, '.*');
|
|
940
|
+
if (new RegExp('^' + regexStr + '$', 'i').test(toolName))
|
|
941
|
+
return r.fidelity;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
catch (e) { }
|
|
945
|
+
return 'summarize';
|
|
946
|
+
}
|