lynkr 9.9.1 → 9.11.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 (93) hide show
  1. package/README.md +50 -9
  2. package/bin/cli.js +2 -0
  3. package/bin/lynkr-init.js +11 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +3 -3
  7. package/public/dashboard.html +29 -0
  8. package/scripts/validate-difficulty-classifier.js +27 -6
  9. package/src/api/providers-handler.js +0 -1
  10. package/src/api/router.js +309 -160
  11. package/src/clients/databricks.js +215 -71
  12. package/src/clients/gpt-utils.js +8 -1
  13. package/src/clients/ollama-utils.js +19 -10
  14. package/src/clients/prompt-cache-injection.js +93 -30
  15. package/src/config/index.js +82 -43
  16. package/src/context/compression.js +27 -8
  17. package/src/context/distill.js +2 -1
  18. package/src/dashboard/api.js +5 -1
  19. package/src/memory/distiller.js +350 -0
  20. package/src/memory/extractor.js +0 -8
  21. package/src/memory/format.js +17 -2
  22. package/src/memory/index.js +10 -0
  23. package/src/memory/retriever.js +7 -2
  24. package/src/memory/skills-cache.js +180 -0
  25. package/src/memory/tencentdb-launcher.js +459 -0
  26. package/src/memory/wiki.js +171 -0
  27. package/src/orchestrator/index.js +202 -1237
  28. package/src/orchestrator/passthrough-stream.js +382 -0
  29. package/src/orchestrator/sse-transformer.js +408 -0
  30. package/src/prompts/system.js +20 -6
  31. package/src/routing/affinity-store.js +85 -4
  32. package/src/routing/agentic-detector.js +9 -1
  33. package/src/routing/cache-economics.js +123 -0
  34. package/src/routing/cache-switch-cost.js +194 -0
  35. package/src/routing/calibration.js +8 -1
  36. package/src/routing/complexity-analyzer.js +7 -1
  37. package/src/routing/context-compressor.js +170 -0
  38. package/src/routing/difficulty-classifier.js +161 -17
  39. package/src/routing/index.js +196 -56
  40. package/src/routing/intent-score.js +20 -2
  41. package/src/routing/model-registry.js +35 -10
  42. package/src/routing/session-affinity.js +68 -1
  43. package/src/routing/side-channel-detector.js +103 -0
  44. package/src/routing/telemetry.js +139 -2
  45. package/src/routing/verifier.js +41 -2
  46. package/src/server.js +36 -49
  47. package/src/tools/code-graph.js +6 -4
  48. package/src/agents/context-manager.js +0 -236
  49. package/src/agents/decomposition/dispatcher.js +0 -185
  50. package/src/agents/decomposition/gate.js +0 -136
  51. package/src/agents/decomposition/index.js +0 -183
  52. package/src/agents/decomposition/model-call.js +0 -75
  53. package/src/agents/decomposition/planner.js +0 -223
  54. package/src/agents/decomposition/synthesizer.js +0 -89
  55. package/src/agents/decomposition/telemetry.js +0 -55
  56. package/src/agents/definitions/loader.js +0 -653
  57. package/src/agents/executor.js +0 -457
  58. package/src/agents/index.js +0 -165
  59. package/src/agents/parallel-coordinator.js +0 -68
  60. package/src/agents/reflector.js +0 -331
  61. package/src/agents/skillbook.js +0 -331
  62. package/src/agents/store.js +0 -259
  63. package/src/edits/index.js +0 -171
  64. package/src/indexer/babel-parser.js +0 -213
  65. package/src/indexer/index.js +0 -1629
  66. package/src/indexer/navigation/index.js +0 -32
  67. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  68. package/src/indexer/parser.js +0 -443
  69. package/src/tasks/store.js +0 -349
  70. package/src/tests/coverage.js +0 -173
  71. package/src/tests/index.js +0 -171
  72. package/src/tests/store.js +0 -213
  73. package/src/tools/agent-task.js +0 -145
  74. package/src/tools/code-mode.js +0 -304
  75. package/src/tools/decompose.js +0 -91
  76. package/src/tools/edits.js +0 -94
  77. package/src/tools/execution.js +0 -171
  78. package/src/tools/git.js +0 -1346
  79. package/src/tools/index.js +0 -306
  80. package/src/tools/indexer.js +0 -360
  81. package/src/tools/lazy-loader.js +0 -366
  82. package/src/tools/mcp-remote.js +0 -88
  83. package/src/tools/mcp.js +0 -116
  84. package/src/tools/process.js +0 -167
  85. package/src/tools/smart-selection.js +0 -180
  86. package/src/tools/stubs.js +0 -55
  87. package/src/tools/tasks.js +0 -260
  88. package/src/tools/tests.js +0 -132
  89. package/src/tools/tinyfish.js +0 -358
  90. package/src/tools/truncate.js +0 -106
  91. package/src/tools/web-client.js +0 -71
  92. package/src/tools/web.js +0 -415
  93. package/src/tools/workspace.js +0 -204
@@ -156,6 +156,7 @@ function getBestLocalProvider() {
156
156
  * @returns {Object} Routing decision with provider and metadata
157
157
  */
158
158
  const sessionAffinity = require('./session-affinity');
159
+ const { evaluateSwitch: evaluateCacheSwitch } = require('./cache-switch-cost');
159
160
 
160
161
  // ---------------------------------------------------------------------------
161
162
  // WS1 — sticky sessions
@@ -258,30 +259,122 @@ function _economicDowngradeAllowed(promptTokensEst, pinModel, freshModel) {
258
259
  }
259
260
  }
260
261
 
261
- function _pinToDecision(pin, { reason, risk }) {
262
+ /**
263
+ * Canonical routing-decision constructor. Every code path that returns a
264
+ * routing decision MUST build it here — the previous ten hand-assembled
265
+ * object literals had drifted (e.g. the non-tier agentic branch omitted
266
+ * `model`; only session_pin carried the full telemetry field set), and the
267
+ * reward pipeline requires `propensity` present on every decision it logs.
268
+ * Defaults first, caller's fields override; unknown extras (e.g.
269
+ * `_queryEmbedding`) pass through untouched.
270
+ */
271
+ function buildDecision(fields = {}) {
272
+ if (!fields.provider || !fields.method) {
273
+ // Fail-open (routing must never throw on shape problems) but loud.
274
+ logger.error({ keys: Object.keys(fields) }, '[Routing] buildDecision called without provider/method');
275
+ }
276
+ // WS2.3 repair: the telemetry record sites read `analysis.requestType`,
277
+ // but nothing ever set that field — every routing_telemetry row recorded
278
+ // request_type NULL (verified live: 4052/4052 rows), so the evidence-based
279
+ // deescalator (keyed on tier + request_type) could never accumulate
280
+ // demotion evidence. Derive it here at the canonical constructor, using
281
+ // the SAME derivation the deescalator applies, so telemetry rows and
282
+ // demotion queries group by identical values.
283
+ let analysis = fields.analysis ?? null;
284
+ if (analysis && typeof analysis === 'object' && analysis.requestType == null) {
285
+ const requestType = analysis.breakdown?.taskType?.reason ?? analysis.taskType ?? null;
286
+ if (requestType != null) analysis = { ...analysis, requestType };
287
+ }
262
288
  return {
263
- provider: pin.provider,
264
- model: pin.model,
265
- tier: pin.tier,
266
- method: 'session_pin',
267
- reason,
289
+ model: null,
290
+ tier: null,
291
+ reason: null,
268
292
  score: null,
269
- analysis: null,
293
+ // `analysis` is appended after the ...fields spread (requestType
294
+ // derivation above); it defaults to null there.
270
295
  embeddingsResult: null,
271
296
  agenticResult: null,
272
- costOptimized: false,
273
- risk: risk ?? null,
274
297
  knnResult: null,
298
+ costOptimized: false,
299
+ risk: null,
275
300
  base_tier: null,
276
301
  escalations: [],
277
302
  escalation_source: null,
278
- pinned: true,
303
+ pinned: false,
279
304
  switch_reason: null,
280
305
  propensity: 1.0,
281
- candidates: [{ provider: pin.provider, model: pin.model }],
306
+ candidates: [{ provider: fields.provider, model: fields.model ?? null }],
307
+ ...fields,
308
+ analysis,
282
309
  };
283
310
  }
284
311
 
312
+ /**
313
+ * Cache-aware switch gate (Phases 2+3).
314
+ *
315
+ * Prices a DOWNWARD (cost-motivated) model change against the session's warm
316
+ * prompt-cache prefix: inside the provider's cache TTL, the pin holds unless
317
+ * the break-even math clears within the session's expected remaining turns.
318
+ * A cold prefix (TTL elapsed — Phase 2) or absent cache state makes the
319
+ * switch cache-free and it passes.
320
+ *
321
+ * Upward/lateral moves (guard escalations, risk, drift to a higher tier)
322
+ * MUST NOT route through here — correctness beats cost, and the math can
323
+ * never favor a pricier model anyway.
324
+ *
325
+ * @returns {object|null} evaluation result, or null when gating doesn't
326
+ * apply (same model, missing data, or evaluator failure — fail-open).
327
+ */
328
+ function _cacheAwareSwitchGate(sessionId, pin, fresh, payload) {
329
+ if (!sessionId || !pin?.model || !fresh?.model) return null;
330
+ if (pin.provider === fresh.provider && pin.model === fresh.model) return null;
331
+ try {
332
+ const cacheState = sessionAffinity.getCacheState(sessionId);
333
+ const currentTurns = Array.isArray(payload?.messages) ? payload.messages.length : 0;
334
+ let expectedRemainingTurns = null;
335
+ try {
336
+ expectedRemainingTurns = telemetry.getExpectedRemainingTurns(currentTurns);
337
+ } catch { /* sparse-data default applies */ }
338
+ return evaluateCacheSwitch({
339
+ cacheState,
340
+ current: { provider: pin.provider, model: pin.model },
341
+ target: { provider: fresh.provider, model: fresh.model },
342
+ expectedRemainingTurns,
343
+ });
344
+ } catch (err) {
345
+ degradation.record('cache_switch_cost', err);
346
+ return null; // fail-open: gate must never block routing on an internal error
347
+ }
348
+ }
349
+
350
+ /** Shape the gate result into the telemetry receipt (Phase 6). */
351
+ function _cacheDecisionReceipt(evaluation, decision) {
352
+ if (!evaluation) return null;
353
+ return {
354
+ decision,
355
+ reason: evaluation.reason,
356
+ warmPrefixTokens: evaluation.warmPrefixTokens,
357
+ breakEvenTurns: Number.isFinite(evaluation.breakEvenTurns)
358
+ ? Number(evaluation.breakEvenTurns.toFixed(2))
359
+ : evaluation.breakEvenTurns === Infinity ? -1 : null,
360
+ projectedSwitchCostUsd: evaluation.switchOnceUsd,
361
+ projectedStaySavingsUsd: evaluation.projectedStaySavingsUsd,
362
+ expectedRemainingTurns: evaluation.expectedRemainingTurns,
363
+ };
364
+ }
365
+
366
+ function _pinToDecision(pin, { reason, risk }) {
367
+ return buildDecision({
368
+ provider: pin.provider,
369
+ model: pin.model,
370
+ tier: pin.tier,
371
+ method: 'session_pin',
372
+ reason,
373
+ risk: risk ?? null,
374
+ pinned: true,
375
+ });
376
+ }
377
+
285
378
  /**
286
379
  * Provider routing with cache-aware session pinning.
287
380
  *
@@ -325,7 +418,28 @@ async function determineProviderSmart(payload, options = {}) {
325
418
  fresh.switch_reason = 'score_drift';
326
419
  if (_tierPriority(fresh.tier) >= _tierPriority(pinCheck.pin.tier)) {
327
420
  writeSessionPin(pinCheck.sessionId, fresh, payload);
421
+ return fresh;
422
+ }
423
+ // Fresh decision came back BELOW the pin on a drift re-decide —
424
+ // that's a cost-motivated downgrade (deescalator/bandit inside the
425
+ // fresh decision), so it must clear the cache break-even gate
426
+ // (Phases 2+3) before abandoning the warm prefix.
427
+ const gate = _cacheAwareSwitchGate(pinCheck.sessionId, pinCheck.pin, fresh, payload);
428
+ if (gate && !gate.switchAllowed) {
429
+ logger.info({
430
+ sessionId: pinCheck.sessionId,
431
+ pinModel: pinCheck.pin.model,
432
+ freshModel: fresh.model,
433
+ reason: gate.reason,
434
+ breakEvenTurns: gate.breakEvenTurns,
435
+ warmPrefixTokens: gate.warmPrefixTokens,
436
+ }, '[Routing] Cache break-even holds pin — downgrade suppressed');
437
+ const served = _pinToDecision(pinCheck.pin, { reason: 'cache_hold', risk: null });
438
+ served._cacheDecision = _cacheDecisionReceipt(gate, 'hold');
439
+ writeSessionPin(pinCheck.sessionId, pinCheck.pin, payload);
440
+ return served;
328
441
  }
442
+ if (gate) fresh._cacheDecision = _cacheDecisionReceipt(gate, 'switch');
329
443
  return fresh;
330
444
  }
331
445
  }
@@ -352,6 +466,18 @@ async function determineProviderSmart(payload, options = {}) {
352
466
  // fires on the compaction path — guard escalations are mandatory.
353
467
  if (pinCheck.reason === 'compaction') {
354
468
  fresh.switch_reason = 'compaction';
469
+ // Compaction reset the provider-side prefix upstream (Phase 2: the
470
+ // cache is cold by construction), so the break-even gate doesn't apply;
471
+ // record the receipt so the dashboard can attribute the free switch.
472
+ fresh._cacheDecision = {
473
+ decision: 'switch',
474
+ reason: 'compaction_cache_reset',
475
+ warmPrefixTokens: 0,
476
+ breakEvenTurns: 0,
477
+ projectedSwitchCostUsd: null,
478
+ projectedStaySavingsUsd: null,
479
+ expectedRemainingTurns: null,
480
+ };
355
481
  const promptTokensEst = _tryCountTokens(payload, pin.model || fresh.model);
356
482
  if (!_economicDowngradeAllowed(promptTokensEst, pin.model, fresh.model)) {
357
483
  logger.debug({
@@ -584,6 +710,8 @@ function checkSessionPin(payload, options = {}) {
584
710
  // the new_conversation guard for everyone after it.
585
711
  messageCount: Math.max(messageCount, pin.messageCount ?? 0),
586
712
  promptTokensEst: pin.promptTokensEst,
713
+ // We're inside payloadHasToolHistory(payload) — session has committed.
714
+ hasToolHistory: true,
587
715
  });
588
716
  }
589
717
  return { serve: true, pin, reason: 'tool_history', sessionId };
@@ -599,11 +727,30 @@ function checkSessionPin(payload, options = {}) {
599
727
  sessionAffinity.setPin(sessionId, pin, {
600
728
  messageCount: Math.max(messageCount, pin.messageCount ?? 0),
601
729
  promptTokensEst: guards.promptTokensEst ?? pin.promptTokensEst,
730
+ // setPin OR-merges with the existing pin's flag, so a plain-text turn
731
+ // between tool exchanges keeps the sticky-true property.
732
+ hasToolHistory: _payloadHasAnyToolBlocks(payload),
602
733
  });
603
734
  }
604
735
  return { serve: true, pin, reason: 'guards_passed', sessionId };
605
736
  }
606
737
 
738
+ /** Whole-payload scan for tool_use/tool_result blocks. Unlike
739
+ * sessionAffinity.payloadHasToolHistory (last message only), this checks
740
+ * every message so we correctly flag sessions where the last frame is a
741
+ * plain text turn but earlier turns carried tools. */
742
+ function _payloadHasAnyToolBlocks(payload) {
743
+ const msgs = payload?.messages;
744
+ if (!Array.isArray(msgs)) return false;
745
+ for (const m of msgs) {
746
+ if (!Array.isArray(m?.content)) continue;
747
+ for (const b of m.content) {
748
+ if (b?.type === 'tool_use' || b?.type === 'tool_result') return true;
749
+ }
750
+ }
751
+ return false;
752
+ }
753
+
607
754
  /**
608
755
  * Write-through helper: persist a fresh routing decision as the session's
609
756
  * new pin. No-op when sessionId is missing or the decision has no provider.
@@ -642,7 +789,11 @@ function writeSessionPin(sessionId, decision, payload) {
642
789
  return;
643
790
  }
644
791
  const promptTokensEst = _tryCountTokens(payload, decision.model);
645
- sessionAffinity.setPin(sessionId, decision, { messageCount, promptTokensEst });
792
+ sessionAffinity.setPin(sessionId, decision, {
793
+ messageCount,
794
+ promptTokensEst,
795
+ hasToolHistory: _payloadHasAnyToolBlocks(payload),
796
+ });
646
797
  }
647
798
 
648
799
  async function _determineProviderSmartInner(payload, options = {}) {
@@ -688,17 +839,14 @@ async function _determineProviderSmartInner(payload, options = {}) {
688
839
 
689
840
  // If tier routing is disabled, use static configuration
690
841
  if (!config.modelTiers?.enabled) {
691
- return {
842
+ return buildDecision({
692
843
  provider: primaryProvider,
693
- model: null,
694
844
  method: 'static',
695
845
  reason: 'tier_routing_disabled',
696
846
  risk,
697
- propensity: 1.0,
698
- candidates: [{ provider: primaryProvider, model: null }],
699
847
  _queryEmbedding: queryEmbedding,
700
848
  _queryText: queryText,
701
- };
849
+ });
702
850
  }
703
851
 
704
852
  // High-risk requests jump straight to COMPLEX and skip the rest of
@@ -711,7 +859,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
711
859
  // trusted provider (Claude) via REASONING, not the mid-tier GLM.
712
860
  // Security/auth/middleware changes belong on the governance path.
713
861
  const modelSelection = selector.selectModel('REASONING', null);
714
- const decision = {
862
+ const decision = buildDecision({
715
863
  provider: modelSelection.provider,
716
864
  model: modelSelection.model,
717
865
  tier: 'REASONING',
@@ -719,7 +867,6 @@ async function _determineProviderSmartInner(payload, options = {}) {
719
867
  reason: 'high_risk_forced_tier',
720
868
  score: 100,
721
869
  risk,
722
- base_tier: null,
723
870
  escalations: [{
724
871
  source: 'risk',
725
872
  fromTier: null,
@@ -728,11 +875,9 @@ async function _determineProviderSmartInner(payload, options = {}) {
728
875
  toModel: modelSelection.model,
729
876
  }],
730
877
  escalation_source: 'risk',
731
- propensity: 1.0,
732
- candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
733
878
  _queryEmbedding: queryEmbedding,
734
879
  _queryText: queryText,
735
- };
880
+ });
736
881
  routingMetrics.record(decision);
737
882
  logger.debug({
738
883
  tier: 'REASONING',
@@ -753,7 +898,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
753
898
  try {
754
899
  const selector = getModelTierSelector();
755
900
  const modelSelection = selector.selectModel('SIMPLE', null);
756
- const decision = {
901
+ const decision = buildDecision({
757
902
  provider: modelSelection.provider,
758
903
  model: modelSelection.model,
759
904
  tier: 'SIMPLE',
@@ -761,11 +906,9 @@ async function _determineProviderSmartInner(payload, options = {}) {
761
906
  reason: 'force_local_pattern',
762
907
  score: 0,
763
908
  risk,
764
- propensity: 1.0,
765
- candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
766
909
  _queryEmbedding: queryEmbedding,
767
910
  _queryText: queryText,
768
- };
911
+ });
769
912
  routingMetrics.record(decision);
770
913
  return decision;
771
914
  } catch (err) {
@@ -773,18 +916,15 @@ async function _determineProviderSmartInner(payload, options = {}) {
773
916
  }
774
917
  }
775
918
  const provider = getBestLocalProvider();
776
- const decision = {
919
+ const decision = buildDecision({
777
920
  provider,
778
- model: null,
779
921
  method: 'force',
780
922
  reason: 'force_local_pattern',
781
923
  score: 0,
782
924
  risk,
783
- propensity: 1.0,
784
- candidates: [{ provider, model: null }],
785
925
  _queryEmbedding: queryEmbedding,
786
926
  _queryText: queryText,
787
- };
927
+ });
788
928
  routingMetrics.record(decision);
789
929
  return decision;
790
930
  }
@@ -796,7 +936,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
796
936
  try {
797
937
  const selector = getModelTierSelector();
798
938
  const modelSelection = selector.selectModel('REASONING', null);
799
- const decision = {
939
+ const decision = buildDecision({
800
940
  provider: modelSelection.provider,
801
941
  model: modelSelection.model,
802
942
  tier: 'REASONING',
@@ -804,11 +944,9 @@ async function _determineProviderSmartInner(payload, options = {}) {
804
944
  reason: 'force_reasoning_pattern',
805
945
  score: 100,
806
946
  risk,
807
- propensity: 1.0,
808
- candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
809
947
  _queryEmbedding: queryEmbedding,
810
948
  _queryText: queryText,
811
- };
949
+ });
812
950
  routingMetrics.record(decision);
813
951
  return decision;
814
952
  } catch (err) {
@@ -829,7 +967,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
829
967
  try {
830
968
  const selector = getModelTierSelector();
831
969
  const modelSelection = selector.selectModel('COMPLEX', null);
832
- const decision = {
970
+ const decision = buildDecision({
833
971
  provider: modelSelection.provider,
834
972
  model: modelSelection.model,
835
973
  tier: 'COMPLEX',
@@ -837,11 +975,9 @@ async function _determineProviderSmartInner(payload, options = {}) {
837
975
  reason: 'force_cloud_pattern',
838
976
  score: 100,
839
977
  risk,
840
- propensity: 1.0,
841
- candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
842
978
  _queryEmbedding: queryEmbedding,
843
979
  _queryText: queryText,
844
- };
980
+ });
845
981
  routingMetrics.record(decision);
846
982
  return decision;
847
983
  } catch (err) {
@@ -849,18 +985,15 @@ async function _determineProviderSmartInner(payload, options = {}) {
849
985
  }
850
986
  }
851
987
  const provider = getBestCloudProvider();
852
- const decision = {
988
+ const decision = buildDecision({
853
989
  provider,
854
- model: null,
855
990
  method: 'force',
856
991
  reason: 'force_cloud_pattern',
857
992
  score: 100,
858
993
  risk,
859
- propensity: 1.0,
860
- candidates: [{ provider, model: null }],
861
994
  _queryEmbedding: queryEmbedding,
862
995
  _queryText: queryText,
863
- };
996
+ });
864
997
  routingMetrics.record(decision);
865
998
  return decision;
866
999
  }
@@ -948,7 +1081,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
948
1081
  try {
949
1082
  const selector = getModelTierSelector();
950
1083
  const modelSelection = selector.selectModel('REASONING', null);
951
- const decision = {
1084
+ const decision = buildDecision({
952
1085
  provider: modelSelection.provider,
953
1086
  model: modelSelection.model,
954
1087
  tier: 'REASONING',
@@ -961,11 +1094,9 @@ async function _determineProviderSmartInner(payload, options = {}) {
961
1094
  score: 100,
962
1095
  agenticResult,
963
1096
  risk,
964
- propensity: 1.0,
965
- candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
966
1097
  _queryEmbedding: queryEmbedding,
967
1098
  _queryText: queryText,
968
- };
1099
+ });
969
1100
  routingMetrics.record(decision);
970
1101
  return decision;
971
1102
  } catch (err) {
@@ -973,18 +1104,16 @@ async function _determineProviderSmartInner(payload, options = {}) {
973
1104
  }
974
1105
  }
975
1106
  const provider = getBestCloudProvider();
976
- const decision = {
1107
+ const decision = buildDecision({
977
1108
  provider,
978
1109
  method: 'agentic',
979
1110
  reason: 'autonomous_workflow',
980
1111
  score: 100, // trigger score matches forced tier — see comment above
981
1112
  agenticResult,
982
1113
  risk,
983
- propensity: 1.0,
984
- candidates: [{ provider, model: null }],
985
1114
  _queryEmbedding: queryEmbedding,
986
1115
  _queryText: queryText,
987
- };
1116
+ });
988
1117
  routingMetrics.record(decision);
989
1118
  return decision;
990
1119
  }
@@ -1088,10 +1217,17 @@ async function _determineProviderSmartInner(payload, options = {}) {
1088
1217
 
1089
1218
  // Phase 1.2 — cost-optimizer override.
1090
1219
  // Only kick in when:
1091
- // - feature flag enabled (default true, disable with LYNKR_COST_OPTIMIZE=false)
1220
+ // - feature flag EXPLICITLY enabled with LYNKR_COST_OPTIMIZE=true (default OFF)
1092
1221
  // - risk level is not high (high-risk keeps the explicitly-configured model)
1093
1222
  // - the optimizer finds a meaningfully cheaper qualifying model
1094
- const costOptimizeEnabled = process.env.LYNKR_COST_OPTIMIZE !== 'false'
1223
+ //
1224
+ // Default changed to OFF (2026-07-20): the optimizer silently overrides
1225
+ // explicit TIER_* config with the cheapest model in the tier pool, which
1226
+ // surprised operators who expected their tier settings to be authoritative
1227
+ // (e.g. TIER_REASONING=azure-anthropic:claude-opus-4.7 was being swapped
1228
+ // for openrouter:deepseek/deepseek-reasoner — which was also a typo that
1229
+ // 400'd upstream). Opt-in only.
1230
+ const costOptimizeEnabled = process.env.LYNKR_COST_OPTIMIZE === 'true'
1095
1231
  && config.routing?.costOptimize !== false;
1096
1232
  if (costOptimizeEnabled && risk?.level !== 'high') {
1097
1233
  try {
@@ -1413,7 +1549,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
1413
1549
  }
1414
1550
  }
1415
1551
 
1416
- const decision = {
1552
+ const decision = buildDecision({
1417
1553
  provider,
1418
1554
  model: selectedModel,
1419
1555
  tier,
@@ -1436,7 +1572,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
1436
1572
  escalation_source: escalations[0]?.source
1437
1573
  ?? (demotedFrom ? 'deescalation' : null),
1438
1574
  demoted_from: demotedFrom,
1439
- };
1575
+ });
1440
1576
 
1441
1577
  // WS4.2 — propensity/candidates for off-policy evaluation from telemetry.
1442
1578
  // Bandit picks populate both. If a deterministic downstream override
@@ -1582,6 +1718,10 @@ module.exports = {
1582
1718
  // WS1.5: upward-drift detection for pinned sessions
1583
1719
  checkPinScoreDrift,
1584
1720
 
1721
+ // Canonical decision constructor — every routing decision must be built
1722
+ // through this (see the anti-drift shape test in test/routing.test.js).
1723
+ buildDecision,
1724
+
1585
1725
  // WS1: test-only internals (exercised by test/sticky-routing.test.js)
1586
1726
  _internals: {
1587
1727
  economicDowngradeAllowed: _economicDowngradeAllowed,
@@ -347,9 +347,21 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
347
347
  // Classifier says LOWER tier — trust it. Fixes over-routing.
348
348
  return { score: TIER_MIDPOINT[classifierTier], reconciled: 'down' };
349
349
  }
350
- // Classifier says HIGHER tier — gate on confidence.
350
+ // Classifier says HIGHER tier — gate on confidence, and cap the jump at
351
+ // ONE band above the anchor. The classifier is a tiebreaker, not an
352
+ // oracle: qwen2.5:3b's confidence is degenerate (92% of eval answers say
353
+ // 0.9-1.0, incl. every wrong one), so an unbounded jump let a 3B verdict
354
+ // catapult "Who kills him ?" from anchor 25 to 88 → REASONING →
355
+ // subscription passthrough (live incident 2026-07-21). Escalating past
356
+ // the adjacent band now takes consecutive turns that keep re-scoring
357
+ // higher, which is exactly the persistence a genuinely hard conversation
358
+ // exhibits.
351
359
  if (classifierResult.confidence >= 0.8) {
352
- return { score: TIER_MIDPOINT[classifierTier], reconciled: 'up' };
360
+ const cappedIdx = Math.min(classifierIdx, anchorIdx + 1);
361
+ return {
362
+ score: TIER_MIDPOINT[TIER_ORDER[cappedIdx]],
363
+ reconciled: cappedIdx < classifierIdx ? 'up_capped' : 'up',
364
+ };
353
365
  }
354
366
  return { score: anchorScore, reconciled: 'up_gated' };
355
367
  }
@@ -385,6 +397,12 @@ async function scoreIntent(payload, opts = {}) {
385
397
  classifierResult = await classifyDifficulty(text, {
386
398
  forceMatched: opts.forceMatched,
387
399
  riskLevel: opts.riskLevel,
400
+ // Condensed prior turns, threaded by the router's window loop.
401
+ // Without it a short follow-up ("Who kills him ?") is
402
+ // unclassifiable in isolation.
403
+ context: typeof payload?._conversationContext === 'string'
404
+ ? payload._conversationContext
405
+ : null,
388
406
  });
389
407
  } catch (err) {
390
408
  logger.debug({ err: err.message }, '[IntentScore] classifier failed — anchor only');
@@ -17,16 +17,19 @@ const CACHE_FILE = path.join(__dirname, '../../data/model-prices-cache.json');
17
17
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
18
18
  const REFRESH_RETRY_MS = 5 * 60 * 1000; // backoff between failed refresh attempts
19
19
 
20
- // Databricks fallback pricing (based on Anthropic direct API prices)
20
+ // Databricks fallback pricing (based on Anthropic direct API prices).
21
+ // cacheRead/cacheWrite are absolute USD/1M (0.1x / 1.25x input — Anthropic
22
+ // explicit caching, 5-min TTL); non-Claude rows omit them and inherit the
23
+ // provider-type multipliers in cache-economics.js.
21
24
  const DATABRICKS_FALLBACK = {
22
25
  // Claude models
23
- 'databricks-claude-opus-4-6': { input: 5.0, output: 25.0, context: 1000000 },
24
- 'databricks-claude-opus-4-5': { input: 5.0, output: 25.0, context: 200000 },
25
- 'databricks-claude-opus-4-1': { input: 15.0, output: 75.0, context: 200000 },
26
- 'databricks-claude-sonnet-4-5': { input: 3.0, output: 15.0, context: 200000 },
27
- 'databricks-claude-sonnet-4': { input: 3.0, output: 15.0, context: 200000 },
28
- 'databricks-claude-3-7-sonnet': { input: 3.0, output: 15.0, context: 200000 },
29
- 'databricks-claude-haiku-4-5': { input: 1.0, output: 5.0, context: 200000 },
26
+ 'databricks-claude-opus-4-6': { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite: 6.25, context: 1000000 },
27
+ 'databricks-claude-opus-4-5': { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite: 6.25, context: 200000 },
28
+ 'databricks-claude-opus-4-1': { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 18.75, context: 200000 },
29
+ 'databricks-claude-sonnet-4-5': { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75, context: 200000 },
30
+ 'databricks-claude-sonnet-4': { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75, context: 200000 },
31
+ 'databricks-claude-3-7-sonnet': { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75, context: 200000 },
32
+ 'databricks-claude-haiku-4-5': { input: 1.0, output: 5.0, cacheRead: 0.1, cacheWrite: 1.25, context: 200000 },
30
33
 
31
34
  // Llama models
32
35
  'databricks-llama-4-maverick': { input: 1.0, output: 1.0, context: 128000 },
@@ -208,6 +211,16 @@ class ModelRegistry {
208
211
  prices[modelId.toLowerCase()] = {
209
212
  input: inputCost,
210
213
  output: outputCost,
214
+ // Cache economics (Phase 4) — LiteLLM publishes per-token cache
215
+ // read/write costs for providers that price them. Absent fields
216
+ // stay undefined so cache-economics.js falls back to its
217
+ // provider-type multiplier table.
218
+ ...(info.cache_read_input_token_cost != null
219
+ ? { cacheRead: info.cache_read_input_token_cost * 1_000_000 }
220
+ : {}),
221
+ ...(info.cache_creation_input_token_cost != null
222
+ ? { cacheWrite: info.cache_creation_input_token_cost * 1_000_000 }
223
+ : {}),
211
224
  context: info.max_input_tokens || info.max_tokens || 128000,
212
225
  maxOutput: info.max_output_tokens || 4096,
213
226
  toolCall: info.supports_function_calling ?? true,
@@ -296,9 +309,21 @@ class ModelRegistry {
296
309
  this.modelIndex.set(modelId, info);
297
310
  }
298
311
 
299
- // Add LiteLLM (highest priority)
312
+ // Add LiteLLM (highest priority) — but merge cache economics from the
313
+ // entry it shadows: LiteLLM wins on base prices, while cacheRead/
314
+ // cacheWrite survive from models.dev when LiteLLM doesn't carry them.
315
+ // Without this, a LiteLLM entry missing cache prices hides the
316
+ // models.dev cache data and the switch-cost math degrades to
317
+ // provider-multiplier approximations (observed live: 0 LiteLLM entries
318
+ // with cacheRead shadowing 4.9k models.dev entries that had it).
300
319
  for (const [modelId, info] of Object.entries(this.litellmPrices)) {
301
- this.modelIndex.set(modelId, info);
320
+ const prev = this.modelIndex.get(modelId);
321
+ const merged = { ...info };
322
+ if (prev) {
323
+ if (merged.cacheRead == null && typeof prev.cacheRead === 'number') merged.cacheRead = prev.cacheRead;
324
+ if (merged.cacheWrite == null && typeof prev.cacheWrite === 'number') merged.cacheWrite = prev.cacheWrite;
325
+ }
326
+ this.modelIndex.set(modelId, merged);
302
327
  }
303
328
  }
304
329