lynkr 9.10.0 → 9.12.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 (50) hide show
  1. package/README.md +46 -11
  2. package/bin/lynkr-init.js +7 -0
  3. package/bin/statusline.js +80 -0
  4. package/bin/wrap.js +10 -6
  5. package/config/model-tiers.json +5 -5
  6. package/package.json +9 -4
  7. package/public/dashboard.html +354 -1
  8. package/src/api/openai-router.js +120 -2
  9. package/src/api/router.js +34 -0
  10. package/src/clients/databricks.js +148 -66
  11. package/src/clients/gpt-utils.js +8 -1
  12. package/src/clients/ollama-utils.js +19 -10
  13. package/src/clients/prompt-cache-injection.js +93 -30
  14. package/src/config/index.js +79 -0
  15. package/src/context/compression.js +27 -8
  16. package/src/context/distill.js +2 -1
  17. package/src/dashboard/api.js +82 -2
  18. package/src/dashboard/recommendations/cache-weakspots.js +88 -0
  19. package/src/dashboard/recommendations/deadweight.js +82 -0
  20. package/src/dashboard/recommendations/downsize.js +109 -0
  21. package/src/dashboard/recommendations/index.js +106 -0
  22. package/src/dashboard/recommendations/side-requests.js +68 -0
  23. package/src/dashboard/recommendations/wilson.js +24 -0
  24. package/src/dashboard/router.js +5 -0
  25. package/src/memory/distiller.js +350 -0
  26. package/src/memory/extractor.js +0 -8
  27. package/src/memory/format.js +17 -2
  28. package/src/memory/index.js +10 -0
  29. package/src/memory/retriever.js +7 -2
  30. package/src/memory/skills-cache.js +180 -0
  31. package/src/memory/tencentdb-launcher.js +459 -0
  32. package/src/memory/wiki.js +171 -0
  33. package/src/orchestrator/index.js +85 -2
  34. package/src/orchestrator/sse-transformer.js +9 -3
  35. package/src/prompts/system.js +20 -6
  36. package/src/routing/affinity-store.js +68 -1
  37. package/src/routing/agentic-detector.js +9 -1
  38. package/src/routing/cache-economics.js +123 -0
  39. package/src/routing/cache-switch-cost.js +194 -0
  40. package/src/routing/calibration.js +8 -1
  41. package/src/routing/complexity-analyzer.js +37 -10
  42. package/src/routing/context-compressor.js +170 -0
  43. package/src/routing/difficulty-classifier.js +115 -13
  44. package/src/routing/index.js +161 -53
  45. package/src/routing/model-registry.js +35 -10
  46. package/src/routing/session-affinity.js +60 -0
  47. package/src/routing/telemetry.js +343 -2
  48. package/src/routing/verifier.js +41 -2
  49. package/src/server.js +36 -3
  50. package/src/tools/code-graph.js +6 -4
@@ -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;
328
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;
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({
@@ -713,17 +839,14 @@ async function _determineProviderSmartInner(payload, options = {}) {
713
839
 
714
840
  // If tier routing is disabled, use static configuration
715
841
  if (!config.modelTiers?.enabled) {
716
- return {
842
+ return buildDecision({
717
843
  provider: primaryProvider,
718
- model: null,
719
844
  method: 'static',
720
845
  reason: 'tier_routing_disabled',
721
846
  risk,
722
- propensity: 1.0,
723
- candidates: [{ provider: primaryProvider, model: null }],
724
847
  _queryEmbedding: queryEmbedding,
725
848
  _queryText: queryText,
726
- };
849
+ });
727
850
  }
728
851
 
729
852
  // High-risk requests jump straight to COMPLEX and skip the rest of
@@ -736,7 +859,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
736
859
  // trusted provider (Claude) via REASONING, not the mid-tier GLM.
737
860
  // Security/auth/middleware changes belong on the governance path.
738
861
  const modelSelection = selector.selectModel('REASONING', null);
739
- const decision = {
862
+ const decision = buildDecision({
740
863
  provider: modelSelection.provider,
741
864
  model: modelSelection.model,
742
865
  tier: 'REASONING',
@@ -744,7 +867,6 @@ async function _determineProviderSmartInner(payload, options = {}) {
744
867
  reason: 'high_risk_forced_tier',
745
868
  score: 100,
746
869
  risk,
747
- base_tier: null,
748
870
  escalations: [{
749
871
  source: 'risk',
750
872
  fromTier: null,
@@ -753,11 +875,9 @@ async function _determineProviderSmartInner(payload, options = {}) {
753
875
  toModel: modelSelection.model,
754
876
  }],
755
877
  escalation_source: 'risk',
756
- propensity: 1.0,
757
- candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
758
878
  _queryEmbedding: queryEmbedding,
759
879
  _queryText: queryText,
760
- };
880
+ });
761
881
  routingMetrics.record(decision);
762
882
  logger.debug({
763
883
  tier: 'REASONING',
@@ -778,7 +898,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
778
898
  try {
779
899
  const selector = getModelTierSelector();
780
900
  const modelSelection = selector.selectModel('SIMPLE', null);
781
- const decision = {
901
+ const decision = buildDecision({
782
902
  provider: modelSelection.provider,
783
903
  model: modelSelection.model,
784
904
  tier: 'SIMPLE',
@@ -786,11 +906,9 @@ async function _determineProviderSmartInner(payload, options = {}) {
786
906
  reason: 'force_local_pattern',
787
907
  score: 0,
788
908
  risk,
789
- propensity: 1.0,
790
- candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
791
909
  _queryEmbedding: queryEmbedding,
792
910
  _queryText: queryText,
793
- };
911
+ });
794
912
  routingMetrics.record(decision);
795
913
  return decision;
796
914
  } catch (err) {
@@ -798,18 +916,15 @@ async function _determineProviderSmartInner(payload, options = {}) {
798
916
  }
799
917
  }
800
918
  const provider = getBestLocalProvider();
801
- const decision = {
919
+ const decision = buildDecision({
802
920
  provider,
803
- model: null,
804
921
  method: 'force',
805
922
  reason: 'force_local_pattern',
806
923
  score: 0,
807
924
  risk,
808
- propensity: 1.0,
809
- candidates: [{ provider, model: null }],
810
925
  _queryEmbedding: queryEmbedding,
811
926
  _queryText: queryText,
812
- };
927
+ });
813
928
  routingMetrics.record(decision);
814
929
  return decision;
815
930
  }
@@ -821,7 +936,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
821
936
  try {
822
937
  const selector = getModelTierSelector();
823
938
  const modelSelection = selector.selectModel('REASONING', null);
824
- const decision = {
939
+ const decision = buildDecision({
825
940
  provider: modelSelection.provider,
826
941
  model: modelSelection.model,
827
942
  tier: 'REASONING',
@@ -829,11 +944,9 @@ async function _determineProviderSmartInner(payload, options = {}) {
829
944
  reason: 'force_reasoning_pattern',
830
945
  score: 100,
831
946
  risk,
832
- propensity: 1.0,
833
- candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
834
947
  _queryEmbedding: queryEmbedding,
835
948
  _queryText: queryText,
836
- };
949
+ });
837
950
  routingMetrics.record(decision);
838
951
  return decision;
839
952
  } catch (err) {
@@ -854,7 +967,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
854
967
  try {
855
968
  const selector = getModelTierSelector();
856
969
  const modelSelection = selector.selectModel('COMPLEX', null);
857
- const decision = {
970
+ const decision = buildDecision({
858
971
  provider: modelSelection.provider,
859
972
  model: modelSelection.model,
860
973
  tier: 'COMPLEX',
@@ -862,11 +975,9 @@ async function _determineProviderSmartInner(payload, options = {}) {
862
975
  reason: 'force_cloud_pattern',
863
976
  score: 100,
864
977
  risk,
865
- propensity: 1.0,
866
- candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
867
978
  _queryEmbedding: queryEmbedding,
868
979
  _queryText: queryText,
869
- };
980
+ });
870
981
  routingMetrics.record(decision);
871
982
  return decision;
872
983
  } catch (err) {
@@ -874,18 +985,15 @@ async function _determineProviderSmartInner(payload, options = {}) {
874
985
  }
875
986
  }
876
987
  const provider = getBestCloudProvider();
877
- const decision = {
988
+ const decision = buildDecision({
878
989
  provider,
879
- model: null,
880
990
  method: 'force',
881
991
  reason: 'force_cloud_pattern',
882
992
  score: 100,
883
993
  risk,
884
- propensity: 1.0,
885
- candidates: [{ provider, model: null }],
886
994
  _queryEmbedding: queryEmbedding,
887
995
  _queryText: queryText,
888
- };
996
+ });
889
997
  routingMetrics.record(decision);
890
998
  return decision;
891
999
  }
@@ -973,7 +1081,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
973
1081
  try {
974
1082
  const selector = getModelTierSelector();
975
1083
  const modelSelection = selector.selectModel('REASONING', null);
976
- const decision = {
1084
+ const decision = buildDecision({
977
1085
  provider: modelSelection.provider,
978
1086
  model: modelSelection.model,
979
1087
  tier: 'REASONING',
@@ -986,11 +1094,9 @@ async function _determineProviderSmartInner(payload, options = {}) {
986
1094
  score: 100,
987
1095
  agenticResult,
988
1096
  risk,
989
- propensity: 1.0,
990
- candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
991
1097
  _queryEmbedding: queryEmbedding,
992
1098
  _queryText: queryText,
993
- };
1099
+ });
994
1100
  routingMetrics.record(decision);
995
1101
  return decision;
996
1102
  } catch (err) {
@@ -998,18 +1104,16 @@ async function _determineProviderSmartInner(payload, options = {}) {
998
1104
  }
999
1105
  }
1000
1106
  const provider = getBestCloudProvider();
1001
- const decision = {
1107
+ const decision = buildDecision({
1002
1108
  provider,
1003
1109
  method: 'agentic',
1004
1110
  reason: 'autonomous_workflow',
1005
1111
  score: 100, // trigger score matches forced tier — see comment above
1006
1112
  agenticResult,
1007
1113
  risk,
1008
- propensity: 1.0,
1009
- candidates: [{ provider, model: null }],
1010
1114
  _queryEmbedding: queryEmbedding,
1011
1115
  _queryText: queryText,
1012
- };
1116
+ });
1013
1117
  routingMetrics.record(decision);
1014
1118
  return decision;
1015
1119
  }
@@ -1445,7 +1549,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
1445
1549
  }
1446
1550
  }
1447
1551
 
1448
- const decision = {
1552
+ const decision = buildDecision({
1449
1553
  provider,
1450
1554
  model: selectedModel,
1451
1555
  tier,
@@ -1468,7 +1572,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
1468
1572
  escalation_source: escalations[0]?.source
1469
1573
  ?? (demotedFrom ? 'deescalation' : null),
1470
1574
  demoted_from: demotedFrom,
1471
- };
1575
+ });
1472
1576
 
1473
1577
  // WS4.2 — propensity/candidates for off-policy evaluation from telemetry.
1474
1578
  // Bandit picks populate both. If a deterministic downstream override
@@ -1614,6 +1718,10 @@ module.exports = {
1614
1718
  // WS1.5: upward-drift detection for pinned sessions
1615
1719
  checkPinScoreDrift,
1616
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
+
1617
1725
  // WS1: test-only internals (exercised by test/sticky-routing.test.js)
1618
1726
  _internals: {
1619
1727
  economicDowngradeAllowed: _economicDowngradeAllowed,
@@ -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
 
@@ -172,6 +172,64 @@ function shouldRepin(pin, payload) {
172
172
  return { repin: false, reason: null };
173
173
  }
174
174
 
175
+ // ---------------------------------------------------------------------------
176
+ // Cache-aware routing (Phase 1) — per-session prompt-cache state.
177
+ //
178
+ // After every upstream response we record how much of the session's prefix
179
+ // is warm on the provider side: cache_read + cache_creation tokens together
180
+ // describe the cached prefix as of that response. Anthropic-style explicit
181
+ // caches refresh their TTL on every read, so `lastRequestAt + ttlMs` is a
182
+ // live cache clock the switch-timing logic (Phase 2) can consult.
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /**
186
+ * Record prompt-cache usage from an upstream response. No-ops when the
187
+ * response carried no cache signal (both counters zero/absent) so providers
188
+ * without cache reporting simply never accrue state.
189
+ *
190
+ * Overwrites unconditionally on signal: if the session switched models, the
191
+ * new response's numbers ARE the new model's cache state (caches are
192
+ * model-scoped — never carry a warm-prefix figure across a switch).
193
+ *
194
+ * @param {string} sessionId
195
+ * @param {{provider:string, model?:string|null, cacheReadTokens?:number, cacheCreationTokens?:number}} usage
196
+ */
197
+ function recordCacheUsage(sessionId, usage) {
198
+ if (!sessionId || !usage?.provider) return;
199
+ const read = Number(usage.cacheReadTokens) || 0;
200
+ const created = Number(usage.cacheCreationTokens) || 0;
201
+ const warm = read + created;
202
+ if (warm <= 0) return; // no cache signal — state stays absent
203
+
204
+ let ttlMs = 5 * 60 * 1000;
205
+ try {
206
+ const { resolveCacheEconomics } = require("./cache-economics");
207
+ ttlMs = resolveCacheEconomics(usage.provider, usage.model ?? null).ttlMs;
208
+ } catch { /* keep default */ }
209
+
210
+ store.saveCacheState(sessionId, {
211
+ warmPrefixTokens: warm,
212
+ provider: usage.provider,
213
+ model: usage.model ?? null,
214
+ lastRequestAt: Date.now(),
215
+ ttlMs,
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Load the session's cache state, annotated with liveness. Returns null when
221
+ * no state exists (provider reports no cache usage, or no response yet).
222
+ *
223
+ * @param {string} sessionId
224
+ * @returns {({warmPrefixTokens:number, provider:string, model:string|null, lastRequestAt:number, ttlMs:number, cold:boolean})|null}
225
+ */
226
+ function getCacheState(sessionId) {
227
+ const state = store.loadCacheState(sessionId);
228
+ if (!state || typeof state.lastRequestAt !== "number") return null;
229
+ const ttl = typeof state.ttlMs === "number" ? state.ttlMs : 5 * 60 * 1000;
230
+ return { ...state, cold: Date.now() - state.lastRequestAt > ttl };
231
+ }
232
+
175
233
  /** Test/maintenance helper — clear the in-memory Map only. */
176
234
  function _clear() {
177
235
  pins.clear();
@@ -223,6 +281,8 @@ module.exports = {
223
281
  setPin,
224
282
  removePin,
225
283
  shouldRepin,
284
+ recordCacheUsage,
285
+ getCacheState,
226
286
  // legacy
227
287
  getPinned,
228
288
  setPinned,