lynkr 9.0.2 → 9.1.2
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/bin/cli.js +18 -1
- package/bin/lynkr-trajectory.js +136 -0
- package/bin/lynkr-usage.js +219 -0
- package/funding.json +110 -0
- package/package.json +2 -2
- package/public/dashboard.html +665 -0
- package/src/api/files-router.js +6 -6
- package/src/api/middleware/budget.js +19 -1
- package/src/api/middleware/load-shedding.js +17 -0
- package/src/api/openai-router.js +1 -1
- package/src/api/router.js +185 -47
- package/src/clients/databricks.js +9 -5
- package/src/clients/openai-format.js +31 -5
- package/src/config/index.js +7 -0
- package/src/dashboard/api.js +170 -0
- package/src/dashboard/router.js +13 -0
- package/src/headroom/client.js +3 -109
- package/src/headroom/index.js +0 -14
- package/src/memory/search.js +0 -50
- package/src/orchestrator/index.js +62 -5
- package/src/orchestrator/preflight.js +188 -0
- package/src/routing/index.js +61 -0
- package/src/routing/interaction.js +183 -0
- package/src/routing/risk-analyzer.js +194 -0
- package/src/routing/telemetry.js +7 -0
- package/src/server.js +3 -0
- package/src/stores/file-store.js +42 -7
- package/src/tools/smart-selection.js +11 -2
- package/src/training/trajectory-compressor.js +266 -0
- package/src/usage/aggregator.js +206 -0
- package/src/utils/markdown-ansi.js +146 -0
package/src/routing/telemetry.js
CHANGED
|
@@ -105,6 +105,9 @@ function init() {
|
|
|
105
105
|
|
|
106
106
|
CREATE INDEX IF NOT EXISTS idx_telemetry_timestamp
|
|
107
107
|
ON routing_telemetry(timestamp);
|
|
108
|
+
|
|
109
|
+
CREATE INDEX IF NOT EXISTS idx_telemetry_session_id
|
|
110
|
+
ON routing_telemetry(session_id, timestamp);
|
|
108
111
|
`);
|
|
109
112
|
|
|
110
113
|
logger.info({ dbPath }, "Routing telemetry database initialised");
|
|
@@ -233,6 +236,10 @@ function query(filters = {}) {
|
|
|
233
236
|
clauses.push("timestamp >= @since");
|
|
234
237
|
params.since = filters.since;
|
|
235
238
|
}
|
|
239
|
+
if (filters.session_id) {
|
|
240
|
+
clauses.push("session_id = @session_id");
|
|
241
|
+
params.session_id = filters.session_id;
|
|
242
|
+
}
|
|
236
243
|
|
|
237
244
|
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
238
245
|
const limit = filters.limit ?? 100;
|
package/src/server.js
CHANGED
package/src/stores/file-store.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
+
const fsp = require("fs").promises;
|
|
2
3
|
const path = require("path");
|
|
3
4
|
const crypto = require("crypto");
|
|
4
5
|
const logger = require("../logger");
|
|
5
6
|
|
|
6
7
|
const STORAGE_DIR = path.resolve(process.env.FILES_STORAGE_PATH || "./data/files");
|
|
8
|
+
const METADATA_FILE = path.join(STORAGE_DIR, "_metadata.json");
|
|
7
9
|
const MAX_FILES = parseInt(process.env.FILES_MAX_COUNT || "1000", 10);
|
|
8
10
|
|
|
9
11
|
const metadata = new Map();
|
|
@@ -14,15 +16,46 @@ function ensureStorageDir() {
|
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
function
|
|
19
|
+
function persistMetadata() {
|
|
20
|
+
try {
|
|
21
|
+
const entries = Array.from(metadata.values());
|
|
22
|
+
fs.writeFileSync(METADATA_FILE, JSON.stringify(entries), "utf8");
|
|
23
|
+
} catch (err) {
|
|
24
|
+
logger.warn({ err: err.message }, "Failed to persist file metadata");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadMetadata() {
|
|
29
|
+
ensureStorageDir();
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.existsSync(METADATA_FILE)) return;
|
|
32
|
+
const entries = JSON.parse(fs.readFileSync(METADATA_FILE, "utf8"));
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
// Only restore entries whose backing file still exists on disk
|
|
35
|
+
if (fs.existsSync(entry.storage_path)) {
|
|
36
|
+
metadata.set(entry.id, entry);
|
|
37
|
+
} else {
|
|
38
|
+
logger.debug({ fileId: entry.id }, "Dropping orphaned metadata entry (file missing)");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
logger.info({ count: metadata.size }, "File metadata restored from disk");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
logger.warn({ err: err.message }, "Could not load file metadata; starting fresh");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Restore metadata at module load so restarts don't orphan files
|
|
48
|
+
loadMetadata();
|
|
49
|
+
|
|
50
|
+
async function storeFile(buffer, { filename, purpose, mimeType }) {
|
|
18
51
|
ensureStorageDir();
|
|
19
52
|
if (metadata.size >= MAX_FILES) {
|
|
20
53
|
const oldest = metadata.keys().next().value;
|
|
21
|
-
deleteFile(oldest);
|
|
54
|
+
await deleteFile(oldest);
|
|
22
55
|
}
|
|
23
56
|
const id = `file-${crypto.randomUUID()}`;
|
|
24
57
|
const storagePath = path.join(STORAGE_DIR, id);
|
|
25
|
-
|
|
58
|
+
await fsp.writeFile(storagePath, buffer);
|
|
26
59
|
const entry = {
|
|
27
60
|
id,
|
|
28
61
|
object: "file",
|
|
@@ -34,6 +67,7 @@ function storeFile(buffer, { filename, purpose, mimeType }) {
|
|
|
34
67
|
storage_path: storagePath,
|
|
35
68
|
};
|
|
36
69
|
metadata.set(id, entry);
|
|
70
|
+
persistMetadata();
|
|
37
71
|
logger.info({ fileId: id, bytes: buffer.length, filename }, "File stored");
|
|
38
72
|
return entry;
|
|
39
73
|
}
|
|
@@ -42,21 +76,22 @@ function getFile(id) {
|
|
|
42
76
|
return metadata.get(id) || null;
|
|
43
77
|
}
|
|
44
78
|
|
|
45
|
-
function getFileContent(id) {
|
|
79
|
+
async function getFileContent(id) {
|
|
46
80
|
const entry = metadata.get(id);
|
|
47
81
|
if (!entry) return null;
|
|
48
82
|
try {
|
|
49
|
-
return
|
|
83
|
+
return await fsp.readFile(entry.storage_path);
|
|
50
84
|
} catch {
|
|
51
85
|
return null;
|
|
52
86
|
}
|
|
53
87
|
}
|
|
54
88
|
|
|
55
|
-
function deleteFile(id) {
|
|
89
|
+
async function deleteFile(id) {
|
|
56
90
|
const entry = metadata.get(id);
|
|
57
91
|
if (!entry) return false;
|
|
58
|
-
try {
|
|
92
|
+
try { await fsp.unlink(entry.storage_path); } catch {}
|
|
59
93
|
metadata.delete(id);
|
|
94
|
+
persistMetadata();
|
|
60
95
|
return true;
|
|
61
96
|
}
|
|
62
97
|
|
|
@@ -280,9 +280,18 @@ function selectToolsSmartly(tools, classification, options = {}) {
|
|
|
280
280
|
|
|
281
281
|
// Get relevant tool names for this request type
|
|
282
282
|
const relevantToolNames = TOOL_SELECTION_MAP[requestType] || TOOL_SELECTION_MAP.coding;
|
|
283
|
+
const relevantLower = new Set(relevantToolNames.map(n => n.toLowerCase()));
|
|
283
284
|
|
|
284
|
-
// Filter to relevant tools only
|
|
285
|
-
|
|
285
|
+
// Filter to relevant tools only (case-insensitive match so external clients
|
|
286
|
+
// using lowercase names like Pi's `bash`/`read` aren't filtered out)
|
|
287
|
+
let selectedTools = tools.filter(tool => relevantLower.has(String(tool.name || '').toLowerCase()));
|
|
288
|
+
|
|
289
|
+
// If nothing matched, the caller is using a tool ecosystem we don't recognize
|
|
290
|
+
// (e.g. Pi's read/write/edit/bash). Pass tools through untouched rather than
|
|
291
|
+
// deleting them — otherwise the LLM gets no schema and hallucinates defaults.
|
|
292
|
+
if (selectedTools.length === 0) {
|
|
293
|
+
return tools;
|
|
294
|
+
}
|
|
286
295
|
|
|
287
296
|
// Mode-specific adjustments
|
|
288
297
|
if (config.mode === 'aggressive') {
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trajectory Compressor
|
|
3
|
+
*
|
|
4
|
+
* Reads completed agent sessions out of Lynkr's session DB,
|
|
5
|
+
* joins with routing telemetry to pick up tier / score / outcome
|
|
6
|
+
* metadata, and emits JSONL training samples for fine-tuning small
|
|
7
|
+
* models on tool-call generation and tier-routing decisions.
|
|
8
|
+
*
|
|
9
|
+
* Each line of the output JSONL is a self-contained sample:
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* "session_id": "...",
|
|
13
|
+
* "messages": [{"role": "...", "content": ...}, ...],
|
|
14
|
+
* "tool_calls": [...],
|
|
15
|
+
* "outcome": "success" | "error",
|
|
16
|
+
* "tier": "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING",
|
|
17
|
+
* "complexity_score": 38,
|
|
18
|
+
* "model_used": "gpt-4o",
|
|
19
|
+
* "provider_used": "azure-openai",
|
|
20
|
+
* "tokens_in": 1234,
|
|
21
|
+
* "tokens_out": 456,
|
|
22
|
+
* "latency_ms": 2400,
|
|
23
|
+
* "started_at": "2026-05-03T10:11:12Z",
|
|
24
|
+
* "ended_at": "2026-05-03T10:11:14Z"
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* The compressor is read-only — it never modifies the source DBs.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require("fs");
|
|
31
|
+
const path = require("path");
|
|
32
|
+
|
|
33
|
+
const db = require("../db");
|
|
34
|
+
const telemetry = require("../routing/telemetry");
|
|
35
|
+
|
|
36
|
+
// Patterns for the optional --anonymize pass. Order matters: more
|
|
37
|
+
// specific patterns first so they don't get clobbered by generic ones.
|
|
38
|
+
const ANONYMIZE_PATTERNS = [
|
|
39
|
+
// API keys and bearer tokens
|
|
40
|
+
[/sk-[A-Za-z0-9_-]{20,}/g, "<API_KEY>"],
|
|
41
|
+
[/Bearer\s+[A-Za-z0-9._\-]+/gi, "Bearer <REDACTED>"],
|
|
42
|
+
[/dapi_[A-Za-z0-9_-]+/g, "<DATABRICKS_KEY>"],
|
|
43
|
+
[/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<JWT>"],
|
|
44
|
+
// AWS keys
|
|
45
|
+
[/AKIA[0-9A-Z]{16}/g, "<AWS_ACCESS_KEY>"],
|
|
46
|
+
// Email addresses
|
|
47
|
+
[/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<EMAIL>"],
|
|
48
|
+
// Absolute filesystem paths under /Users/<name>/ or /home/<name>/
|
|
49
|
+
[/\/Users\/[^/\s]+/g, "/Users/<USER>"],
|
|
50
|
+
[/\/home\/[^/\s]+/g, "/home/<USER>"],
|
|
51
|
+
// IPs
|
|
52
|
+
[/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, "<IP>"],
|
|
53
|
+
// Hostnames containing service-now / corporate domains (configurable)
|
|
54
|
+
[/[A-Za-z0-9-]+\.service-now\.com/gi, "<SERVICENOW_HOST>"],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function anonymize(value) {
|
|
58
|
+
if (typeof value === "string") {
|
|
59
|
+
let out = value;
|
|
60
|
+
for (const [re, replacement] of ANONYMIZE_PATTERNS) {
|
|
61
|
+
out = out.replace(re, replacement);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(value)) return value.map(anonymize);
|
|
66
|
+
if (value && typeof value === "object") {
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [k, v] of Object.entries(value)) out[k] = anonymize(v);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseJsonSafe(text, fallback = null) {
|
|
75
|
+
if (text == null) return fallback;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(text);
|
|
78
|
+
} catch {
|
|
79
|
+
return fallback;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* List session ids in a window, optionally filtered by tier.
|
|
85
|
+
*/
|
|
86
|
+
function listSessions({ since = null, tier = null } = {}) {
|
|
87
|
+
if (!db) return [];
|
|
88
|
+
|
|
89
|
+
const rows = since
|
|
90
|
+
? db
|
|
91
|
+
.prepare(
|
|
92
|
+
"SELECT id, created_at, updated_at, metadata FROM sessions WHERE updated_at >= ? ORDER BY updated_at DESC"
|
|
93
|
+
)
|
|
94
|
+
.all(since)
|
|
95
|
+
: db.prepare("SELECT id, created_at, updated_at, metadata FROM sessions ORDER BY updated_at DESC").all();
|
|
96
|
+
|
|
97
|
+
if (!tier) return rows;
|
|
98
|
+
|
|
99
|
+
// Tier filter requires joining against routing telemetry — we do that
|
|
100
|
+
// per-session lazily so we don't pre-load the whole telemetry table.
|
|
101
|
+
return rows.filter((s) => sessionTier(s.id) === tier);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Find the dominant tier picked across a session's telemetry rows.
|
|
106
|
+
*/
|
|
107
|
+
function sessionTier(sessionId) {
|
|
108
|
+
try {
|
|
109
|
+
const rows = telemetry.query({ session_id: sessionId, limit: 1000 });
|
|
110
|
+
if (rows.length === 0) return null;
|
|
111
|
+
const counts = {};
|
|
112
|
+
for (const r of rows) counts[r.tier || "UNKNOWN"] = (counts[r.tier || "UNKNOWN"] || 0) + 1;
|
|
113
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build one trajectory record for a single session.
|
|
121
|
+
*/
|
|
122
|
+
function buildTrajectory(session, options = {}) {
|
|
123
|
+
if (!db) return null;
|
|
124
|
+
|
|
125
|
+
const historyStmt = db.prepare(
|
|
126
|
+
"SELECT role, type, status, content, metadata, timestamp FROM session_history WHERE session_id = ? ORDER BY timestamp ASC"
|
|
127
|
+
);
|
|
128
|
+
const history = historyStmt.all(session.id);
|
|
129
|
+
|
|
130
|
+
// Convert each session_history row into a chat message, preserving
|
|
131
|
+
// tool-call structure when present in metadata.
|
|
132
|
+
const messages = [];
|
|
133
|
+
const toolCalls = [];
|
|
134
|
+
|
|
135
|
+
for (const row of history) {
|
|
136
|
+
const meta = parseJsonSafe(row.metadata) || {};
|
|
137
|
+
const content = parseJsonSafe(row.content, row.content);
|
|
138
|
+
|
|
139
|
+
if (row.role === "tool" || row.type === "tool_use" || row.type === "tool_result") {
|
|
140
|
+
// Capture tool calls as a separate stream alongside the chat
|
|
141
|
+
toolCalls.push({
|
|
142
|
+
type: row.type,
|
|
143
|
+
timestamp: row.timestamp,
|
|
144
|
+
content,
|
|
145
|
+
metadata: meta,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (row.role === "user" || row.role === "assistant" || row.role === "system") {
|
|
150
|
+
messages.push({
|
|
151
|
+
role: row.role,
|
|
152
|
+
content,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Pull telemetry records associated with this session to enrich.
|
|
158
|
+
const teleRows = telemetry.query({ session_id: session.id, limit: 1000 });
|
|
159
|
+
|
|
160
|
+
const totals = teleRows.reduce(
|
|
161
|
+
(acc, r) => {
|
|
162
|
+
acc.tokens_in += r.input_tokens || 0;
|
|
163
|
+
acc.tokens_out += r.output_tokens || 0;
|
|
164
|
+
acc.latency_ms += r.latency_ms || 0;
|
|
165
|
+
return acc;
|
|
166
|
+
},
|
|
167
|
+
{ tokens_in: 0, tokens_out: 0, latency_ms: 0 }
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Pick the modal tier (most-used) and the most-recent model/provider.
|
|
171
|
+
const tier = sessionTier(session.id);
|
|
172
|
+
const last = teleRows[0]; // telemetry.query orders DESC
|
|
173
|
+
const errorRow = teleRows.find((r) => r.error_type);
|
|
174
|
+
const outcome = errorRow ? "error" : "success";
|
|
175
|
+
const complexityAvg =
|
|
176
|
+
teleRows.length > 0
|
|
177
|
+
? Math.round(
|
|
178
|
+
teleRows.reduce((sum, r) => sum + (r.complexity_score || 0), 0) /
|
|
179
|
+
teleRows.length
|
|
180
|
+
)
|
|
181
|
+
: null;
|
|
182
|
+
|
|
183
|
+
let trajectory = {
|
|
184
|
+
session_id: session.id,
|
|
185
|
+
messages,
|
|
186
|
+
tool_calls: toolCalls,
|
|
187
|
+
outcome,
|
|
188
|
+
tier,
|
|
189
|
+
complexity_score: complexityAvg,
|
|
190
|
+
model_used: last?.model || null,
|
|
191
|
+
provider_used: last?.provider || null,
|
|
192
|
+
tokens_in: totals.tokens_in,
|
|
193
|
+
tokens_out: totals.tokens_out,
|
|
194
|
+
latency_ms: totals.latency_ms,
|
|
195
|
+
started_at: new Date(session.created_at).toISOString(),
|
|
196
|
+
ended_at: new Date(session.updated_at).toISOString(),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (options.anonymize) {
|
|
200
|
+
trajectory = anonymize(trajectory);
|
|
201
|
+
}
|
|
202
|
+
return trajectory;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Stream trajectories to a writable target (a path or a stream).
|
|
207
|
+
*
|
|
208
|
+
* @param {Object} options
|
|
209
|
+
* @param {string|number|Date} [options.since] Window start (ms / Date / "Nd")
|
|
210
|
+
* @param {string} [options.tier] Filter to one tier
|
|
211
|
+
* @param {boolean} [options.anonymize=false] Strip PII / paths / secrets
|
|
212
|
+
* @param {string|stream.Writable} [options.output="-"] File path, "-" for stdout, or a stream
|
|
213
|
+
* @param {function} [options.onProgress] Optional progress callback (count) => void
|
|
214
|
+
* @returns {{ count: number, output: string }}
|
|
215
|
+
*/
|
|
216
|
+
function exportJsonl(options = {}) {
|
|
217
|
+
const since = resolveSince(options.since);
|
|
218
|
+
const sessions = listSessions({ since, tier: options.tier });
|
|
219
|
+
|
|
220
|
+
let stream;
|
|
221
|
+
let outputPath = "-";
|
|
222
|
+
let closeStream = false;
|
|
223
|
+
|
|
224
|
+
if (!options.output || options.output === "-") {
|
|
225
|
+
stream = process.stdout;
|
|
226
|
+
} else if (typeof options.output === "string") {
|
|
227
|
+
outputPath = options.output;
|
|
228
|
+
fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
229
|
+
stream = fs.createWriteStream(outputPath);
|
|
230
|
+
closeStream = true;
|
|
231
|
+
} else {
|
|
232
|
+
stream = options.output;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let count = 0;
|
|
236
|
+
for (const session of sessions) {
|
|
237
|
+
const trajectory = buildTrajectory(session, options);
|
|
238
|
+
if (!trajectory || trajectory.messages.length === 0) continue;
|
|
239
|
+
stream.write(JSON.stringify(trajectory) + "\n");
|
|
240
|
+
count++;
|
|
241
|
+
if (options.onProgress) options.onProgress(count);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (closeStream) stream.end();
|
|
245
|
+
return { count, output: outputPath };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolveSince(value) {
|
|
249
|
+
if (value == null) return null;
|
|
250
|
+
if (value instanceof Date) return value.getTime();
|
|
251
|
+
if (typeof value === "number") return value;
|
|
252
|
+
if (typeof value === "string") {
|
|
253
|
+
const m = value.match(/^(\d+)d$/);
|
|
254
|
+
if (m) return Date.now() - parseInt(m[1], 10) * 24 * 60 * 60 * 1000;
|
|
255
|
+
const parsed = Date.parse(value);
|
|
256
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
exportJsonl,
|
|
263
|
+
buildTrajectory,
|
|
264
|
+
listSessions,
|
|
265
|
+
anonymize,
|
|
266
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage Aggregator
|
|
3
|
+
*
|
|
4
|
+
* Reads routing telemetry from .lynkr/telemetry.db and produces
|
|
5
|
+
* actionable spend / savings reports.
|
|
6
|
+
*
|
|
7
|
+
* The "savings" calculation answers the question:
|
|
8
|
+
* "How much would this same workload have cost if every request
|
|
9
|
+
* had hit the most expensive flagship model?"
|
|
10
|
+
*
|
|
11
|
+
* That's the number Lynkr's tier router exists to make small.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const telemetry = require("../routing/telemetry");
|
|
15
|
+
const { getCostOptimizer } = require("../routing/cost-optimizer");
|
|
16
|
+
|
|
17
|
+
// What we treat as the "flagship comparison" — the model a developer
|
|
18
|
+
// would otherwise run every request against if they didn't have Lynkr.
|
|
19
|
+
// Picked to match Claude Code / Cursor defaults.
|
|
20
|
+
const DEFAULT_FLAGSHIP_MODEL = "claude-sonnet-4-5-20250929";
|
|
21
|
+
|
|
22
|
+
const WINDOW_PRESETS = {
|
|
23
|
+
"1d": 1 * 24 * 60 * 60 * 1000,
|
|
24
|
+
"7d": 7 * 24 * 60 * 60 * 1000,
|
|
25
|
+
"30d": 30 * 24 * 60 * 60 * 1000,
|
|
26
|
+
all: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a window string ("7d", "30d", "all") or a Date / ISO string
|
|
31
|
+
* into a `since` timestamp in ms. Returns null for "all".
|
|
32
|
+
*/
|
|
33
|
+
function resolveSince(window) {
|
|
34
|
+
if (!window || window === "all") return null;
|
|
35
|
+
if (window instanceof Date) return window.getTime();
|
|
36
|
+
if (typeof window === "string") {
|
|
37
|
+
if (WINDOW_PRESETS[window] !== undefined) {
|
|
38
|
+
return WINDOW_PRESETS[window] === null ? null : Date.now() - WINDOW_PRESETS[window];
|
|
39
|
+
}
|
|
40
|
+
if (/^\d+d$/.test(window)) {
|
|
41
|
+
const days = parseInt(window, 10);
|
|
42
|
+
return Date.now() - days * 24 * 60 * 60 * 1000;
|
|
43
|
+
}
|
|
44
|
+
const parsed = Date.parse(window);
|
|
45
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
46
|
+
}
|
|
47
|
+
if (typeof window === "number") return window;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compute usage stats for a time window.
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} options
|
|
55
|
+
* @param {string|Date|number} [options.window="30d"] "1d", "7d", "30d", "all", ISO string, or epoch ms
|
|
56
|
+
* @param {string} [options.flagship] Model id used for the "what if I'd run flagship-only" comparison
|
|
57
|
+
* @param {string} [options.model] Filter to a single model
|
|
58
|
+
* @param {string} [options.provider] Filter to a single provider
|
|
59
|
+
* @returns {Object} Aggregated usage report
|
|
60
|
+
*/
|
|
61
|
+
function getUsage(options = {}) {
|
|
62
|
+
const window = options.window || "30d";
|
|
63
|
+
const since = resolveSince(window);
|
|
64
|
+
const flagship = options.flagship || DEFAULT_FLAGSHIP_MODEL;
|
|
65
|
+
|
|
66
|
+
const filters = { limit: 100000 };
|
|
67
|
+
if (since !== null) filters.since = since;
|
|
68
|
+
if (options.provider) filters.provider = options.provider;
|
|
69
|
+
|
|
70
|
+
let rows;
|
|
71
|
+
try {
|
|
72
|
+
rows = telemetry.query(filters);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return {
|
|
75
|
+
window,
|
|
76
|
+
since: since ? new Date(since).toISOString() : null,
|
|
77
|
+
flagship,
|
|
78
|
+
totals: emptyTotals(),
|
|
79
|
+
byTier: {},
|
|
80
|
+
byProvider: {},
|
|
81
|
+
byModel: {},
|
|
82
|
+
error: err.message,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Optional model filter (telemetry.query doesn't support it natively)
|
|
87
|
+
if (options.model) {
|
|
88
|
+
rows = rows.filter((r) => r.model === options.model);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const optimizer = (() => {
|
|
92
|
+
try {
|
|
93
|
+
return getCostOptimizer();
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
})();
|
|
98
|
+
|
|
99
|
+
const totals = emptyTotals();
|
|
100
|
+
const byTier = {};
|
|
101
|
+
const byProvider = {};
|
|
102
|
+
const byModel = {};
|
|
103
|
+
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
const inputTokens = row.input_tokens || 0;
|
|
106
|
+
const outputTokens = row.output_tokens || 0;
|
|
107
|
+
const totalTokens = inputTokens + outputTokens;
|
|
108
|
+
const actualCost = Number(row.cost_usd) || 0;
|
|
109
|
+
|
|
110
|
+
// Hypothetical cost if this same request had hit the flagship model.
|
|
111
|
+
let flagshipCost = 0;
|
|
112
|
+
if (optimizer && totalTokens > 0) {
|
|
113
|
+
try {
|
|
114
|
+
const est = optimizer.estimateCost(flagship, inputTokens, outputTokens);
|
|
115
|
+
flagshipCost = (est.inputCost || 0) + (est.outputCost || 0);
|
|
116
|
+
} catch {
|
|
117
|
+
flagshipCost = 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const saved = Math.max(0, flagshipCost - actualCost);
|
|
121
|
+
|
|
122
|
+
totals.requests += 1;
|
|
123
|
+
totals.inputTokens += inputTokens;
|
|
124
|
+
totals.outputTokens += outputTokens;
|
|
125
|
+
totals.totalTokens += totalTokens;
|
|
126
|
+
totals.actualCost += actualCost;
|
|
127
|
+
totals.flagshipCost += flagshipCost;
|
|
128
|
+
totals.saved += saved;
|
|
129
|
+
if (row.was_fallback) totals.fallbacks += 1;
|
|
130
|
+
if (row.error_type) totals.errors += 1;
|
|
131
|
+
|
|
132
|
+
bumpBucket(byTier, row.tier || "UNKNOWN", inputTokens, outputTokens, actualCost, flagshipCost);
|
|
133
|
+
bumpBucket(byProvider, row.provider || "unknown", inputTokens, outputTokens, actualCost, flagshipCost);
|
|
134
|
+
bumpBucket(byModel, row.model || "unknown", inputTokens, outputTokens, actualCost, flagshipCost);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
window,
|
|
139
|
+
since: since ? new Date(since).toISOString() : null,
|
|
140
|
+
flagship,
|
|
141
|
+
totals,
|
|
142
|
+
byTier,
|
|
143
|
+
byProvider,
|
|
144
|
+
byModel,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function emptyTotals() {
|
|
149
|
+
return {
|
|
150
|
+
requests: 0,
|
|
151
|
+
inputTokens: 0,
|
|
152
|
+
outputTokens: 0,
|
|
153
|
+
totalTokens: 0,
|
|
154
|
+
actualCost: 0,
|
|
155
|
+
flagshipCost: 0,
|
|
156
|
+
saved: 0,
|
|
157
|
+
savedPercent: 0,
|
|
158
|
+
fallbacks: 0,
|
|
159
|
+
errors: 0,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function bumpBucket(bucket, key, inputTokens, outputTokens, actualCost, flagshipCost) {
|
|
164
|
+
if (!bucket[key]) {
|
|
165
|
+
bucket[key] = {
|
|
166
|
+
requests: 0,
|
|
167
|
+
inputTokens: 0,
|
|
168
|
+
outputTokens: 0,
|
|
169
|
+
totalTokens: 0,
|
|
170
|
+
actualCost: 0,
|
|
171
|
+
flagshipCost: 0,
|
|
172
|
+
saved: 0,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const b = bucket[key];
|
|
176
|
+
b.requests += 1;
|
|
177
|
+
b.inputTokens += inputTokens;
|
|
178
|
+
b.outputTokens += outputTokens;
|
|
179
|
+
b.totalTokens += inputTokens + outputTokens;
|
|
180
|
+
b.actualCost += actualCost;
|
|
181
|
+
b.flagshipCost += flagshipCost;
|
|
182
|
+
b.saved += Math.max(0, flagshipCost - actualCost);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Compute and finalise totals (savedPercent etc.) on a usage object.
|
|
187
|
+
* Mutates and returns the object — convenient for chaining.
|
|
188
|
+
*/
|
|
189
|
+
function finalise(usage) {
|
|
190
|
+
const t = usage.totals;
|
|
191
|
+
t.savedPercent = t.flagshipCost > 0 ? Math.round((t.saved / t.flagshipCost) * 1000) / 10 : 0;
|
|
192
|
+
for (const bucket of [usage.byTier, usage.byProvider, usage.byModel]) {
|
|
193
|
+
for (const key of Object.keys(bucket)) {
|
|
194
|
+
const b = bucket[key];
|
|
195
|
+
b.savedPercent = b.flagshipCost > 0 ? Math.round((b.saved / b.flagshipCost) * 1000) / 10 : 0;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return usage;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
getUsage: (options) => finalise(getUsage(options)),
|
|
203
|
+
resolveSince,
|
|
204
|
+
DEFAULT_FLAGSHIP_MODEL,
|
|
205
|
+
WINDOW_PRESETS,
|
|
206
|
+
};
|