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.
Files changed (80) hide show
  1. package/README.md +63 -25
  2. package/bin/cli.js +16 -1
  3. package/bin/lynkr-init.js +44 -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 +23 -2
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/build-eval-set.js +256 -0
  10. package/scripts/calibrate-thresholds.js +38 -157
  11. package/scripts/compact-dictionary.js +204 -0
  12. package/scripts/mine-difficulty-anchors.js +288 -0
  13. package/scripts/test-deduplication.js +448 -0
  14. package/scripts/validate-difficulty-classifier.js +123 -0
  15. package/scripts/validate-intent-anchors.js +186 -0
  16. package/scripts/ws7-anchor-replay.js +108 -0
  17. package/skills/lynkr/SKILL.md +195 -0
  18. package/src/api/middleware/loop-guard.js +87 -0
  19. package/src/api/middleware/request-logging.js +5 -64
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +120 -101
  22. package/src/api/providers-handler.js +27 -2
  23. package/src/api/router.js +467 -125
  24. package/src/budget/index.js +2 -19
  25. package/src/cache/semantic.js +9 -0
  26. package/src/clients/databricks.js +455 -142
  27. package/src/clients/gpt-utils.js +11 -105
  28. package/src/clients/openai-format.js +10 -3
  29. package/src/clients/openrouter-utils.js +49 -24
  30. package/src/clients/prompt-cache-injection.js +1 -0
  31. package/src/clients/provider-capabilities.js +1 -1
  32. package/src/clients/responses-format.js +34 -3
  33. package/src/clients/routing.js +15 -0
  34. package/src/config/index.js +36 -2
  35. package/src/context/gcf.js +275 -0
  36. package/src/context/tool-result-compressor.js +932 -47
  37. package/src/dashboard/api.js +1 -0
  38. package/src/logger/index.js +14 -1
  39. package/src/memory/search.js +12 -40
  40. package/src/memory/tools.js +3 -24
  41. package/src/orchestrator/bypass.js +4 -2
  42. package/src/orchestrator/index.js +120 -85
  43. package/src/routing/affinity-store.js +194 -0
  44. package/src/routing/agentic-detector.js +36 -6
  45. package/src/routing/bandit.js +25 -6
  46. package/src/routing/calibration.js +212 -0
  47. package/src/routing/classifier-setup.js +207 -0
  48. package/src/routing/client-profiles.js +292 -0
  49. package/src/routing/complexity-analyzer.js +88 -15
  50. package/src/routing/deescalator.js +148 -0
  51. package/src/routing/degradation.js +109 -0
  52. package/src/routing/difficulty-classifier.js +219 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +931 -90
  55. package/src/routing/intent-score.js +441 -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 +286 -13
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +86 -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
@@ -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
- * Provider routing with session affinity.
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
- * 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.
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 sessionId = payload?._sessionId || null;
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
- // 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
- };
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
- const decision = await _determineProviderSmartInner(payload, options);
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
- // Remember the chosen provider so later tool-bearing turns stay consistent.
173
- if (sessionId && decision?.provider && !options.forceProvider) {
174
- sessionAffinity.setPinned(sessionId, decision);
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
- return decision;
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
- logger.debug({ err: err.message }, '[Routing] Risk analysis failed, ignoring');
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
- const modelSelection = selector.selectModel('COMPLEX', null);
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: 'COMPLEX',
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: 'COMPLEX',
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
- logger.debug({ err: err.message }, '[Routing] Risk-forced tier selection failed, falling through');
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
- logger.debug({ err: err.message }, 'Tier selection failed for force_local, falling back to local provider');
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
- // Phase 4: Optional embeddings adjustment
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
- logger.debug({ err: err.message }, 'Embeddings analysis failed, using heuristics only');
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
- agenticResult = detector.detect(payload);
310
-
311
- // Boost complexity score for agentic workflows
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
- analysis.score = Math.min(100, analysis.score + agenticResult.scoreBoost);
314
- analysis.agenticBoost = agenticResult.scoreBoost;
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: analysis.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
- logger.debug({ err: err.message }, 'Agentic detection failed');
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
- logger.debug({ from: selector.getTier(analysis.score), to: tier }, '[Routing] Upgrading tier for agentic workflow');
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
- logger.debug({ err: err.message }, 'Tier selection failed');
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
- logger.debug({ err: err.message }, '[Routing] Cost-optimize failed, keeping tier_config selection');
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
- logger.debug({ err: err.message }, '[Routing] Context validation failed, proceeding without check');
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
- logger.debug({ err: err.message }, '[Routing] Vision guard check failed, proceeding');
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 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);
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 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');
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
- logger.debug({ err: err.message }, '[Routing] kNN query failed, ignoring');
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 && 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');
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
- logger.debug({ err: err.message }, '[Routing] Bandit pick failed, ignoring');
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
- logger.debug({ err: err.message }, '[Routing] Deadline check failed, ignoring');
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
- logger.debug({ err: err.message }, '[Routing] Tenant override failed, ignoring');
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
- return routingMetrics.getStats();
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
  };