lynkr 9.7.2 → 9.9.0

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