lynkr 9.14.1 → 9.14.3
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 +5 -1
- package/bin/cli.js +14 -6
- package/bin/lynkr-desktop-token.js +108 -0
- package/bin/lynkr-init.js +13 -1
- package/bin/lynkr-restart.js +125 -0
- package/bin/opencode-setup.js +175 -0
- package/bin/run.js +92 -0
- package/config/model-capabilities.json +20 -0
- package/config/model-capability-seeds.json +75 -0
- package/config/model-tiers.json +13 -1
- package/package.json +16 -22
- package/scripts/claude-desktop.js +212 -0
- package/scripts/ope-report.js +78 -0
- package/scripts/seed-capabilities.js +230 -0
- package/src/api/claude-desktop-gateway.js +123 -0
- package/src/api/health.js +0 -1
- package/src/api/mcp-broker.js +149 -0
- package/src/api/middleware/budget-enforcer.js +77 -7
- package/src/api/middleware/budget.js +36 -2
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +210 -15
- package/src/api/providers-handler.js +16 -1
- package/src/api/router.js +73 -106
- package/src/auth-mode.js +40 -4
- package/src/budget/index.js +86 -0
- package/src/cache/embeddings.js +125 -35
- package/src/cache/onnx-embedder.js +92 -0
- package/src/cache/prompt.js +0 -7
- package/src/clients/bedrock-utils.js +2 -1
- package/src/clients/codex-process.js +1 -1
- package/src/clients/codex-utils.js +0 -2
- package/src/clients/databricks.js +337 -46
- package/src/clients/gpt-utils.js +84 -8
- package/src/clients/health-probe.js +157 -0
- package/src/clients/openai-format.js +73 -23
- package/src/clients/provider-capabilities.js +27 -2
- package/src/clients/resilience.js +0 -1
- package/src/clients/responses-format.js +0 -56
- package/src/clients/routing.js +1 -1
- package/src/config/index.js +13 -2
- package/src/context/budget.js +1 -1
- package/src/context/compression.js +127 -19
- package/src/context/tool-result-compressor.js +3 -5
- package/src/dashboard/api.js +1 -1
- package/src/logger/oversized-error-stream.js +1 -1
- package/src/memory/extractor.js +3 -3
- package/src/memory/search.js +2 -2
- package/src/memory/store.js +9 -24
- package/src/memory/tools.js +14 -0
- package/src/observability/metrics.js +52 -3
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +216 -212
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +130 -13
- package/src/policy/safe-commands.js +0 -1
- package/src/routing/bandit.js +39 -0
- package/src/routing/calibration.js +21 -1
- package/src/routing/capabilities.js +88 -0
- package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
- package/src/routing/capability-seeds/family-heuristics.js +77 -0
- package/src/routing/capability-seeds/family.js +105 -0
- package/src/routing/capability-seeds/models-dev.js +64 -0
- package/src/routing/capability-seeds/normalize.js +160 -0
- package/src/routing/capability-seeds/registry.js +125 -0
- package/src/routing/capability-seeds/sources.js +69 -0
- package/src/routing/capability-seeds/swebench.js +66 -0
- package/src/routing/client-profiles.js +26 -0
- package/src/routing/complexity-analyzer.js +5 -8
- package/src/routing/confidence-scorer.js +2 -2
- package/src/routing/cost-optimizer.js +1 -2
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +38 -1
- package/src/routing/embedding-cache.js +0 -1
- package/src/routing/index.js +202 -86
- package/src/routing/intent-score.js +0 -1
- package/src/routing/knn-router.js +27 -5
- package/src/routing/latency-tracker.js +1 -1
- package/src/routing/model-registry.js +25 -0
- package/src/routing/model-slots.js +65 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/ope.js +255 -0
- package/src/routing/openai-model-slots.js +82 -0
- package/src/routing/regret-estimator.js +1 -1
- package/src/routing/shortfall.js +290 -0
- package/src/routing/stuck-detector.js +127 -0
- package/src/routing/telemetry.js +11 -2
- package/src/server.js +41 -3
- package/src/stores/file-store.js +5 -1
- package/src/tools/web-search-exec.js +182 -0
- package/src/training/trajectory-compressor.js +1 -1
- package/src/workers/pool.js +1 -1
- package/src/workers/worker.js +1 -1
- package/scripts/convert-routellm.py +0 -105
- package/src/diff/comments.js +0 -153
- package/src/policy/web-fallback.js +0 -33
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing decision core — the harness-shaped, standalone pieces of the
|
|
3
|
+
* per-request model decision, extracted from routing/index.js so they can be
|
|
4
|
+
* consumed by more than one caller:
|
|
5
|
+
*
|
|
6
|
+
* - routing/index.js (the live request path, unchanged behavior)
|
|
7
|
+
* - the off-policy evaluator (WS4 follow-up), which must re-derive a
|
|
8
|
+
* candidate policy's π(a|x) on logged rows using the exact same context
|
|
9
|
+
* vector and candidate-eligibility rules the live policy used
|
|
10
|
+
* - a future remote decision endpoint, which needs decide-shaped logic
|
|
11
|
+
* that doesn't reach into request/response plumbing
|
|
12
|
+
*
|
|
13
|
+
* Deliberately excluded: session pins, the escalation ladder, kNN confidence
|
|
14
|
+
* branching, deadline/tenant overrides. Those are decision *inputs and
|
|
15
|
+
* post-passes* owned by the caller; this module owns only "given a context
|
|
16
|
+
* and a candidate set, pick one and report the probability of that pick."
|
|
17
|
+
*
|
|
18
|
+
* Nothing here catches errors — callers keep their existing
|
|
19
|
+
* degradation.record() wrapping so failure accounting stays where it was.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { getBandit } = require('./bandit');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Task-type one-hot vocabulary. Order is load-bearing: it defines feature
|
|
26
|
+
* indices 6..11 of the context vector, and every persisted bandit arm was
|
|
27
|
+
* trained against this order. Do not reorder or insert — append only, and
|
|
28
|
+
* only together with a bandit-state migration.
|
|
29
|
+
*/
|
|
30
|
+
const TASK_TYPES = ['code_gen', 'summarization', 'reasoning', 'factoid', 'chat', 'other'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the 12-dim context vector the bandit (and any off-policy estimator
|
|
34
|
+
* replaying its decisions) scores against.
|
|
35
|
+
*
|
|
36
|
+
* Layout: [score, log-tokens, has-tools, streaming, risk, agentic,
|
|
37
|
+
* ...one-hot task type (6)].
|
|
38
|
+
*
|
|
39
|
+
* @param {object} args
|
|
40
|
+
* @param {object} args.analysis — complexity analysis ({ score, breakdown })
|
|
41
|
+
* @param {object} [args.payload] — request payload (for tools presence)
|
|
42
|
+
* @param {object} [args.options] — routing options (for streaming flag)
|
|
43
|
+
* @param {object} [args.risk] — risk analysis ({ level })
|
|
44
|
+
* @param {object} [args.agenticResult] — agentic detection ({ isAgentic })
|
|
45
|
+
* @returns {number[]} 12-dim feature vector
|
|
46
|
+
*/
|
|
47
|
+
function buildContextVector({ analysis, payload, options, risk, agenticResult }) {
|
|
48
|
+
const inferredTask = (analysis?.breakdown?.taskType?.reason || 'other').toLowerCase();
|
|
49
|
+
const taskIdx = Math.max(0, TASK_TYPES.findIndex(t => inferredTask.includes(t)));
|
|
50
|
+
return [
|
|
51
|
+
(analysis?.score || 0) / 100,
|
|
52
|
+
Math.log(Math.max(1, analysis?.breakdown?.tokenCount || 0) + 1) / 15,
|
|
53
|
+
((payload?.tools?.length ?? 0) > 0) ? 1 : 0,
|
|
54
|
+
options?.streaming ? 1 : 0,
|
|
55
|
+
risk?.level === 'high' ? 1 : risk?.level === 'medium' ? 0.5 : 0,
|
|
56
|
+
agenticResult?.isAgentic ? 1 : 0,
|
|
57
|
+
...TASK_TYPES.map((_, i) => i === taskIdx ? 1 : 0),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the bandit's candidate set: the current selection plus the kNN
|
|
63
|
+
* alternative, if it differs AND is configured in some TIER_* entry.
|
|
64
|
+
*
|
|
65
|
+
* Tier-aware filter: the bandit may explore freely across the user's
|
|
66
|
+
* configured tiers (e.g. swap a SIMPLE request to the COMPLEX-tier model),
|
|
67
|
+
* but never pick a credentialed-but-untiered model (e.g. an Azure deployment
|
|
68
|
+
* present in .env for another purpose but referenced by no TIER_*). Tier
|
|
69
|
+
* routing stays the source of truth for eligibility.
|
|
70
|
+
*
|
|
71
|
+
* @param {{ provider: string, model: string }} current
|
|
72
|
+
* @param {{ provider: string, model: string }|null} alternative
|
|
73
|
+
* @returns {Array<{ provider: string, model: string }>}
|
|
74
|
+
*/
|
|
75
|
+
function buildCandidates(current, alternative) {
|
|
76
|
+
const candidates = [{ provider: current.provider, model: current.model }];
|
|
77
|
+
if (alternative && alternative.model && alternative.model !== current.model) {
|
|
78
|
+
const configured = require('./model-tiers').getModelTierSelector().getAllConfiguredModels();
|
|
79
|
+
const inConfig = configured.some(
|
|
80
|
+
m => m.provider === alternative.provider && m.model === alternative.model
|
|
81
|
+
);
|
|
82
|
+
if (inConfig) {
|
|
83
|
+
candidates.push({ provider: alternative.provider, model: alternative.model });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return candidates;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The decision core: given a tier, a candidate set, and a context vector,
|
|
91
|
+
* have the bandit pick one and report the pick's propensity.
|
|
92
|
+
*
|
|
93
|
+
* Returns null when there's nothing to adjudicate (fewer than 2 candidates)
|
|
94
|
+
* or the bandit declined — callers keep their pre-existing selection.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} tier
|
|
97
|
+
* @param {Array<{ provider, model }>} candidates
|
|
98
|
+
* @param {number[]} ctx — from buildContextVector
|
|
99
|
+
* @returns {null | { provider, model, ucb, explored, propensity, candidates, context }}
|
|
100
|
+
*/
|
|
101
|
+
function decide(tier, candidates, ctx) {
|
|
102
|
+
if (!candidates || candidates.length < 2) return null;
|
|
103
|
+
const picked = getBandit().pick(tier, candidates, ctx);
|
|
104
|
+
if (!picked) return null;
|
|
105
|
+
return { ...picked, candidates, context: ctx };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Stamp propensity/candidates/_banditContext onto a built decision (WS4.2).
|
|
110
|
+
*
|
|
111
|
+
* Collapse rule: the bandit's propensity only describes the served choice if
|
|
112
|
+
* the served (provider, model) is still one of the bandit's candidates. If a
|
|
113
|
+
* deterministic downstream override (deadline / tenant) swapped the served
|
|
114
|
+
* model out of that set — or the bandit never ran — collapse to
|
|
115
|
+
* propensity=1.0 with a single-entry candidate list, so off-policy
|
|
116
|
+
* estimators treat the row as a deterministic decision.
|
|
117
|
+
*
|
|
118
|
+
* _banditContext is underscored so it never leaks to response headers; the
|
|
119
|
+
* feedback path consumes it to call bandit.update().
|
|
120
|
+
*
|
|
121
|
+
* @param {object} decision — mutated in place (matches prior inline behavior)
|
|
122
|
+
* @param {{ provider: string, model: string }} served
|
|
123
|
+
* @param {null | { propensity, candidates, context }} banditResult — from decide()
|
|
124
|
+
* @returns {object} the same decision, for chaining
|
|
125
|
+
*/
|
|
126
|
+
function stampPropensity(decision, served, banditResult) {
|
|
127
|
+
const banditPickedServed = banditResult?.candidates
|
|
128
|
+
&& banditResult.candidates.some(
|
|
129
|
+
c => c.provider === served.provider && c.model === served.model
|
|
130
|
+
);
|
|
131
|
+
if (banditPickedServed) {
|
|
132
|
+
decision.propensity = banditResult.propensity ?? 1.0;
|
|
133
|
+
decision.candidates = banditResult.candidates;
|
|
134
|
+
decision._banditContext = banditResult.context;
|
|
135
|
+
} else {
|
|
136
|
+
decision.propensity = 1.0;
|
|
137
|
+
decision.candidates = [{ provider: served.provider, model: served.model }];
|
|
138
|
+
decision._banditContext = null;
|
|
139
|
+
}
|
|
140
|
+
return decision;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
TASK_TYPES,
|
|
145
|
+
buildContextVector,
|
|
146
|
+
buildCandidates,
|
|
147
|
+
decide,
|
|
148
|
+
stampPropensity,
|
|
149
|
+
};
|
|
@@ -57,7 +57,7 @@ function _lowerTier(tier) {
|
|
|
57
57
|
* @param {Function} [args.deps.now] - Override Date.now() for tests.
|
|
58
58
|
* @returns {string|null} The lower tier, or null if demotion is not warranted.
|
|
59
59
|
*/
|
|
60
|
-
function suggestDemotion({ tier, requestType, analysis, deps = {} } = {}) {
|
|
60
|
+
function suggestDemotion({ tier, requestType, analysis: _analysis, deps = {} } = {}) {
|
|
61
61
|
if (!tier || !requestType) return null;
|
|
62
62
|
const lower = _lowerTier(tier);
|
|
63
63
|
if (!lower) return null;
|
|
@@ -103,6 +103,42 @@ function _clearCache() {
|
|
|
103
103
|
_cache.clear();
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Percentage-based demotion holdout (ROUTING-NOTES §4.10.4).
|
|
108
|
+
*
|
|
109
|
+
* Even when the evidence supports demotion, a configurable slice of sessions
|
|
110
|
+
* is deliberately excluded and served at the original tier. Those held-out
|
|
111
|
+
* rows are a continuously-running baseline: comparing their outcomes against
|
|
112
|
+
* demoted rows (method suffix '+deescalated' vs '+deescalation_holdout' in
|
|
113
|
+
* routing telemetry) proves the demotion rule is still net-positive over
|
|
114
|
+
* time, instead of trusting a one-time calibration.
|
|
115
|
+
*
|
|
116
|
+
* The bucket is a deterministic hash of the session key, so a session lands
|
|
117
|
+
* on the same side of the holdout on every turn — cohorts stay clean and a
|
|
118
|
+
* conversation never flip-flops tiers because of the holdout itself.
|
|
119
|
+
*
|
|
120
|
+
* LYNKR_DEESCALATION_HOLDOUT_PCT: 0–100, default 10. 0 disables the holdout.
|
|
121
|
+
* Read at call time (not module load) so tests and live re-config work.
|
|
122
|
+
*
|
|
123
|
+
* @param {string|null} sessionKey — session id/fingerprint; null → never held out
|
|
124
|
+
* @returns {boolean} true when this session must NOT be demoted (baseline cohort)
|
|
125
|
+
*/
|
|
126
|
+
function isHeldOut(sessionKey) {
|
|
127
|
+
if (!sessionKey) return false;
|
|
128
|
+
const raw = Number.parseInt(process.env.LYNKR_DEESCALATION_HOLDOUT_PCT, 10);
|
|
129
|
+
const pct = Number.isNaN(raw) ? 10 : Math.min(100, Math.max(0, raw));
|
|
130
|
+
if (pct === 0) return false;
|
|
131
|
+
// FNV-1a over the session key → stable bucket in 0..99.
|
|
132
|
+
let hash = 0x811c9dc5;
|
|
133
|
+
const s = String(sessionKey);
|
|
134
|
+
for (let i = 0; i < s.length; i++) {
|
|
135
|
+
hash ^= s.charCodeAt(i);
|
|
136
|
+
hash = Math.imul(hash, 0x01000193);
|
|
137
|
+
}
|
|
138
|
+
const bucket = (hash >>> 0) % 100;
|
|
139
|
+
return bucket < pct;
|
|
140
|
+
}
|
|
141
|
+
|
|
106
142
|
/**
|
|
107
143
|
* Shadow-mode policy. Wraps the live routing decision by delegating to
|
|
108
144
|
* `determineProviderSmart` and then applying `suggestDemotion` on the result.
|
|
@@ -143,6 +179,7 @@ async function shadowDeescalate(payload) {
|
|
|
143
179
|
module.exports = {
|
|
144
180
|
suggestDemotion,
|
|
145
181
|
shadowDeescalate,
|
|
182
|
+
isHeldOut,
|
|
146
183
|
TIER_ORDER,
|
|
147
184
|
_clearCache,
|
|
148
185
|
};
|
package/src/routing/index.js
CHANGED
|
@@ -29,6 +29,7 @@ const { scoreIntent, intentScoreMode } = require('./intent-score');
|
|
|
29
29
|
// Phase 3-6 routing modules
|
|
30
30
|
const { getKnnRouter } = require('./knn-router');
|
|
31
31
|
const { getBandit } = require('./bandit');
|
|
32
|
+
const { buildCandidates, buildContextVector, decide, stampPropensity } = require('./decide');
|
|
32
33
|
const { getShadowPolicy, compareAndLog: shadowCompareAndLog } = require('./shadow-mode');
|
|
33
34
|
const { chooseFastest } = require('./deadline');
|
|
34
35
|
const { applyTenantOverrides } = require('./tenant-policy');
|
|
@@ -391,6 +392,11 @@ function _pinToDecision(pin, { reason, risk }) {
|
|
|
391
392
|
async function determineProviderSmart(payload, options = {}) {
|
|
392
393
|
const pinCheck = checkSessionPin(payload, options);
|
|
393
394
|
|
|
395
|
+
// Thread the session identity into fresh routing without mutating the
|
|
396
|
+
// caller's options object. Used by the de-escalation holdout so a session
|
|
397
|
+
// deterministically lands on one side of the holdout on every turn.
|
|
398
|
+
options = pinCheck.sessionId ? { ...options, _sessionId: pinCheck.sessionId } : options;
|
|
399
|
+
|
|
394
400
|
// Bypass (no session / forceProvider / feature-off) or no pin yet →
|
|
395
401
|
// straight to fresh routing, then persist the outcome for the next turn.
|
|
396
402
|
if (pinCheck.reason === 'bypass' || pinCheck.reason === 'no_pin') {
|
|
@@ -680,6 +686,27 @@ function checkSessionPin(payload, options = {}) {
|
|
|
680
686
|
const refreshOk = Array.isArray(payload?.tools) && payload.tools.length > 0;
|
|
681
687
|
|
|
682
688
|
if (sessionAffinity.payloadHasToolHistory(payload)) {
|
|
689
|
+
// Stuck-loop detection: the pinned model re-issuing the same tool call
|
|
690
|
+
// (or the same text) over and over. Switching mid-exchange is forbidden
|
|
691
|
+
// (tool-call IDs aren't provider-portable), so the intervention is the
|
|
692
|
+
// same safe pattern as the embedded-text triggers below — serve the pin
|
|
693
|
+
// this turn but DROP it, so the next turn boundary re-routes fresh.
|
|
694
|
+
try {
|
|
695
|
+
const { detectStuckLoop } = require('./stuck-detector');
|
|
696
|
+
const stuck = detectStuckLoop(payload);
|
|
697
|
+
if (stuck.stuck) {
|
|
698
|
+
sessionAffinity.removePin(sessionId);
|
|
699
|
+
logger.warn({
|
|
700
|
+
sessionId,
|
|
701
|
+
trigger: stuck.reason,
|
|
702
|
+
repeats: stuck.repeats,
|
|
703
|
+
signature: stuck.signature,
|
|
704
|
+
pinnedTier: pin.tier,
|
|
705
|
+
pinnedModel: pin.model,
|
|
706
|
+
}, '[Routing] Stuck loop detected — pin dropped, next boundary re-routes fresh');
|
|
707
|
+
return { serve: true, pin, reason: 'tool_history_pin_dropped', sessionId };
|
|
708
|
+
}
|
|
709
|
+
} catch { /* never block the pin-serve path */ }
|
|
683
710
|
// Text typed during a tool loop arrives merged with the pending
|
|
684
711
|
// tool_result, where the pin serves unconditionally (id linkage forbids
|
|
685
712
|
// switching mid-exchange). If that embedded text trips a trigger, drop
|
|
@@ -705,6 +732,32 @@ function checkSessionPin(payload, options = {}) {
|
|
|
705
732
|
}
|
|
706
733
|
}
|
|
707
734
|
} catch { /* never block the pin-serve path */ }
|
|
735
|
+
// Context-overflow escape hatch. Every frame of a tool loop is an
|
|
736
|
+
// unconditional pin serve, so a long agentic session could NEVER escape
|
|
737
|
+
// to a larger-context model — the guards that check context fit only run
|
|
738
|
+
// on non-tool turns, which a busy agent may not produce for hours (live
|
|
739
|
+
// incident: an 884k-token session stayed pinned to a 128k model for 43
|
|
740
|
+
// straight requests, with 93.7% of history amputated on every one).
|
|
741
|
+
// Same safe pattern as the triggers above: serve the pin THIS turn
|
|
742
|
+
// (tool-call IDs aren't provider-portable), drop it so the next boundary
|
|
743
|
+
// re-routes — and fresh routing's context guard picks a model that fits.
|
|
744
|
+
try {
|
|
745
|
+
if (pin.model) {
|
|
746
|
+
const promptTokensEst = countPayloadTokens(payload, pin.model);
|
|
747
|
+
const ctxResult = contextValidator.validate(pin.model, promptTokensEst);
|
|
748
|
+
if (!ctxResult.ok) {
|
|
749
|
+
sessionAffinity.removePin(sessionId);
|
|
750
|
+
logger.warn({
|
|
751
|
+
sessionId,
|
|
752
|
+
trigger: 'context_overflow',
|
|
753
|
+
pinnedModel: pin.model,
|
|
754
|
+
pinnedTier: pin.tier,
|
|
755
|
+
promptTokensEst,
|
|
756
|
+
}, '[Routing] Session outgrew pinned model\'s context window — pin dropped, next boundary re-routes to a context-capable model');
|
|
757
|
+
return { serve: true, pin, reason: 'tool_history_pin_dropped', sessionId };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
} catch { /* never block the pin-serve path */ }
|
|
708
761
|
if (refreshOk) {
|
|
709
762
|
sessionAffinity.setPin(sessionId, pin, {
|
|
710
763
|
// Monotonic within a pin's lifetime: refreshes must never SHRINK the
|
|
@@ -1177,6 +1230,91 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1177
1230
|
selectedModel = modelSelection.model;
|
|
1178
1231
|
logger.debug({ tier, provider, model: selectedModel }, '[Routing] Using tier config');
|
|
1179
1232
|
|
|
1233
|
+
// Item 1 — capability-decoupled shortfall matching (HyDRA port).
|
|
1234
|
+
// Shadow-computes whenever possible (weighted mode only); serves only when
|
|
1235
|
+
// config/model-capabilities.json has enabled:true. Never overrides risk-high (returned earlier
|
|
1236
|
+
// upstream), static mode (tier null), or legacy scorer mode. Downstream
|
|
1237
|
+
// guards (de-escalation, context, vision, kNN, bandit, deadline, tenant)
|
|
1238
|
+
// still run after and dominate — shortfall only replaces the tier_config pick.
|
|
1239
|
+
let shortfallInfo = null;
|
|
1240
|
+
try {
|
|
1241
|
+
if (tier && config.modelTiers?.enabled && risk?.level !== 'high'
|
|
1242
|
+
&& analysis?.mode === 'weighted' && analysis?.breakdown) {
|
|
1243
|
+
const { buildRequirementVector } = require('./capabilities');
|
|
1244
|
+
const sf = require('./shortfall');
|
|
1245
|
+
const req = buildRequirementVector({ dimensions: analysis.breakdown, agenticResult });
|
|
1246
|
+
// Candidates constrained to the user's TIER_* (same eligibility rule
|
|
1247
|
+
// as the bandit in decide.js) with tier labels attached for capability
|
|
1248
|
+
// resolution. Dedupe identical provider:model keeping the highest tier.
|
|
1249
|
+
const seen = new Map();
|
|
1250
|
+
for (const t of ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING']) {
|
|
1251
|
+
for (const m of selector.getModelsForTier(t)) {
|
|
1252
|
+
const key = `${m.provider}:${m.model}`;
|
|
1253
|
+
const prev = seen.get(key);
|
|
1254
|
+
if (!prev || (TIER_DEFINITIONS[t]?.priority || 0) > (TIER_DEFINITIONS[prev.tier]?.priority || 0)) {
|
|
1255
|
+
seen.set(key, { provider: m.provider, model: m.model, tier: t });
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
let registry = null;
|
|
1260
|
+
try { registry = require('./model-registry').getModelRegistrySync(); } catch { registry = null; }
|
|
1261
|
+
let optimizer = null;
|
|
1262
|
+
try { optimizer = require('./cost-optimizer').getCostOptimizer(); } catch { optimizer = null; }
|
|
1263
|
+
const candidates = [...seen.values()].map((c) => {
|
|
1264
|
+
let cost = Number.POSITIVE_INFINITY; // unknown price never wins on cheapness
|
|
1265
|
+
try {
|
|
1266
|
+
const info = registry?.getCost?.(c.model);
|
|
1267
|
+
if (info && !info.unknown) {
|
|
1268
|
+
const est = optimizer?.estimateCost?.(c.model, 1000);
|
|
1269
|
+
cost = Number.isFinite(est?.totalEstimate)
|
|
1270
|
+
? est.totalEstimate
|
|
1271
|
+
: (Number(info.input) || 0) + (Number(info.output) || 0);
|
|
1272
|
+
}
|
|
1273
|
+
} catch { /* keep Infinity */ }
|
|
1274
|
+
return { ...c, cost };
|
|
1275
|
+
});
|
|
1276
|
+
const result = sf.selectByShortfall(req, candidates);
|
|
1277
|
+
if (result) {
|
|
1278
|
+
const agreed = result.selected.provider === provider && result.selected.model === selectedModel;
|
|
1279
|
+
shortfallInfo = {
|
|
1280
|
+
req,
|
|
1281
|
+
tau: result.tau,
|
|
1282
|
+
selected: result.selected,
|
|
1283
|
+
agreed,
|
|
1284
|
+
legacy: { provider, model: selectedModel, tier },
|
|
1285
|
+
};
|
|
1286
|
+
logger.debug({
|
|
1287
|
+
req,
|
|
1288
|
+
tau: result.tau,
|
|
1289
|
+
legacy: `${tier}:${provider}:${selectedModel}`,
|
|
1290
|
+
shortfall: `${result.selected.tier}:${result.selected.provider}:${result.selected.model}`,
|
|
1291
|
+
agreed,
|
|
1292
|
+
}, '[Routing] Shortfall shadow compare');
|
|
1293
|
+
if (sf.isEnabled() && !agreed) {
|
|
1294
|
+
const fromTier = tier;
|
|
1295
|
+
const fromModel = selectedModel;
|
|
1296
|
+
provider = result.selected.provider;
|
|
1297
|
+
selectedModel = result.selected.model;
|
|
1298
|
+
tier = result.selected.tier;
|
|
1299
|
+
analysis.tier = tier;
|
|
1300
|
+
method = method + '+shortfall';
|
|
1301
|
+
if ((TIER_DEFINITIONS[tier]?.priority || 0) > (TIER_DEFINITIONS[fromTier]?.priority || 0)) {
|
|
1302
|
+
escalations.push({
|
|
1303
|
+
source: 'shortfall',
|
|
1304
|
+
fromTier,
|
|
1305
|
+
toTier: tier,
|
|
1306
|
+
fromModel,
|
|
1307
|
+
toModel: selectedModel,
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
logger.info({ from: `${fromTier}:${fromModel}`, to: `${tier}:${selectedModel}` }, '[Routing] Shortfall override');
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
degradation.record('shortfall', err);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1180
1318
|
// WS2.3 — evidence-based de-escalation.
|
|
1181
1319
|
//
|
|
1182
1320
|
// The check is intentionally gated by evidence, not a feature flag: the
|
|
@@ -1200,17 +1338,31 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1200
1338
|
analysis,
|
|
1201
1339
|
});
|
|
1202
1340
|
if (demoted && demoted !== tier) {
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1341
|
+
// Percentage holdout (LYNKR_DEESCALATION_HOLDOUT_PCT, default 10):
|
|
1342
|
+
// a deterministic slice of sessions is served at the original tier
|
|
1343
|
+
// even though the evidence supports demotion. Their telemetry rows
|
|
1344
|
+
// (method '+deescalation_holdout') are the running baseline that
|
|
1345
|
+
// proves demoted sessions aren't quietly doing worse.
|
|
1346
|
+
if (deescalator.isHeldOut(options._sessionId)) {
|
|
1347
|
+
method = method + '+deescalation_holdout';
|
|
1348
|
+
logger.debug({
|
|
1349
|
+
tier,
|
|
1350
|
+
wouldDemoteTo: demoted,
|
|
1351
|
+
requestType,
|
|
1352
|
+
}, '[Routing] De-escalation — evidence supports demotion, session held out as baseline');
|
|
1353
|
+
} else {
|
|
1354
|
+
const demotedSelection = selector.selectModel(demoted, null);
|
|
1355
|
+
logger.debug({
|
|
1356
|
+
from: `${tier}:${provider}:${selectedModel}`,
|
|
1357
|
+
to: `${demoted}:${demotedSelection.provider}:${demotedSelection.model}`,
|
|
1358
|
+
requestType,
|
|
1359
|
+
}, '[Routing] De-escalation — demoting tier on evidence');
|
|
1360
|
+
demotedFrom = tier;
|
|
1361
|
+
provider = demotedSelection.provider;
|
|
1362
|
+
selectedModel = demotedSelection.model;
|
|
1363
|
+
tier = demoted;
|
|
1364
|
+
method = method + '+deescalated';
|
|
1365
|
+
}
|
|
1214
1366
|
}
|
|
1215
1367
|
} catch (err) {
|
|
1216
1368
|
degradation.record('tier_select', err);
|
|
@@ -1441,65 +1593,29 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1441
1593
|
// WS4.2 — capture propensity + candidates + context so the outcome row can
|
|
1442
1594
|
// support off-policy evaluation. banditContext is stashed on the decision
|
|
1443
1595
|
// (underscored → won't leak through headers, see WS4.2 verification).
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1596
|
+
//
|
|
1597
|
+
// The candidate-building / context-vector / pick logic lives in decide.js
|
|
1598
|
+
// so the off-policy evaluator can replay it against logged rows.
|
|
1599
|
+
let banditResult = null;
|
|
1447
1600
|
if (config.routing?.banditEnabled !== false && knnResult && knnResult.model) {
|
|
1448
1601
|
try {
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
);
|
|
1466
|
-
if (inConfig) {
|
|
1467
|
-
allCandidates.push({ provider: knnResult.provider, model: knnResult.model });
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
if (allCandidates.length > 1) {
|
|
1472
|
-
const bandit = getBandit();
|
|
1473
|
-
const TASK_TYPES = ['code_gen', 'summarization', 'reasoning', 'factoid', 'chat', 'other'];
|
|
1474
|
-
const inferredTask = (analysis.breakdown?.taskType?.reason || 'other').toLowerCase();
|
|
1475
|
-
const taskIdx = Math.max(0, TASK_TYPES.findIndex(t => inferredTask.includes(t)));
|
|
1476
|
-
const ctx = [
|
|
1477
|
-
(analysis.score || 0) / 100,
|
|
1478
|
-
Math.log(Math.max(1, analysis.breakdown?.tokenCount || 0) + 1) / 15,
|
|
1479
|
-
((payload?.tools?.length ?? 0) > 0) ? 1 : 0,
|
|
1480
|
-
options.streaming ? 1 : 0,
|
|
1481
|
-
risk?.level === 'high' ? 1 : risk?.level === 'medium' ? 0.5 : 0,
|
|
1482
|
-
agenticResult?.isAgentic ? 1 : 0,
|
|
1483
|
-
...TASK_TYPES.map((_, i) => i === taskIdx ? 1 : 0),
|
|
1484
|
-
];
|
|
1485
|
-
const picked = bandit.pick(tier, allCandidates, ctx);
|
|
1486
|
-
if (picked) {
|
|
1487
|
-
banditCandidates = allCandidates;
|
|
1488
|
-
banditPropensity = picked.propensity ?? null;
|
|
1489
|
-
banditContext = ctx;
|
|
1490
|
-
if (picked.model !== selectedModel) {
|
|
1491
|
-
logger.debug({
|
|
1492
|
-
from: `${provider}:${selectedModel}`,
|
|
1493
|
-
to: `${picked.provider}:${picked.model}`,
|
|
1494
|
-
ucb: picked.ucb?.toFixed(4),
|
|
1495
|
-
explored: picked.explored,
|
|
1496
|
-
propensity: picked.propensity,
|
|
1497
|
-
}, '[Routing] Bandit override');
|
|
1498
|
-
provider = picked.provider;
|
|
1499
|
-
selectedModel = picked.model;
|
|
1500
|
-
method = method + (picked.explored ? '+bandit_explore' : '+bandit');
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1602
|
+
const allCandidates = buildCandidates(
|
|
1603
|
+
{ provider, model: selectedModel },
|
|
1604
|
+
{ provider: knnResult.provider, model: knnResult.model },
|
|
1605
|
+
);
|
|
1606
|
+
const ctx = buildContextVector({ analysis, payload, options, risk, agenticResult });
|
|
1607
|
+
banditResult = decide(tier, allCandidates, ctx);
|
|
1608
|
+
if (banditResult && banditResult.model !== selectedModel) {
|
|
1609
|
+
logger.debug({
|
|
1610
|
+
from: `${provider}:${selectedModel}`,
|
|
1611
|
+
to: `${banditResult.provider}:${banditResult.model}`,
|
|
1612
|
+
ucb: banditResult.ucb?.toFixed(4),
|
|
1613
|
+
explored: banditResult.explored,
|
|
1614
|
+
propensity: banditResult.propensity,
|
|
1615
|
+
}, '[Routing] Bandit override');
|
|
1616
|
+
provider = banditResult.provider;
|
|
1617
|
+
selectedModel = banditResult.model;
|
|
1618
|
+
method = method + (banditResult.explored ? '+bandit_explore' : '+bandit');
|
|
1503
1619
|
}
|
|
1504
1620
|
} catch (err) {
|
|
1505
1621
|
degradation.record('bandit', err);
|
|
@@ -1568,6 +1684,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1568
1684
|
knnResult,
|
|
1569
1685
|
base_tier: baseTier,
|
|
1570
1686
|
escalations,
|
|
1687
|
+
shortfall: shortfallInfo,
|
|
1571
1688
|
// Upward escalations take precedence in the source label; a demotion is
|
|
1572
1689
|
// only surfaced when nothing else escalated (guarded by the wire above,
|
|
1573
1690
|
// but re-checked here for clarity).
|
|
@@ -1577,24 +1694,13 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1577
1694
|
});
|
|
1578
1695
|
|
|
1579
1696
|
// WS4.2 — propensity/candidates for off-policy evaluation from telemetry.
|
|
1580
|
-
//
|
|
1581
|
-
// (deadline / tenant)
|
|
1582
|
-
// candidate set
|
|
1583
|
-
//
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
1586
|
-
|
|
1587
|
-
const banditPickedServed = banditCandidates
|
|
1588
|
-
&& banditCandidates.some(c => c.provider === provider && c.model === selectedModel);
|
|
1589
|
-
if (banditPickedServed) {
|
|
1590
|
-
decision.propensity = banditPropensity ?? 1.0;
|
|
1591
|
-
decision.candidates = banditCandidates;
|
|
1592
|
-
decision._banditContext = banditContext;
|
|
1593
|
-
} else {
|
|
1594
|
-
decision.propensity = 1.0;
|
|
1595
|
-
decision.candidates = [{ provider, model: selectedModel }];
|
|
1596
|
-
decision._banditContext = null;
|
|
1597
|
-
}
|
|
1697
|
+
// Collapse rule lives in decide.js (stampPropensity): if a deterministic
|
|
1698
|
+
// downstream override (deadline / tenant) swapped the served model out of
|
|
1699
|
+
// the bandit's candidate set — or the bandit never ran — the row collapses
|
|
1700
|
+
// to propensity=1.0 with a single candidate. _banditContext is underscored
|
|
1701
|
+
// so it never leaks to response headers; the feedback path consumes it to
|
|
1702
|
+
// call bandit.update().
|
|
1703
|
+
stampPropensity(decision, { provider, model: selectedModel }, banditResult);
|
|
1598
1704
|
|
|
1599
1705
|
// WS5.5 — attach the query embedding + raw query text so the feedback
|
|
1600
1706
|
// path can turn conclusive outcomes into new kNN exemplars without
|
|
@@ -1658,6 +1764,16 @@ function getRoutingHeaders(decision) {
|
|
|
1658
1764
|
|
|
1659
1765
|
if (decision.model) {
|
|
1660
1766
|
headers['X-Lynkr-Model'] = decision.model;
|
|
1767
|
+
// Served-model context window (workweave pattern): the client can't know
|
|
1768
|
+
// the real window behind a virtual model name when tier routing serves
|
|
1769
|
+
// different models per request — this header is the authoritative per-
|
|
1770
|
+
// turn answer, intended as the client's compaction budget. Omitted (never
|
|
1771
|
+
// guessed) when the registry doesn't know the model.
|
|
1772
|
+
const { contextWindowFor } = require('./model-registry');
|
|
1773
|
+
const contextWindow = contextWindowFor(decision.model);
|
|
1774
|
+
if (contextWindow) {
|
|
1775
|
+
headers['X-Lynkr-Context-Window'] = String(contextWindow);
|
|
1776
|
+
}
|
|
1661
1777
|
}
|
|
1662
1778
|
|
|
1663
1779
|
if (decision.agenticResult?.isAgentic) {
|
|
@@ -206,7 +206,6 @@ function classify(embedding, centroids) {
|
|
|
206
206
|
* Lexical fallback path stays clamped ≤75 (see _lexicalCleanScore).
|
|
207
207
|
*/
|
|
208
208
|
function blendScore(sims) {
|
|
209
|
-
const classes = Object.keys(CLASS_VALUES);
|
|
210
209
|
// Frontier similarity floor: a weak topical match can't jump tiers.
|
|
211
210
|
const frontierSim = sims.frontier ?? -1;
|
|
212
211
|
const activeSims = { ...sims };
|
|
@@ -22,7 +22,11 @@ const logger = require('../logger');
|
|
|
22
22
|
const { generateEmbedding } = require('../cache/embeddings');
|
|
23
23
|
const { getEmbeddingCache } = require('./embedding-cache');
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// LYNKR_KNN_DIR override exists so test runs point at a scratch dir instead
|
|
26
|
+
// of the production index — parallel test processes saving to the real path
|
|
27
|
+
// produced a torn read → corrupt link-list lengths → a 135GB zero-filled
|
|
28
|
+
// index file (2026-08-21).
|
|
29
|
+
const INDEX_DIR = process.env.LYNKR_KNN_DIR || path.join(__dirname, '../../data/knn');
|
|
26
30
|
const INDEX_FILE = path.join(INDEX_DIR, 'index.hnsw');
|
|
27
31
|
const META_FILE = path.join(INDEX_DIR, 'meta.json');
|
|
28
32
|
|
|
@@ -85,7 +89,7 @@ class KnnRouter {
|
|
|
85
89
|
// (Earlier Lynkr code passed MAX_ELEMENTS here — wrong type, threw on load.)
|
|
86
90
|
this.index.readIndexSync(INDEX_FILE, false);
|
|
87
91
|
// resize if needed so we can keep adding up to MAX_ELEMENTS
|
|
88
|
-
try { this.index.resizeIndex(MAX_ELEMENTS); } catch (
|
|
92
|
+
try { this.index.resizeIndex(MAX_ELEMENTS); } catch (err) { logger.debug({ err: err.message }, '[KnnRouter] resizeIndex failed — index may not grow past loaded size'); }
|
|
89
93
|
this.ready = true;
|
|
90
94
|
logger.info({ size: this.size, dim: this.dim }, '[KnnRouter] Index loaded');
|
|
91
95
|
return true;
|
|
@@ -99,8 +103,26 @@ class KnnRouter {
|
|
|
99
103
|
if (!this.ready || !this.index) return;
|
|
100
104
|
try {
|
|
101
105
|
fs.mkdirSync(INDEX_DIR, { recursive: true });
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
// Atomic save: hnswlib's writeIndexSync is truncate-then-write, so a
|
|
107
|
+
// concurrent reader can observe a torn image and later persist a
|
|
108
|
+
// corrupt index. Write to a per-pid temp file and rename into place —
|
|
109
|
+
// readers then always see a complete image.
|
|
110
|
+
const tmpIndex = `${INDEX_FILE}.tmp-${process.pid}`;
|
|
111
|
+
const tmpMeta = `${META_FILE}.tmp-${process.pid}`;
|
|
112
|
+
this.index.writeIndexSync(tmpIndex);
|
|
113
|
+
// Sanity bound: a healthy index is ~(elements × ~3.3KB) + slack. A
|
|
114
|
+
// corrupted link-list length manifests as a wildly oversized file —
|
|
115
|
+
// refuse to install it over the good one.
|
|
116
|
+
const expectedMax = (this.size + 1000) * 8192 + 1_000_000;
|
|
117
|
+
const written = fs.statSync(tmpIndex).size;
|
|
118
|
+
if (written > expectedMax) {
|
|
119
|
+
fs.rmSync(tmpIndex, { force: true });
|
|
120
|
+
logger.error({ written, expectedMax, size: this.size }, '[KnnRouter] Refusing to save implausibly large index — in-memory index likely corrupt');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
fs.writeFileSync(tmpMeta, JSON.stringify({ dim: this.dim, entries: this.meta }, null, 0));
|
|
124
|
+
fs.renameSync(tmpIndex, INDEX_FILE);
|
|
125
|
+
fs.renameSync(tmpMeta, META_FILE);
|
|
104
126
|
} catch (err) {
|
|
105
127
|
logger.warn({ err: err.message }, '[KnnRouter] Index save failed');
|
|
106
128
|
}
|
|
@@ -197,7 +219,7 @@ class KnnRouter {
|
|
|
197
219
|
|
|
198
220
|
let best = null;
|
|
199
221
|
let bestScore = -Infinity;
|
|
200
|
-
for (const [
|
|
222
|
+
for (const [, agg] of byModel) {
|
|
201
223
|
const avgQ = agg.quality / agg.weight;
|
|
202
224
|
const avgC = agg.cost / agg.weight;
|
|
203
225
|
// Score = quality / log(cost+1) — reward quality, penalise cost gently
|
|
@@ -95,7 +95,7 @@ class LatencyTracker {
|
|
|
95
95
|
}
|
|
96
96
|
// Aggregate across all models for this provider
|
|
97
97
|
const provEntries = [];
|
|
98
|
-
for (const [
|
|
98
|
+
for (const [_k, entry] of this._entries) {
|
|
99
99
|
if (entry.provider === provider) provEntries.push(entry);
|
|
100
100
|
}
|
|
101
101
|
if (provEntries.length === 0) return null;
|