lynkr 9.11.0 → 9.13.0
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/README.md +58 -9
- package/bin/lynkr-init.js +25 -2
- package/bin/statusline.js +80 -0
- package/bin/wrap.js +10 -6
- package/package.json +8 -3
- package/public/dashboard.html +325 -1
- package/src/api/openai-router.js +163 -2
- package/src/api/providers-handler.js +28 -0
- package/src/clients/databricks.js +297 -30
- package/src/clients/provider-capabilities.js +1 -1
- package/src/config/index.js +37 -3
- package/src/context/tool-result-compressor.js +6 -3
- package/src/dashboard/api.js +78 -2
- package/src/dashboard/recommendations/cache-weakspots.js +88 -0
- package/src/dashboard/recommendations/deadweight.js +82 -0
- package/src/dashboard/recommendations/downsize.js +109 -0
- package/src/dashboard/recommendations/index.js +106 -0
- package/src/dashboard/recommendations/side-requests.js +68 -0
- package/src/dashboard/recommendations/wilson.js +24 -0
- package/src/dashboard/router.js +5 -0
- package/src/orchestrator/index.js +28 -5
- package/src/orchestrator/sse-transformer.js +10 -3
- package/src/routing/complexity-analyzer.js +30 -9
- package/src/routing/index.js +2 -0
- package/src/routing/intent-score.js +27 -2
- package/src/routing/model-tiers.js +4 -0
- package/src/routing/telemetry.js +206 -2
- package/scripts/convert-routellm.py +0 -105
|
@@ -20,16 +20,23 @@
|
|
|
20
20
|
*/
|
|
21
21
|
const logger = require("../logger");
|
|
22
22
|
|
|
23
|
-
// Upstreams whose streaming wire format is OpenAI Chat Completions SSE.
|
|
24
|
-
// ollama is excluded (own format + buffering quirks)
|
|
25
|
-
//
|
|
23
|
+
// Upstreams whose streaming streaming wire format is OpenAI Chat Completions SSE.
|
|
24
|
+
// ollama is excluded (own format + buffering quirks); zai is excluded (its
|
|
25
|
+
// invoke fn converts buffered responses to Anthropic, and its Anthropic-format
|
|
26
|
+
// endpoint has the native passthrough path instead). moonshot joined the list
|
|
27
|
+
// once invokeMoonshot learned to return the raw stream (its old forced
|
|
28
|
+
// stream:false predated this transformer). Caveat: reasoning_content deltas
|
|
29
|
+
// (kimi thinking) are not reshaped — thinking text is dropped from streamed
|
|
30
|
+
// responses; the buffered path still lifts it into thinking blocks.
|
|
26
31
|
const DEFAULT_OPENAI_SSE_PROVIDERS = [
|
|
27
32
|
"openai",
|
|
33
|
+
"atlas",
|
|
28
34
|
"azure-openai",
|
|
29
35
|
"openrouter",
|
|
30
36
|
"databricks",
|
|
31
37
|
"lmstudio",
|
|
32
38
|
"llamacpp",
|
|
39
|
+
"moonshot",
|
|
33
40
|
];
|
|
34
41
|
|
|
35
42
|
function _transformProviders() {
|
|
@@ -16,6 +16,7 @@ const logger = require('../logger');
|
|
|
16
16
|
const config = require('../config');
|
|
17
17
|
const codeGraph = require('../tools/code-graph');
|
|
18
18
|
const clientProfiles = require('./client-profiles');
|
|
19
|
+
const { compressMessages, shouldCompress } = require('./context-compressor');
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Return the tools the user actually added beyond the client harness's
|
|
@@ -769,13 +770,31 @@ function getThreshold() {
|
|
|
769
770
|
* @returns {Object} Complexity analysis result
|
|
770
771
|
*/
|
|
771
772
|
async function analyzeComplexity(payload, options = {}) {
|
|
772
|
-
|
|
773
|
-
|
|
773
|
+
// Phase 0: Context Compression (TencentDB-inspired)
|
|
774
|
+
// Compress conversation before scoring to account for token reduction
|
|
775
|
+
let workingPayload = payload;
|
|
776
|
+
let compressionStats = null;
|
|
777
|
+
|
|
778
|
+
const useCompression = options.compression ?? config.routing?.compression ?? true;
|
|
779
|
+
if (useCompression && shouldCompress(payload)) {
|
|
780
|
+
const { compressed, stats } = compressMessages(payload.messages);
|
|
781
|
+
compressionStats = stats;
|
|
782
|
+
workingPayload = { ...payload, messages: compressed };
|
|
783
|
+
|
|
784
|
+
logger.debug({
|
|
785
|
+
reduction: stats.reduction,
|
|
786
|
+
originalTokens: stats.original,
|
|
787
|
+
compressedTokens: stats.compressed,
|
|
788
|
+
}, '[complexity] Context compressed');
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const content = extractContent(workingPayload);
|
|
792
|
+
const messageCount = workingPayload?.messages?.length ?? 0;
|
|
774
793
|
const useWeighted = options.weighted ?? config.routing?.weightedScoring ?? false;
|
|
775
794
|
|
|
776
795
|
// Use weighted scoring if enabled
|
|
777
796
|
if (useWeighted) {
|
|
778
|
-
const weighted = calculateWeightedScore(
|
|
797
|
+
const weighted = calculateWeightedScore(workingPayload, content);
|
|
779
798
|
const threshold = getThreshold();
|
|
780
799
|
const mode = config.smartToolSelection?.mode ?? 'heuristic';
|
|
781
800
|
|
|
@@ -807,6 +826,7 @@ async function analyzeComplexity(payload, options = {}) {
|
|
|
807
826
|
forceReason: taskTypeResult.reason?.startsWith('force_') ? taskTypeResult.reason : null,
|
|
808
827
|
content: content.slice(0, 100) + (content.length > 100 ? '...' : ''),
|
|
809
828
|
graphSignals: null,
|
|
829
|
+
compression: compressionStats, // Add compression stats
|
|
810
830
|
};
|
|
811
831
|
|
|
812
832
|
// Phase 5: Structural Analysis (code-review-graph, optional)
|
|
@@ -841,9 +861,9 @@ async function analyzeComplexity(payload, options = {}) {
|
|
|
841
861
|
return result;
|
|
842
862
|
}
|
|
843
863
|
|
|
844
|
-
// Standard scoring (original logic)
|
|
845
|
-
const tokenScore = scoreTokens(
|
|
846
|
-
const toolScore = scoreTools(
|
|
864
|
+
// Standard scoring (original logic) - use compressed payload
|
|
865
|
+
const tokenScore = scoreTokens(workingPayload);
|
|
866
|
+
const toolScore = scoreTools(workingPayload);
|
|
847
867
|
const taskTypeResult = scoreTaskType(content);
|
|
848
868
|
const codeComplexityResult = scoreCodeComplexity(content);
|
|
849
869
|
const reasoningResult = scoreReasoning(content);
|
|
@@ -881,8 +901,8 @@ async function analyzeComplexity(payload, options = {}) {
|
|
|
881
901
|
mode,
|
|
882
902
|
recommendation,
|
|
883
903
|
breakdown: {
|
|
884
|
-
tokens: { score: tokenScore, estimated: estimateTokens(
|
|
885
|
-
tools: { score: toolScore, count: _effectiveTools(
|
|
904
|
+
tokens: { score: tokenScore, estimated: estimateTokens(workingPayload) },
|
|
905
|
+
tools: { score: toolScore, count: _effectiveTools(workingPayload).length },
|
|
886
906
|
taskType: taskTypeResult,
|
|
887
907
|
codeComplexity: codeComplexityResult,
|
|
888
908
|
reasoning: reasoningResult,
|
|
@@ -890,11 +910,12 @@ async function analyzeComplexity(payload, options = {}) {
|
|
|
890
910
|
},
|
|
891
911
|
content: content.slice(0, 100) + (content.length > 100 ? '...' : ''),
|
|
892
912
|
graphSignals: null,
|
|
913
|
+
compression: compressionStats, // Add compression stats
|
|
893
914
|
};
|
|
894
915
|
|
|
895
916
|
// Phase 5: Structural Analysis (code-review-graph, optional)
|
|
896
917
|
try {
|
|
897
|
-
const filePaths = extractFilePaths(
|
|
918
|
+
const filePaths = extractFilePaths(workingPayload);
|
|
898
919
|
const graphOpts = { filePaths, workspace: options?.workspace };
|
|
899
920
|
const graphAvailable = await codeGraph.isAvailable(graphOpts);
|
|
900
921
|
if (graphAvailable && filePaths.length > 0) {
|
package/src/routing/index.js
CHANGED
|
@@ -84,6 +84,7 @@ function _enabledProviders() {
|
|
|
84
84
|
if (config.openrouter?.apiKey) out.push('openrouter');
|
|
85
85
|
if (config.edenai?.apiKey) out.push('edenai');
|
|
86
86
|
if (config.openai?.apiKey) out.push('openai');
|
|
87
|
+
if (config.atlas?.apiKey) out.push('atlas');
|
|
87
88
|
if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) out.push('azure-openai');
|
|
88
89
|
if (config.ollama?.endpoint) out.push('ollama');
|
|
89
90
|
if (config.llamacpp?.endpoint) out.push('llamacpp');
|
|
@@ -126,6 +127,7 @@ function getBestCloudProvider() {
|
|
|
126
127
|
if (config.openrouter?.apiKey) return 'openrouter';
|
|
127
128
|
if (config.edenai?.apiKey) return 'edenai';
|
|
128
129
|
if (config.openai?.apiKey) return 'openai';
|
|
130
|
+
if (config.atlas?.apiKey) return 'atlas';
|
|
129
131
|
if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) return 'azure-openai';
|
|
130
132
|
|
|
131
133
|
return getFallbackProvider();
|
|
@@ -366,6 +366,14 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
|
|
|
366
366
|
return { score: anchorScore, reconciled: 'up_gated' };
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
// Memoize the final reconciled score per cleaned text. The classifier leg is
|
|
370
|
+
// live and can time out under load, sending identical text down different
|
|
371
|
+
// fallback paths — which made the same turn score differently between the
|
|
372
|
+
// router and the pin-drift checker (observed: 63 vs 77 for one prompt under
|
|
373
|
+
// suite-wide Ollama contention). First resolution wins for the process life.
|
|
374
|
+
const _scoreMemo = new Map();
|
|
375
|
+
const _SCORE_MEMO_MAX = 500;
|
|
376
|
+
|
|
369
377
|
async function scoreIntent(payload, opts = {}) {
|
|
370
378
|
const mode = opts.mode ?? intentScoreMode();
|
|
371
379
|
if (mode === 'legacy') return null;
|
|
@@ -373,6 +381,21 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
373
381
|
const text = extractCleanUserText(payload);
|
|
374
382
|
if (!text) return null;
|
|
375
383
|
|
|
384
|
+
// Only memoize plain calls — opts that alter scoring (custom centroids,
|
|
385
|
+
// embedFn, risk/context inputs) must not share cache entries.
|
|
386
|
+
const memoizable = !opts.centroids && !opts.embedFn && !opts.forceMatched
|
|
387
|
+
&& !opts.riskLevel && !opts.skipClassifier && !opts.priorTurns;
|
|
388
|
+
if (memoizable && _scoreMemo.has(text)) return _scoreMemo.get(text);
|
|
389
|
+
const _memoSet = (result) => {
|
|
390
|
+
if (memoizable && result) {
|
|
391
|
+
if (_scoreMemo.size >= _SCORE_MEMO_MAX) {
|
|
392
|
+
_scoreMemo.delete(_scoreMemo.keys().next().value);
|
|
393
|
+
}
|
|
394
|
+
_scoreMemo.set(text, result);
|
|
395
|
+
}
|
|
396
|
+
return result;
|
|
397
|
+
};
|
|
398
|
+
|
|
376
399
|
try {
|
|
377
400
|
const centroids = opts.centroids !== undefined ? opts.centroids : await getDefaultCentroids();
|
|
378
401
|
if (centroids) {
|
|
@@ -421,7 +444,7 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
421
444
|
finalScore: score,
|
|
422
445
|
}, '[IntentScore] classifier reconciled anchor score');
|
|
423
446
|
}
|
|
424
|
-
return {
|
|
447
|
+
return _memoSet({
|
|
425
448
|
score,
|
|
426
449
|
mode: reconciled ? 'anchor+classifier' : 'anchor',
|
|
427
450
|
class: cls,
|
|
@@ -431,13 +454,15 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
431
454
|
classifierTier: classifierResult?.tier ?? null,
|
|
432
455
|
classifierConfidence: classifierResult?.confidence ?? null,
|
|
433
456
|
reconciled,
|
|
434
|
-
};
|
|
457
|
+
});
|
|
435
458
|
}
|
|
436
459
|
}
|
|
437
460
|
} catch (err) {
|
|
438
461
|
logger.debug({ err: err.message }, '[IntentScore] anchor scoring failed — lexical fallback');
|
|
439
462
|
}
|
|
440
463
|
|
|
464
|
+
// Lexical fallback is NOT memoized: it usually means the embedding service
|
|
465
|
+
// was unavailable; pinning it would lock in degraded scores after recovery.
|
|
441
466
|
return { score: _lexicalCleanScore(text), mode: 'lexical', text };
|
|
442
467
|
}
|
|
443
468
|
|
|
@@ -333,6 +333,8 @@ class ModelTierSelector {
|
|
|
333
333
|
return config.azureOpenAI?.deployment || null;
|
|
334
334
|
case 'openai':
|
|
335
335
|
return config.openai?.model || null;
|
|
336
|
+
case 'atlas':
|
|
337
|
+
return config.atlas?.model || null;
|
|
336
338
|
case 'ollama':
|
|
337
339
|
return config.ollama?.model || null;
|
|
338
340
|
case 'openrouter':
|
|
@@ -349,6 +351,8 @@ class ModelTierSelector {
|
|
|
349
351
|
return config.zai?.model || null;
|
|
350
352
|
case 'moonshot':
|
|
351
353
|
return config.moonshot?.model || null;
|
|
354
|
+
case 'baidu':
|
|
355
|
+
return config.baidu?.model || null;
|
|
352
356
|
case 'codex':
|
|
353
357
|
return config.codex?.model || null;
|
|
354
358
|
case 'vertex':
|
package/src/routing/telemetry.js
CHANGED
|
@@ -170,6 +170,11 @@ function init() {
|
|
|
170
170
|
// projectedSwitchCostUsd, projectedStaySavingsUsd,
|
|
171
171
|
// expectedRemainingTurns} as JSON.
|
|
172
172
|
["cache_decision", "TEXT"],
|
|
173
|
+
// Lens dashboard — per-request cache counters from the provider's
|
|
174
|
+
// usage payload. NULL on rows recorded before capture began (or by
|
|
175
|
+
// providers that report none) — "not measured" is distinct from 0.
|
|
176
|
+
["cache_read_tokens", "INTEGER"],
|
|
177
|
+
["cache_creation_tokens", "INTEGER"],
|
|
173
178
|
];
|
|
174
179
|
for (const [col, type] of additiveCols) {
|
|
175
180
|
if (!existingCols.has(col)) {
|
|
@@ -232,7 +237,7 @@ function record(data) {
|
|
|
232
237
|
retry_count, circuit_breaker_state, quality_score, tokens_per_second,
|
|
233
238
|
cost_efficiency, request_text, response_text,
|
|
234
239
|
base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
|
|
235
|
-
cache_decision
|
|
240
|
+
cache_decision, cache_read_tokens, cache_creation_tokens
|
|
236
241
|
) VALUES (
|
|
237
242
|
@request_id, @session_id, @timestamp, @complexity_score, @tier,
|
|
238
243
|
@agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
|
|
@@ -241,7 +246,7 @@ function record(data) {
|
|
|
241
246
|
@retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
|
|
242
247
|
@cost_efficiency, @request_text, @response_text,
|
|
243
248
|
@base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
|
|
244
|
-
@cache_decision
|
|
249
|
+
@cache_decision, @cache_read_tokens, @cache_creation_tokens
|
|
245
250
|
)`
|
|
246
251
|
);
|
|
247
252
|
if (!insert) return;
|
|
@@ -292,6 +297,8 @@ function record(data) {
|
|
|
292
297
|
: (typeof data.cache_decision === "string"
|
|
293
298
|
? data.cache_decision
|
|
294
299
|
: JSON.stringify(data.cache_decision)),
|
|
300
|
+
cache_read_tokens: data.cache_read_tokens ?? null,
|
|
301
|
+
cache_creation_tokens: data.cache_creation_tokens ?? null,
|
|
295
302
|
});
|
|
296
303
|
} catch (err) {
|
|
297
304
|
logger.debug({ err: err.message }, "Telemetry record failed");
|
|
@@ -926,6 +933,200 @@ function getExpectedRemainingTurns(currentTurns = 0) {
|
|
|
926
933
|
}
|
|
927
934
|
}
|
|
928
935
|
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
// Lens dashboard queries
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* MEASURED cache savings — backward-looking, auditable against the bill:
|
|
942
|
+
* for rows that carried cache counters, what did cache reads actually save
|
|
943
|
+
* vs. paying full input price? Distinct from the projected figures in
|
|
944
|
+
* getCacheEconomics. Also reports the hit ratio per (provider, model).
|
|
945
|
+
*
|
|
946
|
+
* @param {Object} [opts] {since}
|
|
947
|
+
* @returns {{measuredSavedUsd:number, rowsMeasured:number, perModel:Array}|null}
|
|
948
|
+
*/
|
|
949
|
+
function getMeasuredCacheSavings(opts = {}) {
|
|
950
|
+
if (!init()) return null;
|
|
951
|
+
const since = opts.since ?? Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
952
|
+
try {
|
|
953
|
+
const rows = db
|
|
954
|
+
.prepare(
|
|
955
|
+
`SELECT provider, model, COUNT(*) n,
|
|
956
|
+
SUM(COALESCE(cache_read_tokens,0)) rd,
|
|
957
|
+
SUM(COALESCE(cache_creation_tokens,0)) cr,
|
|
958
|
+
SUM(COALESCE(input_tokens,0)) inp
|
|
959
|
+
FROM routing_telemetry
|
|
960
|
+
WHERE timestamp > ? AND cache_read_tokens IS NOT NULL
|
|
961
|
+
GROUP BY provider, model`
|
|
962
|
+
)
|
|
963
|
+
.all(since);
|
|
964
|
+
const { resolveCacheEconomics } = require("./cache-economics");
|
|
965
|
+
let saved = 0;
|
|
966
|
+
let measured = 0;
|
|
967
|
+
const perModel = [];
|
|
968
|
+
for (const r of rows) {
|
|
969
|
+
measured += r.n;
|
|
970
|
+
const econ = resolveCacheEconomics(r.provider, r.model);
|
|
971
|
+
const savedUsd = econ.unknownPricing
|
|
972
|
+
? null
|
|
973
|
+
: (r.rd * Math.max(0, econ.inputPerM - econ.cacheReadPerM)) / 1_000_000;
|
|
974
|
+
if (savedUsd != null) saved += savedUsd;
|
|
975
|
+
const total = r.rd + r.cr + r.inp;
|
|
976
|
+
perModel.push({
|
|
977
|
+
provider: r.provider,
|
|
978
|
+
model: r.model,
|
|
979
|
+
requests: r.n,
|
|
980
|
+
cacheReadTokens: r.rd,
|
|
981
|
+
hitRatio: total > 0 ? r.rd / total : null,
|
|
982
|
+
savedUsd,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
perModel.sort((a, b) => (a.hitRatio ?? 1) - (b.hitRatio ?? 1));
|
|
986
|
+
return { measuredSavedUsd: saved, rowsMeasured: measured, perModel };
|
|
987
|
+
} catch (err) {
|
|
988
|
+
logger.debug({ err: err.message }, "Telemetry getMeasuredCacheSavings failed");
|
|
989
|
+
return null;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Session drill-down: per-model mix, context-growth series, tool totals.
|
|
995
|
+
* @param {string} sessionId
|
|
996
|
+
*/
|
|
997
|
+
function getSessionDetail(sessionId) {
|
|
998
|
+
if (!sessionId || !init()) return null;
|
|
999
|
+
try {
|
|
1000
|
+
const rows = db
|
|
1001
|
+
.prepare(
|
|
1002
|
+
`SELECT timestamp, provider, model, tier, routing_method, input_tokens,
|
|
1003
|
+
output_tokens, cost_usd, latency_ms, tool_count, tool_calls_made,
|
|
1004
|
+
cache_read_tokens, cache_creation_tokens, status_code, error_type,
|
|
1005
|
+
pinned, switch_reason, message_count
|
|
1006
|
+
FROM routing_telemetry WHERE session_id = ? ORDER BY timestamp ASC LIMIT 2000`
|
|
1007
|
+
)
|
|
1008
|
+
.all(sessionId);
|
|
1009
|
+
if (!rows.length) return null;
|
|
1010
|
+
|
|
1011
|
+
const perModel = new Map();
|
|
1012
|
+
let toolCalls = 0;
|
|
1013
|
+
let cost = 0;
|
|
1014
|
+
for (const r of rows) {
|
|
1015
|
+
const key = `${r.provider}:${r.model}`;
|
|
1016
|
+
const m = perModel.get(key) ?? {
|
|
1017
|
+
provider: r.provider, model: r.model, requests: 0,
|
|
1018
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUsd: 0,
|
|
1019
|
+
};
|
|
1020
|
+
m.requests++;
|
|
1021
|
+
m.inputTokens += r.input_tokens ?? 0;
|
|
1022
|
+
m.outputTokens += r.output_tokens ?? 0;
|
|
1023
|
+
m.cacheReadTokens += r.cache_read_tokens ?? 0;
|
|
1024
|
+
m.costUsd += r.cost_usd ?? 0;
|
|
1025
|
+
perModel.set(key, m);
|
|
1026
|
+
toolCalls += r.tool_calls_made ?? 0;
|
|
1027
|
+
cost += r.cost_usd ?? 0;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return {
|
|
1031
|
+
sessionId,
|
|
1032
|
+
requests: rows.length,
|
|
1033
|
+
totalCostUsd: cost,
|
|
1034
|
+
toolCalls,
|
|
1035
|
+
firstSeen: rows[0].timestamp,
|
|
1036
|
+
lastSeen: rows[rows.length - 1].timestamp,
|
|
1037
|
+
models: [...perModel.values()].sort((a, b) => b.costUsd - a.costUsd),
|
|
1038
|
+
// Input (context) tokens per call over the session — the distiller
|
|
1039
|
+
// cliff and frozen-block plateau are visible here.
|
|
1040
|
+
contextSeries: rows.map((r) => ({
|
|
1041
|
+
t: r.timestamp,
|
|
1042
|
+
inputTokens: r.input_tokens ?? 0,
|
|
1043
|
+
cacheReadTokens: r.cache_read_tokens ?? null,
|
|
1044
|
+
tier: r.tier,
|
|
1045
|
+
model: r.model,
|
|
1046
|
+
})),
|
|
1047
|
+
rows,
|
|
1048
|
+
};
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
logger.debug({ err: err.message }, "Telemetry getSessionDetail failed");
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Pivot explorer — every metric/dimension is whitelisted; params never reach
|
|
1056
|
+
// SQL as strings.
|
|
1057
|
+
const ANALYTICS_METRICS = {
|
|
1058
|
+
spend: "SUM(COALESCE(cost_usd,0))",
|
|
1059
|
+
tokens: "SUM(COALESCE(input_tokens,0)+COALESCE(output_tokens,0))",
|
|
1060
|
+
requests: "COUNT(*)",
|
|
1061
|
+
sessions: "COUNT(DISTINCT session_id)",
|
|
1062
|
+
};
|
|
1063
|
+
const ANALYTICS_DIMS = {
|
|
1064
|
+
provider: "provider",
|
|
1065
|
+
model: "model",
|
|
1066
|
+
tier: "tier",
|
|
1067
|
+
request_type: "request_type",
|
|
1068
|
+
routing_method: "routing_method",
|
|
1069
|
+
switch_reason: "switch_reason",
|
|
1070
|
+
day: "date(timestamp/1000, 'unixepoch')",
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Generic Metric × Dimension (× Stack) pivot with KPI row and
|
|
1075
|
+
* period-over-period deltas (suppressed when the prior window is thin).
|
|
1076
|
+
*
|
|
1077
|
+
* @param {Object} [opts] {metric, by, stack, since, until}
|
|
1078
|
+
*/
|
|
1079
|
+
function getAnalytics(opts = {}) {
|
|
1080
|
+
if (!init()) return null;
|
|
1081
|
+
const metricExpr = ANALYTICS_METRICS[opts.metric] ?? ANALYTICS_METRICS.spend;
|
|
1082
|
+
const metric = ANALYTICS_METRICS[opts.metric] ? opts.metric : "spend";
|
|
1083
|
+
const byExpr = ANALYTICS_DIMS[opts.by] ?? ANALYTICS_DIMS.provider;
|
|
1084
|
+
const by = ANALYTICS_DIMS[opts.by] ? opts.by : "provider";
|
|
1085
|
+
const stackExpr = opts.stack && ANALYTICS_DIMS[opts.stack] ? ANALYTICS_DIMS[opts.stack] : null;
|
|
1086
|
+
const until = opts.until ?? Date.now();
|
|
1087
|
+
const since = opts.since ?? until - 7 * 24 * 60 * 60 * 1000;
|
|
1088
|
+
|
|
1089
|
+
try {
|
|
1090
|
+
const groupCols = stackExpr ? `${byExpr} AS dim, ${stackExpr} AS stack` : `${byExpr} AS dim`;
|
|
1091
|
+
const groupBy = stackExpr ? "GROUP BY dim, stack" : "GROUP BY dim";
|
|
1092
|
+
const rows = db
|
|
1093
|
+
.prepare(
|
|
1094
|
+
`SELECT ${groupCols}, ${metricExpr} AS value
|
|
1095
|
+
FROM routing_telemetry
|
|
1096
|
+
WHERE timestamp BETWEEN ? AND ?
|
|
1097
|
+
${groupBy} ORDER BY value DESC LIMIT 500`
|
|
1098
|
+
)
|
|
1099
|
+
.all(since, until);
|
|
1100
|
+
|
|
1101
|
+
const kpiRow = (lo, hi) =>
|
|
1102
|
+
db
|
|
1103
|
+
.prepare(
|
|
1104
|
+
`SELECT SUM(COALESCE(cost_usd,0)) spend,
|
|
1105
|
+
SUM(COALESCE(input_tokens,0)+COALESCE(output_tokens,0)) tokens,
|
|
1106
|
+
COUNT(*) requests,
|
|
1107
|
+
COUNT(DISTINCT session_id) sessions
|
|
1108
|
+
FROM routing_telemetry WHERE timestamp BETWEEN ? AND ?`
|
|
1109
|
+
)
|
|
1110
|
+
.get(lo, hi);
|
|
1111
|
+
const kpis = kpiRow(since, until);
|
|
1112
|
+
const prev = kpiRow(since - (until - since), since);
|
|
1113
|
+
// Thin prior window → deltas are noise, suppress rather than mislead.
|
|
1114
|
+
const kpiDeltas = (prev?.requests ?? 0) >= 10
|
|
1115
|
+
? {
|
|
1116
|
+
spend: kpis.spend - (prev.spend ?? 0),
|
|
1117
|
+
tokens: kpis.tokens - (prev.tokens ?? 0),
|
|
1118
|
+
requests: kpis.requests - prev.requests,
|
|
1119
|
+
sessions: kpis.sessions - (prev.sessions ?? 0),
|
|
1120
|
+
}
|
|
1121
|
+
: null;
|
|
1122
|
+
|
|
1123
|
+
return { metric, by, stack: stackExpr ? opts.stack : null, since, until, rows, kpis, kpiDeltas };
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
logger.debug({ err: err.message }, "Telemetry getAnalytics failed");
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
929
1130
|
module.exports = {
|
|
930
1131
|
record,
|
|
931
1132
|
query,
|
|
@@ -936,6 +1137,9 @@ module.exports = {
|
|
|
936
1137
|
getQualityByTierAndType,
|
|
937
1138
|
getExpectedRemainingTurns,
|
|
938
1139
|
getCacheEconomics,
|
|
1140
|
+
getMeasuredCacheSavings,
|
|
1141
|
+
getSessionDetail,
|
|
1142
|
+
getAnalytics,
|
|
939
1143
|
recordSavings,
|
|
940
1144
|
getSavingsSummary,
|
|
941
1145
|
cleanup,
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Convert routellm/gpt4_dataset (train.jsonl) → Lynkr kNN bootstrap JSONL.
|
|
4
|
-
|
|
5
|
-
Stream-downloads from HuggingFace and maps mixtral_score (1-5) to your
|
|
6
|
-
TIER_* model config so the kNN index learns to recommend YOUR real models,
|
|
7
|
-
not RouteLLM's pool (mixtral / gpt-4).
|
|
8
|
-
|
|
9
|
-
Output format matches scripts/build-knn-index.js bootstrap input:
|
|
10
|
-
{"query": "...", "provider": "ollama", "model": "minimax-m2.5:cloud",
|
|
11
|
-
"quality": 95, "cost": 0.001, "latency": 200, "tier": "SIMPLE"}
|
|
12
|
-
|
|
13
|
-
Usage:
|
|
14
|
-
.venv/bin/python scripts/convert-routellm.py [--limit 30000] -o routellm.jsonl
|
|
15
|
-
"""
|
|
16
|
-
import argparse
|
|
17
|
-
import json
|
|
18
|
-
import sys
|
|
19
|
-
import urllib.request
|
|
20
|
-
import ssl
|
|
21
|
-
|
|
22
|
-
# Map mixtral_score → user's tier config.
|
|
23
|
-
# Spread is intentionally aggressive: only score=5 (Mixtral truly nailed it)
|
|
24
|
-
# stays SIMPLE; score=4 escalates to MEDIUM so the kNN index has enough MEDIUM
|
|
25
|
-
# examples to actually recommend non-SIMPLE tiers (vs the previous mapping
|
|
26
|
-
# where 86% of records were SIMPLE and kNN never disagreed with the heuristic).
|
|
27
|
-
SCORE_TO_ROUTE = {
|
|
28
|
-
5: {"tier": "SIMPLE", "provider": "ollama", "model": "minimax-m2.5:cloud", "quality": 95, "cost": 0.001, "latency": 200},
|
|
29
|
-
4: {"tier": "MEDIUM", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 80, "cost": 0.003, "latency": 1500},
|
|
30
|
-
3: {"tier": "MEDIUM", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 70, "cost": 0.003, "latency": 1500},
|
|
31
|
-
2: {"tier": "COMPLEX", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 55, "cost": 0.003, "latency": 1500},
|
|
32
|
-
1: {"tier": "REASONING", "provider": "azure-anthropic", "model": "claude-opus-4-8", "quality": 40, "cost": 0.015, "latency": 3500},
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
URL = "https://huggingface.co/datasets/routellm/gpt4_dataset/resolve/main/train.jsonl"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def main():
|
|
39
|
-
p = argparse.ArgumentParser()
|
|
40
|
-
p.add_argument("--output", "-o", default="data/routellm-bootstrap.jsonl")
|
|
41
|
-
p.add_argument("--limit", type=int, default=None,
|
|
42
|
-
help="Only convert first N rows")
|
|
43
|
-
args = p.parse_args()
|
|
44
|
-
|
|
45
|
-
print(f"Streaming {URL} …", file=sys.stderr)
|
|
46
|
-
# Some macOS Python builds have SSL issues with huggingface.co — fall back
|
|
47
|
-
# to unverified context if needed. The data is public, no auth.
|
|
48
|
-
ctx = ssl.create_default_context()
|
|
49
|
-
try:
|
|
50
|
-
with urllib.request.urlopen(URL, context=ctx, timeout=60) as resp:
|
|
51
|
-
_stream(resp, args.output, args.limit)
|
|
52
|
-
except (ssl.SSLError, OSError) as e:
|
|
53
|
-
print(f"SSL strict failed ({e}); retrying with unverified context", file=sys.stderr)
|
|
54
|
-
ctx = ssl._create_unverified_context()
|
|
55
|
-
with urllib.request.urlopen(URL, context=ctx, timeout=60) as resp:
|
|
56
|
-
_stream(resp, args.output, args.limit)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _stream(resp, out_path, limit):
|
|
60
|
-
written = 0
|
|
61
|
-
by_score = {s: 0 for s in SCORE_TO_ROUTE}
|
|
62
|
-
skipped = 0
|
|
63
|
-
with open(out_path, "w") as out:
|
|
64
|
-
for line in resp:
|
|
65
|
-
if limit and written >= limit:
|
|
66
|
-
break
|
|
67
|
-
try:
|
|
68
|
-
row = json.loads(line)
|
|
69
|
-
except Exception:
|
|
70
|
-
skipped += 1
|
|
71
|
-
continue
|
|
72
|
-
score = row.get("mixtral_score")
|
|
73
|
-
if score not in SCORE_TO_ROUTE:
|
|
74
|
-
skipped += 1
|
|
75
|
-
continue
|
|
76
|
-
route = SCORE_TO_ROUTE[score]
|
|
77
|
-
rec = {
|
|
78
|
-
"query": row.get("prompt", ""),
|
|
79
|
-
"provider": route["provider"],
|
|
80
|
-
"model": route["model"],
|
|
81
|
-
"quality": route["quality"],
|
|
82
|
-
"cost": route["cost"],
|
|
83
|
-
"latency": route["latency"],
|
|
84
|
-
"tier": route["tier"],
|
|
85
|
-
}
|
|
86
|
-
if not rec["query"]:
|
|
87
|
-
skipped += 1
|
|
88
|
-
continue
|
|
89
|
-
out.write(json.dumps(rec) + "\n")
|
|
90
|
-
written += 1
|
|
91
|
-
by_score[score] += 1
|
|
92
|
-
if written % 5000 == 0:
|
|
93
|
-
print(f" {written} written…", file=sys.stderr)
|
|
94
|
-
|
|
95
|
-
print(f"\nWrote {written} records to {out_path} ({skipped} skipped)", file=sys.stderr)
|
|
96
|
-
print(f"By mixtral_score: {by_score}", file=sys.stderr)
|
|
97
|
-
print(f"\nTier distribution:", file=sys.stderr)
|
|
98
|
-
for score, route in SCORE_TO_ROUTE.items():
|
|
99
|
-
n = by_score[score]
|
|
100
|
-
if n:
|
|
101
|
-
print(f" {route['tier']:>10} {route['provider']}:{route['model']:30s} n={n}", file=sys.stderr)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if __name__ == "__main__":
|
|
105
|
-
main()
|