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.
- package/README.md +46 -11
- package/bin/lynkr-init.js +7 -0
- package/bin/statusline.js +80 -0
- package/bin/wrap.js +10 -6
- package/config/model-tiers.json +5 -5
- package/package.json +9 -4
- package/public/dashboard.html +354 -1
- package/src/api/openai-router.js +120 -2
- package/src/api/router.js +34 -0
- package/src/clients/databricks.js +148 -66
- package/src/clients/gpt-utils.js +8 -1
- package/src/clients/ollama-utils.js +19 -10
- package/src/clients/prompt-cache-injection.js +93 -30
- package/src/config/index.js +79 -0
- package/src/context/compression.js +27 -8
- package/src/context/distill.js +2 -1
- package/src/dashboard/api.js +82 -2
- package/src/dashboard/recommendations/cache-weakspots.js +88 -0
- package/src/dashboard/recommendations/deadweight.js +82 -0
- package/src/dashboard/recommendations/downsize.js +109 -0
- package/src/dashboard/recommendations/index.js +106 -0
- package/src/dashboard/recommendations/side-requests.js +68 -0
- package/src/dashboard/recommendations/wilson.js +24 -0
- package/src/dashboard/router.js +5 -0
- package/src/memory/distiller.js +350 -0
- package/src/memory/extractor.js +0 -8
- package/src/memory/format.js +17 -2
- package/src/memory/index.js +10 -0
- package/src/memory/retriever.js +7 -2
- package/src/memory/skills-cache.js +180 -0
- package/src/memory/tencentdb-launcher.js +459 -0
- package/src/memory/wiki.js +171 -0
- package/src/orchestrator/index.js +85 -2
- package/src/orchestrator/sse-transformer.js +9 -3
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +68 -1
- package/src/routing/agentic-detector.js +9 -1
- package/src/routing/cache-economics.js +123 -0
- package/src/routing/cache-switch-cost.js +194 -0
- package/src/routing/calibration.js +8 -1
- package/src/routing/complexity-analyzer.js +37 -10
- package/src/routing/context-compressor.js +170 -0
- package/src/routing/difficulty-classifier.js +115 -13
- package/src/routing/index.js +161 -53
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +60 -0
- package/src/routing/telemetry.js +343 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -3
- package/src/tools/code-graph.js +6 -4
package/src/routing/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
method: 'session_pin',
|
|
267
|
-
reason,
|
|
289
|
+
model: null,
|
|
290
|
+
tier: null,
|
|
291
|
+
reason: null,
|
|
268
292
|
score: null,
|
|
269
|
-
analysis
|
|
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:
|
|
303
|
+
pinned: false,
|
|
279
304
|
switch_reason: null,
|
|
280
305
|
propensity: 1.0,
|
|
281
|
-
candidates: [{ provider:
|
|
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.
|
|
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,
|