@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,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tokens saved per day and all-time, from the local ledger alone.
|
|
3
|
+
*
|
|
4
|
+
* The ledger is the complete record; Langfuse keeps only its rolling retention window, so
|
|
5
|
+
* its cards cover less as days drop off. Reuses the per-event arithmetic the Langfuse scores
|
|
6
|
+
* are built from (perEventTokensSaved / perEventBaselineTokens), so a day here equals the
|
|
7
|
+
* Tokens Saved card for the same UTC day. Opens the ledger read-only.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* pnpm run ledger:report
|
|
11
|
+
*/
|
|
12
|
+
import Database from 'better-sqlite3';
|
|
13
|
+
import { CONFIG } from '../config.js';
|
|
14
|
+
import { isEntryPoint } from '../utils/entry-point.js';
|
|
15
|
+
import { perEventBaselineTokens, perEventTokensSaved } from './index.js';
|
|
16
|
+
/** One row per UTC day in date order, then an all-time row that is their sum. */
|
|
17
|
+
export function savingsByDay(events) {
|
|
18
|
+
const days = new Map();
|
|
19
|
+
const total = { day: 'all-time', events: 0, baselineTokens: 0, tokensSaved: 0, benchTokensSaved: 0 };
|
|
20
|
+
for (const event of events) {
|
|
21
|
+
const day = new Date(event.ts).toISOString().slice(0, 10);
|
|
22
|
+
const row = days.get(day) ?? { day, events: 0, baselineTokens: 0, tokensSaved: 0, benchTokensSaved: 0 };
|
|
23
|
+
const saved = perEventTokensSaved(event);
|
|
24
|
+
const baseline = perEventBaselineTokens(event);
|
|
25
|
+
const bench = event.environment === 'bench' ? saved : 0;
|
|
26
|
+
for (const target of [row, total]) {
|
|
27
|
+
target.events += 1;
|
|
28
|
+
target.baselineTokens += baseline;
|
|
29
|
+
target.tokensSaved += saved;
|
|
30
|
+
target.benchTokensSaved += bench;
|
|
31
|
+
}
|
|
32
|
+
days.set(day, row);
|
|
33
|
+
}
|
|
34
|
+
return [...[...days.values()].sort((a, b) => a.day.localeCompare(b.day)), total];
|
|
35
|
+
}
|
|
36
|
+
function report() {
|
|
37
|
+
const db = new Database(CONFIG.LEDGER_PATH, { readonly: true, fileMustExist: true });
|
|
38
|
+
let events;
|
|
39
|
+
try {
|
|
40
|
+
events = db.prepare('SELECT * FROM events ORDER BY ts').all();
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
db.close();
|
|
44
|
+
}
|
|
45
|
+
console.log('=== SLM Gate: tokens saved (local ledger) ===\n');
|
|
46
|
+
console.log(`Ledger: ${CONFIG.LEDGER_PATH}`);
|
|
47
|
+
console.log(`The ledger is the complete record. Langfuse keeps only the last ${CONFIG.LANGFUSE_RETENTION_DAYS} days ` +
|
|
48
|
+
'(LANGFUSE_RETENTION_DAYS), so its cards cover less as older days drop off.\n');
|
|
49
|
+
const pct = (part, whole) => whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : 'n/a';
|
|
50
|
+
console.table(savingsByDay(events).map(r => ({
|
|
51
|
+
'Day (UTC)': r.day,
|
|
52
|
+
Events: r.events,
|
|
53
|
+
'Baseline tokens': r.baselineTokens.toLocaleString('en-US'),
|
|
54
|
+
'Tokens saved': r.tokensSaved.toLocaleString('en-US'),
|
|
55
|
+
'Saved %': pct(r.tokensSaved, r.baselineTokens),
|
|
56
|
+
'of which bench': r.benchTokensSaved.toLocaleString('en-US'),
|
|
57
|
+
})));
|
|
58
|
+
console.log('Days are UTC: set Langfuse\'s date picker to the same UTC day to compare with the Tokens Saved card.');
|
|
59
|
+
console.log('"of which bench" is savings from benchmark runs (Env = bench), already included in "Tokens saved".');
|
|
60
|
+
}
|
|
61
|
+
if (isEntryPoint(import.meta.url)) {
|
|
62
|
+
try {
|
|
63
|
+
report();
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
console.error('ledger:report failed:', err instanceof Error ? err.message : err);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Utility script to setup a dedicated Custom Dashboard in Langfuse
|
|
3
|
+
* with tailored widgets to properly visualize SLM Gate categorical and numeric scores.
|
|
4
|
+
*/
|
|
5
|
+
import { CONFIG, requireKeys } from '../config.js';
|
|
6
|
+
import { setTimeout } from 'timers/promises';
|
|
7
|
+
import { RETIRED_SCORE_NAMES } from './sync-config.js';
|
|
8
|
+
// The cards do not filter on SLM_GATE_SOURCE_TAG. Each already filters on a score name only
|
|
9
|
+
// the gate writes, which keeps other writers out. A tag filter looked equivalent but was not:
|
|
10
|
+
// Langfuse's v2 query engine (the dashboard's) sees trace tags on scores only for data it
|
|
11
|
+
// received after about 2026-09-21, and re-sending older data does not add them — the cards
|
|
12
|
+
// matched 59 of 760 tokens_saved scores and read near zero (checked 2026-09-23).
|
|
13
|
+
// Langfuse keeps only the last LANGFUSE_RETENTION_DAYS of data, so a total can fall while
|
|
14
|
+
// the gate is busy. Said on the dashboard and on every card that sums, where it misleads most.
|
|
15
|
+
const DASHBOARD_DESCRIPTION = `Rolling ${CONFIG.LANGFUSE_RETENTION_DAYS}-day window (Langfuse data retention). ` +
|
|
16
|
+
'Older days drop off as new ones arrive, so totals can fall even during heavy use. ' +
|
|
17
|
+
`The permanent record is the local ledger at ${CONFIG.LEDGER_PATH}.`;
|
|
18
|
+
const RANGE_TOTAL_NOTE = `Total across the selected range only, within the rolling ${CONFIG.LANGFUSE_RETENTION_DAYS}-day window.`;
|
|
19
|
+
/**
|
|
20
|
+
* Names cards used before they were renamed. Their placements must be removed too:
|
|
21
|
+
* otherwise the old card keeps its grid slot, and the renamed card placed on the same
|
|
22
|
+
* slot is silently dropped by Langfuse.
|
|
23
|
+
*/
|
|
24
|
+
const RETIRED_WIDGET_NAMES = [
|
|
25
|
+
'Claude Cycle Extended (min)',
|
|
26
|
+
'ChatGPT Cycle Extended (min)',
|
|
27
|
+
'Gemini Cycle Extended (min)',
|
|
28
|
+
// Retired because they read in decimal minutes: a typical prompt showed '0.18934',
|
|
29
|
+
// which the reader had to multiply by 60 to understand. Replaced by a seconds card
|
|
30
|
+
// (per prompt) and a minutes card (range total) per provider.
|
|
31
|
+
'Claude Cycle: Estimated Minutes Saved',
|
|
32
|
+
'ChatGPT Cycle: Estimated Minutes Saved',
|
|
33
|
+
'Gemini Cycle: Estimated Minutes Saved',
|
|
34
|
+
];
|
|
35
|
+
/** A widget from an earlier build: retired by name, or counting a score name no longer written. */
|
|
36
|
+
function isStaleWidget(widget) {
|
|
37
|
+
return RETIRED_WIDGET_NAMES.includes(widget.name)
|
|
38
|
+
|| (widget.filters ?? []).some(f => f.column === 'name' && typeof f.value === 'string' && RETIRED_SCORE_NAMES.includes(f.value));
|
|
39
|
+
}
|
|
40
|
+
async function apiFetch(url, init, label) {
|
|
41
|
+
let attempt = 0;
|
|
42
|
+
const maxRetries = 5;
|
|
43
|
+
while (attempt <= maxRetries) {
|
|
44
|
+
// Pace all mutating/read requests to ensure we stay under 30/min (~2000ms/req)
|
|
45
|
+
await setTimeout(2100);
|
|
46
|
+
const res = await fetch(url, init);
|
|
47
|
+
if (res.status === 429) {
|
|
48
|
+
attempt++;
|
|
49
|
+
if (attempt > maxRetries) {
|
|
50
|
+
throw new Error(`Rate limit exceeded on ${label} after ${maxRetries} retries.`);
|
|
51
|
+
}
|
|
52
|
+
let retryAfter = 60;
|
|
53
|
+
try {
|
|
54
|
+
const body = await res.json();
|
|
55
|
+
if (body.details && typeof body.details.retryAfterSeconds === 'number') {
|
|
56
|
+
retryAfter = body.details.retryAfterSeconds;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
// body might not be JSON or might be empty
|
|
61
|
+
}
|
|
62
|
+
console.log(`⏳ Rate limited on ${label}; waiting ${retryAfter}s...`);
|
|
63
|
+
await setTimeout((retryAfter + 1) * 1000);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
return res;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Failed to fetch ${label}`);
|
|
69
|
+
}
|
|
70
|
+
async function setupDashboard() {
|
|
71
|
+
requireKeys(['LANGFUSE_PUBLIC_KEY', 'LANGFUSE_SECRET_KEY', 'LANGFUSE_HOST']);
|
|
72
|
+
if (process.argv.includes('--wait')) {
|
|
73
|
+
console.log('Sleeping for 60s to ensure Langfuse rate limits are reset...');
|
|
74
|
+
await setTimeout(60000);
|
|
75
|
+
}
|
|
76
|
+
const baseUrl = CONFIG.LANGFUSE_HOST.replace(/\/$/, '');
|
|
77
|
+
const auth = `Basic ${Buffer.from(`${CONFIG.LANGFUSE_PUBLIC_KEY}:${CONFIG.LANGFUSE_SECRET_KEY}`).toString('base64')}`;
|
|
78
|
+
const headers = { Authorization: auth, 'Content-Type': 'application/json' };
|
|
79
|
+
console.log('=== SLM Gate: Langfuse Dashboard Setup ===\n');
|
|
80
|
+
console.log('If you just ran ledger:sync, wait ~60s — Langfuse limits 30 req/min.\n');
|
|
81
|
+
console.log('IMPORTANT: setup-dashboard.ts dedupes widgets and the dashboard by name.');
|
|
82
|
+
// Track failures
|
|
83
|
+
let failures = 0;
|
|
84
|
+
let placedCount = 0;
|
|
85
|
+
// 1. Create/Retrieve Widgets
|
|
86
|
+
console.log('\nFetching existing widgets...');
|
|
87
|
+
let existingWidgets = [];
|
|
88
|
+
try {
|
|
89
|
+
const wRes = await apiFetch(`${baseUrl}/api/public/unstable/dashboard-widgets`, { headers }, 'fetch widgets');
|
|
90
|
+
if (wRes.ok) {
|
|
91
|
+
const data = await wRes.json();
|
|
92
|
+
existingWidgets = data.data || data;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
console.warn(`⚠ Failed to fetch existing widgets: ${await wRes.text()}`);
|
|
96
|
+
failures++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
console.warn(`⚠ Error fetching existing widgets:`, err);
|
|
101
|
+
failures++;
|
|
102
|
+
}
|
|
103
|
+
const widgets = [
|
|
104
|
+
{
|
|
105
|
+
name: 'Routing Decision',
|
|
106
|
+
description: 'Whether the SLM resolved the prompt ($0) or escalated it',
|
|
107
|
+
view: 'scores-categorical',
|
|
108
|
+
chartType: 'PIE',
|
|
109
|
+
metrics: [{ measure: 'count', agg: 'count' }],
|
|
110
|
+
dimensions: [{ field: 'stringValue' }],
|
|
111
|
+
filters: [{ type: 'string', column: 'name', operator: '=', value: 'verified' }],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'Tokens Saved',
|
|
115
|
+
description: `Total cloud tokens saved by local SLM deferral. ${RANGE_TOTAL_NOTE}`,
|
|
116
|
+
view: 'scores-numeric',
|
|
117
|
+
chartType: 'NUMBER',
|
|
118
|
+
metrics: [{ measure: 'value', agg: 'sum' }],
|
|
119
|
+
dimensions: [],
|
|
120
|
+
filters: [{ type: 'string', column: 'name', operator: '=', value: 'tokens_saved' }],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'Cost Saved (Cents)',
|
|
124
|
+
description: `Estimated cloud API dollars avoided (in Cents). ${RANGE_TOTAL_NOTE}`,
|
|
125
|
+
view: 'scores-numeric',
|
|
126
|
+
chartType: 'NUMBER',
|
|
127
|
+
metrics: [{ measure: 'value', agg: 'sum' }],
|
|
128
|
+
dimensions: [],
|
|
129
|
+
filters: [{ type: 'string', column: 'name', operator: '=', value: 'cost_saved_cents' }],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'SLM Accuracy Rate (%)',
|
|
133
|
+
description: 'Only measured for prompts that pass through the model gate. Shows 0 when the model gate is not in the request path — that means not measured, not 0% accurate. MCP tool calls never answer prompts, so they never score accuracy. Share of local-model answers the verifier accepted (0-100%); benchmark runs land under Env = bench.',
|
|
134
|
+
view: 'scores-numeric',
|
|
135
|
+
chartType: 'NUMBER',
|
|
136
|
+
metrics: [{ measure: 'value', agg: 'avg' }],
|
|
137
|
+
dimensions: [],
|
|
138
|
+
filters: [{ type: 'string', column: 'name', operator: '=', value: 'accuracy_rate_pct' }],
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'Claude Cycle: Est. Seconds Saved (per prompt)',
|
|
142
|
+
description: "Only measured when Claude traffic passes through the gate and CLAUDE_WINDOW_BUDGET is set; shows 0 otherwise, which means not measured. SECONDS of your 5-hour Claude window freed per prompt, averaged — bounded [0, 18000]. Read it as-is: 11.4 means eleven and a half seconds of window time given back by the average prompt. An estimate within a margin of error: providers do not publish their window limits, so CLAUDE_WINDOW_BUDGET is a measured best guess. Claude's limits scale with tokens sent, so shrunk tool results and prompts extend the cycle.",
|
|
143
|
+
view: 'scores-numeric',
|
|
144
|
+
chartType: 'NUMBER',
|
|
145
|
+
metrics: [{ measure: 'value', agg: 'avg' }],
|
|
146
|
+
dimensions: [],
|
|
147
|
+
filters: [{ type: 'string', column: 'name', operator: '=', value: 'cycle_extended_seconds_claude' }],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'ChatGPT Cycle: Est. Seconds Saved (per prompt)',
|
|
151
|
+
description: "Only measured when ChatGPT traffic passes through the gate and CHATGPT_WINDOW_BUDGET is set; shows 0 otherwise, which means not measured. SECONDS of your 3-hour ChatGPT window freed per prompt, averaged — bounded [0, 10800]. Read it as-is. An estimate within a margin of error: providers do not publish their window limits, so CHATGPT_WINDOW_BUDGET is a best guess. ChatGPT uses message-based metering, so only a prompt answered entirely locally frees anything; a distilled-but-forwarded prompt still costs a message and scores 0.",
|
|
152
|
+
view: 'scores-numeric',
|
|
153
|
+
chartType: 'NUMBER',
|
|
154
|
+
metrics: [{ measure: 'value', agg: 'avg' }],
|
|
155
|
+
dimensions: [],
|
|
156
|
+
filters: [{ type: 'string', column: 'name', operator: '=', value: 'cycle_extended_seconds_chatgpt' }],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'Gemini Cycle: Est. Seconds Saved (per prompt)',
|
|
160
|
+
description: "Only measured when Gemini traffic passes through the gate and GEMINI_WINDOW_BUDGET is set; shows 0 otherwise, which means not measured. SECONDS of your 5-hour Gemini window freed per prompt, averaged — bounded [0, 18000]. Read it as-is. An estimate within a margin of error: providers do not publish their window limits, so GEMINI_WINDOW_BUDGET is a best guess. Gemini's limits scale with tokens sent, so shrunk tool results and prompts extend the cycle.",
|
|
161
|
+
view: 'scores-numeric',
|
|
162
|
+
chartType: 'NUMBER',
|
|
163
|
+
metrics: [{ measure: 'value', agg: 'avg' }],
|
|
164
|
+
dimensions: [],
|
|
165
|
+
filters: [{ type: 'string', column: 'name', operator: '=', value: 'cycle_extended_seconds_gemini' }],
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: 'Claude Cycle: Est. Minutes Saved (total)',
|
|
169
|
+
description: "Only measured when Claude traffic passes through the gate and CLAUDE_WINDOW_BUDGET is set; shows 0 otherwise, which means not measured. Total MINUTES of your 5-hour Claude window freed across every prompt in the selected date range. This is the companion to the per-prompt seconds card above it: same quantity, summed instead of averaged, so it grows as you use the gate. An estimate within a margin of error — CLAUDE_WINDOW_BUDGET is a measured best guess. " + RANGE_TOTAL_NOTE,
|
|
170
|
+
view: 'scores-numeric',
|
|
171
|
+
chartType: 'NUMBER',
|
|
172
|
+
metrics: [{ measure: 'value', agg: 'sum' }],
|
|
173
|
+
dimensions: [],
|
|
174
|
+
filters: [{ type: 'string', column: 'name', operator: '=', value: 'cycle_extended_minutes_claude' }],
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'ChatGPT Cycle: Est. Minutes Saved (total)',
|
|
178
|
+
description: "Only measured when ChatGPT traffic passes through the gate and CHATGPT_WINDOW_BUDGET is set; shows 0 otherwise, which means not measured. Total MINUTES of your 3-hour ChatGPT window freed across every prompt in the selected date range. The companion to the per-prompt seconds card above it: same quantity, summed instead of averaged. An estimate within a margin of error — CHATGPT_WINDOW_BUDGET is a best guess. " + RANGE_TOTAL_NOTE,
|
|
179
|
+
view: 'scores-numeric',
|
|
180
|
+
chartType: 'NUMBER',
|
|
181
|
+
metrics: [{ measure: 'value', agg: 'sum' }],
|
|
182
|
+
dimensions: [],
|
|
183
|
+
filters: [{ type: 'string', column: 'name', operator: '=', value: 'cycle_extended_minutes_chatgpt' }],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'Gemini Cycle: Est. Minutes Saved (total)',
|
|
187
|
+
description: "Only measured when Gemini traffic passes through the gate and GEMINI_WINDOW_BUDGET is set; shows 0 otherwise, which means not measured. Total MINUTES of your 5-hour Gemini window freed across every prompt in the selected date range. The companion to the per-prompt seconds card above it: same quantity, summed instead of averaged. An estimate within a margin of error — GEMINI_WINDOW_BUDGET is a best guess. " + RANGE_TOTAL_NOTE,
|
|
188
|
+
view: 'scores-numeric',
|
|
189
|
+
chartType: 'NUMBER',
|
|
190
|
+
metrics: [{ measure: 'value', agg: 'sum' }],
|
|
191
|
+
dimensions: [],
|
|
192
|
+
filters: [{ type: 'string', column: 'name', operator: '=', value: 'cycle_extended_minutes_gemini' }],
|
|
193
|
+
}
|
|
194
|
+
];
|
|
195
|
+
console.log('\nCreating widgets...');
|
|
196
|
+
const targetWidgets = [];
|
|
197
|
+
for (const w of widgets) {
|
|
198
|
+
const existing = existingWidgets.find(ew => ew.name === w.name);
|
|
199
|
+
if (existing) {
|
|
200
|
+
// Reused by name, so a changed description or filter would otherwise never reach
|
|
201
|
+
// Langfuse. Patched in place: the widget keeps its id and its placement.
|
|
202
|
+
// Compared field by field: Langfuse returns filter keys in its own order.
|
|
203
|
+
const filterKey = (filters) => JSON.stringify(filters.map(f => [f.column, f.operator, f.type, f.value, f.key ?? null]));
|
|
204
|
+
const upToDate = existing.description === w.description
|
|
205
|
+
&& filterKey(existing.filters ?? []) === filterKey(w.filters);
|
|
206
|
+
if (upToDate) {
|
|
207
|
+
console.log(`✓ Reusing existing widget: ${w.name}`);
|
|
208
|
+
targetWidgets.push(existing);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const res = await apiFetch(`${baseUrl}/api/public/unstable/dashboard-widgets/${existing.id}`, {
|
|
213
|
+
method: 'PATCH',
|
|
214
|
+
headers,
|
|
215
|
+
body: JSON.stringify({ description: w.description, filters: w.filters }),
|
|
216
|
+
}, `update widget '${w.name}'`);
|
|
217
|
+
if (res.ok) {
|
|
218
|
+
console.log(`✓ Updated existing widget: ${w.name}`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
console.warn(`⚠ Failed to update widget '${w.name}': ${await res.text()}`);
|
|
222
|
+
failures++;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
console.warn(`⚠ Error updating widget '${w.name}':`, err);
|
|
227
|
+
failures++;
|
|
228
|
+
}
|
|
229
|
+
targetWidgets.push(existing);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const res = await apiFetch(`${baseUrl}/api/public/unstable/dashboard-widgets`, {
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers,
|
|
236
|
+
body: JSON.stringify(w),
|
|
237
|
+
}, `create widget '${w.name}'`);
|
|
238
|
+
if (!res.ok) {
|
|
239
|
+
console.warn(`⚠ Failed to create widget '${w.name}': ${await res.text()}`);
|
|
240
|
+
failures++;
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
const data = await res.json();
|
|
244
|
+
console.log(`✓ Created widget: ${w.name}`);
|
|
245
|
+
targetWidgets.push(data);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
console.warn(`⚠ Error creating widget '${w.name}':`, err);
|
|
250
|
+
failures++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// 2. Create Dashboard
|
|
254
|
+
console.log('\nCreating/Fetching SLM Gate Dashboard...');
|
|
255
|
+
let dashboard;
|
|
256
|
+
try {
|
|
257
|
+
const listRes = await apiFetch(`${baseUrl}/api/public/unstable/dashboards`, { headers }, 'fetch dashboards');
|
|
258
|
+
if (listRes.ok) {
|
|
259
|
+
const listData = await listRes.json();
|
|
260
|
+
const existing = (listData.data || listData).find((d) => d.name === 'SLM Gate Performance');
|
|
261
|
+
if (existing) {
|
|
262
|
+
dashboard = existing;
|
|
263
|
+
console.log(`✓ Found existing dashboard: ${dashboard.name} (ID: ${dashboard.id})`);
|
|
264
|
+
if (existing.description !== DASHBOARD_DESCRIPTION) {
|
|
265
|
+
const patchRes = await apiFetch(`${baseUrl}/api/public/unstable/dashboards/${existing.id}`, {
|
|
266
|
+
method: 'PATCH',
|
|
267
|
+
headers,
|
|
268
|
+
body: JSON.stringify({ description: DASHBOARD_DESCRIPTION }),
|
|
269
|
+
}, 'update dashboard description');
|
|
270
|
+
if (patchRes.ok) {
|
|
271
|
+
console.log('✓ Updated dashboard description');
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
console.warn(`⚠ Failed to update dashboard description: ${await patchRes.text()}`);
|
|
275
|
+
failures++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
console.warn(`⚠ Failed to list dashboards: ${await listRes.text()}`);
|
|
282
|
+
failures++;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
console.warn(`⚠ Error fetching dashboards:`, err);
|
|
287
|
+
failures++;
|
|
288
|
+
}
|
|
289
|
+
if (!dashboard && failures === 0) {
|
|
290
|
+
try {
|
|
291
|
+
const dashboardRes = await apiFetch(`${baseUrl}/api/public/unstable/dashboards`, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers,
|
|
294
|
+
body: JSON.stringify({
|
|
295
|
+
name: 'SLM Gate Performance',
|
|
296
|
+
description: DASHBOARD_DESCRIPTION,
|
|
297
|
+
}),
|
|
298
|
+
}, 'create dashboard');
|
|
299
|
+
if (!dashboardRes.ok) {
|
|
300
|
+
console.error(`Failed to create dashboard: ${await dashboardRes.text()}`);
|
|
301
|
+
failures++;
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
dashboard = await dashboardRes.json();
|
|
305
|
+
console.log(`✓ Created NEW dashboard: ${dashboard.name} (ID: ${dashboard.id})`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
console.warn(`⚠ Error creating dashboard:`, err);
|
|
310
|
+
failures++;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// 3. Attach Widgets to Dashboard
|
|
314
|
+
if (dashboard && targetWidgets.length === widgets.length) {
|
|
315
|
+
console.log('\nChecking existing placements...');
|
|
316
|
+
try {
|
|
317
|
+
const dashRes = await apiFetch(`${baseUrl}/api/public/unstable/dashboards/${dashboard.id}`, { headers }, 'fetch specific dashboard');
|
|
318
|
+
if (dashRes.ok) {
|
|
319
|
+
const dashData = await dashRes.json();
|
|
320
|
+
const existingPlacements = dashData.definition?.widgets || [];
|
|
321
|
+
// Remove placements whose widget name matches one we are about to place
|
|
322
|
+
for (const placement of existingPlacements) {
|
|
323
|
+
const placementWidget = existingWidgets.find(ew => ew.id === placement.widgetId)
|
|
324
|
+
|| targetWidgets.find(tw => tw.id === placement.widgetId);
|
|
325
|
+
if (placementWidget && (widgets.some(w => w.name === placementWidget.name) || isStaleWidget(placementWidget))) {
|
|
326
|
+
console.log(`Removing existing placement for '${placementWidget.name}'...`);
|
|
327
|
+
const delRes = await apiFetch(`${baseUrl}/api/public/unstable/dashboards/${dashboard.id}/placements/${placement.id}`, {
|
|
328
|
+
method: 'DELETE',
|
|
329
|
+
headers,
|
|
330
|
+
}, `delete placement for '${placementWidget.name}'`);
|
|
331
|
+
if (!delRes.ok) {
|
|
332
|
+
console.warn(`⚠ Failed to delete placement: ${await delRes.text()}`);
|
|
333
|
+
failures++;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// With their placements gone, stale widgets can be deleted. Langfuse answers 409 while
|
|
339
|
+
// a widget is still placed on some other dashboard; that one is left and reported.
|
|
340
|
+
for (const stale of existingWidgets.filter(isStaleWidget)) {
|
|
341
|
+
const delRes = await apiFetch(`${baseUrl}/api/public/unstable/dashboard-widgets/${stale.id}`, {
|
|
342
|
+
method: 'DELETE',
|
|
343
|
+
headers,
|
|
344
|
+
}, `delete stale widget '${stale.name}'`);
|
|
345
|
+
if (delRes.ok) {
|
|
346
|
+
console.log(`✓ Deleted stale widget: ${stale.name}`);
|
|
347
|
+
}
|
|
348
|
+
else if (delRes.status === 409) {
|
|
349
|
+
console.warn(`⚠ Kept stale widget '${stale.name}': it is still placed on another dashboard.`);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
console.warn(`⚠ Failed to delete stale widget '${stale.name}': ${await delRes.text()}`);
|
|
353
|
+
failures++;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
console.warn(`⚠ Error removing existing placements:`, err);
|
|
359
|
+
failures++;
|
|
360
|
+
}
|
|
361
|
+
console.log('\nPlacing widgets on dashboard...');
|
|
362
|
+
// Explicit 12-column grid. Omitting x/y/width/height makes Langfuse auto-place each
|
|
363
|
+
// widget, and colliding auto-placements overwrite each other server-side — the POST
|
|
364
|
+
// still returns 200, so four of seven cards silently vanished from the definition.
|
|
365
|
+
// Rows 6 and 9 are column-aligned by provider, so each provider's per-prompt seconds
|
|
366
|
+
// card sits directly above its range-total minutes card.
|
|
367
|
+
const layout = [
|
|
368
|
+
{ x: 0, y: 0, width: 6, height: 6 }, // Routing Decision (pie)
|
|
369
|
+
{ x: 6, y: 0, width: 3, height: 3 }, // Tokens Saved
|
|
370
|
+
{ x: 9, y: 0, width: 3, height: 3 }, // Cost Saved
|
|
371
|
+
{ x: 6, y: 3, width: 6, height: 3 }, // SLM Accuracy Rate
|
|
372
|
+
{ x: 0, y: 6, width: 4, height: 3 }, // Claude — seconds per prompt
|
|
373
|
+
{ x: 4, y: 6, width: 4, height: 3 }, // ChatGPT — seconds per prompt
|
|
374
|
+
{ x: 8, y: 6, width: 4, height: 3 }, // Gemini — seconds per prompt
|
|
375
|
+
{ x: 0, y: 9, width: 4, height: 3 }, // Claude — minutes total
|
|
376
|
+
{ x: 4, y: 9, width: 4, height: 3 }, // ChatGPT — minutes total
|
|
377
|
+
{ x: 8, y: 9, width: 4, height: 3 }, // Gemini — minutes total
|
|
378
|
+
];
|
|
379
|
+
const placements = targetWidgets.map((w, i) => ({
|
|
380
|
+
type: 'widget',
|
|
381
|
+
widgetId: w.id,
|
|
382
|
+
...layout[i],
|
|
383
|
+
}));
|
|
384
|
+
for (const [i, p] of placements.entries()) {
|
|
385
|
+
if (!p.widgetId)
|
|
386
|
+
continue;
|
|
387
|
+
try {
|
|
388
|
+
const res = await apiFetch(`${baseUrl}/api/public/unstable/dashboards/${dashboard.id}/placements`, {
|
|
389
|
+
method: 'POST',
|
|
390
|
+
headers,
|
|
391
|
+
body: JSON.stringify(p),
|
|
392
|
+
}, `place widget '${widgets[i].name}'`);
|
|
393
|
+
if (res.ok) {
|
|
394
|
+
console.log(`✓ Placed widget: ${widgets[i].name}`);
|
|
395
|
+
placedCount++;
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
console.warn(`⚠ Failed to place widget '${widgets[i].name}': ${await res.text()}`);
|
|
399
|
+
failures++;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
console.warn(`⚠ Error placing widget '${widgets[i].name}':`, err);
|
|
404
|
+
failures++;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// A 200 on POST /placements does not mean the placement survived: colliding positions
|
|
408
|
+
// are dropped server-side. Re-read the definition and confirm every widget is present.
|
|
409
|
+
console.log('\nVerifying persisted placements...');
|
|
410
|
+
try {
|
|
411
|
+
const vRes = await apiFetch(`${baseUrl}/api/public/unstable/dashboards/${dashboard.id}`, { headers }, 'verify placements');
|
|
412
|
+
if (!vRes.ok) {
|
|
413
|
+
console.warn(`⚠ Could not verify placements: ${await vRes.text()}`);
|
|
414
|
+
failures++;
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
const vData = await vRes.json();
|
|
418
|
+
const persisted = vData.definition?.widgets || [];
|
|
419
|
+
const persistedIds = new Set(persisted.map(p => p.widgetId));
|
|
420
|
+
for (const [i, w] of targetWidgets.entries()) {
|
|
421
|
+
if (!persistedIds.has(w.id)) {
|
|
422
|
+
console.error(`✗ Widget '${widgets[i].name}' did NOT persist on the dashboard.`);
|
|
423
|
+
failures++;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
placedCount = targetWidgets.filter(w => persistedIds.has(w.id)).length;
|
|
427
|
+
console.log(`✓ ${persisted.length} placement(s) present in the dashboard definition.`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
console.warn(`⚠ Error verifying placements:`, err);
|
|
432
|
+
failures++;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
console.log(`\n================================`);
|
|
436
|
+
if (failures > 0) {
|
|
437
|
+
console.error(`❌ Setup failed! Encountered ${failures} error(s) during dashboard configuration.`);
|
|
438
|
+
console.error(`Please review the warnings above.`);
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
console.log(`🎉 Dashboard setup successful!`);
|
|
442
|
+
console.log(`✓ ${placedCount}/${widgets.length} widgets placed on SLM Gate Performance.`);
|
|
443
|
+
if (dashboard) {
|
|
444
|
+
const projectId = dashboard.projectId;
|
|
445
|
+
if (projectId) {
|
|
446
|
+
console.log(`👉 View it at: ${baseUrl}/project/${projectId}/dashboards/${dashboard.id}`);
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
console.log(`👉 To view it, open Langfuse and click "Dashboards" in the left sidebar.`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
setupDashboard().catch((err) => {
|
|
454
|
+
console.error('Fatal error setting up dashboard:', err);
|
|
455
|
+
process.exit(1);
|
|
456
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { calculateCostUsd } from '../pricing/index.js';
|
|
2
|
+
async function main() {
|
|
3
|
+
console.log('--- SLM Gate Ledger Smoke Test ---');
|
|
4
|
+
const apiModel = 'gpt-5.6-luna';
|
|
5
|
+
const apiCost = calculateCostUsd(apiModel, 250, 60);
|
|
6
|
+
console.log(`Cost for ${apiModel} (250 in, 60 out): $${apiCost.toFixed(8)}`);
|
|
7
|
+
const localModel = 'qwen3.5:1.5b';
|
|
8
|
+
const localCost = calculateCostUsd(localModel, 250, 60);
|
|
9
|
+
console.log(`Cost for ${localModel} (250 in, 60 out): $${localCost.toFixed(8)}`);
|
|
10
|
+
if (localCost !== 0) {
|
|
11
|
+
console.error(`❌ Local cost is not zero! Got: $${localCost}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
console.log(`✅ Local cost is $0.00000000 as expected.`);
|
|
16
|
+
}
|
|
17
|
+
const missingModel = 'not-a-real-model-123';
|
|
18
|
+
try {
|
|
19
|
+
calculateCostUsd(missingModel, 250, 60);
|
|
20
|
+
console.error(`❌ Missing model did not throw error!`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err.name === 'PricingMissingError') {
|
|
25
|
+
console.log(`✅ Missing model cleanly threw named error: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
console.error(`❌ Expected PricingMissingError, got ${err.name}: ${err.message}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
console.log('Smoke test passed successfully.');
|
|
33
|
+
}
|
|
34
|
+
main().catch(err => {
|
|
35
|
+
console.error(err);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|