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.
- package/README.md +50 -9
- package/bin/cli.js +2 -0
- package/bin/lynkr-init.js +11 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/public/dashboard.html +29 -0
- package/scripts/validate-difficulty-classifier.js +27 -6
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +309 -160
- package/src/clients/databricks.js +215 -71
- 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 +82 -43
- package/src/context/compression.js +27 -8
- package/src/context/distill.js +2 -1
- package/src/dashboard/api.js +5 -1
- 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 +202 -1237
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +85 -4
- 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 +7 -1
- package/src/routing/context-compressor.js +170 -0
- package/src/routing/difficulty-classifier.js +161 -17
- package/src/routing/index.js +196 -56
- package/src/routing/intent-score.js +20 -2
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +68 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/routing/telemetry.js +139 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -49
- package/src/tools/code-graph.js +6 -4
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- package/src/tools/workspace.js +0 -204
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;
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|