lynkr 9.7.2 → 9.9.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 +29 -19
- package/bin/cli.js +11 -0
- package/bin/lynkr-init.js +14 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +24 -3
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/agents/context-manager.js +18 -2
- package/src/agents/definitions/loader.js +90 -0
- package/src/agents/executor.js +24 -2
- package/src/agents/index.js +9 -1
- package/src/agents/parallel-coordinator.js +2 -2
- package/src/agents/reflector.js +11 -1
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +450 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +459 -146
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +51 -9
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +144 -88
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +48 -11
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +897 -87
- package/src/routing/intent-score.js +339 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +298 -10
- package/src/routing/verifier.js +267 -0
- package/src/server.js +66 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- package/src/workers/helpers.js +0 -185
package/src/routing/telemetry.js
CHANGED
|
@@ -32,6 +32,15 @@ let db = null;
|
|
|
32
32
|
/** @type {boolean} */
|
|
33
33
|
let initialised = false;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Test-only escape hatches. Production code should never touch these — the
|
|
37
|
+
* DB path is hardcoded to `<cwd>/.lynkr/telemetry.db`. Tests call these
|
|
38
|
+
* before the first `record()` to isolate their state.
|
|
39
|
+
* @type {string|null}
|
|
40
|
+
*/
|
|
41
|
+
let _testDbPath = null;
|
|
42
|
+
let _testDbDisabled = false;
|
|
43
|
+
|
|
35
44
|
/** Default retention: 30 days */
|
|
36
45
|
const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
37
46
|
|
|
@@ -49,12 +58,28 @@ function init() {
|
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
// Path is hardcoded to <cwd>/.lynkr/telemetry.db in production. The
|
|
62
|
+
// pre-B `LYNKR_TELEMETRY_DB_PATH` env override was removed so operators
|
|
63
|
+
// can't accidentally divert telemetry to a stale path. Tests still need
|
|
64
|
+
// to isolate their DB — see `_setDbPathForTests` / `_disableForTests`
|
|
65
|
+
// below (module-scoped setters called before the first `record()`).
|
|
66
|
+
let dbPath;
|
|
67
|
+
if (_testDbDisabled) {
|
|
68
|
+
logger.debug("Telemetry: disabled for tests");
|
|
69
|
+
return false;
|
|
70
|
+
} else if (_testDbPath) {
|
|
71
|
+
dbPath = path.resolve(_testDbPath);
|
|
72
|
+
const overrideDir = path.dirname(dbPath);
|
|
73
|
+
if (!fs.existsSync(overrideDir)) {
|
|
74
|
+
fs.mkdirSync(overrideDir, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
const dbDir = path.resolve(process.cwd(), ".lynkr");
|
|
78
|
+
if (!fs.existsSync(dbDir)) {
|
|
79
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
dbPath = path.join(dbDir, "telemetry.db");
|
|
55
82
|
}
|
|
56
|
-
|
|
57
|
-
const dbPath = path.join(dbDir, "telemetry.db");
|
|
58
83
|
db = new Database(dbPath, {
|
|
59
84
|
verbose: process.env.DEBUG_SQL ? console.log : null,
|
|
60
85
|
fileMustExist: false,
|
|
@@ -96,7 +121,13 @@ function init() {
|
|
|
96
121
|
tokens_per_second REAL,
|
|
97
122
|
cost_efficiency REAL,
|
|
98
123
|
request_text TEXT,
|
|
99
|
-
response_text TEXT
|
|
124
|
+
response_text TEXT,
|
|
125
|
+
base_tier TEXT,
|
|
126
|
+
escalation_source TEXT,
|
|
127
|
+
propensity REAL,
|
|
128
|
+
candidates TEXT,
|
|
129
|
+
pinned INTEGER DEFAULT 0,
|
|
130
|
+
switch_reason TEXT
|
|
100
131
|
);
|
|
101
132
|
|
|
102
133
|
CREATE INDEX IF NOT EXISTS idx_telemetry_provider
|
|
@@ -110,14 +141,34 @@ function init() {
|
|
|
110
141
|
|
|
111
142
|
CREATE INDEX IF NOT EXISTS idx_telemetry_session_id
|
|
112
143
|
ON routing_telemetry(session_id, timestamp);
|
|
144
|
+
|
|
145
|
+
CREATE TABLE IF NOT EXISTS savings_events (
|
|
146
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
147
|
+
timestamp INTEGER NOT NULL,
|
|
148
|
+
category TEXT NOT NULL,
|
|
149
|
+
tokens_saved INTEGER NOT NULL
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
CREATE INDEX IF NOT EXISTS idx_savings_timestamp
|
|
153
|
+
ON savings_events(timestamp);
|
|
113
154
|
`);
|
|
114
155
|
|
|
115
156
|
// Migration: add columns to pre-existing tables (CREATE TABLE IF NOT EXISTS
|
|
116
157
|
// won't add them to a DB created before these columns existed).
|
|
117
158
|
const existingCols = new Set(db.prepare("PRAGMA table_info(routing_telemetry)").all().map((c) => c.name));
|
|
118
|
-
|
|
159
|
+
const additiveCols = [
|
|
160
|
+
["request_text", "TEXT"],
|
|
161
|
+
["response_text", "TEXT"],
|
|
162
|
+
["base_tier", "TEXT"],
|
|
163
|
+
["escalation_source", "TEXT"],
|
|
164
|
+
["propensity", "REAL"],
|
|
165
|
+
["candidates", "TEXT"],
|
|
166
|
+
["pinned", "INTEGER DEFAULT 0"],
|
|
167
|
+
["switch_reason", "TEXT"],
|
|
168
|
+
];
|
|
169
|
+
for (const [col, type] of additiveCols) {
|
|
119
170
|
if (!existingCols.has(col)) {
|
|
120
|
-
db.exec(`ALTER TABLE routing_telemetry ADD COLUMN ${col}
|
|
171
|
+
db.exec(`ALTER TABLE routing_telemetry ADD COLUMN ${col} ${type}`);
|
|
121
172
|
}
|
|
122
173
|
}
|
|
123
174
|
|
|
@@ -174,18 +225,27 @@ function record(data) {
|
|
|
174
225
|
provider, model, routing_method, was_fallback, output_tokens,
|
|
175
226
|
latency_ms, status_code, error_type, cost_usd, tool_calls_made,
|
|
176
227
|
retry_count, circuit_breaker_state, quality_score, tokens_per_second,
|
|
177
|
-
cost_efficiency, request_text, response_text
|
|
228
|
+
cost_efficiency, request_text, response_text,
|
|
229
|
+
base_tier, escalation_source, propensity, candidates, pinned, switch_reason
|
|
178
230
|
) VALUES (
|
|
179
231
|
@request_id, @session_id, @timestamp, @complexity_score, @tier,
|
|
180
232
|
@agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
|
|
181
233
|
@provider, @model, @routing_method, @was_fallback, @output_tokens,
|
|
182
234
|
@latency_ms, @status_code, @error_type, @cost_usd, @tool_calls_made,
|
|
183
235
|
@retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
|
|
184
|
-
@cost_efficiency, @request_text, @response_text
|
|
236
|
+
@cost_efficiency, @request_text, @response_text,
|
|
237
|
+
@base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason
|
|
185
238
|
)`
|
|
186
239
|
);
|
|
187
240
|
if (!insert) return;
|
|
188
241
|
|
|
242
|
+
let candidatesJson = null;
|
|
243
|
+
if (data.candidates != null) {
|
|
244
|
+
candidatesJson = typeof data.candidates === "string"
|
|
245
|
+
? data.candidates
|
|
246
|
+
: JSON.stringify(data.candidates);
|
|
247
|
+
}
|
|
248
|
+
|
|
189
249
|
insert.run({
|
|
190
250
|
request_id: data.request_id ?? null,
|
|
191
251
|
session_id: data.session_id ?? null,
|
|
@@ -214,6 +274,12 @@ function record(data) {
|
|
|
214
274
|
cost_efficiency: data.cost_efficiency ?? null,
|
|
215
275
|
request_text: data.request_text ?? null,
|
|
216
276
|
response_text: data.response_text ?? null,
|
|
277
|
+
base_tier: data.base_tier ?? null,
|
|
278
|
+
escalation_source: data.escalation_source ?? null,
|
|
279
|
+
propensity: data.propensity ?? null,
|
|
280
|
+
candidates: candidatesJson,
|
|
281
|
+
pinned: data.pinned ? 1 : 0,
|
|
282
|
+
switch_reason: data.switch_reason ?? null,
|
|
217
283
|
});
|
|
218
284
|
} catch (err) {
|
|
219
285
|
logger.debug({ err: err.message }, "Telemetry record failed");
|
|
@@ -451,6 +517,121 @@ function getRoutingAccuracy(timeRange = {}) {
|
|
|
451
517
|
}
|
|
452
518
|
}
|
|
453
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Aggregate escalation statistics.
|
|
522
|
+
*
|
|
523
|
+
* For every row where `escalation_source` is not null, returns a per-source
|
|
524
|
+
* breakdown: request count, total cost_usd, and total input_tokens (so
|
|
525
|
+
* callers can approximate a base-tier-vs-served-tier cost delta by
|
|
526
|
+
* combining with cost-optimizer.estimateCost for the two tiers).
|
|
527
|
+
*
|
|
528
|
+
* @param {Object} [timeRange]
|
|
529
|
+
* @param {number} [timeRange.since]
|
|
530
|
+
* @param {number} [timeRange.until]
|
|
531
|
+
* @returns {Object|null} { totalRequests, totalEscalated, escalatedPct, bySource: {source: {count, costUsd, inputTokens, avgQuality}} }
|
|
532
|
+
*/
|
|
533
|
+
function getEscalationStats(timeRange = {}) {
|
|
534
|
+
if (!init()) return null;
|
|
535
|
+
|
|
536
|
+
const since = timeRange.since ?? Date.now() - 24 * 60 * 60 * 1000;
|
|
537
|
+
const until = timeRange.until ?? Date.now();
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
const total = db
|
|
541
|
+
.prepare("SELECT COUNT(*) as cnt FROM routing_telemetry WHERE timestamp BETWEEN ? AND ?")
|
|
542
|
+
.get(since, until);
|
|
543
|
+
if (!total || total.cnt === 0) return null;
|
|
544
|
+
|
|
545
|
+
const rows = db
|
|
546
|
+
.prepare(
|
|
547
|
+
`SELECT
|
|
548
|
+
escalation_source as source,
|
|
549
|
+
COUNT(*) as cnt,
|
|
550
|
+
SUM(COALESCE(cost_usd, 0)) as cost_usd,
|
|
551
|
+
SUM(COALESCE(input_tokens, 0)) as input_tokens,
|
|
552
|
+
AVG(quality_score) as avg_quality
|
|
553
|
+
FROM routing_telemetry
|
|
554
|
+
WHERE timestamp BETWEEN ? AND ?
|
|
555
|
+
AND escalation_source IS NOT NULL
|
|
556
|
+
GROUP BY escalation_source`
|
|
557
|
+
)
|
|
558
|
+
.all(since, until);
|
|
559
|
+
|
|
560
|
+
const bySource = {};
|
|
561
|
+
let totalEscalated = 0;
|
|
562
|
+
for (const r of rows) {
|
|
563
|
+
bySource[r.source] = {
|
|
564
|
+
count: r.cnt,
|
|
565
|
+
costUsd: Math.round((r.cost_usd || 0) * 10000) / 10000,
|
|
566
|
+
inputTokens: r.input_tokens || 0,
|
|
567
|
+
avgQuality: r.avg_quality != null ? Math.round(r.avg_quality * 10) / 10 : null,
|
|
568
|
+
};
|
|
569
|
+
totalEscalated += r.cnt;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
totalRequests: total.cnt,
|
|
574
|
+
totalEscalated,
|
|
575
|
+
escalatedPct: Math.round((totalEscalated / total.cnt) * 1000) / 10,
|
|
576
|
+
bySource,
|
|
577
|
+
};
|
|
578
|
+
} catch (err) {
|
|
579
|
+
logger.debug({ err: err.message }, "Telemetry getEscalationStats failed");
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Aggregate quality-by-tier-and-request-type — feeds the de-escalator's
|
|
586
|
+
* evidence check. Returns rows of {tier, request_type, count, avg_quality,
|
|
587
|
+
* error_rate}.
|
|
588
|
+
*
|
|
589
|
+
* @param {Object} [opts]
|
|
590
|
+
* @param {number} [opts.since] - ms epoch, defaults to 7 days ago
|
|
591
|
+
* @param {number} [opts.until] - defaults to now
|
|
592
|
+
* @param {string[]} [opts.tiers] - filter to specific tiers
|
|
593
|
+
* @returns {Array<{tier:string, request_type:string, count:number, avg_quality:number|null, error_rate:number}>|null}
|
|
594
|
+
*/
|
|
595
|
+
function getQualityByTierAndType(opts = {}) {
|
|
596
|
+
if (!init()) return null;
|
|
597
|
+
|
|
598
|
+
const since = opts.since ?? Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
599
|
+
const until = opts.until ?? Date.now();
|
|
600
|
+
|
|
601
|
+
try {
|
|
602
|
+
const tierFilter = Array.isArray(opts.tiers) && opts.tiers.length > 0
|
|
603
|
+
? `AND tier IN (${opts.tiers.map(() => "?").join(",")})`
|
|
604
|
+
: "";
|
|
605
|
+
const params = [since, until, ...(Array.isArray(opts.tiers) ? opts.tiers : [])];
|
|
606
|
+
const rows = db
|
|
607
|
+
.prepare(
|
|
608
|
+
`SELECT
|
|
609
|
+
tier,
|
|
610
|
+
request_type,
|
|
611
|
+
COUNT(*) as count,
|
|
612
|
+
AVG(quality_score) as avg_quality,
|
|
613
|
+
SUM(CASE WHEN error_type IS NOT NULL THEN 1 ELSE 0 END) * 1.0 / COUNT(*) as error_rate
|
|
614
|
+
FROM routing_telemetry
|
|
615
|
+
WHERE timestamp BETWEEN ? AND ?
|
|
616
|
+
AND tier IS NOT NULL
|
|
617
|
+
AND request_type IS NOT NULL
|
|
618
|
+
${tierFilter}
|
|
619
|
+
GROUP BY tier, request_type`
|
|
620
|
+
)
|
|
621
|
+
.all(...params);
|
|
622
|
+
return rows.map((r) => ({
|
|
623
|
+
tier: r.tier,
|
|
624
|
+
request_type: r.request_type,
|
|
625
|
+
count: r.count,
|
|
626
|
+
avg_quality: r.avg_quality != null ? Math.round(r.avg_quality * 10) / 10 : null,
|
|
627
|
+
error_rate: Math.round(r.error_rate * 10000) / 10000,
|
|
628
|
+
}));
|
|
629
|
+
} catch (err) {
|
|
630
|
+
logger.debug({ err: err.message }, "Telemetry getQualityByTierAndType failed");
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
454
635
|
/**
|
|
455
636
|
* Delete telemetry records older than a given threshold.
|
|
456
637
|
*
|
|
@@ -512,11 +693,118 @@ function getProviderStatsCached(provider, timeRange = {}) {
|
|
|
512
693
|
return result;
|
|
513
694
|
}
|
|
514
695
|
|
|
696
|
+
/**
|
|
697
|
+
* Return the shared telemetry sqlite handle (initialising if needed) so other
|
|
698
|
+
* routing subsystems can persist state alongside routing telemetry without
|
|
699
|
+
* opening a second WAL connection to the same file. Returns null when
|
|
700
|
+
* better-sqlite3 is unavailable or initialisation failed.
|
|
701
|
+
* @returns {import('better-sqlite3').Database|null}
|
|
702
|
+
*/
|
|
703
|
+
function getDb() {
|
|
704
|
+
if (!init()) return null;
|
|
705
|
+
return db;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Record a token-savings event (tool stripping, compression, cache hit).
|
|
710
|
+
* Fire-and-forget like record(): never blocks or throws on the request path.
|
|
711
|
+
*
|
|
712
|
+
* @param {"tool_stripping"|"compression"|"cache_hit"} category
|
|
713
|
+
* @param {number} tokensSaved - Estimated tokens avoided (must be > 0 to record)
|
|
714
|
+
*/
|
|
715
|
+
function recordSavings(category, tokensSaved) {
|
|
716
|
+
if (!Number.isFinite(tokensSaved) || tokensSaved <= 0) return;
|
|
717
|
+
if (!init()) return;
|
|
718
|
+
|
|
719
|
+
setImmediate(() => {
|
|
720
|
+
try {
|
|
721
|
+
const insert = stmt(
|
|
722
|
+
"insertSavings",
|
|
723
|
+
`INSERT INTO savings_events (timestamp, category, tokens_saved)
|
|
724
|
+
VALUES (@timestamp, @category, @tokens_saved)`
|
|
725
|
+
);
|
|
726
|
+
if (!insert) return;
|
|
727
|
+
insert.run({
|
|
728
|
+
timestamp: Date.now(),
|
|
729
|
+
category: String(category),
|
|
730
|
+
tokens_saved: Math.round(tokensSaved),
|
|
731
|
+
});
|
|
732
|
+
} catch (err) {
|
|
733
|
+
logger.debug({ err: err.message }, "Failed to record savings event");
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Summarise savings events since a timestamp.
|
|
740
|
+
*
|
|
741
|
+
* @param {number} sinceMs - Epoch ms lower bound (0 for all time)
|
|
742
|
+
* @returns {{total: number, byCategory: Object<string, number>}}
|
|
743
|
+
*/
|
|
744
|
+
function getSavingsSummary(sinceMs = 0) {
|
|
745
|
+
const empty = { total: 0, byCategory: {} };
|
|
746
|
+
if (!init()) return empty;
|
|
747
|
+
try {
|
|
748
|
+
const rows = db
|
|
749
|
+
.prepare(
|
|
750
|
+
`SELECT category, SUM(tokens_saved) AS tokens
|
|
751
|
+
FROM savings_events WHERE timestamp >= ? GROUP BY category`
|
|
752
|
+
)
|
|
753
|
+
.all(sinceMs);
|
|
754
|
+
const byCategory = {};
|
|
755
|
+
let total = 0;
|
|
756
|
+
for (const row of rows) {
|
|
757
|
+
byCategory[row.category] = row.tokens || 0;
|
|
758
|
+
total += row.tokens || 0;
|
|
759
|
+
}
|
|
760
|
+
return { total, byCategory };
|
|
761
|
+
} catch (err) {
|
|
762
|
+
logger.debug({ err: err.message }, "Failed to read savings summary");
|
|
763
|
+
return empty;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Test-only helpers. Do NOT call from production code. These exist because
|
|
769
|
+
* the DB path was hardcoded (no more `LYNKR_TELEMETRY_DB_PATH` env var), so
|
|
770
|
+
* tests need an alternative way to route telemetry at an isolated file.
|
|
771
|
+
*
|
|
772
|
+
* Both helpers must be called BEFORE the first `record()` call — after
|
|
773
|
+
* that the DB handle is memoised and won't re-open. Call `_resetForTests`
|
|
774
|
+
* between tests to force re-initialisation.
|
|
775
|
+
*/
|
|
776
|
+
function _setDbPathForTests(p) {
|
|
777
|
+
_testDbPath = p || null;
|
|
778
|
+
_testDbDisabled = false;
|
|
779
|
+
}
|
|
780
|
+
function _disableForTests() {
|
|
781
|
+
_testDbDisabled = true;
|
|
782
|
+
_testDbPath = null;
|
|
783
|
+
}
|
|
784
|
+
function _resetForTests() {
|
|
785
|
+
if (db) {
|
|
786
|
+
try { db.close(); } catch { /* ignore */ }
|
|
787
|
+
}
|
|
788
|
+
db = null;
|
|
789
|
+
initialised = false;
|
|
790
|
+
_testDbPath = null;
|
|
791
|
+
_testDbDisabled = false;
|
|
792
|
+
}
|
|
793
|
+
|
|
515
794
|
module.exports = {
|
|
516
795
|
record,
|
|
517
796
|
query,
|
|
518
797
|
getStats: getStatsCached,
|
|
519
798
|
getProviderStats: getProviderStatsCached,
|
|
520
799
|
getRoutingAccuracy,
|
|
800
|
+
getEscalationStats,
|
|
801
|
+
getQualityByTierAndType,
|
|
802
|
+
recordSavings,
|
|
803
|
+
getSavingsSummary,
|
|
521
804
|
cleanup,
|
|
805
|
+
getDb,
|
|
806
|
+
// Test-only — do not use in production code.
|
|
807
|
+
_setDbPathForTests,
|
|
808
|
+
_disableForTests,
|
|
809
|
+
_resetForTests,
|
|
522
810
|
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WS6 — response verifier for cascade routing.
|
|
3
|
+
*
|
|
4
|
+
* Judges whether a CHEAP-tier response is good enough to serve, so the
|
|
5
|
+
* cascade can discard bad answers and escalate instead of delivering them.
|
|
6
|
+
* Rationale (deep-research, 2026-07-08): ex-ante difficulty prediction
|
|
7
|
+
* failed to beat trivial baselines on agentic coding traffic (SWE-Bench),
|
|
8
|
+
* while try-cheap-then-verify cascades gained up to 14%. The verifier is
|
|
9
|
+
* where that gain lives.
|
|
10
|
+
*
|
|
11
|
+
* Design constraints:
|
|
12
|
+
* - Layer 1: deterministic structural checks targeting the cheap-model
|
|
13
|
+
* failure modes observed live — language drift (CJK mid-English),
|
|
14
|
+
* degeneration loops, truncation, malformed tool calls, empty/echo
|
|
15
|
+
* output. High precision: flag only what is definitely broken.
|
|
16
|
+
* - Layer 2: a coarse content-quality score with a conservative
|
|
17
|
+
* threshold. Catches low-effort responses to substantive asks.
|
|
18
|
+
* - No LLM-judge in v1 (research: unvalidated self-assessment is weak),
|
|
19
|
+
* no logprobs (Ollama's Anthropic passthrough doesn't expose them).
|
|
20
|
+
* A confidently-wrong but fluent answer WILL pass — the target is
|
|
21
|
+
* garbage, not falsehood.
|
|
22
|
+
* - Pure function, no I/O. Never throws: any internal error returns
|
|
23
|
+
* verdict "pass" (fail-open — a broken verifier must not break
|
|
24
|
+
* serving or force spurious escalations).
|
|
25
|
+
*
|
|
26
|
+
* Verification only ever applies to cheap-tier responses; expensive-tier
|
|
27
|
+
* answers are never second-guessed (caller enforces).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const logger = require('../logger');
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Text extraction
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function _responseText(responseBody) {
|
|
37
|
+
const content = responseBody?.content;
|
|
38
|
+
if (typeof content === 'string') return content;
|
|
39
|
+
if (!Array.isArray(content)) return '';
|
|
40
|
+
return content
|
|
41
|
+
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
|
42
|
+
.map((b) => b.text)
|
|
43
|
+
.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _lastUserText(payload) {
|
|
47
|
+
const msgs = payload?.messages;
|
|
48
|
+
if (!Array.isArray(msgs)) return '';
|
|
49
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
50
|
+
const m = msgs[i];
|
|
51
|
+
if (m?.role !== 'user') continue;
|
|
52
|
+
const raw = typeof m.content === 'string'
|
|
53
|
+
? m.content
|
|
54
|
+
: Array.isArray(m.content)
|
|
55
|
+
? m.content.filter((b) => b?.type === 'text').map((b) => b.text || '').join(' ')
|
|
56
|
+
: '';
|
|
57
|
+
return raw
|
|
58
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
|
|
59
|
+
// Codex harness blocks merged into the typed text — inflate askLen
|
|
60
|
+
// and trip the wants-code regexes ("claude-code", access="write"),
|
|
61
|
+
// failing every short answer to a trivial prompt.
|
|
62
|
+
.replace(/<environment_context>[\s\S]*?<\/environment_context>/g, ' ')
|
|
63
|
+
.replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, ' ')
|
|
64
|
+
// Goose wraps every typed message in a turn-context block (time, cwd,
|
|
65
|
+
// todo notes) — 275+ chars of harness plumbing that made a bare "Hi"
|
|
66
|
+
// look like a substantive ask, failing every short cheap-tier answer.
|
|
67
|
+
.replace(/<turn-context>[\s\S]*?<\/turn-context>/g, ' ')
|
|
68
|
+
.trim();
|
|
69
|
+
}
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Layer 1 — structural checks (each returns a reason string or null)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Language drift: unexpected CJK/Cyrillic content in a conversation whose
|
|
79
|
+
* user text contains none. Live incident: minimax emitting "+统一接口:" in
|
|
80
|
+
* the middle of an English refactor plan. Threshold is a RATIO so quoting
|
|
81
|
+
* a foreign identifier from the repo doesn't trip it.
|
|
82
|
+
*/
|
|
83
|
+
function checkLanguageDrift(userText, answerText) {
|
|
84
|
+
if (!answerText) return null;
|
|
85
|
+
const FOREIGN = /[一-鿿-ヿ가-Ѐ-ӿ]/g;
|
|
86
|
+
const userForeign = (userText.match(FOREIGN) || []).length;
|
|
87
|
+
if (userForeign > 0) return null; // user writes that script — anything goes
|
|
88
|
+
const answerForeign = (answerText.match(FOREIGN) || []).length;
|
|
89
|
+
if (answerForeign === 0) return null;
|
|
90
|
+
const ratio = answerForeign / Math.max(1, answerText.length);
|
|
91
|
+
// A couple of quoted characters is fine; sustained drift is not.
|
|
92
|
+
if (answerForeign >= 6 || ratio > 0.02) {
|
|
93
|
+
return `language-drift (${answerForeign} foreign chars, user text had none)`;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Degeneration: the same shingle repeating far beyond what natural text
|
|
100
|
+
* (or even a list) produces. Catches small-model repetition loops.
|
|
101
|
+
*/
|
|
102
|
+
function checkDegeneration(answerText) {
|
|
103
|
+
if (!answerText || answerText.length < 200) return null;
|
|
104
|
+
const words = answerText.toLowerCase().split(/\s+/).filter(Boolean);
|
|
105
|
+
if (words.length < 40) return null;
|
|
106
|
+
const SHINGLE = 5;
|
|
107
|
+
const counts = new Map();
|
|
108
|
+
for (let i = 0; i + SHINGLE <= words.length; i++) {
|
|
109
|
+
const key = words.slice(i, i + SHINGLE).join(' ');
|
|
110
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
111
|
+
}
|
|
112
|
+
let max = 0;
|
|
113
|
+
for (const c of counts.values()) if (c > max) max = c;
|
|
114
|
+
const total = Math.max(1, words.length - SHINGLE + 1);
|
|
115
|
+
if (max >= 5 && max / total > 0.08) {
|
|
116
|
+
return `degeneration (a 5-gram repeats ${max}× across ${total} positions)`;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Truncation: response ran into max_tokens mid-structure. A length stop
|
|
123
|
+
* by itself is common (long answers); a length stop with an UNCLOSED code
|
|
124
|
+
* fence means the useful part is cut.
|
|
125
|
+
*/
|
|
126
|
+
function checkTruncation(answerText, responseBody) {
|
|
127
|
+
if (responseBody?.stop_reason !== 'max_tokens' && responseBody?.stop_reason !== 'length') return null;
|
|
128
|
+
const fences = (answerText.match(/```/g) || []).length;
|
|
129
|
+
if (fences % 2 === 1) {
|
|
130
|
+
return 'truncation (hit token limit inside an open code fence)';
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Malformed tool calls: tool_use blocks whose input is not an object or
|
|
137
|
+
* whose name is missing. Live symptom: Claude Code's "Invalid tool
|
|
138
|
+
* parameters" errors on cheap-model responses.
|
|
139
|
+
*/
|
|
140
|
+
function checkMalformedToolCalls(responseBody) {
|
|
141
|
+
const content = responseBody?.content;
|
|
142
|
+
if (!Array.isArray(content)) return null;
|
|
143
|
+
for (const b of content) {
|
|
144
|
+
if (b?.type !== 'tool_use') continue;
|
|
145
|
+
if (!b.name || typeof b.name !== 'string') return 'malformed-tool-call (missing name)';
|
|
146
|
+
if (b.input === undefined || b.input === null || typeof b.input !== 'object' || Array.isArray(b.input)) {
|
|
147
|
+
return `malformed-tool-call (input is ${Array.isArray(b.input) ? 'array' : typeof b.input})`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Empty or echo output. */
|
|
154
|
+
function checkEmptyOrEcho(userText, answerText, responseBody) {
|
|
155
|
+
const hasToolUse = Array.isArray(responseBody?.content)
|
|
156
|
+
&& responseBody.content.some((b) => b?.type === 'tool_use');
|
|
157
|
+
if (hasToolUse) return null; // tool-only turns legitimately carry no prose
|
|
158
|
+
const t = (answerText || '').trim();
|
|
159
|
+
if (t.length < 2) return 'empty-response';
|
|
160
|
+
if (userText.length > 40 && t.length > 40) {
|
|
161
|
+
const a = t.toLowerCase().slice(0, 200);
|
|
162
|
+
const u = userText.toLowerCase().slice(0, 200);
|
|
163
|
+
if (a === u) return 'prompt-echo';
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Layer 2 — coarse content-quality score
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 0-100. Conservative: only very low scores fail (threshold below).
|
|
174
|
+
* Signals: effort proportional to the ask, structure when structure was
|
|
175
|
+
* requested, code when code was requested.
|
|
176
|
+
*/
|
|
177
|
+
function contentScore(userText, answerText, responseBody) {
|
|
178
|
+
const hasToolUse = Array.isArray(responseBody?.content)
|
|
179
|
+
&& responseBody.content.some((b) => b?.type === 'tool_use');
|
|
180
|
+
if (hasToolUse) return 100; // agent turns are judged by their tool calls, not prose
|
|
181
|
+
|
|
182
|
+
let score = 60; // neutral prior
|
|
183
|
+
const askLen = userText.length;
|
|
184
|
+
const ansLen = (answerText || '').length;
|
|
185
|
+
|
|
186
|
+
// Effort: a substantive ask answered in a stub.
|
|
187
|
+
if (askLen > 200 && ansLen < 60) score -= 35;
|
|
188
|
+
else if (askLen > 100 && ansLen < 30) score -= 30;
|
|
189
|
+
else if (ansLen >= 120) score += 15;
|
|
190
|
+
|
|
191
|
+
// Structure requested → structure delivered?
|
|
192
|
+
const wantsStructure = /\b(list|steps?|plan|compare|table|pros and cons|trade-?offs)\b/i.test(userText);
|
|
193
|
+
const hasStructure = /(^|\n)\s*([-*•]|\d+[.)])\s+|\n#{1,3}\s|\|.*\|/.test(answerText || '');
|
|
194
|
+
if (wantsStructure) score += hasStructure ? 15 : -20;
|
|
195
|
+
|
|
196
|
+
// Code requested → code delivered?
|
|
197
|
+
const wantsCode = /\b(code|function|implement|snippet|example|replacement|fix)\b/i.test(userText)
|
|
198
|
+
&& /```/.test(userText || '') === false; // asking about pasted code still often warrants code back — keep loose
|
|
199
|
+
const hasCode = /```|(^|\n) {4}\S/.test(answerText || '');
|
|
200
|
+
if (wantsCode && /\b(write|implement|show|give me)\b/i.test(userText)) {
|
|
201
|
+
score += hasCode ? 10 : -15;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return Math.max(0, Math.min(100, score));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Public API
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
const CONTENT_SCORE_FAIL_THRESHOLD = 30;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {object} args
|
|
215
|
+
* @param {object} args.payload — the request payload
|
|
216
|
+
* @param {object} args.responseBody — Anthropic-format response body
|
|
217
|
+
* @returns {{verdict: 'pass'|'fail', score: number|null, reasons: string[]}}
|
|
218
|
+
*/
|
|
219
|
+
function verify({ payload, responseBody } = {}) {
|
|
220
|
+
try {
|
|
221
|
+
// No body / unrecognizable content shape = nothing to verify (upstream
|
|
222
|
+
// error paths have their own handling). Verification judges answers,
|
|
223
|
+
// not absences or shapes we don't understand.
|
|
224
|
+
if (!responseBody || typeof responseBody !== 'object') {
|
|
225
|
+
return { verdict: 'pass', score: null, reasons: [] };
|
|
226
|
+
}
|
|
227
|
+
const c = responseBody.content;
|
|
228
|
+
if (typeof c !== 'string' && !Array.isArray(c)) {
|
|
229
|
+
return { verdict: 'pass', score: null, reasons: [] };
|
|
230
|
+
}
|
|
231
|
+
const userText = _lastUserText(payload);
|
|
232
|
+
const answerText = _responseText(responseBody);
|
|
233
|
+
|
|
234
|
+
const reasons = [
|
|
235
|
+
checkLanguageDrift(userText, answerText),
|
|
236
|
+
checkDegeneration(answerText),
|
|
237
|
+
checkTruncation(answerText, responseBody),
|
|
238
|
+
checkMalformedToolCalls(responseBody),
|
|
239
|
+
checkEmptyOrEcho(userText, answerText, responseBody),
|
|
240
|
+
].filter(Boolean);
|
|
241
|
+
|
|
242
|
+
const score = contentScore(userText, answerText, responseBody);
|
|
243
|
+
if (reasons.length === 0 && score < CONTENT_SCORE_FAIL_THRESHOLD) {
|
|
244
|
+
reasons.push(`low-content-score (${score} < ${CONTENT_SCORE_FAIL_THRESHOLD})`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { verdict: reasons.length ? 'fail' : 'pass', score, reasons };
|
|
248
|
+
} catch (err) {
|
|
249
|
+
// Fail-open: a broken verifier must never block serving.
|
|
250
|
+
logger.debug({ err: err.message }, '[Verifier] error — failing open');
|
|
251
|
+
return { verdict: 'pass', score: null, reasons: [] };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
verify,
|
|
257
|
+
CONTENT_SCORE_FAIL_THRESHOLD,
|
|
258
|
+
// exported for unit tests
|
|
259
|
+
_internal: {
|
|
260
|
+
checkLanguageDrift,
|
|
261
|
+
checkDegeneration,
|
|
262
|
+
checkTruncation,
|
|
263
|
+
checkMalformedToolCalls,
|
|
264
|
+
checkEmptyOrEcho,
|
|
265
|
+
contentScore,
|
|
266
|
+
},
|
|
267
|
+
};
|