@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.
Files changed (106) hide show
  1. package/.env.example +669 -0
  2. package/LICENSE +21 -0
  3. package/README.md +317 -0
  4. package/configs/antigravity/.env.16gb.example +674 -0
  5. package/configs/antigravity/.env.24gb.example +674 -0
  6. package/configs/antigravity/.env.32gb.example +674 -0
  7. package/configs/antigravity/README.md +109 -0
  8. package/configs/claude-code/.env.16gb.example +674 -0
  9. package/configs/claude-code/.env.24gb.example +674 -0
  10. package/configs/claude-code/.env.32gb.example +674 -0
  11. package/configs/claude-code/README.md +52 -0
  12. package/configs/claude-desktop/.env.16gb.example +674 -0
  13. package/configs/claude-desktop/.env.24gb.example +674 -0
  14. package/configs/claude-desktop/.env.32gb.example +674 -0
  15. package/configs/claude-desktop/README.md +37 -0
  16. package/configs/cline-continue-opencode/.env.16gb.example +674 -0
  17. package/configs/cline-continue-opencode/.env.24gb.example +674 -0
  18. package/configs/cline-continue-opencode/.env.32gb.example +674 -0
  19. package/configs/cline-continue-opencode/README.md +34 -0
  20. package/configs/cursor/.env.16gb.example +674 -0
  21. package/configs/cursor/.env.24gb.example +674 -0
  22. package/configs/cursor/.env.32gb.example +674 -0
  23. package/configs/cursor/README.md +26 -0
  24. package/configs/generic-http/.env.16gb.example +674 -0
  25. package/configs/generic-http/.env.24gb.example +674 -0
  26. package/configs/generic-http/.env.32gb.example +674 -0
  27. package/configs/generic-http/README.md +20 -0
  28. package/configs/generic-stdio/.env.16gb.example +674 -0
  29. package/configs/generic-stdio/.env.24gb.example +674 -0
  30. package/configs/generic-stdio/.env.32gb.example +674 -0
  31. package/configs/generic-stdio/README.md +24 -0
  32. package/configs/preserve/README.md +26 -0
  33. package/configs/preserve/tls.json +61 -0
  34. package/dist/adapters/tech-lead-stack.js +38 -0
  35. package/dist/cache/index.js +173 -0
  36. package/dist/cli.js +256 -0
  37. package/dist/config.js +255 -0
  38. package/dist/dashboard/data.js +149 -0
  39. package/dist/dashboard/export.js +42 -0
  40. package/dist/dashboard/serve.js +63 -0
  41. package/dist/doctor.js +338 -0
  42. package/dist/hardware.js +126 -0
  43. package/dist/home-dir.js +39 -0
  44. package/dist/ledger/flush-lifecycle.js +50 -0
  45. package/dist/ledger/index.js +946 -0
  46. package/dist/ledger/report.js +69 -0
  47. package/dist/ledger/setup-dashboard.js +456 -0
  48. package/dist/ledger/smoke.js +37 -0
  49. package/dist/ledger/sync-config.js +177 -0
  50. package/dist/ledger/sync.js +307 -0
  51. package/dist/ledger/verify.js +185 -0
  52. package/dist/ledger/wipe-langfuse.js +130 -0
  53. package/dist/llm-gate/distill.js +239 -0
  54. package/dist/llm-gate/formats/anthropic.js +185 -0
  55. package/dist/llm-gate/formats/chat-completions.js +103 -0
  56. package/dist/llm-gate/formats/contract.js +29 -0
  57. package/dist/llm-gate/formats/gemini.js +84 -0
  58. package/dist/llm-gate/formats/internal.js +1 -0
  59. package/dist/llm-gate/formats/openai.js +77 -0
  60. package/dist/llm-gate/formats/responses.js +146 -0
  61. package/dist/llm-gate/forward.js +150 -0
  62. package/dist/llm-gate/index.js +40 -0
  63. package/dist/llm-gate/local-first.js +217 -0
  64. package/dist/llm-gate/pipeline.js +267 -0
  65. package/dist/llm-gate/server.js +289 -0
  66. package/dist/mcp-gate/ground.js +64 -0
  67. package/dist/mcp-gate/index.js +57 -0
  68. package/dist/mcp-gate/pipeline.js +252 -0
  69. package/dist/mcp-gate/server.js +302 -0
  70. package/dist/mcp-gate/tool-names.js +57 -0
  71. package/dist/models/check.js +26 -0
  72. package/dist/models/footprint.js +137 -0
  73. package/dist/models/helpers.js +91 -0
  74. package/dist/models/index.js +5 -0
  75. package/dist/models/reasoning.js +91 -0
  76. package/dist/models/roles.js +9 -0
  77. package/dist/models/slm.js +243 -0
  78. package/dist/models/types.js +1 -0
  79. package/dist/pricing/index.js +115 -0
  80. package/dist/pricing/plans.js +54 -0
  81. package/dist/pricing/providers.js +172 -0
  82. package/dist/resolver/index.js +277 -0
  83. package/dist/resolver/types.js +1 -0
  84. package/dist/setup/claim.js +41 -0
  85. package/dist/setup/gate-command.js +41 -0
  86. package/dist/setup/init.js +92 -0
  87. package/dist/setup/local-models.js +123 -0
  88. package/dist/setup/model-gate.js +220 -0
  89. package/dist/setup/notify.js +45 -0
  90. package/dist/setup/ollama-install.js +53 -0
  91. package/dist/setup/parent-watch.js +84 -0
  92. package/dist/setup/required-models.js +20 -0
  93. package/dist/setup/startup.js +132 -0
  94. package/dist/setup/tool-settings.js +101 -0
  95. package/dist/utils/backoff.js +47 -0
  96. package/dist/utils/compression.js +145 -0
  97. package/dist/utils/constants.js +22 -0
  98. package/dist/utils/duration.js +43 -0
  99. package/dist/utils/elision.js +556 -0
  100. package/dist/utils/embedding.js +32 -0
  101. package/dist/utils/entry-point.js +23 -0
  102. package/dist/utils/local-only.js +82 -0
  103. package/dist/utils/preserve-patterns.js +115 -0
  104. package/dist/utils/safety.js +30 -0
  105. package/dist/verifier/index.js +67 -0
  106. package/package.json +121 -0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * @fileoverview Programmatic setup utility to initialize Langfuse Score Configs,
3
+ * Model Definitions, and Monitors via the Langfuse Management API.
4
+ */
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { CONFIG } from '../config.js';
8
+ import { PRICING } from '../pricing/index.js';
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ /**
11
+ * Automatically creates Score Configs in Langfuse:
12
+ * - 'verified' (Categorical: 1='Passed', 0='Escalated') -> replaces '0' and '1' in Langfuse UI with informative labels!
13
+ * - 'cost_saved_usd' (Numeric)
14
+ * - 'tokens_saved' (Numeric)
15
+ * - 'quality_score' (Numeric)
16
+ */
17
+ /**
18
+ * Score names an earlier build wrote and this one no longer does. Their rows stay in Langfuse
19
+ * until deleted; no card reads them. setup-dashboard removes widgets that still filter on them,
20
+ * and ledger:verify labels them instead of reporting them as an unexplained surplus.
21
+ */
22
+ export const RETIRED_SCORE_NAMES = [
23
+ 'cycle_extended_per_window_claude',
24
+ 'cycle_extended_per_window_chatgpt',
25
+ 'cycle_extended_per_window_gemini',
26
+ ];
27
+ /** The score configs the gate registers. Also the list langfuse:wipe archives, and nothing else. */
28
+ export const SCORE_CONFIGS = [
29
+ {
30
+ name: 'verified',
31
+ dataType: 'CATEGORICAL',
32
+ categories: [
33
+ { label: 'Passed (Local SLM)', value: 'Passed (Local SLM)' },
34
+ { label: 'Distilled (Forwarded)', value: 'Distilled (Forwarded)' },
35
+ { label: 'Escalated (Cloud)', value: 'Escalated (Cloud)' }
36
+ ],
37
+ description: 'Routing outcome: resolved locally, distilled then forwarded, or escalated to cloud'
38
+ },
39
+ {
40
+ name: 'cost_saved_cents',
41
+ dataType: 'NUMERIC',
42
+ description: 'Estimated cloud API dollars avoided (converted to Cents) by the local SLM'
43
+ },
44
+ {
45
+ name: 'tokens_saved',
46
+ dataType: 'NUMERIC',
47
+ description: 'Number of cloud LLM tokens avoided via local SLM routing'
48
+ },
49
+ {
50
+ name: 'accuracy_rate_pct',
51
+ dataType: 'NUMERIC',
52
+ maxValue: 100,
53
+ minValue: 0,
54
+ description: 'Accuracy of the SLM gate output against the expected cloud model standard (%)'
55
+ },
56
+ // Each provider is registered twice: the same quantity in minutes and in seconds.
57
+ // A score row carries a number and no unit, and a dashboard widget cannot convert
58
+ // between units, so a card that reads in seconds needs a score already in seconds.
59
+ {
60
+ name: 'cycle_extended_minutes_chatgpt',
61
+ dataType: 'NUMERIC',
62
+ description: 'Extra MINUTES of a 3h ChatGPT window freed by this single request (summed for the range total)'
63
+ },
64
+ {
65
+ name: 'cycle_extended_seconds_chatgpt',
66
+ dataType: 'NUMERIC',
67
+ description: 'Extra SECONDS of a 3h ChatGPT window freed by this single request (averaged for the per-prompt figure)'
68
+ },
69
+ {
70
+ name: 'cycle_extended_minutes_claude',
71
+ dataType: 'NUMERIC',
72
+ description: 'Extra MINUTES of a 5h Claude window freed by this single request (summed for the range total)'
73
+ },
74
+ {
75
+ name: 'cycle_extended_seconds_claude',
76
+ dataType: 'NUMERIC',
77
+ description: 'Extra SECONDS of a 5h Claude window freed by this single request (averaged for the per-prompt figure)'
78
+ },
79
+ {
80
+ name: 'cycle_extended_minutes_gemini',
81
+ dataType: 'NUMERIC',
82
+ description: 'Extra MINUTES of a 5h Gemini window freed by this single request (summed for the range total)'
83
+ },
84
+ {
85
+ name: 'cycle_extended_seconds_gemini',
86
+ dataType: 'NUMERIC',
87
+ description: 'Extra SECONDS of a 5h Gemini window freed by this single request (averaged for the per-prompt figure)'
88
+ }
89
+ ];
90
+ export async function syncScoreConfigs() {
91
+ if (!CONFIG.LANGFUSE_PUBLIC_KEY || !CONFIG.LANGFUSE_SECRET_KEY || !CONFIG.LANGFUSE_HOST) {
92
+ return;
93
+ }
94
+ const authHeader = 'Basic ' + Buffer.from(`${CONFIG.LANGFUSE_PUBLIC_KEY}:${CONFIG.LANGFUSE_SECRET_KEY}`).toString('base64');
95
+ const baseUrl = CONFIG.LANGFUSE_HOST.replace(/\/$/, '');
96
+ console.log('Initializing Langfuse Score Configurations...');
97
+ for (const config of SCORE_CONFIGS) {
98
+ try {
99
+ const res = await fetch(`${baseUrl}/api/public/score-configs`, {
100
+ method: 'POST',
101
+ headers: {
102
+ 'Authorization': authHeader,
103
+ 'Content-Type': 'application/json'
104
+ },
105
+ body: JSON.stringify(config)
106
+ });
107
+ if (res.ok) {
108
+ console.log(` ✓ Score config registered: ${config.name}`);
109
+ }
110
+ else if (res.status === 409 || res.status === 400) {
111
+ // Already exists or duplicate name
112
+ console.log(` • Score config already active: ${config.name}`);
113
+ }
114
+ else {
115
+ console.warn(` ! Score config ${config.name} response: ${res.statusText}`);
116
+ }
117
+ }
118
+ catch (err) {
119
+ console.warn(` ! Could not sync score config ${config.name}: ${err.message}`);
120
+ }
121
+ }
122
+ }
123
+ /**
124
+ * Automatically creates Model Definitions and Pricing in Langfuse
125
+ */
126
+ export async function syncModelDefinitions() {
127
+ if (!CONFIG.LANGFUSE_PUBLIC_KEY || !CONFIG.LANGFUSE_SECRET_KEY || !CONFIG.LANGFUSE_HOST) {
128
+ return;
129
+ }
130
+ const authHeader = 'Basic ' + Buffer.from(`${CONFIG.LANGFUSE_PUBLIC_KEY}:${CONFIG.LANGFUSE_SECRET_KEY}`).toString('base64');
131
+ const baseUrl = CONFIG.LANGFUSE_HOST.replace(/\/$/, '');
132
+ console.log('Initializing Langfuse Model Definitions...');
133
+ for (const [modelName, rates] of Object.entries(PRICING)) {
134
+ try {
135
+ const modelPayload = {
136
+ modelName,
137
+ matchPattern: `(?i)^${modelName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`,
138
+ unit: 'TOKENS',
139
+ inputPrice: rates.in / 1e6,
140
+ outputPrice: rates.out / 1e6,
141
+ };
142
+ const res = await fetch(`${baseUrl}/api/public/models`, {
143
+ method: 'POST',
144
+ headers: {
145
+ 'Authorization': authHeader,
146
+ 'Content-Type': 'application/json'
147
+ },
148
+ body: JSON.stringify(modelPayload)
149
+ });
150
+ if (res.ok) {
151
+ console.log(` ✓ Model definition registered: ${modelName}`);
152
+ }
153
+ else if (res.status === 409 || res.status === 400) {
154
+ console.log(` • Model definition already active: ${modelName}`);
155
+ }
156
+ }
157
+ catch (err) {
158
+ console.warn(` ! Could not sync model ${modelName}: ${err.message}`);
159
+ }
160
+ }
161
+ }
162
+ export async function initLangfuseConfigs() {
163
+ await syncScoreConfigs();
164
+ await syncModelDefinitions();
165
+ }
166
+ // Direct execution
167
+ if (process.argv[1] && (process.argv[1].endsWith('sync-config.ts') || process.argv[1].endsWith('sync-config.js'))) {
168
+ initLangfuseConfigs()
169
+ .then(() => {
170
+ console.log('Langfuse configurations successfully initialized.');
171
+ process.exit(0);
172
+ })
173
+ .catch((err) => {
174
+ console.error('Failed to initialize Langfuse configs:', err);
175
+ process.exit(1);
176
+ });
177
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * @fileoverview CLI utility script to synchronize/backfill SQLite ledger records to Langfuse Cloud.
3
+ *
4
+ * Every id it sends is deterministic (trace = request_id, scores and generations derived
5
+ * from it), so a re-send updates in place and never duplicates. That makes it the backfill:
6
+ * re-running it gives old traces the current tags. Each event keeps the environment it was
7
+ * written under: Langfuse moves a re-sent trace to a new environment but never its scores
8
+ * (tested 2026-09-23), so moving history would split every trace from its own scores.
9
+ *
10
+ * Usage:
11
+ * pnpm run ledger:sync [--all] [--limit <n>] [--after <rowid>] [--dry-run]
12
+ */
13
+ import crypto from 'node:crypto';
14
+ import path from 'node:path';
15
+ import { setTimeout } from 'node:timers/promises';
16
+ import { CONFIG, requireKeys } from '../config.js';
17
+ import { waitWithBackoff } from '../utils/backoff.js';
18
+ import { formatDuration } from '../utils/duration.js';
19
+ import { computeCycleRateAvg, computeCycleRates, formatEventForLangfuse, getDb, LangfuseSink, logLedgerInfo } from './index.js';
20
+ import { initLangfuseConfigs } from './sync-config.js';
21
+ export { computeCycleRateAvg };
22
+ /** Hobby projects allow 30 requests a minute; one batch every 2.1 s stays under it. */
23
+ const REQUEST_SPACING_MS = 2100;
24
+ const MAX_ATTEMPTS = 5;
25
+ export async function syncLedgerToLangfuse(options = {}) {
26
+ const { limit, after = 0, dryRun = false } = options;
27
+ console.log('=== SLM Gate: SQLite to Langfuse Ledger Sync ===\n');
28
+ console.log(`Database Source : ${path.resolve(CONFIG.LEDGER_PATH)}`);
29
+ console.log(`Target Host : ${CONFIG.LANGFUSE_HOST || '<Not Set>'}`);
30
+ console.log(`Mode : ${dryRun ? 'DRY-RUN (No network requests)' : 'LIVE SYNC'}\n`);
31
+ logLedgerInfo('sync');
32
+ if (!dryRun) {
33
+ requireKeys(['LANGFUSE_PUBLIC_KEY', 'LANGFUSE_SECRET_KEY', 'LANGFUSE_HOST']);
34
+ await initLangfuseConfigs();
35
+ }
36
+ const db = getDb();
37
+ let query = 'SELECT rowid AS ledger_rowid, * FROM events WHERE rowid > ? ORDER BY rowid ASC';
38
+ if (limit && limit > 0) {
39
+ query += ` LIMIT ${limit}`;
40
+ }
41
+ const rows = db.prepare(query).all(after);
42
+ console.log(`Found ${rows.length} total event(s) in SQLite ledger.\n`);
43
+ if (rows.length === 0) {
44
+ console.log('No events to synchronize.');
45
+ return {
46
+ totalEvents: 0,
47
+ syncedTraces: 0,
48
+ localCalls: 0,
49
+ cloudCalls: 0,
50
+ localTokens: 0,
51
+ cloudTokens: 0,
52
+ baselineCostUsd: 0,
53
+ actualCostUsd: 0,
54
+ costSavedUsd: 0,
55
+ tokensSaved: 0,
56
+ baselineTokens: 0,
57
+ errors: 0,
58
+ };
59
+ }
60
+ const stats = {
61
+ totalEvents: rows.length,
62
+ syncedTraces: 0,
63
+ localCalls: 0,
64
+ cloudCalls: 0,
65
+ localTokens: 0,
66
+ cloudTokens: 0,
67
+ baselineCostUsd: 0,
68
+ actualCostUsd: 0,
69
+ costSavedUsd: 0,
70
+ tokensSaved: 0,
71
+ baselineTokens: 0,
72
+ errors: 0,
73
+ };
74
+ const BATCH_SIZE = 50;
75
+ let batchCount = 0;
76
+ // Rowid of the last event whose batch Langfuse accepted: where a stopped run resumes.
77
+ let lastAcceptedRowid = after;
78
+ let batchLastRowid = after;
79
+ let batch = [];
80
+ /**
81
+ * Sends the pending batch, paced under the rate limit and retried on 429, 5xx and network
82
+ * errors. Returns false when Langfuse still refuses it, so the caller stops instead of
83
+ * skipping a batch: a skipped batch would leave a gap nobody sees.
84
+ */
85
+ const flushBatch = async () => {
86
+ if (batch.length === 0)
87
+ return true;
88
+ const auth = Buffer.from(`${CONFIG.LANGFUSE_PUBLIC_KEY}:${CONFIG.LANGFUSE_SECRET_KEY}`).toString('base64');
89
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
90
+ await setTimeout(REQUEST_SPACING_MS);
91
+ let res;
92
+ try {
93
+ res = await fetch(`${CONFIG.LANGFUSE_HOST}/api/public/ingestion`, {
94
+ method: 'POST',
95
+ headers: {
96
+ 'Authorization': `Basic ${auth}`,
97
+ 'Content-Type': 'application/json'
98
+ },
99
+ body: JSON.stringify({ batch })
100
+ });
101
+ }
102
+ catch (err) {
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ if (attempt === MAX_ATTEMPTS - 1) {
105
+ console.error('\nNetwork error during sync:', message);
106
+ break;
107
+ }
108
+ await waitWithBackoff(attempt, MAX_ATTEMPTS, `Langfuse network error (${message})`, null, 'sync');
109
+ continue;
110
+ }
111
+ let body = null;
112
+ try {
113
+ body = await res.json();
114
+ }
115
+ catch { /* not JSON */ }
116
+ if (res.ok) {
117
+ // A 207 accepts the batch but may reject single items; they are counted, and
118
+ // ledger:verify shows which score names came up short.
119
+ if (body && Array.isArray(body.errors) && body.errors.length > 0) {
120
+ for (const e of body.errors) {
121
+ console.error(`[sync] Langfuse per-item error: id=${e.id ?? 'unknown'} status=${e.status ?? 'unknown'} ${e.message ?? e.error ?? ''}`);
122
+ }
123
+ stats.errors += body.errors.length;
124
+ }
125
+ stats.syncedTraces += batchCount;
126
+ lastAcceptedRowid = batchLastRowid;
127
+ process.stdout.write(`\rProgress: ${stats.syncedTraces}/${rows.length} traces synced...`);
128
+ batch = [];
129
+ batchCount = 0;
130
+ return true;
131
+ }
132
+ const transient = res.status === 429 || res.status >= 500;
133
+ if (!transient || attempt === MAX_ATTEMPTS - 1) {
134
+ console.warn(`\nLangfuse sync failed (${res.status}): ${body ? JSON.stringify(body) : '(no body)'}`);
135
+ break;
136
+ }
137
+ await waitWithBackoff(attempt, MAX_ATTEMPTS, `Langfuse ingestion ${res.status}`, res.headers.get('retry-after'), 'sync');
138
+ }
139
+ stats.errors += batchCount;
140
+ return false;
141
+ };
142
+ const hasClient = !dryRun && LangfuseSink.hasValidConfig();
143
+ if (!dryRun && !hasClient) {
144
+ throw new Error('Could not initialize Langfuse sync. Verify LANGFUSE_* keys in .env.');
145
+ }
146
+ for (let i = 0; i < rows.length; i++) {
147
+ const event = rows[i];
148
+ try {
149
+ const payload = formatEventForLangfuse(event);
150
+ const meta = payload.trace.metadata || {};
151
+ stats.actualCostUsd += event.cost_usd || 0;
152
+ stats.baselineCostUsd += Number(meta.baseline_cost_usd || event.cost_usd || 0);
153
+ stats.costSavedUsd += Number(meta.cost_saved_usd || 0);
154
+ stats.tokensSaved += Number(meta.tokens_saved || 0);
155
+ stats.baselineTokens += Number(meta.baseline_tokens || 0);
156
+ if (event.is_local_call) {
157
+ stats.localCalls++;
158
+ stats.localTokens += (event.in_tok || 0) + (event.out_tok || 0);
159
+ }
160
+ if (event.api_model && (event.api_in_tok > 0 || event.api_out_tok > 0)) {
161
+ stats.cloudCalls++;
162
+ stats.cloudTokens += (event.api_in_tok || 0) + (event.api_out_tok || 0);
163
+ }
164
+ if (hasClient) {
165
+ // Langfuse stamps the ingestion envelope, not the body, and ScoreBody has no
166
+ // timestamp field. Using new Date() here backdated nothing: it collapsed the entire
167
+ // backfill onto the moment the sync ran, destroying the time series.
168
+ const envelopeTs = payload.eventTs ?? payload.trace?.timestamp ?? new Date(event.ts).toISOString();
169
+ batch.push({
170
+ id: crypto.randomUUID(),
171
+ type: 'trace-create',
172
+ timestamp: envelopeTs,
173
+ body: payload.trace
174
+ });
175
+ const generations = payload.generations || (payload.generation ? [payload.generation] : []);
176
+ for (const gen of generations) {
177
+ batch.push({
178
+ id: crypto.randomUUID(),
179
+ type: 'generation-create',
180
+ timestamp: envelopeTs,
181
+ body: {
182
+ ...gen,
183
+ traceId: payload.trace.id
184
+ }
185
+ });
186
+ }
187
+ if (payload.scores && Array.isArray(payload.scores)) {
188
+ for (const score of payload.scores) {
189
+ batch.push({
190
+ id: crypto.randomUUID(),
191
+ type: 'score-create',
192
+ timestamp: envelopeTs,
193
+ body: {
194
+ ...score,
195
+ traceId: payload.trace.id
196
+ }
197
+ });
198
+ }
199
+ }
200
+ if (payload.span) {
201
+ batch.push({
202
+ id: crypto.randomUUID(),
203
+ type: 'span-create',
204
+ timestamp: envelopeTs,
205
+ body: {
206
+ ...payload.span,
207
+ traceId: payload.trace.id
208
+ }
209
+ });
210
+ }
211
+ batchCount++;
212
+ batchLastRowid = event.ledger_rowid;
213
+ if (batchCount >= BATCH_SIZE && !(await flushBatch())) {
214
+ stats.resumeAfter = lastAcceptedRowid;
215
+ break;
216
+ }
217
+ }
218
+ else {
219
+ stats.syncedTraces++;
220
+ }
221
+ }
222
+ catch (err) {
223
+ stats.errors++;
224
+ console.error(`\nError syncing event ${event.request_id}:`, err.message || err);
225
+ }
226
+ }
227
+ if (hasClient && stats.resumeAfter === undefined && !(await flushBatch())) {
228
+ stats.resumeAfter = lastAcceptedRowid;
229
+ }
230
+ if (stats.resumeAfter !== undefined) {
231
+ console.error(`\nStopped: Langfuse refused a batch after ${MAX_ATTEMPTS} attempts. Everything up to ledger rowid ${stats.resumeAfter} was accepted.`);
232
+ console.error(`Continue with: pnpm run ledger:sync --after ${stats.resumeAfter}`);
233
+ }
234
+ const avgRates = computeCycleRateAvg(rows);
235
+ const totalRates = computeCycleRates(rows);
236
+ /**
237
+ * One summary row per provider, rendered as real durations so the reader never has to
238
+ * convert a decimal fraction of a minute in their head.
239
+ *
240
+ * @param id Provider id as used by computeCycleRateAvg / computeCycleRates
241
+ * @param budgetVar Name of the env var that supplies the denominator
242
+ */
243
+ const cycleRow = (id, budgetVar) => {
244
+ const avgMinutes = avgRates[id];
245
+ if (avgMinutes === null) {
246
+ return `n/a (no traffic, or ${budgetVar} unset)`;
247
+ }
248
+ return `~${formatDuration(avgMinutes * 60)} per prompt · ~${formatDuration(totalRates[id] * 60)} total`;
249
+ };
250
+ const claudePlan = CONFIG.RESOLVED_PLAN_CLAUDE;
251
+ const chatgptPlan = CONFIG.RESOLVED_PLAN_CHATGPT;
252
+ const geminiPlan = CONFIG.RESOLVED_PLAN_GEMINI;
253
+ if (!dryRun) {
254
+ process.stdout.write(`\rProgress: ${stats.syncedTraces}/${rows.length} traces synced.\n\n`);
255
+ }
256
+ else {
257
+ console.log();
258
+ }
259
+ console.log('--- Sync Summary Table ---');
260
+ console.table([
261
+ { Metric: 'Total Events in SQLite', Value: stats.totalEvents },
262
+ { Metric: 'Traces Processed', Value: stats.syncedTraces },
263
+ { Metric: 'Local SLM Calls ($0.00)', Value: stats.localCalls },
264
+ { Metric: 'Local Tokens Processed', Value: stats.localTokens.toLocaleString() },
265
+ { Metric: 'Cloud API Calls', Value: stats.cloudCalls },
266
+ { Metric: 'Cloud Tokens Billed', Value: stats.cloudTokens.toLocaleString() },
267
+ { Metric: 'Baseline Estimated Cost', Value: `$${stats.baselineCostUsd.toFixed(4)}` },
268
+ { Metric: 'Actual Cost Incurred', Value: `$${stats.actualCostUsd.toFixed(4)}` },
269
+ { Metric: 'Net Dollars Saved', Value: `$${stats.costSavedUsd.toFixed(4)}` },
270
+ { Metric: 'Net Tokens Saved', Value: stats.tokensSaved.toLocaleString() },
271
+ { Metric: 'Sync Errors', Value: stats.errors },
272
+ { Metric: `Window Time Saved (ChatGPT ${chatgptPlan.windowMinutes}m window)`, Value: cycleRow('chatgpt', 'CHATGPT_WINDOW_BUDGET') },
273
+ { Metric: `Window Time Saved (Claude ${claudePlan.windowMinutes}m window)`, Value: cycleRow('claude', 'CLAUDE_WINDOW_BUDGET') },
274
+ { Metric: `Window Time Saved (Gemini ${geminiPlan.windowMinutes}m window)`, Value: cycleRow('gemini', 'GEMINI_WINDOW_BUDGET') },
275
+ ]);
276
+ console.log('Window time saved is an estimate within a margin of error: providers do not publish their window limits, so the *_WINDOW_BUDGET values are best guesses.');
277
+ return stats;
278
+ }
279
+ // Execution entry point
280
+ const isDirectCall = process.argv[1] && (process.argv[1].endsWith('sync.ts') ||
281
+ process.argv[1].endsWith('sync.js'));
282
+ if (isDirectCall) {
283
+ const args = process.argv.slice(2);
284
+ const dryRun = args.includes('--dry-run');
285
+ const limitIdx = args.indexOf('--limit');
286
+ let limit;
287
+ if (limitIdx >= 0 && args[limitIdx + 1]) {
288
+ limit = parseInt(args[limitIdx + 1], 10);
289
+ }
290
+ const afterIdx = args.indexOf('--after');
291
+ const after = afterIdx >= 0 ? Number(args[afterIdx + 1]) : 0;
292
+ if (!Number.isInteger(after) || after < 0) {
293
+ console.error('--after takes a ledger rowid (a whole number), as printed by a stopped sync.');
294
+ process.exit(1);
295
+ }
296
+ syncLedgerToLangfuse({ limit, after, dryRun })
297
+ .then((stats) => {
298
+ if (stats.resumeAfter !== undefined)
299
+ process.exit(1);
300
+ console.log('Sync process complete.');
301
+ process.exit(0);
302
+ })
303
+ .catch((err) => {
304
+ console.error('Fatal sync error:', err.message || err);
305
+ process.exit(1);
306
+ });
307
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * @fileoverview Read-only reconciliation of the local ledger against Langfuse.
3
+ *
4
+ * For a window of UTC days, prints how many events the ledger holds, how many traces
5
+ * Langfuse holds (from every writer and from the gate alone), and, per score name, how many
6
+ * scores the ledger's events produce against how many Langfuse holds. Run it before and
7
+ * after any repair. It never writes: the ledger is opened read-only (getDb() is not — it
8
+ * migrates and prunes on open) and only GET requests are sent.
9
+ *
10
+ * Usage:
11
+ * pnpm run ledger:verify --from 2026-09-20 --to 2026-09-23 (both days included)
12
+ */
13
+ import Database from 'better-sqlite3';
14
+ import { CONFIG, requireKeys } from '../config.js';
15
+ import { isEntryPoint } from '../utils/entry-point.js';
16
+ import { waitWithBackoff } from '../utils/backoff.js';
17
+ import { formatEventForLangfuse, SLM_GATE_SOURCE_TAG } from './index.js';
18
+ import { RETIRED_SCORE_NAMES } from './sync-config.js';
19
+ /** Hobby projects allow 30 requests a minute; one every 2.1 s stays under it. */
20
+ const REQUEST_SPACING_MS = 2100;
21
+ const MAX_RETRIES = 3;
22
+ /**
23
+ * Reads `--from YYYY-MM-DD --to YYYY-MM-DD` (both days included, UTC).
24
+ *
25
+ * @throws When either date is missing or malformed, or --to is before --from.
26
+ */
27
+ export function parseWindow(args) {
28
+ const day = (flag) => {
29
+ const value = args[args.indexOf(flag) + 1];
30
+ if (!args.includes(flag) || !/^\d{4}-\d{2}-\d{2}$/.test(value ?? '') || Number.isNaN(Date.parse(`${value}T00:00:00Z`))) {
31
+ throw new Error(`${flag} YYYY-MM-DD is required. Usage: pnpm run ledger:verify --from 2026-09-20 --to 2026-09-23 (both days included, UTC)`);
32
+ }
33
+ return new Date(`${value}T00:00:00Z`);
34
+ };
35
+ const from = day('--from');
36
+ const to = day('--to');
37
+ if (to < from)
38
+ throw new Error('--to is before --from.');
39
+ to.setUTCDate(to.getUTCDate() + 1);
40
+ return { from: from.toISOString(), to: to.toISOString() };
41
+ }
42
+ /**
43
+ * Score counts, by name, that the ledger's events produce with this build's formatting —
44
+ * what a sync of these events would send today.
45
+ */
46
+ export function expectedScoreCounts(events) {
47
+ const counts = new Map();
48
+ for (const event of events) {
49
+ for (const score of formatEventForLangfuse(event).scores ?? []) {
50
+ counts.set(score.name, (counts.get(score.name) ?? 0) + 1);
51
+ }
52
+ }
53
+ return counts;
54
+ }
55
+ /** Every score name found on either side, with actual minus expected. Sorted by name. */
56
+ export function scoreDeltas(params) {
57
+ const names = [...new Set([...params.expected.keys(), ...params.actual.keys()])].sort();
58
+ return names.map(name => {
59
+ const expected = params.expected.get(name) ?? 0;
60
+ const actual = params.actual.get(name) ?? 0;
61
+ const note = RETIRED_SCORE_NAMES.includes(name) ? 'retired name, safe to ignore'
62
+ : expected === 0 && actual > 0 ? 'not written by this build'
63
+ : '';
64
+ return { name, expected, actual, delta: actual - expected, note };
65
+ });
66
+ }
67
+ function readLedgerEvents(window) {
68
+ const db = new Database(CONFIG.LEDGER_PATH, { readonly: true, fileMustExist: true });
69
+ try {
70
+ return db.prepare('SELECT * FROM events WHERE ts >= ? AND ts < ? ORDER BY ts').all(window.from, window.to);
71
+ }
72
+ finally {
73
+ db.close();
74
+ }
75
+ }
76
+ /** One paced GET against Langfuse, retried on 429 and 5xx. Returns null (and says why) on any other failure. */
77
+ async function langfuseGet(pathAndQuery) {
78
+ const baseUrl = CONFIG.LANGFUSE_HOST.replace(/\/$/, '');
79
+ const auth = `Basic ${Buffer.from(`${CONFIG.LANGFUSE_PUBLIC_KEY}:${CONFIG.LANGFUSE_SECRET_KEY}`).toString('base64')}`;
80
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
81
+ await new Promise(resolve => setTimeout(resolve, REQUEST_SPACING_MS));
82
+ const res = await fetch(`${baseUrl}${pathAndQuery}`, { headers: { Authorization: auth } });
83
+ if (res.ok)
84
+ return res.json();
85
+ const transient = res.status === 429 || res.status >= 500;
86
+ if (!transient || attempt === MAX_RETRIES - 1) {
87
+ console.error(`[verify] GET ${pathAndQuery.split('?')[0]} failed (${res.status}): ${(await res.text().catch(() => '')).slice(0, 300)}`);
88
+ return null;
89
+ }
90
+ await waitWithBackoff(attempt, MAX_RETRIES, `Langfuse ${res.status}`, res.headers.get('retry-after'), 'verify');
91
+ }
92
+ return null;
93
+ }
94
+ /** Score counts by name for one score view, from the v2 metrics API. */
95
+ async function langfuseScoreCounts(params) {
96
+ const query = {
97
+ view: params.view,
98
+ dimensions: [{ field: 'name' }],
99
+ metrics: [{ measure: 'count', aggregation: 'count' }],
100
+ filters: [],
101
+ fromTimestamp: params.window.from,
102
+ toTimestamp: params.window.to,
103
+ };
104
+ const body = await langfuseGet(`/api/public/v2/metrics?query=${encodeURIComponent(JSON.stringify(query))}`);
105
+ if (!body?.data)
106
+ return null;
107
+ return new Map(body.data.filter(row => row.name).map(row => [row.name, Number(row.count_count)]));
108
+ }
109
+ /**
110
+ * Trace count in the window, optionally only traces carrying `tag`.
111
+ *
112
+ * The v2 metrics API has no traces view, so this reads the total from the trace list
113
+ * (limit=1). Langfuse retires that endpoint on Cloud on 2026-11-16; from then this
114
+ * returns null and the report says the count is unavailable.
115
+ */
116
+ async function langfuseTraceCount(params) {
117
+ const query = new URLSearchParams({ limit: '1', fromTimestamp: params.window.from, toTimestamp: params.window.to });
118
+ if (params.tag)
119
+ query.set('tags', params.tag);
120
+ const body = await langfuseGet(`/api/public/traces?${query}`);
121
+ return typeof body?.meta?.totalItems === 'number' ? body.meta.totalItems : null;
122
+ }
123
+ async function verify(window) {
124
+ requireKeys(['LANGFUSE_PUBLIC_KEY', 'LANGFUSE_SECRET_KEY', 'LANGFUSE_HOST']);
125
+ const events = readLedgerEvents(window);
126
+ const byEnvironment = new Map();
127
+ for (const e of events)
128
+ byEnvironment.set(e.environment ?? '(none)', (byEnvironment.get(e.environment ?? '(none)') ?? 0) + 1);
129
+ console.log('=== SLM Gate: ledger ↔ Langfuse reconciliation (read-only) ===\n');
130
+ console.log(`Window : ${window.from} → ${window.to} (end excluded)`);
131
+ console.log(`Ledger : ${CONFIG.LEDGER_PATH}`);
132
+ console.log(`Langfuse: ${CONFIG.LANGFUSE_HOST}\n`);
133
+ console.log('Querying Langfuse (paced for the 30 requests/minute limit)...\n');
134
+ const allTraces = await langfuseTraceCount({ window });
135
+ const gateTaggedTraces = await langfuseTraceCount({ window, tag: SLM_GATE_SOURCE_TAG });
136
+ const numeric = await langfuseScoreCounts({ view: 'scores-numeric', window });
137
+ const categorical = await langfuseScoreCounts({ view: 'scores-categorical', window });
138
+ const show = (n) => n === null ? 'unavailable' : String(n);
139
+ const diff = (n) => n === null ? '' : `${n - events.length >= 0 ? '+' : ''}${n - events.length}`;
140
+ // Every gate event writes exactly one 'verified' score and no other writer does, so this
141
+ // counts the gate's traces even when they predate the source tag.
142
+ const gateVerified = categorical ? (categorical.get('verified') ?? 0) : null;
143
+ console.table([
144
+ { Measure: 'Ledger events', Count: String(events.length), 'Δ vs ledger': '' },
145
+ ...[...byEnvironment].map(([env, n]) => ({ Measure: ` environment=${env}`, Count: String(n), 'Δ vs ledger': '' })),
146
+ { Measure: 'Langfuse traces, all writers', Count: show(allTraces), 'Δ vs ledger': diff(allTraces) },
147
+ { Measure: `Langfuse traces tagged ${SLM_GATE_SOURCE_TAG}`, Count: show(gateTaggedTraces), 'Δ vs ledger': diff(gateTaggedTraces) },
148
+ { Measure: "Langfuse gate traces (one 'verified' score each)", Count: show(gateVerified), 'Δ vs ledger': diff(gateVerified) },
149
+ {
150
+ Measure: 'Langfuse traces from other writers',
151
+ Count: allTraces === null || gateVerified === null ? 'unavailable' : String(allTraces - gateVerified),
152
+ 'Δ vs ledger': '',
153
+ },
154
+ ]);
155
+ if (!numeric || !categorical) {
156
+ console.error('Score counts are unavailable; see the errors above.');
157
+ process.exitCode = 1;
158
+ return;
159
+ }
160
+ const actual = new Map([...numeric, ...categorical]);
161
+ const rows = scoreDeltas({ expected: expectedScoreCounts(events), actual });
162
+ console.log('\nScores by name. Expected = what this build produces for the ledger events above.');
163
+ console.table(rows.map(r => ({
164
+ 'Score name': r.name,
165
+ Expected: r.expected,
166
+ Langfuse: r.actual,
167
+ Δ: r.delta === 0 ? '0' : `${r.delta > 0 ? '+' : ''}${r.delta}`,
168
+ Note: r.note,
169
+ })));
170
+ console.log('Langfuse list endpoints can lag live data by about 10 minutes.');
171
+ }
172
+ if (isEntryPoint(import.meta.url)) {
173
+ let window;
174
+ try {
175
+ window = parseWindow(process.argv.slice(2));
176
+ }
177
+ catch (err) {
178
+ console.error(err instanceof Error ? err.message : String(err));
179
+ process.exit(1);
180
+ }
181
+ verify(window).catch(err => {
182
+ console.error('ledger:verify failed:', err instanceof Error ? err.message : err);
183
+ process.exit(1);
184
+ });
185
+ }