lynkr 9.7.3 → 9.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -25
- package/bin/cli.js +16 -1
- package/bin/lynkr-init.js +44 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +23 -2
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/build-eval-set.js +256 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/validate-difficulty-classifier.js +123 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- 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 +467 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +455 -142
- 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 +932 -47
- 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 +120 -85
- 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/classifier-setup.js +207 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +88 -15
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/difficulty-classifier.js +219 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +931 -90
- package/src/routing/intent-score.js +441 -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 +286 -13
- package/src/routing/verifier.js +267 -0
- package/src/server.js +86 -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/index.js
CHANGED
|
@@ -14,6 +14,7 @@ const {
|
|
|
14
14
|
analyzeComplexity,
|
|
15
15
|
shouldForceLocal,
|
|
16
16
|
shouldForceCloud,
|
|
17
|
+
shouldForceReasoning,
|
|
17
18
|
routingMetrics,
|
|
18
19
|
analyzeWithEmbeddings,
|
|
19
20
|
} = require('./complexity-analyzer');
|
|
@@ -23,6 +24,7 @@ const { getAgenticDetector, AGENT_TYPES } = require('./agentic-detector');
|
|
|
23
24
|
const { getModelTierSelector, TIER_DEFINITIONS } = require('./model-tiers');
|
|
24
25
|
const { getCostOptimizer } = require('./cost-optimizer');
|
|
25
26
|
const { analyzeRisk } = require('./risk-classifier');
|
|
27
|
+
const { scoreIntent, intentScoreMode } = require('./intent-score');
|
|
26
28
|
|
|
27
29
|
// Phase 3-6 routing modules
|
|
28
30
|
const { getKnnRouter } = require('./knn-router');
|
|
@@ -40,6 +42,19 @@ const { getLatencyTracker } = require('./latency-tracker');
|
|
|
40
42
|
const contextValidator = require('./context-validator');
|
|
41
43
|
const { countPayloadTokens } = require('./tokenizer');
|
|
42
44
|
|
|
45
|
+
// Degradation registry — swallows silent fallbacks into a counted signal.
|
|
46
|
+
const degradation = require('./degradation');
|
|
47
|
+
|
|
48
|
+
// WS2.3 — de-escalation policy (evidence-gated tier demotion).
|
|
49
|
+
const deescalator = require('./deescalator');
|
|
50
|
+
try {
|
|
51
|
+
const shadow = require('./shadow-mode');
|
|
52
|
+
shadow.registerPolicy('deescalate-v1', deescalator.shadowDeescalate);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// shadow-mode is optional here — the policy just won't be available
|
|
55
|
+
logger.debug({ err: err.message }, '[Routing] Failed to register deescalate-v1 shadow policy');
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
// Local providers
|
|
44
59
|
const LOCAL_PROVIDERS = ['ollama', 'llamacpp', 'lmstudio'];
|
|
45
60
|
|
|
@@ -67,6 +82,7 @@ function _enabledProviders() {
|
|
|
67
82
|
if (config.azureAnthropic?.endpoint && config.azureAnthropic?.apiKey) out.push('azure-anthropic');
|
|
68
83
|
if (config.bedrock?.apiKey) out.push('bedrock');
|
|
69
84
|
if (config.openrouter?.apiKey) out.push('openrouter');
|
|
85
|
+
if (config.edenai?.apiKey) out.push('edenai');
|
|
70
86
|
if (config.openai?.apiKey) out.push('openai');
|
|
71
87
|
if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) out.push('azure-openai');
|
|
72
88
|
if (config.ollama?.endpoint) out.push('ollama');
|
|
@@ -108,6 +124,7 @@ function getBestCloudProvider() {
|
|
|
108
124
|
if (config.azureAnthropic?.endpoint && config.azureAnthropic?.apiKey) return 'azure-anthropic';
|
|
109
125
|
if (config.bedrock?.apiKey) return 'bedrock';
|
|
110
126
|
if (config.openrouter?.apiKey) return 'openrouter';
|
|
127
|
+
if (config.edenai?.apiKey) return 'edenai';
|
|
111
128
|
if (config.openai?.apiKey) return 'openai';
|
|
112
129
|
if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) return 'azure-openai';
|
|
113
130
|
|
|
@@ -140,41 +157,492 @@ function getBestLocalProvider() {
|
|
|
140
157
|
*/
|
|
141
158
|
const sessionAffinity = require('./session-affinity');
|
|
142
159
|
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// WS1 — sticky sessions
|
|
162
|
+
//
|
|
163
|
+
// The wrapper below implements cache-aware sticky routing. Routing decisions
|
|
164
|
+
// happen once per session (persisted so process restarts don't lose the pin)
|
|
165
|
+
// and are re-evaluated only at explicit triggers: compaction, guard
|
|
166
|
+
// escalation (risk/context/vision), or an economic downgrade that beats the
|
|
167
|
+
// estimated cost of the cold-cache re-read.
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
171
|
+
|
|
172
|
+
function _tierPriority(tier) {
|
|
173
|
+
const idx = TIER_ORDER.indexOf(tier);
|
|
174
|
+
return idx < 0 ? -1 : idx;
|
|
175
|
+
}
|
|
176
|
+
|
|
143
177
|
/**
|
|
144
|
-
*
|
|
178
|
+
* Run the cheap guards a pin must satisfy for the current turn:
|
|
179
|
+
* - Risk analysis: if `risk.level === 'high'`, the pin can't serve
|
|
180
|
+
* regardless of its model.
|
|
181
|
+
* - Context fit: pinned model's context window vs estimated prompt tokens.
|
|
182
|
+
* - Vision need: payload has images ⇒ pinned model must have vision.
|
|
145
183
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
184
|
+
* @param {object} payload
|
|
185
|
+
* @param {{provider:string, model:string|null, tier:string|null}} pin
|
|
186
|
+
* @returns {{ok:boolean, reason:string|null, risk:object|null, promptTokensEst:number|null}}
|
|
187
|
+
*/
|
|
188
|
+
function _runPinGuards(payload, pin) {
|
|
189
|
+
let risk = null;
|
|
190
|
+
try {
|
|
191
|
+
risk = analyzeRisk(payload);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
degradation.record('risk', err);
|
|
194
|
+
}
|
|
195
|
+
if (risk?.level === 'high') {
|
|
196
|
+
return { ok: false, reason: 'risk', risk, promptTokensEst: null };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let promptTokensEst = null;
|
|
200
|
+
try {
|
|
201
|
+
if (pin.model) {
|
|
202
|
+
promptTokensEst = countPayloadTokens(payload, pin.model);
|
|
203
|
+
const ctxResult = contextValidator.validate(pin.model, promptTokensEst);
|
|
204
|
+
if (!ctxResult.ok) {
|
|
205
|
+
return { ok: false, reason: 'context', risk, promptTokensEst };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch (err) {
|
|
209
|
+
degradation.record('context_validate', err);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (_payloadHasImages(payload)) {
|
|
213
|
+
try {
|
|
214
|
+
const { getModelRegistrySync } = require('./model-registry');
|
|
215
|
+
const registry = getModelRegistrySync();
|
|
216
|
+
const modelInfo = pin.model ? registry.getCost(pin.model) : null;
|
|
217
|
+
if (!modelInfo?.vision) {
|
|
218
|
+
return { ok: false, reason: 'vision', risk, promptTokensEst };
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
degradation.record('vision_guard', err);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { ok: true, reason: null, risk, promptTokensEst };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* True when the fresh decision may replace the pin from a cost perspective.
|
|
230
|
+
* The rule (per WS1 plan):
|
|
231
|
+
* - Fresh model must be strictly cheaper than the pin's model per
|
|
232
|
+
* cost-optimizer.estimateCost (per-1000-token estimate).
|
|
233
|
+
* - Estimated prompt tokens must be below LYNKR_SWITCH_MAX_PROMPT_TOKENS
|
|
234
|
+
* (default 20k) — beyond that the cold-cache re-read dominates the
|
|
235
|
+
* savings.
|
|
236
|
+
* - Fresh must be ≥25% cheaper.
|
|
237
|
+
*
|
|
238
|
+
* Returns false (i.e. suppress the switch) when either bound fails.
|
|
239
|
+
*
|
|
240
|
+
* @param {number|null} promptTokensEst
|
|
241
|
+
* @param {string|null} pinModel
|
|
242
|
+
* @param {string|null} freshModel
|
|
243
|
+
* @returns {boolean}
|
|
244
|
+
*/
|
|
245
|
+
function _economicDowngradeAllowed(promptTokensEst, pinModel, freshModel) {
|
|
246
|
+
if (!pinModel || !freshModel || pinModel === freshModel) return true;
|
|
247
|
+
const maxPromptTokens = Number(process.env.LYNKR_SWITCH_MAX_PROMPT_TOKENS) || 20000;
|
|
248
|
+
if (promptTokensEst != null && promptTokensEst >= maxPromptTokens) return false;
|
|
249
|
+
try {
|
|
250
|
+
const optimizer = getCostOptimizer();
|
|
251
|
+
const pinCost = optimizer.estimateCost(pinModel, 1000);
|
|
252
|
+
const freshCost = optimizer.estimateCost(freshModel, 1000);
|
|
253
|
+
if (!pinCost?.totalEstimate || !freshCost?.totalEstimate) return true;
|
|
254
|
+
return freshCost.totalEstimate <= pinCost.totalEstimate * 0.75;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
degradation.record('cost_optimize', err);
|
|
257
|
+
return true; // fail-open: if we can't compare, let the fresh decision through
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function _pinToDecision(pin, { reason, risk }) {
|
|
262
|
+
return {
|
|
263
|
+
provider: pin.provider,
|
|
264
|
+
model: pin.model,
|
|
265
|
+
tier: pin.tier,
|
|
266
|
+
method: 'session_pin',
|
|
267
|
+
reason,
|
|
268
|
+
score: null,
|
|
269
|
+
analysis: null,
|
|
270
|
+
embeddingsResult: null,
|
|
271
|
+
agenticResult: null,
|
|
272
|
+
costOptimized: false,
|
|
273
|
+
risk: risk ?? null,
|
|
274
|
+
knnResult: null,
|
|
275
|
+
base_tier: null,
|
|
276
|
+
escalations: [],
|
|
277
|
+
escalation_source: null,
|
|
278
|
+
pinned: true,
|
|
279
|
+
switch_reason: null,
|
|
280
|
+
propensity: 1.0,
|
|
281
|
+
candidates: [{ provider: pin.provider, model: pin.model }],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Provider routing with cache-aware session pinning.
|
|
287
|
+
*
|
|
288
|
+
* Fast path: when a session has a pin and the cheap guards pass, we skip
|
|
289
|
+
* complexity analysis, kNN, and the bandit entirely and serve the pin.
|
|
290
|
+
* Slow path: no pin, compaction, or a guard forces a re-decide.
|
|
291
|
+
*
|
|
292
|
+
* The pin evaluation itself lives in `checkSessionPin` so this path and the
|
|
293
|
+
* OAuth intent path in `src/api/router.js` use the same trigger rules.
|
|
294
|
+
* Gated by `LYNKR_STICKY_SESSIONS !== 'false'`.
|
|
149
295
|
*/
|
|
150
296
|
async function determineProviderSmart(payload, options = {}) {
|
|
151
|
-
const
|
|
297
|
+
const pinCheck = checkSessionPin(payload, options);
|
|
298
|
+
|
|
299
|
+
// Bypass (no session / forceProvider / feature-off) or no pin yet →
|
|
300
|
+
// straight to fresh routing, then persist the outcome for the next turn.
|
|
301
|
+
if (pinCheck.reason === 'bypass' || pinCheck.reason === 'no_pin') {
|
|
302
|
+
const fresh = await _determineProviderSmartInner(payload, options);
|
|
303
|
+
if (pinCheck.sessionId && fresh?.provider) {
|
|
304
|
+
writeSessionPin(pinCheck.sessionId, fresh, payload);
|
|
305
|
+
}
|
|
306
|
+
return fresh;
|
|
307
|
+
}
|
|
152
308
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
309
|
+
// Pin serves — either mid-tool-exchange (unconditional) or guards passed.
|
|
310
|
+
if (pinCheck.serve) {
|
|
311
|
+
// WS1.5 — upward-drift escape hatch. Only on guards_passed turns:
|
|
312
|
+
// tool_history serves must NEVER switch (tool-call IDs break across
|
|
313
|
+
// providers), and drift-checking them would risk exactly that.
|
|
314
|
+
if (pinCheck.reason === 'guards_passed') {
|
|
315
|
+
const drift = await checkPinScoreDrift(pinCheck.pin, payload);
|
|
316
|
+
if (drift.drift) {
|
|
317
|
+
logger.info({
|
|
318
|
+
sessionId: pinCheck.sessionId,
|
|
319
|
+
pinnedTier: pinCheck.pin.tier,
|
|
320
|
+
freshScore: drift.freshScore,
|
|
321
|
+
ceiling: drift.ceiling,
|
|
322
|
+
}, '[Routing] Pin score drift — re-deciding');
|
|
323
|
+
const fresh = await _determineProviderSmartInner(payload, options);
|
|
324
|
+
fresh.pinned = false;
|
|
325
|
+
fresh.switch_reason = 'score_drift';
|
|
326
|
+
if (_tierPriority(fresh.tier) >= _tierPriority(pinCheck.pin.tier)) {
|
|
327
|
+
writeSessionPin(pinCheck.sessionId, fresh, payload);
|
|
328
|
+
}
|
|
329
|
+
return fresh;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const reasonLabel = pinCheck.reason === 'tool_history'
|
|
333
|
+
? 'tool_history_provider_pin'
|
|
334
|
+
: 'guards_passed';
|
|
335
|
+
logger.debug({
|
|
336
|
+
sessionId: pinCheck.sessionId,
|
|
337
|
+
provider: pinCheck.pin.provider,
|
|
338
|
+
tier: pinCheck.pin.tier,
|
|
339
|
+
reason: reasonLabel,
|
|
340
|
+
}, '[Routing] Serving session pin');
|
|
341
|
+
return _pinToDecision(pinCheck.pin, { reason: reasonLabel, risk: null });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Pin exists but a trigger fired. Run fresh routing.
|
|
345
|
+
const pin = pinCheck.pin;
|
|
346
|
+
const fresh = await _determineProviderSmartInner(payload, options);
|
|
347
|
+
fresh.pinned = false;
|
|
348
|
+
|
|
349
|
+
// Compaction: prompt cache was reset upstream, so switching is free EXCEPT
|
|
350
|
+
// when the switch would be to a cheaper model that doesn't pay for the
|
|
351
|
+
// cold-cache re-read at the current prompt size. The economic guard only
|
|
352
|
+
// fires on the compaction path — guard escalations are mandatory.
|
|
353
|
+
if (pinCheck.reason === 'compaction') {
|
|
354
|
+
fresh.switch_reason = 'compaction';
|
|
355
|
+
const promptTokensEst = _tryCountTokens(payload, pin.model || fresh.model);
|
|
356
|
+
if (!_economicDowngradeAllowed(promptTokensEst, pin.model, fresh.model)) {
|
|
357
|
+
logger.debug({
|
|
358
|
+
sessionId: pinCheck.sessionId,
|
|
359
|
+
pinModel: pin.model,
|
|
360
|
+
freshModel: fresh.model,
|
|
361
|
+
promptTokensEst,
|
|
362
|
+
}, '[Routing] Economic downgrade suppressed — staying on pin');
|
|
363
|
+
const served = _pinToDecision(pin, { reason: 'economic_suppressed', risk: null });
|
|
364
|
+
writeSessionPin(pinCheck.sessionId, pin, payload);
|
|
365
|
+
return served;
|
|
167
366
|
}
|
|
367
|
+
writeSessionPin(pinCheck.sessionId, fresh, payload);
|
|
368
|
+
return fresh;
|
|
168
369
|
}
|
|
169
370
|
|
|
170
|
-
|
|
371
|
+
// Vision-only escalation: use the vision-capable model for THIS turn but
|
|
372
|
+
// don't overwrite the pin — the next non-image turn should fall back to
|
|
373
|
+
// the cheaper pinned model.
|
|
374
|
+
if (pinCheck.reason === 'vision') {
|
|
375
|
+
fresh.switch_reason = 'guard_escalation';
|
|
376
|
+
logger.debug({
|
|
377
|
+
sessionId: pinCheck.sessionId,
|
|
378
|
+
pinModel: pin.model,
|
|
379
|
+
freshModel: fresh.model,
|
|
380
|
+
}, '[Routing] Vision guard fired — pinExempt, not re-pinning');
|
|
381
|
+
return fresh;
|
|
382
|
+
}
|
|
171
383
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
384
|
+
// Risk/context escalation: re-pin upward when the fresh tier is at least
|
|
385
|
+
// as capable as the pinned tier (typical) — downgrades from a guard fail
|
|
386
|
+
// are rare and we leave the old pin alone in that case.
|
|
387
|
+
fresh.switch_reason = 'guard_escalation';
|
|
388
|
+
if (_tierPriority(fresh.tier) >= _tierPriority(pin.tier)) {
|
|
389
|
+
writeSessionPin(pinCheck.sessionId, fresh, payload);
|
|
175
390
|
}
|
|
391
|
+
return fresh;
|
|
392
|
+
}
|
|
176
393
|
|
|
177
|
-
|
|
394
|
+
function _tryCountTokens(payload, model) {
|
|
395
|
+
if (!model) return null;
|
|
396
|
+
try {
|
|
397
|
+
return countPayloadTokens(payload, model);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
degradation.record('context_validate', err);
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// WS1.5 — upward-drift margin. A pinned session re-decides when the latest
|
|
405
|
+
// user message scores this many points ABOVE the pinned tier's calibrated
|
|
406
|
+
// ceiling. 15 ≈ half a tier band: enough that score jitter on borderline
|
|
407
|
+
// messages doesn't thrash the pin, small enough that "Hi" → "refactor the
|
|
408
|
+
// whole repo" escapes a SIMPLE pin on the very turn the task escalates.
|
|
409
|
+
const PIN_DRIFT_MARGIN = Number(process.env.LYNKR_PIN_DRIFT_MARGIN) || 15;
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* WS1.5 — detect upward complexity drift on a pinned session.
|
|
413
|
+
*
|
|
414
|
+
* WS1's re-decide triggers (compaction, risk, context, vision, economics)
|
|
415
|
+
* all miss the most common real-world case: the session OPENED trivially
|
|
416
|
+
* ("Hi" → pinned SIMPLE) and then the real task arrived ("plan a refactor
|
|
417
|
+
* of the whole repo"). Nothing about that turn trips a guard, so the pin
|
|
418
|
+
* held the session on the SIMPLE-tier model forever.
|
|
419
|
+
*
|
|
420
|
+
* This scores ONLY the latest user message (cheap heuristic pass — no
|
|
421
|
+
* embeddings, no kNN, no bandit) and compares it against the pinned
|
|
422
|
+
* tier's calibrated score ceiling. Exceeding ceiling + PIN_DRIFT_MARGIN
|
|
423
|
+
* means the conversation has outgrown its pin → caller falls through to
|
|
424
|
+
* full routing and re-pins upward.
|
|
425
|
+
*
|
|
426
|
+
* Deliberately one-directional: downward drift stays pinned (the economic
|
|
427
|
+
* downgrade rule owns that case, where switching costs a cold cache read).
|
|
428
|
+
*
|
|
429
|
+
* @param {{tier:string|null}} pin
|
|
430
|
+
* @param {object} payload — full request payload
|
|
431
|
+
* @returns {Promise<{drift:boolean, freshScore:number|null, ceiling:number|null}>}
|
|
432
|
+
*/
|
|
433
|
+
async function checkPinScoreDrift(pin, payload) {
|
|
434
|
+
const none = { drift: false, freshScore: null, ceiling: null };
|
|
435
|
+
try {
|
|
436
|
+
const tier = pin?.tier;
|
|
437
|
+
if (!tier || tier === 'REASONING') return none; // already at the top
|
|
438
|
+
const idx = _tierPriority(tier);
|
|
439
|
+
if (idx < 0) return none;
|
|
440
|
+
|
|
441
|
+
const msgs = payload?.messages;
|
|
442
|
+
if (!Array.isArray(msgs)) return none;
|
|
443
|
+
const lastUser = [...msgs].reverse().find(m => m?.role === 'user');
|
|
444
|
+
if (!lastUser) return none;
|
|
445
|
+
const rawText = typeof lastUser.content === 'string'
|
|
446
|
+
? lastUser.content
|
|
447
|
+
: Array.isArray(lastUser.content)
|
|
448
|
+
? lastUser.content.filter(b => b?.type === 'text').map(b => b.text || '').join(' ')
|
|
449
|
+
: '';
|
|
450
|
+
const text = rawText.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
451
|
+
if (!text) return none; // tool-result-only turn etc. — nothing to score
|
|
452
|
+
|
|
453
|
+
// Force-cloud patterns are absolute overrides ("refactor the entire
|
|
454
|
+
// codebase", "security audit", "architecture review" — always cloud,
|
|
455
|
+
// regardless of score). Full routing honours them, but a pinned turn
|
|
456
|
+
// never reaches full routing — live incident (2026-07-07): "refactor
|
|
457
|
+
// the entire codebase give me a plan" scored 28, missed the drift
|
|
458
|
+
// threshold by 1, and rode a SIMPLE pin instead of force-routing to
|
|
459
|
+
// cloud. Treat a force-cloud match on the TYPED text as drift so the
|
|
460
|
+
// caller falls through to full routing, where the pattern fires
|
|
461
|
+
// properly. (force_local is deliberately NOT checked here — downward
|
|
462
|
+
// moves stay pinned per the economics rule.)
|
|
463
|
+
if (shouldForceCloud({ messages: [{ role: 'user', content: text }] })) {
|
|
464
|
+
return { drift: true, freshScore: 100, ceiling: null, forced: 'force_cloud' };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Agentic-autonomous is a trigger of the same rank as force-cloud, and
|
|
468
|
+
// its anchor score alone can't clear ceiling+margin — without this
|
|
469
|
+
// escape, autonomous asks are trapped by any pin.
|
|
470
|
+
try {
|
|
471
|
+
const agentic = getAgenticDetector().detect(
|
|
472
|
+
{ messages: [{ role: 'user', content: text }], tools: payload.tools },
|
|
473
|
+
{ clientProfile: payload._clientProfile || null },
|
|
474
|
+
);
|
|
475
|
+
if (agentic?.agentType === 'AUTONOMOUS') {
|
|
476
|
+
return { drift: true, freshScore: 100, ceiling: null, forced: 'agentic_autonomous' };
|
|
477
|
+
}
|
|
478
|
+
} catch { /* detector failure never blocks the pin path */ }
|
|
479
|
+
|
|
480
|
+
// Heuristic-only score of the isolated message. Mirrors the intent
|
|
481
|
+
// scorer's shape (single message + tools + client profile) so the
|
|
482
|
+
// number is comparable to the score that produced the pin.
|
|
483
|
+
// Pins hold anchor scores, so drift must use the same scorer or
|
|
484
|
+
// ceiling+margin comparisons are meaningless.
|
|
485
|
+
let freshScore = null;
|
|
486
|
+
if (intentScoreMode() !== 'legacy') {
|
|
487
|
+
const intent = await scoreIntent({ messages: [{ role: 'user', content: text }] });
|
|
488
|
+
if (intent && Number.isFinite(intent.score)) freshScore = intent.score;
|
|
489
|
+
}
|
|
490
|
+
if (freshScore === null) {
|
|
491
|
+
const analysis = await analyzeComplexity({
|
|
492
|
+
messages: [{ role: 'user', content: text }],
|
|
493
|
+
tools: payload.tools,
|
|
494
|
+
_clientProfile: payload._clientProfile,
|
|
495
|
+
}, {});
|
|
496
|
+
freshScore = analysis?.score;
|
|
497
|
+
}
|
|
498
|
+
if (typeof freshScore !== 'number') return none;
|
|
499
|
+
|
|
500
|
+
// Calibrated ceiling for the pinned tier (falls back to defaults).
|
|
501
|
+
const selector = getModelTierSelector();
|
|
502
|
+
const ranges = selector.ranges || {};
|
|
503
|
+
const ceiling = Array.isArray(ranges[tier]) ? ranges[tier][1]
|
|
504
|
+
: TIER_DEFINITIONS[tier]?.range?.[1];
|
|
505
|
+
if (typeof ceiling !== 'number') return none;
|
|
506
|
+
|
|
507
|
+
return { drift: freshScore > ceiling + PIN_DRIFT_MARGIN, freshScore, ceiling };
|
|
508
|
+
} catch (err) {
|
|
509
|
+
degradation.record('tier_select', err);
|
|
510
|
+
return none;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Sticky-session pin check used by both `determineProviderSmart` (full
|
|
516
|
+
* pipeline) and the OAuth intent path in `src/api/router.js`. The two paths
|
|
517
|
+
* decide provider differently but agree on WHEN to reuse a pin, so this
|
|
518
|
+
* central function keeps that policy in one place.
|
|
519
|
+
*
|
|
520
|
+
* Returns:
|
|
521
|
+
* { serve: true, pin, reason, sessionId } — reuse the pinned decision
|
|
522
|
+
* { serve: false, pin?, reason?, sessionId } — run full routing
|
|
523
|
+
*
|
|
524
|
+
* Refreshes the pin's ts/messageCount on serve so an active session doesn't
|
|
525
|
+
* TTL-expire mid-conversation. Never throws.
|
|
526
|
+
*
|
|
527
|
+
* @param {object} payload
|
|
528
|
+
* @param {object} [options]
|
|
529
|
+
* @returns {{serve:boolean, pin?:object, reason:string, sessionId:string|null}}
|
|
530
|
+
*/
|
|
531
|
+
function checkSessionPin(payload, options = {}) {
|
|
532
|
+
const sessionId = payload?._sessionId || null;
|
|
533
|
+
const stickyEnabled = process.env.LYNKR_STICKY_SESSIONS !== 'false';
|
|
534
|
+
if (!stickyEnabled || !sessionId || options.forceProvider) {
|
|
535
|
+
return { serve: false, sessionId: null, reason: 'bypass' };
|
|
536
|
+
}
|
|
537
|
+
const pin = sessionAffinity.getPin(sessionId);
|
|
538
|
+
if (!pin) return { serve: false, sessionId, reason: 'no_pin' };
|
|
539
|
+
|
|
540
|
+
const messageCount = Array.isArray(payload?.messages) ? payload.messages.length : 0;
|
|
541
|
+
// Opener-only conversations (≤2 messages) never consume pins either —
|
|
542
|
+
// a visible pin may belong to a different conversation with the same
|
|
543
|
+
// opener, and a full route on frame 1-2 costs one cached embed.
|
|
544
|
+
if (messageCount <= 2) {
|
|
545
|
+
return { serve: false, pin, sessionId, reason: 'opener_conversation' };
|
|
546
|
+
}
|
|
547
|
+
// Tool-less requests are harness side traffic (title generation,
|
|
548
|
+
// summarization, memory extraction) replaying the conversation. They may
|
|
549
|
+
// be SERVED from the pin but must not refresh its ts/messageCount — a
|
|
550
|
+
// side request's message count includes wrapper turns, and persisting it
|
|
551
|
+
// makes the next real turn look compacted (phantom re-route).
|
|
552
|
+
const refreshOk = Array.isArray(payload?.tools) && payload.tools.length > 0;
|
|
553
|
+
|
|
554
|
+
if (sessionAffinity.payloadHasToolHistory(payload)) {
|
|
555
|
+
// Text typed during a tool loop arrives merged with the pending
|
|
556
|
+
// tool_result, where the pin serves unconditionally (id linkage forbids
|
|
557
|
+
// switching mid-exchange). If that embedded text trips a trigger, drop
|
|
558
|
+
// the pin so the next turn boundary re-routes.
|
|
559
|
+
try {
|
|
560
|
+
const { extractCleanUserText } = require('./intent-score');
|
|
561
|
+
const lastMsg = payload.messages[payload.messages.length - 1];
|
|
562
|
+
const embedded = extractCleanUserText({ messages: [lastMsg] });
|
|
563
|
+
if (embedded) {
|
|
564
|
+
const trippedForce = shouldForceCloud({ messages: [{ role: 'user', content: embedded }] });
|
|
565
|
+
const agentic = trippedForce ? null : getAgenticDetector().detect(
|
|
566
|
+
{ messages: [{ role: 'user', content: embedded }], tools: payload.tools },
|
|
567
|
+
{ clientProfile: payload._clientProfile || null },
|
|
568
|
+
);
|
|
569
|
+
if (trippedForce || agentic?.agentType === 'AUTONOMOUS') {
|
|
570
|
+
sessionAffinity.removePin(sessionId);
|
|
571
|
+
logger.info({
|
|
572
|
+
sessionId,
|
|
573
|
+
trigger: trippedForce ? 'force_cloud' : 'agentic_autonomous',
|
|
574
|
+
pinnedTier: pin.tier,
|
|
575
|
+
}, '[Routing] Trigger text embedded in tool exchange — pin dropped, next boundary re-routes');
|
|
576
|
+
return { serve: true, pin, reason: 'tool_history_pin_dropped', sessionId };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
} catch { /* never block the pin-serve path */ }
|
|
580
|
+
if (refreshOk) {
|
|
581
|
+
sessionAffinity.setPin(sessionId, pin, {
|
|
582
|
+
// Monotonic within a pin's lifetime: refreshes must never SHRINK the
|
|
583
|
+
// recorded conversation size, or a short colliding session blinds
|
|
584
|
+
// the new_conversation guard for everyone after it.
|
|
585
|
+
messageCount: Math.max(messageCount, pin.messageCount ?? 0),
|
|
586
|
+
promptTokensEst: pin.promptTokensEst,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return { serve: true, pin, reason: 'tool_history', sessionId };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const repin = sessionAffinity.shouldRepin(pin, payload);
|
|
593
|
+
if (repin.repin) return { serve: false, pin, sessionId, reason: repin.reason };
|
|
594
|
+
|
|
595
|
+
const guards = _runPinGuards(payload, pin);
|
|
596
|
+
if (!guards.ok) return { serve: false, pin, sessionId, reason: guards.reason };
|
|
597
|
+
|
|
598
|
+
if (refreshOk) {
|
|
599
|
+
sessionAffinity.setPin(sessionId, pin, {
|
|
600
|
+
messageCount: Math.max(messageCount, pin.messageCount ?? 0),
|
|
601
|
+
promptTokensEst: guards.promptTokensEst ?? pin.promptTokensEst,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
return { serve: true, pin, reason: 'guards_passed', sessionId };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Write-through helper: persist a fresh routing decision as the session's
|
|
609
|
+
* new pin. No-op when sessionId is missing or the decision has no provider.
|
|
610
|
+
*
|
|
611
|
+
* Risk-forced decisions are NEVER pinned. Risk analysis runs on every turn
|
|
612
|
+
* (both in `_runPinGuards` and inner routing), so escalating THIS turn is
|
|
613
|
+
* already guaranteed without a pin — pinning it only creates a one-way
|
|
614
|
+
* ratchet where a single phantom risk hit (live incidents: harness
|
|
615
|
+
* suggestion-mode wrapper text, replayed repo transcripts) locks the whole
|
|
616
|
+
* conversation onto the expensive tier. If the next turn is genuinely
|
|
617
|
+
* risky, risk fires again then.
|
|
618
|
+
*
|
|
619
|
+
* @param {string|null} sessionId
|
|
620
|
+
* @param {object} decision
|
|
621
|
+
* @param {object} payload
|
|
622
|
+
*/
|
|
623
|
+
function writeSessionPin(sessionId, decision, payload) {
|
|
624
|
+
if (!sessionId || !decision?.provider) return;
|
|
625
|
+
const method = decision.method || '';
|
|
626
|
+
if (method === 'risk' || method.startsWith('risk+') || decision.escalation_source === 'risk') {
|
|
627
|
+
logger.debug({
|
|
628
|
+
sessionId,
|
|
629
|
+
provider: decision.provider,
|
|
630
|
+
tier: decision.tier,
|
|
631
|
+
method,
|
|
632
|
+
}, '[Routing] Risk-forced decision — pin write skipped');
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const messageCount = Array.isArray(payload?.messages) ? payload.messages.length : 0;
|
|
636
|
+
// Opener-only sessions (≤2 messages) never pin: a bare opener has nothing
|
|
637
|
+
// to stabilize, and identical openers share a fingerprint for the 6h TTL,
|
|
638
|
+
// so any pin written here leaks to unrelated sessions.
|
|
639
|
+
if (messageCount <= 2) {
|
|
640
|
+
logger.debug({ sessionId, tier: decision.tier, messageCount },
|
|
641
|
+
'[Routing] Opener-only session — pin write skipped');
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const promptTokensEst = _tryCountTokens(payload, decision.model);
|
|
645
|
+
sessionAffinity.setPin(sessionId, decision, { messageCount, promptTokensEst });
|
|
178
646
|
}
|
|
179
647
|
|
|
180
648
|
async function _determineProviderSmartInner(payload, options = {}) {
|
|
@@ -188,10 +656,36 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
188
656
|
try {
|
|
189
657
|
risk = analyzeRisk(payload);
|
|
190
658
|
} catch (err) {
|
|
191
|
-
|
|
659
|
+
degradation.record('risk', err);
|
|
192
660
|
risk = null;
|
|
193
661
|
}
|
|
194
662
|
|
|
663
|
+
// WS5.5 — hoist query-text + embedding capture to the top so every
|
|
664
|
+
// decision-return path (static, risk-forced, force-local/cloud,
|
|
665
|
+
// autonomous-agentic, kNN-driven main path) attaches `_queryEmbedding`
|
|
666
|
+
// consistently. Without this, risk-forced short-circuits skip the kNN
|
|
667
|
+
// block entirely and the feedback loop can never add those outcomes as
|
|
668
|
+
// exemplars — which is exactly the path that most needs learning (e.g.
|
|
669
|
+
// "encrypt" trigrams that keep landing high-risk on trivial prompts).
|
|
670
|
+
// Extraction is cheap (string parse); embedding is one HTTP call to
|
|
671
|
+
// Ollama (~200ms). Both are best-effort and fall through as null.
|
|
672
|
+
let queryText = null;
|
|
673
|
+
let queryEmbedding = null;
|
|
674
|
+
if (config.routing?.knnEnabled !== false) {
|
|
675
|
+
try {
|
|
676
|
+
const msgs = payload?.messages;
|
|
677
|
+
const lastMsg = Array.isArray(msgs) ? msgs[msgs.length - 1]?.content : null;
|
|
678
|
+
queryText = typeof lastMsg === 'string' ? lastMsg
|
|
679
|
+
: Array.isArray(lastMsg) ? lastMsg.filter(b => b?.type === 'text').map(b => b.text || '').join(' ')
|
|
680
|
+
: null;
|
|
681
|
+
if (queryText) {
|
|
682
|
+
queryEmbedding = await getKnnRouter().embed(queryText);
|
|
683
|
+
}
|
|
684
|
+
} catch (err) {
|
|
685
|
+
degradation.record('knn', err);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
195
689
|
// If tier routing is disabled, use static configuration
|
|
196
690
|
if (!config.modelTiers?.enabled) {
|
|
197
691
|
return {
|
|
@@ -200,6 +694,10 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
200
694
|
method: 'static',
|
|
201
695
|
reason: 'tier_routing_disabled',
|
|
202
696
|
risk,
|
|
697
|
+
propensity: 1.0,
|
|
698
|
+
candidates: [{ provider: primaryProvider, model: null }],
|
|
699
|
+
_queryEmbedding: queryEmbedding,
|
|
700
|
+
_queryText: queryText,
|
|
203
701
|
};
|
|
204
702
|
}
|
|
205
703
|
|
|
@@ -209,26 +707,42 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
209
707
|
if (risk?.level === 'high' && isFallbackEnabled()) {
|
|
210
708
|
try {
|
|
211
709
|
const selector = getModelTierSelector();
|
|
212
|
-
|
|
710
|
+
// Config B (local → GLM → Claude): high-risk requests route to the
|
|
711
|
+
// trusted provider (Claude) via REASONING, not the mid-tier GLM.
|
|
712
|
+
// Security/auth/middleware changes belong on the governance path.
|
|
713
|
+
const modelSelection = selector.selectModel('REASONING', null);
|
|
213
714
|
const decision = {
|
|
214
715
|
provider: modelSelection.provider,
|
|
215
716
|
model: modelSelection.model,
|
|
216
|
-
tier: '
|
|
717
|
+
tier: 'REASONING',
|
|
217
718
|
method: 'risk',
|
|
218
719
|
reason: 'high_risk_forced_tier',
|
|
219
720
|
score: 100,
|
|
220
721
|
risk,
|
|
722
|
+
base_tier: null,
|
|
723
|
+
escalations: [{
|
|
724
|
+
source: 'risk',
|
|
725
|
+
fromTier: null,
|
|
726
|
+
toTier: 'REASONING',
|
|
727
|
+
fromModel: null,
|
|
728
|
+
toModel: modelSelection.model,
|
|
729
|
+
}],
|
|
730
|
+
escalation_source: 'risk',
|
|
731
|
+
propensity: 1.0,
|
|
732
|
+
candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
|
|
733
|
+
_queryEmbedding: queryEmbedding,
|
|
734
|
+
_queryText: queryText,
|
|
221
735
|
};
|
|
222
736
|
routingMetrics.record(decision);
|
|
223
737
|
logger.debug({
|
|
224
|
-
tier: '
|
|
738
|
+
tier: 'REASONING',
|
|
225
739
|
provider: decision.provider,
|
|
226
740
|
instructionHits: risk.instructionHits,
|
|
227
741
|
pathHits: risk.pathHits,
|
|
228
742
|
}, '[Routing] High risk → forcing tier');
|
|
229
743
|
return decision;
|
|
230
744
|
} catch (err) {
|
|
231
|
-
|
|
745
|
+
degradation.record('tier_select', err);
|
|
232
746
|
}
|
|
233
747
|
}
|
|
234
748
|
|
|
@@ -247,11 +761,15 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
247
761
|
reason: 'force_local_pattern',
|
|
248
762
|
score: 0,
|
|
249
763
|
risk,
|
|
764
|
+
propensity: 1.0,
|
|
765
|
+
candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
|
|
766
|
+
_queryEmbedding: queryEmbedding,
|
|
767
|
+
_queryText: queryText,
|
|
250
768
|
};
|
|
251
769
|
routingMetrics.record(decision);
|
|
252
770
|
return decision;
|
|
253
771
|
} catch (err) {
|
|
254
|
-
|
|
772
|
+
degradation.record('tier_select', err);
|
|
255
773
|
}
|
|
256
774
|
}
|
|
257
775
|
const provider = getBestLocalProvider();
|
|
@@ -262,12 +780,74 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
262
780
|
reason: 'force_local_pattern',
|
|
263
781
|
score: 0,
|
|
264
782
|
risk,
|
|
783
|
+
propensity: 1.0,
|
|
784
|
+
candidates: [{ provider, model: null }],
|
|
785
|
+
_queryEmbedding: queryEmbedding,
|
|
786
|
+
_queryText: queryText,
|
|
265
787
|
};
|
|
266
788
|
routingMetrics.record(decision);
|
|
267
789
|
return decision;
|
|
268
790
|
}
|
|
269
791
|
|
|
792
|
+
// Force REASONING (Claude in config B) — checked before force_cloud.
|
|
793
|
+
// Matches ultrathink/prove/security-audit/first-principles. Deterministic
|
|
794
|
+
// routing to the top tier regardless of embedding score.
|
|
795
|
+
if (shouldForceReasoning(payload) && isFallbackEnabled() && config.modelTiers?.enabled) {
|
|
796
|
+
try {
|
|
797
|
+
const selector = getModelTierSelector();
|
|
798
|
+
const modelSelection = selector.selectModel('REASONING', null);
|
|
799
|
+
const decision = {
|
|
800
|
+
provider: modelSelection.provider,
|
|
801
|
+
model: modelSelection.model,
|
|
802
|
+
tier: 'REASONING',
|
|
803
|
+
method: 'force',
|
|
804
|
+
reason: 'force_reasoning_pattern',
|
|
805
|
+
score: 100,
|
|
806
|
+
risk,
|
|
807
|
+
propensity: 1.0,
|
|
808
|
+
candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
|
|
809
|
+
_queryEmbedding: queryEmbedding,
|
|
810
|
+
_queryText: queryText,
|
|
811
|
+
};
|
|
812
|
+
routingMetrics.record(decision);
|
|
813
|
+
return decision;
|
|
814
|
+
} catch (err) {
|
|
815
|
+
degradation.record('tier_select', err);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
270
819
|
if (shouldForceCloud(payload) && isFallbackEnabled()) {
|
|
820
|
+
// When tier routing is enabled, force-cloud means the COMPLEX tier's
|
|
821
|
+
// configured model — NOT getBestCloudProvider()'s credential-priority
|
|
822
|
+
// list. That list starts with databricks-if-credentialed, and installs
|
|
823
|
+
// running pure tier routing carry DUMMY databricks values to pass
|
|
824
|
+
// startup validation (live incident 2026-07-07: an "architecture
|
|
825
|
+
// review" force-cloud routed to the dummy base http://localhost:8081 —
|
|
826
|
+
// Lynkr proxying to itself — and hung). Mirrors the force_local branch,
|
|
827
|
+
// which got the same tier-aware fix long ago.
|
|
828
|
+
if (config.modelTiers?.enabled) {
|
|
829
|
+
try {
|
|
830
|
+
const selector = getModelTierSelector();
|
|
831
|
+
const modelSelection = selector.selectModel('COMPLEX', null);
|
|
832
|
+
const decision = {
|
|
833
|
+
provider: modelSelection.provider,
|
|
834
|
+
model: modelSelection.model,
|
|
835
|
+
tier: 'COMPLEX',
|
|
836
|
+
method: 'force',
|
|
837
|
+
reason: 'force_cloud_pattern',
|
|
838
|
+
score: 100,
|
|
839
|
+
risk,
|
|
840
|
+
propensity: 1.0,
|
|
841
|
+
candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
|
|
842
|
+
_queryEmbedding: queryEmbedding,
|
|
843
|
+
_queryText: queryText,
|
|
844
|
+
};
|
|
845
|
+
routingMetrics.record(decision);
|
|
846
|
+
return decision;
|
|
847
|
+
} catch (err) {
|
|
848
|
+
degradation.record('tier_select', err);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
271
851
|
const provider = getBestCloudProvider();
|
|
272
852
|
const decision = {
|
|
273
853
|
provider,
|
|
@@ -276,6 +856,10 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
276
856
|
reason: 'force_cloud_pattern',
|
|
277
857
|
score: 100,
|
|
278
858
|
risk,
|
|
859
|
+
propensity: 1.0,
|
|
860
|
+
candidates: [{ provider, model: null }],
|
|
861
|
+
_queryEmbedding: queryEmbedding,
|
|
862
|
+
_queryText: queryText,
|
|
279
863
|
};
|
|
280
864
|
routingMetrics.record(decision);
|
|
281
865
|
return decision;
|
|
@@ -285,9 +869,33 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
285
869
|
const useWeightedScoring = config.routing?.weightedScoring ?? false;
|
|
286
870
|
const analysis = await analyzeComplexity(payload, { weighted: useWeightedScoring, workspace: options.workspace });
|
|
287
871
|
|
|
288
|
-
//
|
|
872
|
+
// WS7 — replace the full-payload lexical score with the anchor score of
|
|
873
|
+
// cleaned user text (payload-invariant). Envelope signals escalate via
|
|
874
|
+
// triggers, never via this score. LYNKR_INTENT_SCORE_MODE=legacy opts out.
|
|
875
|
+
let intentScored = false;
|
|
876
|
+
try {
|
|
877
|
+
const intent = await scoreIntent(payload);
|
|
878
|
+
if (intent && Number.isFinite(intent.score)) {
|
|
879
|
+
analysis.lexicalScore = analysis.score;
|
|
880
|
+
analysis.score = intent.score;
|
|
881
|
+
analysis.scoreMode = intent.mode; // 'anchor' | 'lexical' (clean-text fallback)
|
|
882
|
+
analysis.anchorClass = intent.class ?? null;
|
|
883
|
+
intentScored = true;
|
|
884
|
+
logger.debug({
|
|
885
|
+
score: intent.score,
|
|
886
|
+
mode: intent.mode,
|
|
887
|
+
class: intent.class,
|
|
888
|
+
lexicalScore: analysis.lexicalScore,
|
|
889
|
+
}, '[Routing] WS7 intent score');
|
|
890
|
+
}
|
|
891
|
+
} catch (err) {
|
|
892
|
+
degradation.record('intent_score', err);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Phase 4 embeddings adjustment: legacy path only — it reads the full
|
|
896
|
+
// payload and would reintroduce envelope noise into an anchor score.
|
|
289
897
|
let embeddingsResult = null;
|
|
290
|
-
if (options.useEmbeddings !== false && config.ollama?.embeddingsModel) {
|
|
898
|
+
if (!intentScored && options.useEmbeddings !== false && config.ollama?.embeddingsModel) {
|
|
291
899
|
try {
|
|
292
900
|
embeddingsResult = await analyzeWithEmbeddings(payload);
|
|
293
901
|
if (embeddingsResult?.adjustment) {
|
|
@@ -297,7 +905,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
297
905
|
analysis.embeddingsAdjustment = embeddingsResult.adjustment;
|
|
298
906
|
}
|
|
299
907
|
} catch (err) {
|
|
300
|
-
|
|
908
|
+
degradation.record('embeddings', err);
|
|
301
909
|
}
|
|
302
910
|
}
|
|
303
911
|
|
|
@@ -306,12 +914,20 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
306
914
|
if (config.routing?.agenticDetection !== false) {
|
|
307
915
|
try {
|
|
308
916
|
const detector = getAgenticDetector();
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
917
|
+
// WS3.2 — thread the client profile so the detector subtracts the
|
|
918
|
+
// harness's baseline tool loadout before scoring tool-count signals.
|
|
919
|
+
const clientProfile = payload?._clientProfile
|
|
920
|
+
|| options.clientProfile
|
|
921
|
+
|| null;
|
|
922
|
+
agenticResult = detector.detect(payload, { clientProfile });
|
|
923
|
+
|
|
924
|
+
// Agentic boost: legacy mode only. In anchor mode the score must stay
|
|
925
|
+
// payload-invariant; agentic escalation happens via minTier instead.
|
|
312
926
|
if (agenticResult.isAgentic) {
|
|
313
|
-
|
|
314
|
-
|
|
927
|
+
if (!intentScored) {
|
|
928
|
+
analysis.score = Math.min(100, analysis.score + agenticResult.scoreBoost);
|
|
929
|
+
analysis.agenticBoost = agenticResult.scoreBoost;
|
|
930
|
+
}
|
|
315
931
|
analysis.agentType = agenticResult.agentType;
|
|
316
932
|
|
|
317
933
|
logger.debug({
|
|
@@ -320,48 +936,100 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
320
936
|
newScore: analysis.score,
|
|
321
937
|
}, '[Routing] Agentic workflow detected, boosting score');
|
|
322
938
|
|
|
323
|
-
// Force cloud for autonomous workflows
|
|
939
|
+
// Force cloud for autonomous workflows. AUTONOMOUS carries
|
|
940
|
+
// minTier=REASONING, but this early return used to bypass tier
|
|
941
|
+
// mapping entirely and pick from getBestCloudProvider()'s
|
|
942
|
+
// credential list — the same dormant landmine as the force-cloud
|
|
943
|
+
// branch (dummy databricks credentials → self-proxy). When tier
|
|
944
|
+
// routing is on, honour the agent type's declared minTier and
|
|
945
|
+
// serve the REASONING tier's configured model.
|
|
324
946
|
if (agenticResult.agentType === 'AUTONOMOUS' && isFallbackEnabled()) {
|
|
947
|
+
if (config.modelTiers?.enabled) {
|
|
948
|
+
try {
|
|
949
|
+
const selector = getModelTierSelector();
|
|
950
|
+
const modelSelection = selector.selectModel('REASONING', null);
|
|
951
|
+
const decision = {
|
|
952
|
+
provider: modelSelection.provider,
|
|
953
|
+
model: modelSelection.model,
|
|
954
|
+
tier: 'REASONING',
|
|
955
|
+
method: 'agentic',
|
|
956
|
+
reason: 'autonomous_workflow',
|
|
957
|
+
// Triggers present a score consistent with the tier they
|
|
958
|
+
// force (like force_cloud/risk) — score-comparing consumers
|
|
959
|
+
// (intent window decay, pin ceilings) would otherwise treat
|
|
960
|
+
// a REASONING decision as trivial.
|
|
961
|
+
score: 100,
|
|
962
|
+
agenticResult,
|
|
963
|
+
risk,
|
|
964
|
+
propensity: 1.0,
|
|
965
|
+
candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
|
|
966
|
+
_queryEmbedding: queryEmbedding,
|
|
967
|
+
_queryText: queryText,
|
|
968
|
+
};
|
|
969
|
+
routingMetrics.record(decision);
|
|
970
|
+
return decision;
|
|
971
|
+
} catch (err) {
|
|
972
|
+
degradation.record('tier_select', err);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
325
975
|
const provider = getBestCloudProvider();
|
|
326
976
|
const decision = {
|
|
327
977
|
provider,
|
|
328
978
|
method: 'agentic',
|
|
329
979
|
reason: 'autonomous_workflow',
|
|
330
|
-
score:
|
|
980
|
+
score: 100, // trigger score matches forced tier — see comment above
|
|
331
981
|
agenticResult,
|
|
332
982
|
risk,
|
|
983
|
+
propensity: 1.0,
|
|
984
|
+
candidates: [{ provider, model: null }],
|
|
985
|
+
_queryEmbedding: queryEmbedding,
|
|
986
|
+
_queryText: queryText,
|
|
333
987
|
};
|
|
334
988
|
routingMetrics.record(decision);
|
|
335
989
|
return decision;
|
|
336
990
|
}
|
|
337
991
|
}
|
|
338
992
|
} catch (err) {
|
|
339
|
-
|
|
993
|
+
degradation.record('agentic', err);
|
|
340
994
|
}
|
|
341
995
|
}
|
|
342
996
|
|
|
343
997
|
// Tier-based model selection
|
|
344
998
|
let selectedModel = null;
|
|
345
999
|
let tier = null;
|
|
1000
|
+
// baseTier is the tier chosen by pure complexity analysis, before any
|
|
1001
|
+
// guard/override. escalations[] accumulates every deviation from base_tier
|
|
1002
|
+
// (agentic minTier, context, vision, kNN-ambiguous) for telemetry.
|
|
1003
|
+
let baseTier = null;
|
|
1004
|
+
const escalations = [];
|
|
346
1005
|
if (config.modelTiers?.enabled) {
|
|
347
1006
|
try {
|
|
348
1007
|
const selector = getModelTierSelector();
|
|
349
1008
|
tier = selector.getTier(analysis.score);
|
|
1009
|
+
baseTier = tier;
|
|
350
1010
|
|
|
351
1011
|
// Check if agentic detection requires a higher tier
|
|
352
1012
|
if (agenticResult?.minTier) {
|
|
353
1013
|
const agenticTierPriority = TIER_DEFINITIONS[agenticResult.minTier]?.priority || 0;
|
|
354
1014
|
const currentTierPriority = TIER_DEFINITIONS[tier]?.priority || 0;
|
|
355
1015
|
if (agenticTierPriority > currentTierPriority) {
|
|
1016
|
+
const fromTier = tier;
|
|
356
1017
|
tier = agenticResult.minTier;
|
|
357
|
-
|
|
1018
|
+
escalations.push({
|
|
1019
|
+
source: 'agentic_min_tier',
|
|
1020
|
+
fromTier,
|
|
1021
|
+
toTier: tier,
|
|
1022
|
+
fromModel: null,
|
|
1023
|
+
toModel: null,
|
|
1024
|
+
});
|
|
1025
|
+
logger.debug({ from: fromTier, to: tier }, '[Routing] Upgrading tier for agentic workflow');
|
|
358
1026
|
}
|
|
359
1027
|
}
|
|
360
1028
|
|
|
361
1029
|
// Select model for the tier (will be applied after provider selection)
|
|
362
1030
|
analysis.tier = tier;
|
|
363
1031
|
} catch (err) {
|
|
364
|
-
|
|
1032
|
+
degradation.record('tier_select', err);
|
|
365
1033
|
}
|
|
366
1034
|
}
|
|
367
1035
|
|
|
@@ -378,6 +1046,46 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
378
1046
|
selectedModel = modelSelection.model;
|
|
379
1047
|
logger.debug({ tier, provider, model: selectedModel }, '[Routing] Using tier config');
|
|
380
1048
|
|
|
1049
|
+
// WS2.3 — evidence-based de-escalation.
|
|
1050
|
+
//
|
|
1051
|
+
// The check is intentionally gated by evidence, not a feature flag: the
|
|
1052
|
+
// deescalator only returns a lower tier when the lower tier has >=30
|
|
1053
|
+
// rows of this request_type at avg quality >=70 with error rate <5% in
|
|
1054
|
+
// the last 7 days. On a fresh install with no telemetry, this never
|
|
1055
|
+
// fires. On a mature install with proven data, it demotes safely.
|
|
1056
|
+
//
|
|
1057
|
+
// Never applied when risk=high (upstream forces COMPLEX) or when any
|
|
1058
|
+
// upward escalation has already fired (agentic minTier, context, vision,
|
|
1059
|
+
// kNN) — those signals dominate.
|
|
1060
|
+
let demotedFrom = null;
|
|
1061
|
+
if (risk?.level !== 'high' && escalations.length === 0) {
|
|
1062
|
+
try {
|
|
1063
|
+
const requestType = analysis?.breakdown?.taskType?.reason
|
|
1064
|
+
?? analysis?.taskType
|
|
1065
|
+
?? null;
|
|
1066
|
+
const demoted = deescalator.suggestDemotion({
|
|
1067
|
+
tier,
|
|
1068
|
+
requestType,
|
|
1069
|
+
analysis,
|
|
1070
|
+
});
|
|
1071
|
+
if (demoted && demoted !== tier) {
|
|
1072
|
+
const demotedSelection = selector.selectModel(demoted, null);
|
|
1073
|
+
logger.debug({
|
|
1074
|
+
from: `${tier}:${provider}:${selectedModel}`,
|
|
1075
|
+
to: `${demoted}:${demotedSelection.provider}:${demotedSelection.model}`,
|
|
1076
|
+
requestType,
|
|
1077
|
+
}, '[Routing] De-escalation — demoting tier on evidence');
|
|
1078
|
+
demotedFrom = tier;
|
|
1079
|
+
provider = demotedSelection.provider;
|
|
1080
|
+
selectedModel = demotedSelection.model;
|
|
1081
|
+
tier = demoted;
|
|
1082
|
+
method = method + '+deescalated';
|
|
1083
|
+
}
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
degradation.record('tier_select', err);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
381
1089
|
// Phase 1.2 — cost-optimizer override.
|
|
382
1090
|
// Only kick in when:
|
|
383
1091
|
// - feature flag enabled (default true, disable with LYNKR_COST_OPTIMIZE=false)
|
|
@@ -407,7 +1115,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
407
1115
|
}
|
|
408
1116
|
}
|
|
409
1117
|
} catch (err) {
|
|
410
|
-
|
|
1118
|
+
degradation.record('cost_optimize', err);
|
|
411
1119
|
}
|
|
412
1120
|
}
|
|
413
1121
|
|
|
@@ -420,6 +1128,8 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
420
1128
|
if (!ctxResult.ok) {
|
|
421
1129
|
const capable = selector.findContextCapable(estimatedTokens, tier);
|
|
422
1130
|
if (capable) {
|
|
1131
|
+
const fromTier = tier;
|
|
1132
|
+
const fromModel = selectedModel;
|
|
423
1133
|
logger.info({
|
|
424
1134
|
from: `${provider}:${selectedModel}`,
|
|
425
1135
|
to: `${capable.provider}:${capable.model}`,
|
|
@@ -431,6 +1141,13 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
431
1141
|
selectedModel = capable.model;
|
|
432
1142
|
if (capable.tier) tier = capable.tier;
|
|
433
1143
|
method = method + '+context_escalated';
|
|
1144
|
+
escalations.push({
|
|
1145
|
+
source: 'context',
|
|
1146
|
+
fromTier,
|
|
1147
|
+
toTier: tier,
|
|
1148
|
+
fromModel,
|
|
1149
|
+
toModel: selectedModel,
|
|
1150
|
+
});
|
|
434
1151
|
} else {
|
|
435
1152
|
logger.warn({
|
|
436
1153
|
model: selectedModel,
|
|
@@ -440,7 +1157,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
440
1157
|
}
|
|
441
1158
|
}
|
|
442
1159
|
} catch (err) {
|
|
443
|
-
|
|
1160
|
+
degradation.record('context_validate', err);
|
|
444
1161
|
}
|
|
445
1162
|
|
|
446
1163
|
// Phase 1.4 — vision capability guard.
|
|
@@ -455,6 +1172,8 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
455
1172
|
if (!modelInfo?.vision) {
|
|
456
1173
|
const visionModel = selector.findVisionCapable(tier);
|
|
457
1174
|
if (visionModel) {
|
|
1175
|
+
const fromTier = tier;
|
|
1176
|
+
const fromModel = selectedModel;
|
|
458
1177
|
logger.info({
|
|
459
1178
|
from: `${provider}:${selectedModel}`,
|
|
460
1179
|
to: `${visionModel.provider}:${visionModel.model}`,
|
|
@@ -464,28 +1183,41 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
464
1183
|
selectedModel = visionModel.model;
|
|
465
1184
|
if (visionModel.tier !== tier) tier = visionModel.tier;
|
|
466
1185
|
method = method + '+vision_guard';
|
|
1186
|
+
escalations.push({
|
|
1187
|
+
source: 'vision_guard',
|
|
1188
|
+
fromTier,
|
|
1189
|
+
toTier: tier,
|
|
1190
|
+
fromModel,
|
|
1191
|
+
toModel: selectedModel,
|
|
1192
|
+
});
|
|
467
1193
|
} else {
|
|
468
1194
|
logger.warn({ model: selectedModel }, '[Routing] Vision guard — no vision-capable model found, request may fail');
|
|
469
1195
|
}
|
|
470
1196
|
}
|
|
471
1197
|
} catch (err) {
|
|
472
|
-
|
|
1198
|
+
degradation.record('vision_guard', err);
|
|
473
1199
|
}
|
|
474
1200
|
}
|
|
475
1201
|
|
|
476
1202
|
// Phase 3.1 — kNN routing hint.
|
|
477
1203
|
// If the index has enough entries, query it with the last user message.
|
|
478
1204
|
// A high-confidence kNN suggestion overrides the heuristic selection.
|
|
1205
|
+
//
|
|
1206
|
+
// WS5.5 — capture the query embedding at decision time so the feedback
|
|
1207
|
+
// path can turn a conclusive outcome into a new kNN exemplar without
|
|
1208
|
+
// paying for a second embedding call. The embedding is only computed
|
|
1209
|
+
// once regardless of whether the kNN query runs (sparse index / no
|
|
1210
|
+
// embedder → we skip the search but still keep the embedding for later
|
|
1211
|
+
// add() from feedback).
|
|
1212
|
+
// queryText + queryEmbedding are already captured at the top of the
|
|
1213
|
+
// function (WS5.5). Reuse them here so we don't re-embed the same text
|
|
1214
|
+
// — router.embed() is cache-backed but the extra call is still wasteful.
|
|
479
1215
|
let knnResult = null;
|
|
480
|
-
if (config.routing?.knnEnabled !== false) {
|
|
1216
|
+
if (config.routing?.knnEnabled !== false && queryEmbedding) {
|
|
481
1217
|
try {
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
: Array.isArray(lastMsg) ? lastMsg.filter(b => b?.type === 'text').map(b => b.text || '').join(' ')
|
|
486
|
-
: null;
|
|
487
|
-
if (queryText) {
|
|
488
|
-
knnResult = await getKnnRouter().query(queryText);
|
|
1218
|
+
const router = getKnnRouter();
|
|
1219
|
+
knnResult = await router.query(queryText);
|
|
1220
|
+
{
|
|
489
1221
|
// Confidence thresholds (env-configurable; defaults 0.7 high / 0.4 low):
|
|
490
1222
|
const KNN_HIGH = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_HIGH) || 0.7;
|
|
491
1223
|
const KNN_LOW = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_LOW) || 0.4;
|
|
@@ -500,39 +1232,80 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
500
1232
|
selectedModel = knnResult.model;
|
|
501
1233
|
method = method + '+knn';
|
|
502
1234
|
} else if (knnResult && knnResult.confidence > KNN_LOW && knnResult.confidence <= KNN_HIGH) {
|
|
503
|
-
// Ambiguous signal — neighbors are split, we can't trust any single
|
|
504
|
-
// recommendation.
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
1235
|
+
// Ambiguous signal — neighbors are split, we can't trust any single
|
|
1236
|
+
// model recommendation. Historically this always escalated one tier
|
|
1237
|
+
// "for safety", but that's a one-way ratchet: on a system where
|
|
1238
|
+
// cheap tiers are NOT failing, the bump is pure over-provisioning.
|
|
1239
|
+
//
|
|
1240
|
+
// Evidence-based leash: escalate only when telemetry shows
|
|
1241
|
+
// underProvisionedPct >= 2% (cheap tiers actually failing lately).
|
|
1242
|
+
// Otherwise keep the current tier. Fail-open to legacy escalation
|
|
1243
|
+
// if telemetry lookup itself fails, so we never regress safety.
|
|
1244
|
+
let shouldEscalate = false;
|
|
1245
|
+
try {
|
|
1246
|
+
const acc = telemetry.getRoutingAccuracy?.();
|
|
1247
|
+
const under = acc?.underProvisionedPct ?? 0;
|
|
1248
|
+
shouldEscalate = under >= 2;
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
degradation.record('knn', err);
|
|
1251
|
+
shouldEscalate = true;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (!shouldEscalate) {
|
|
1255
|
+
method = method + '+knn_ambiguous_kept';
|
|
1256
|
+
logger.debug({
|
|
1257
|
+
tier,
|
|
1258
|
+
provider,
|
|
1259
|
+
model: selectedModel,
|
|
1260
|
+
confidence: knnResult.confidence.toFixed(3),
|
|
1261
|
+
}, '[Routing] kNN ambiguous — leash held, keeping tier');
|
|
1262
|
+
} else {
|
|
1263
|
+
const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
1264
|
+
const currentIdx = TIER_ORDER.indexOf(tier);
|
|
1265
|
+
if (currentIdx >= 0 && currentIdx < TIER_ORDER.length - 1) {
|
|
1266
|
+
const upgradedTier = TIER_ORDER[currentIdx + 1];
|
|
1267
|
+
try {
|
|
1268
|
+
const upgraded = selector.selectModel(upgradedTier, null);
|
|
1269
|
+
const fromTier = tier;
|
|
1270
|
+
const fromModel = selectedModel;
|
|
1271
|
+
logger.debug({
|
|
1272
|
+
from: `${tier}:${provider}:${selectedModel}`,
|
|
1273
|
+
to: `${upgradedTier}:${upgraded.provider}:${upgraded.model}`,
|
|
1274
|
+
confidence: knnResult.confidence.toFixed(3),
|
|
1275
|
+
}, '[Routing] kNN ambiguous — escalating tier for safety');
|
|
1276
|
+
provider = upgraded.provider;
|
|
1277
|
+
selectedModel = upgraded.model;
|
|
1278
|
+
tier = upgradedTier;
|
|
1279
|
+
method = method + '+knn_ambiguous_escalate';
|
|
1280
|
+
escalations.push({
|
|
1281
|
+
source: 'knn_ambiguous',
|
|
1282
|
+
fromTier,
|
|
1283
|
+
toTier: tier,
|
|
1284
|
+
fromModel,
|
|
1285
|
+
toModel: selectedModel,
|
|
1286
|
+
});
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
degradation.record('knn', err);
|
|
1289
|
+
}
|
|
524
1290
|
}
|
|
525
1291
|
}
|
|
526
1292
|
}
|
|
527
1293
|
}
|
|
528
1294
|
} catch (err) {
|
|
529
|
-
|
|
1295
|
+
degradation.record('knn', err);
|
|
530
1296
|
}
|
|
531
1297
|
}
|
|
532
1298
|
|
|
533
1299
|
// Phase 4.1 — LinUCB bandit intra-tier selection.
|
|
534
1300
|
// When there are two candidates (heuristic vs kNN), the bandit picks the
|
|
535
1301
|
// one with the highest estimated UCB score for the current context.
|
|
1302
|
+
//
|
|
1303
|
+
// WS4.2 — capture propensity + candidates + context so the outcome row can
|
|
1304
|
+
// support off-policy evaluation. banditContext is stashed on the decision
|
|
1305
|
+
// (underscored → won't leak through headers, see WS4.2 verification).
|
|
1306
|
+
let banditPropensity = null;
|
|
1307
|
+
let banditCandidates = null;
|
|
1308
|
+
let banditContext = null;
|
|
536
1309
|
if (config.routing?.banditEnabled !== false && knnResult && knnResult.model) {
|
|
537
1310
|
try {
|
|
538
1311
|
// Build candidates: current selection and kNN alternative if different.
|
|
@@ -572,20 +1345,26 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
572
1345
|
...TASK_TYPES.map((_, i) => i === taskIdx ? 1 : 0),
|
|
573
1346
|
];
|
|
574
1347
|
const picked = bandit.pick(tier, allCandidates, ctx);
|
|
575
|
-
if (picked
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
1348
|
+
if (picked) {
|
|
1349
|
+
banditCandidates = allCandidates;
|
|
1350
|
+
banditPropensity = picked.propensity ?? null;
|
|
1351
|
+
banditContext = ctx;
|
|
1352
|
+
if (picked.model !== selectedModel) {
|
|
1353
|
+
logger.debug({
|
|
1354
|
+
from: `${provider}:${selectedModel}`,
|
|
1355
|
+
to: `${picked.provider}:${picked.model}`,
|
|
1356
|
+
ucb: picked.ucb?.toFixed(4),
|
|
1357
|
+
explored: picked.explored,
|
|
1358
|
+
propensity: picked.propensity,
|
|
1359
|
+
}, '[Routing] Bandit override');
|
|
1360
|
+
provider = picked.provider;
|
|
1361
|
+
selectedModel = picked.model;
|
|
1362
|
+
method = method + (picked.explored ? '+bandit_explore' : '+bandit');
|
|
1363
|
+
}
|
|
585
1364
|
}
|
|
586
1365
|
}
|
|
587
1366
|
} catch (err) {
|
|
588
|
-
|
|
1367
|
+
degradation.record('bandit', err);
|
|
589
1368
|
}
|
|
590
1369
|
}
|
|
591
1370
|
|
|
@@ -607,7 +1386,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
607
1386
|
method = method + '+deadline';
|
|
608
1387
|
}
|
|
609
1388
|
} catch (err) {
|
|
610
|
-
|
|
1389
|
+
degradation.record('deadline', err);
|
|
611
1390
|
}
|
|
612
1391
|
}
|
|
613
1392
|
|
|
@@ -630,7 +1409,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
630
1409
|
method = overridden.method;
|
|
631
1410
|
}
|
|
632
1411
|
} catch (err) {
|
|
633
|
-
|
|
1412
|
+
degradation.record('tenant', err);
|
|
634
1413
|
}
|
|
635
1414
|
}
|
|
636
1415
|
|
|
@@ -649,8 +1428,43 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
649
1428
|
costOptimized,
|
|
650
1429
|
risk,
|
|
651
1430
|
knnResult,
|
|
1431
|
+
base_tier: baseTier,
|
|
1432
|
+
escalations,
|
|
1433
|
+
// Upward escalations take precedence in the source label; a demotion is
|
|
1434
|
+
// only surfaced when nothing else escalated (guarded by the wire above,
|
|
1435
|
+
// but re-checked here for clarity).
|
|
1436
|
+
escalation_source: escalations[0]?.source
|
|
1437
|
+
?? (demotedFrom ? 'deescalation' : null),
|
|
1438
|
+
demoted_from: demotedFrom,
|
|
652
1439
|
};
|
|
653
1440
|
|
|
1441
|
+
// WS4.2 — propensity/candidates for off-policy evaluation from telemetry.
|
|
1442
|
+
// Bandit picks populate both. If a deterministic downstream override
|
|
1443
|
+
// (deadline / tenant) then swapped the served model out of the bandit's
|
|
1444
|
+
// candidate set, the bandit's propensity no longer describes the served
|
|
1445
|
+
// choice — collapse to propensity=1.0 with a single candidate. Otherwise
|
|
1446
|
+
// deterministic branches (bandit didn't run at all) always collapse.
|
|
1447
|
+
// _banditContext is underscored so it never leaks to response headers;
|
|
1448
|
+
// WS5 will consume it in the feedback path to call bandit.update().
|
|
1449
|
+
const banditPickedServed = banditCandidates
|
|
1450
|
+
&& banditCandidates.some(c => c.provider === provider && c.model === selectedModel);
|
|
1451
|
+
if (banditPickedServed) {
|
|
1452
|
+
decision.propensity = banditPropensity ?? 1.0;
|
|
1453
|
+
decision.candidates = banditCandidates;
|
|
1454
|
+
decision._banditContext = banditContext;
|
|
1455
|
+
} else {
|
|
1456
|
+
decision.propensity = 1.0;
|
|
1457
|
+
decision.candidates = [{ provider, model: selectedModel }];
|
|
1458
|
+
decision._banditContext = null;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// WS5.5 — attach the query embedding + raw query text so the feedback
|
|
1462
|
+
// path can turn conclusive outcomes into new kNN exemplars without
|
|
1463
|
+
// re-embedding. Underscored so it doesn't leak into headers or JSON
|
|
1464
|
+
// serialisation of the decision.
|
|
1465
|
+
decision._queryEmbedding = queryEmbedding;
|
|
1466
|
+
decision._queryText = queryText;
|
|
1467
|
+
|
|
654
1468
|
// Phase 4.4 — shadow-mode policy comparison (fire-and-forget).
|
|
655
1469
|
const shadowFn = getShadowPolicy();
|
|
656
1470
|
if (shadowFn) {
|
|
@@ -744,13 +1558,37 @@ function getRoutingHeaders(decision) {
|
|
|
744
1558
|
* Phase 3: Metrics access
|
|
745
1559
|
*/
|
|
746
1560
|
function getRoutingStats() {
|
|
747
|
-
|
|
1561
|
+
const base = routingMetrics.getStats() || {};
|
|
1562
|
+
let escalations = null;
|
|
1563
|
+
try {
|
|
1564
|
+
escalations = telemetry.getEscalationStats?.() ?? null;
|
|
1565
|
+
} catch (err) {
|
|
1566
|
+
degradation.record('tenant', err); // reuse a bucket; not worth a new one
|
|
1567
|
+
}
|
|
1568
|
+
return {
|
|
1569
|
+
...base,
|
|
1570
|
+
degradation: degradation.getCounts(),
|
|
1571
|
+
escalations,
|
|
1572
|
+
};
|
|
748
1573
|
}
|
|
749
1574
|
|
|
750
1575
|
module.exports = {
|
|
751
1576
|
// Main routing function
|
|
752
1577
|
determineProviderSmart,
|
|
753
1578
|
|
|
1579
|
+
// WS1: sticky-session pin helpers (shared with OAuth intent path)
|
|
1580
|
+
checkSessionPin,
|
|
1581
|
+
writeSessionPin,
|
|
1582
|
+
// WS1.5: upward-drift detection for pinned sessions
|
|
1583
|
+
checkPinScoreDrift,
|
|
1584
|
+
|
|
1585
|
+
// WS1: test-only internals (exercised by test/sticky-routing.test.js)
|
|
1586
|
+
_internals: {
|
|
1587
|
+
economicDowngradeAllowed: _economicDowngradeAllowed,
|
|
1588
|
+
runPinGuards: _runPinGuards,
|
|
1589
|
+
tierPriority: _tierPriority,
|
|
1590
|
+
},
|
|
1591
|
+
|
|
754
1592
|
// Helpers
|
|
755
1593
|
isFallbackEnabled,
|
|
756
1594
|
getFallbackProvider,
|
|
@@ -785,4 +1623,7 @@ module.exports = {
|
|
|
785
1623
|
telemetry,
|
|
786
1624
|
scoreResponseQuality,
|
|
787
1625
|
getLatencyTracker,
|
|
1626
|
+
|
|
1627
|
+
// WS2.3 — de-escalation
|
|
1628
|
+
deescalator,
|
|
788
1629
|
};
|