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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-provider prompt-cache economics.
|
|
3
|
+
*
|
|
4
|
+
* Single resolution point for "what does the prompt cache cost on this
|
|
5
|
+
* provider/model": read price, write price, TTL, and mechanism. The
|
|
6
|
+
* switch-cost math in `cache-switch-cost.js` and the cache-state tracker in
|
|
7
|
+
* `session-affinity.js` read from here — decision code never hardcodes
|
|
8
|
+
* per-model numbers.
|
|
9
|
+
*
|
|
10
|
+
* Resolution order per field:
|
|
11
|
+
* 1. model-registry entry (models.dev carries absolute cacheRead/cacheWrite
|
|
12
|
+
* $/1M for many models)
|
|
13
|
+
* 2. provider-type fallback table below (multipliers of the model's input
|
|
14
|
+
* price), researched Aug 2026.
|
|
15
|
+
*
|
|
16
|
+
* Mechanisms:
|
|
17
|
+
* - 'explicit' — cache_control breakpoints, paid writes (Anthropic-style).
|
|
18
|
+
* TTL refreshes on every read, so lastRequestAt + ttlMs is a live clock.
|
|
19
|
+
* - 'automatic' — provider caches transparently, writes are free
|
|
20
|
+
* (OpenAI/DeepSeek/Gemini-style). TTL is best-effort.
|
|
21
|
+
* - 'local' — no dollar cost at all; the "cost" of a cold prefix is
|
|
22
|
+
* prefill latency, handled separately in cache-switch-cost.js.
|
|
23
|
+
*
|
|
24
|
+
* @module routing/cache-economics
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const logger = require('../logger');
|
|
28
|
+
|
|
29
|
+
// Fallback economics keyed by Lynkr provider type. `readMult`/`writeMult`
|
|
30
|
+
// are multipliers of the model's per-1M input price.
|
|
31
|
+
const PROVIDER_CACHE_DEFAULTS = {
|
|
32
|
+
// Anthropic-hosted (explicit cache_control, 1.25x write for the 5-min TTL,
|
|
33
|
+
// TTL refreshed on every read).
|
|
34
|
+
'azure-anthropic': { readMult: 0.1, writeMult: 1.25, ttlMs: 5 * 60 * 1000, mechanism: 'explicit' },
|
|
35
|
+
bedrock: { readMult: 0.1, writeMult: 1.25, ttlMs: 5 * 60 * 1000, mechanism: 'explicit' },
|
|
36
|
+
databricks: { readMult: 0.1, writeMult: 1.25, ttlMs: 5 * 60 * 1000, mechanism: 'explicit' },
|
|
37
|
+
|
|
38
|
+
// OpenAI automatic prefix caching: writes free, ~5-10 min idle eviction.
|
|
39
|
+
openai: { readMult: 0.1, writeMult: 0, ttlMs: 10 * 60 * 1000, mechanism: 'automatic' },
|
|
40
|
+
'azure-openai': { readMult: 0.1, writeMult: 0, ttlMs: 10 * 60 * 1000, mechanism: 'automatic' },
|
|
41
|
+
|
|
42
|
+
// Gemini implicit caching / GLM / Kimi / aggregators: free automatic
|
|
43
|
+
// caching, best-effort TTL. Aggregators (openrouter/edenai) depend on the
|
|
44
|
+
// underlying model; models.dev per-model data wins when present.
|
|
45
|
+
vertex: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
|
|
46
|
+
zai: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
|
|
47
|
+
moonshot: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
|
|
48
|
+
openrouter: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
|
|
49
|
+
edenai: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
|
|
50
|
+
// DeepSeek direct (via aggregators today, kept for model-level matches):
|
|
51
|
+
deepseek: { readMult: 0.02, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
|
|
52
|
+
|
|
53
|
+
// Local runtimes: no dollars; KV cache lives as long as the model stays
|
|
54
|
+
// loaded (ollama keep_alive default ~5 min).
|
|
55
|
+
ollama: { readMult: 0, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'local' },
|
|
56
|
+
llamacpp: { readMult: 0, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'local' },
|
|
57
|
+
lmstudio: { readMult: 0, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'local' },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const GENERIC_DEFAULT = { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fallback cache economics for a provider type (no model lookup).
|
|
64
|
+
* @param {string|null} providerType
|
|
65
|
+
* @returns {{readMult:number, writeMult:number, ttlMs:number, mechanism:string}}
|
|
66
|
+
*/
|
|
67
|
+
function getProviderCacheDefaults(providerType) {
|
|
68
|
+
return PROVIDER_CACHE_DEFAULTS[providerType] || GENERIC_DEFAULT;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve full cache economics for a provider/model pair.
|
|
73
|
+
*
|
|
74
|
+
* @param {string|null} providerType - Lynkr provider type (e.g. 'databricks')
|
|
75
|
+
* @param {string|null} model - model name for registry lookup
|
|
76
|
+
* @returns {{
|
|
77
|
+
* inputPerM:number, outputPerM:number,
|
|
78
|
+
* cacheReadPerM:number, cacheWritePerM:number,
|
|
79
|
+
* ttlMs:number, mechanism:string, unknownPricing:boolean
|
|
80
|
+
* }} All prices are USD per 1M tokens.
|
|
81
|
+
*/
|
|
82
|
+
function resolveCacheEconomics(providerType, model) {
|
|
83
|
+
const defaults = getProviderCacheDefaults(providerType);
|
|
84
|
+
|
|
85
|
+
let cost = null;
|
|
86
|
+
try {
|
|
87
|
+
const { getModelRegistrySync } = require('./model-registry');
|
|
88
|
+
cost = model ? getModelRegistrySync().getCost(model) : null;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
logger.debug({ err: err.message }, '[CacheEconomics] registry lookup failed');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const inputPerM = typeof cost?.input === 'number' ? cost.input : 0;
|
|
94
|
+
const outputPerM = typeof cost?.output === 'number' ? cost.output : 0;
|
|
95
|
+
|
|
96
|
+
// Registry cacheTtlMs/cacheMechanism (Phase 4 entries) win over the
|
|
97
|
+
// provider fallback; models.dev absolute cache prices win over multipliers.
|
|
98
|
+
const mechanism = cost?.cacheMechanism || defaults.mechanism;
|
|
99
|
+
const ttlMs = typeof cost?.cacheTtlMs === 'number' ? cost.cacheTtlMs : defaults.ttlMs;
|
|
100
|
+
|
|
101
|
+
const cacheReadPerM = mechanism === 'local'
|
|
102
|
+
? 0
|
|
103
|
+
: (typeof cost?.cacheRead === 'number' ? cost.cacheRead : inputPerM * defaults.readMult);
|
|
104
|
+
const cacheWritePerM = mechanism === 'local'
|
|
105
|
+
? 0
|
|
106
|
+
: (typeof cost?.cacheWrite === 'number' ? cost.cacheWrite : inputPerM * defaults.writeMult);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
inputPerM,
|
|
110
|
+
outputPerM,
|
|
111
|
+
cacheReadPerM,
|
|
112
|
+
cacheWritePerM,
|
|
113
|
+
ttlMs,
|
|
114
|
+
mechanism,
|
|
115
|
+
unknownPricing: !!cost?.unknown,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
PROVIDER_CACHE_DEFAULTS,
|
|
121
|
+
getProviderCacheDefaults,
|
|
122
|
+
resolveCacheEconomics,
|
|
123
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Switch-cost break-even model (Phase 3, cache-aware routing).
|
|
3
|
+
*
|
|
4
|
+
* Answers one question for a session that holds a warm prompt-cache prefix
|
|
5
|
+
* on its pinned model: does switching to a cheaper target model pay for the
|
|
6
|
+
* cache it breaks, within the turns this session is expected to have left?
|
|
7
|
+
*
|
|
8
|
+
* stayPerTurn = warm*cacheRead(cur) + new*input(cur) + out*output(cur)
|
|
9
|
+
* switchOnce = (warm + new) * cacheWrite(target) // one-time
|
|
10
|
+
* switchPerTurn = warm*cacheRead(tgt) + new*input(tgt) + out*output(tgt)
|
|
11
|
+
* breakEvenTurns = switchOnce / (stayPerTurn - switchPerTurn)
|
|
12
|
+
*
|
|
13
|
+
* Switch iff breakEvenTurns <= expectedRemainingTurns.
|
|
14
|
+
*
|
|
15
|
+
* The output-token term is deliberately included: for de-escalation the
|
|
16
|
+
* dominant per-turn saving is often the output-price spread (e.g. Opus $25/M
|
|
17
|
+
* vs Haiku $5/M out), and omitting it makes the router hold expensive pins
|
|
18
|
+
* long past the point the economics justify.
|
|
19
|
+
*
|
|
20
|
+
* Scope: this module prices COST-MOTIVATED switches (de-escalator, bandit
|
|
21
|
+
* exploration, economic downgrades). Hard triggers — risk keywords, force
|
|
22
|
+
* phrases, context overflow, vision, upward drift — are never gated here;
|
|
23
|
+
* correctness beats cost and the math can never favor a pricier model anyway
|
|
24
|
+
* (savings would be negative).
|
|
25
|
+
*
|
|
26
|
+
* Local models have no dollar cost; the price of a cold prefix is prefill
|
|
27
|
+
* latency, so a warm local prefix above a size threshold holds the pin.
|
|
28
|
+
*
|
|
29
|
+
* All pricing comes from cache-economics.js (registry-first, provider
|
|
30
|
+
* fallback) — no per-model numbers live in this file.
|
|
31
|
+
*
|
|
32
|
+
* @module routing/cache-switch-cost
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const config = require('../config');
|
|
36
|
+
const logger = require('../logger');
|
|
37
|
+
|
|
38
|
+
const PER_TOKEN = 1 / 1_000_000; // registry prices are USD per 1M tokens
|
|
39
|
+
|
|
40
|
+
function _cfg() {
|
|
41
|
+
const c = config.routing?.cacheAware || {};
|
|
42
|
+
return {
|
|
43
|
+
enabled: c.enabled !== false,
|
|
44
|
+
defaultRemainingTurns: c.defaultRemainingTurns ?? 10,
|
|
45
|
+
newTokensPerTurn: c.newTokensPerTurn ?? 2000,
|
|
46
|
+
outputTokensPerTurn: c.outputTokensPerTurn ?? 800,
|
|
47
|
+
localMaxSwitchPrefixTokens: c.localMaxSwitchPrefixTokens ?? 16000,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Evaluate whether a cost-motivated switch away from the session's warm
|
|
53
|
+
* prefix should be allowed.
|
|
54
|
+
*
|
|
55
|
+
* @param {Object} args
|
|
56
|
+
* @param {Object|null} args.cacheState - from sessionAffinity.getCacheState:
|
|
57
|
+
* {warmPrefixTokens, provider, model, lastRequestAt, ttlMs, cold} or null.
|
|
58
|
+
* @param {{provider:string, model:string|null}} args.current - pinned target.
|
|
59
|
+
* @param {{provider:string, model:string|null}} args.target - proposed target.
|
|
60
|
+
* @param {number} [args.newTokensPerTurn] - est. fresh input tokens per turn.
|
|
61
|
+
* @param {number} [args.outputTokensPerTurn] - est. output tokens per turn.
|
|
62
|
+
* @param {number|null} [args.expectedRemainingTurns] - median remaining
|
|
63
|
+
* turns; falls back to the conservative config default when null.
|
|
64
|
+
* @param {Object} [args.deps] - test injection: {resolveCacheEconomics}.
|
|
65
|
+
* @returns {{
|
|
66
|
+
* switchAllowed: boolean,
|
|
67
|
+
* reason: string,
|
|
68
|
+
* breakEvenTurns: number|null,
|
|
69
|
+
* expectedRemainingTurns: number,
|
|
70
|
+
* warmPrefixTokens: number,
|
|
71
|
+
* switchOnceUsd: number|null,
|
|
72
|
+
* stayPerTurnUsd: number|null,
|
|
73
|
+
* switchPerTurnUsd: number|null,
|
|
74
|
+
* projectedStaySavingsUsd: number|null,
|
|
75
|
+
* }}
|
|
76
|
+
*/
|
|
77
|
+
function evaluateSwitch({
|
|
78
|
+
cacheState,
|
|
79
|
+
current,
|
|
80
|
+
target,
|
|
81
|
+
newTokensPerTurn,
|
|
82
|
+
outputTokensPerTurn,
|
|
83
|
+
expectedRemainingTurns,
|
|
84
|
+
deps = {},
|
|
85
|
+
} = {}) {
|
|
86
|
+
const cfg = _cfg();
|
|
87
|
+
const remaining = Number.isFinite(expectedRemainingTurns) && expectedRemainingTurns > 0
|
|
88
|
+
? expectedRemainingTurns
|
|
89
|
+
: cfg.defaultRemainingTurns;
|
|
90
|
+
|
|
91
|
+
const base = {
|
|
92
|
+
switchAllowed: true,
|
|
93
|
+
reason: 'no_cache_state',
|
|
94
|
+
breakEvenTurns: null,
|
|
95
|
+
expectedRemainingTurns: remaining,
|
|
96
|
+
warmPrefixTokens: cacheState?.warmPrefixTokens ?? 0,
|
|
97
|
+
switchOnceUsd: null,
|
|
98
|
+
stayPerTurnUsd: null,
|
|
99
|
+
switchPerTurnUsd: null,
|
|
100
|
+
projectedStaySavingsUsd: null,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (!cfg.enabled) return { ...base, reason: 'feature_disabled' };
|
|
104
|
+
if (!current?.provider || !target?.provider) return base;
|
|
105
|
+
if (current.provider === target.provider && current.model === target.model) {
|
|
106
|
+
return { ...base, reason: 'same_model' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// No tracked state → nothing warm to protect (provider reports no cache
|
|
110
|
+
// usage, or no response seen yet). Switching is cache-free.
|
|
111
|
+
if (!cacheState || !(cacheState.warmPrefixTokens > 0)) return base;
|
|
112
|
+
|
|
113
|
+
// State recorded for a different model than the pin we're defending —
|
|
114
|
+
// stale after a prior switch; treat as no protection.
|
|
115
|
+
if (cacheState.model && current.model && cacheState.model !== current.model) {
|
|
116
|
+
return { ...base, reason: 'stale_cache_state' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Phase 2 — TTL clock. Anthropic-style caches refresh on every read, so
|
|
120
|
+
// lastRequestAt + ttlMs going stale means the prefix is already cold and
|
|
121
|
+
// the switch costs nothing extra.
|
|
122
|
+
if (cacheState.cold) return { ...base, reason: 'cache_cold' };
|
|
123
|
+
|
|
124
|
+
let resolve = deps.resolveCacheEconomics;
|
|
125
|
+
if (typeof resolve !== 'function') {
|
|
126
|
+
({ resolveCacheEconomics: resolve } = require('./cache-economics'));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const econCur = resolve(current.provider, current.model);
|
|
130
|
+
const econTgt = resolve(target.provider, target.model);
|
|
131
|
+
|
|
132
|
+
const W = cacheState.warmPrefixTokens;
|
|
133
|
+
const N = Number.isFinite(newTokensPerTurn) && newTokensPerTurn >= 0
|
|
134
|
+
? newTokensPerTurn : cfg.newTokensPerTurn;
|
|
135
|
+
const O = Number.isFinite(outputTokensPerTurn) && outputTokensPerTurn >= 0
|
|
136
|
+
? outputTokensPerTurn : cfg.outputTokensPerTurn;
|
|
137
|
+
|
|
138
|
+
// Local pin: no dollars in either column — the cost of abandoning the
|
|
139
|
+
// warm prefix is prefill latency on whatever serves the next turn.
|
|
140
|
+
// Hold while the prefix is large; small prefixes re-fill fast enough.
|
|
141
|
+
if (econCur.mechanism === 'local' && econTgt.mechanism === 'local') {
|
|
142
|
+
const allowed = W <= cfg.localMaxSwitchPrefixTokens;
|
|
143
|
+
return {
|
|
144
|
+
...base,
|
|
145
|
+
switchAllowed: allowed,
|
|
146
|
+
reason: allowed ? 'local_prefix_small' : 'local_prefill_hold',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const stayPerTurn = (W * econCur.cacheReadPerM + N * econCur.inputPerM + O * econCur.outputPerM) * PER_TOKEN;
|
|
151
|
+
const switchOnce = (W + N) * econTgt.cacheWritePerM * PER_TOKEN;
|
|
152
|
+
const switchPerTurn = (W * econTgt.cacheReadPerM + N * econTgt.inputPerM + O * econTgt.outputPerM) * PER_TOKEN;
|
|
153
|
+
const savingsPerTurn = stayPerTurn - switchPerTurn;
|
|
154
|
+
|
|
155
|
+
const priced = {
|
|
156
|
+
...base,
|
|
157
|
+
switchOnceUsd: switchOnce,
|
|
158
|
+
stayPerTurnUsd: stayPerTurn,
|
|
159
|
+
switchPerTurnUsd: switchPerTurn,
|
|
160
|
+
projectedStaySavingsUsd: savingsPerTurn,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Switching to a same-or-more-expensive target never pays on economics.
|
|
164
|
+
// (Escalations don't route through this gate, so blocking is safe.)
|
|
165
|
+
if (savingsPerTurn <= 0) {
|
|
166
|
+
return {
|
|
167
|
+
...priced,
|
|
168
|
+
switchAllowed: false,
|
|
169
|
+
reason: 'never_profitable',
|
|
170
|
+
breakEvenTurns: Infinity,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const breakEvenTurns = switchOnce / savingsPerTurn;
|
|
175
|
+
const allowed = breakEvenTurns <= remaining;
|
|
176
|
+
|
|
177
|
+
logger.debug({
|
|
178
|
+
current: `${current.provider}:${current.model}`,
|
|
179
|
+
target: `${target.provider}:${target.model}`,
|
|
180
|
+
warmPrefixTokens: W,
|
|
181
|
+
breakEvenTurns: Number(breakEvenTurns.toFixed(2)),
|
|
182
|
+
expectedRemainingTurns: remaining,
|
|
183
|
+
allowed,
|
|
184
|
+
}, '[CacheSwitchCost] break-even evaluated');
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
...priced,
|
|
188
|
+
switchAllowed: allowed,
|
|
189
|
+
reason: allowed ? 'break_even_cleared' : 'break_even_blocked',
|
|
190
|
+
breakEvenTurns,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { evaluateSwitch };
|
|
@@ -146,7 +146,8 @@ function runCalibration({ days = DEFAULT_DAYS, dryRun = false, dbPath, outputPat
|
|
|
146
146
|
continue;
|
|
147
147
|
}
|
|
148
148
|
const ordered = Array.from(tierBuckets.entries()).sort((a, b) => a[0] - b[0]);
|
|
149
|
-
|
|
149
|
+
const [defaultLower, defaultUpper] = DEFAULT_RANGES[tier];
|
|
150
|
+
let suggestedUpper = defaultUpper;
|
|
150
151
|
const bucketsSummary = [];
|
|
151
152
|
for (const [lo, vals] of ordered) {
|
|
152
153
|
if (vals.length < 5) {
|
|
@@ -155,6 +156,12 @@ function runCalibration({ days = DEFAULT_DAYS, dryRun = false, dbPath, outputPat
|
|
|
155
156
|
}
|
|
156
157
|
const med = _median(vals);
|
|
157
158
|
bucketsSummary.push({ bucket: lo, samples: vals.length, median: med });
|
|
159
|
+
// Buckets outside the tier's own default range come from fallback-served
|
|
160
|
+
// requests (another tier's scores recorded under the serving tier). They
|
|
161
|
+
// say nothing about this tier's real range, and letting them shrink the
|
|
162
|
+
// upper bound below the lower bound collapses the tier to a single point
|
|
163
|
+
// in re-stitching (observed 2026-07-26: MEDIUM → [20,20]).
|
|
164
|
+
if (lo + 4 < defaultLower || lo > defaultUpper) continue;
|
|
158
165
|
if (med < floor && lo + 4 < suggestedUpper) {
|
|
159
166
|
// shrink tier upper bound just below the failing bucket
|
|
160
167
|
suggestedUpper = lo + 4;
|
|
@@ -795,7 +795,13 @@ async function analyzeComplexity(payload, options = {}) {
|
|
|
795
795
|
threshold,
|
|
796
796
|
mode: 'weighted',
|
|
797
797
|
recommendation,
|
|
798
|
-
|
|
798
|
+
// taskType rides alongside the numeric dimensions. The legacy path
|
|
799
|
+
// has always carried breakdown.taskType, and both the deescalator
|
|
800
|
+
// (tier + request_type evidence key) and the telemetry request_type
|
|
801
|
+
// column derive from it — without it here, weighted-mode requests
|
|
802
|
+
// (the default) recorded request_type NULL and could never
|
|
803
|
+
// accumulate demotion evidence.
|
|
804
|
+
breakdown: { ...weighted.dimensions, taskType: taskTypeResult },
|
|
799
805
|
weights: weighted.weights,
|
|
800
806
|
meta: weighted.meta,
|
|
801
807
|
forceReason: taskTypeResult.reason?.startsWith('force_') ? taskTypeResult.reason : null,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Compression Module
|
|
3
|
+
*
|
|
4
|
+
* Inspired by TencentDB Agent Memory's short-term memory compression.
|
|
5
|
+
* Reduces conversation context size before complexity scoring to enable
|
|
6
|
+
* more accurate tier routing.
|
|
7
|
+
*
|
|
8
|
+
* Key insight: Long conversations bloat context → force expensive routing
|
|
9
|
+
* even for simple follow-ups. By compressing tool outputs and previous
|
|
10
|
+
* turns, we keep more requests in SIMPLE/MEDIUM tiers.
|
|
11
|
+
*
|
|
12
|
+
* @module routing/context-compressor
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const logger = require('../logger');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compress conversation messages by:
|
|
19
|
+
* 1. Offloading verbose tool_result content
|
|
20
|
+
* 2. Summarizing repetitive patterns
|
|
21
|
+
* 3. Keeping only essential context for scoring
|
|
22
|
+
*
|
|
23
|
+
* Does NOT modify the actual request sent to LLM — only used for complexity scoring.
|
|
24
|
+
*
|
|
25
|
+
* @param {Array} messages - Conversation messages
|
|
26
|
+
* @returns {{ compressed: Array, stats: Object }} - Compressed messages + stats
|
|
27
|
+
*/
|
|
28
|
+
function compressMessages(messages) {
|
|
29
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
30
|
+
return { compressed: messages, stats: { original: 0, compressed: 0, ratio: 1.0 } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const compressed = [];
|
|
34
|
+
let originalSize = 0;
|
|
35
|
+
let compressedSize = 0;
|
|
36
|
+
let toolResultsOffloaded = 0;
|
|
37
|
+
let messagesKept = 0;
|
|
38
|
+
|
|
39
|
+
// Keep system message always
|
|
40
|
+
const systemMsg = messages.find(m => m.role === 'system');
|
|
41
|
+
if (systemMsg) {
|
|
42
|
+
compressed.push(systemMsg);
|
|
43
|
+
const size = estimateMessageSize(systemMsg);
|
|
44
|
+
originalSize += size;
|
|
45
|
+
compressedSize += size;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Keep last N user/assistant turns (sliding window)
|
|
49
|
+
const WINDOW_SIZE = 5;
|
|
50
|
+
const recentTurns = messages
|
|
51
|
+
.filter(m => m.role === 'user' || m.role === 'assistant')
|
|
52
|
+
.slice(-WINDOW_SIZE);
|
|
53
|
+
|
|
54
|
+
for (const msg of recentTurns) {
|
|
55
|
+
const original = estimateMessageSize(msg);
|
|
56
|
+
originalSize += original;
|
|
57
|
+
|
|
58
|
+
// Compress tool_result blocks (biggest token consumers)
|
|
59
|
+
if (Array.isArray(msg.content)) {
|
|
60
|
+
const compressedContent = msg.content.map(block => {
|
|
61
|
+
if (block?.type === 'tool_result') {
|
|
62
|
+
toolResultsOffloaded++;
|
|
63
|
+
// Keep only metadata, offload actual content
|
|
64
|
+
return {
|
|
65
|
+
type: 'tool_result',
|
|
66
|
+
tool_use_id: block.tool_use_id,
|
|
67
|
+
content: '[offloaded]', // Replaced with placeholder
|
|
68
|
+
_compressed: true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return block;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const compressedMsg = { ...msg, content: compressedContent };
|
|
75
|
+
compressed.push(compressedMsg);
|
|
76
|
+
compressedSize += estimateMessageSize(compressedMsg);
|
|
77
|
+
messagesKept++;
|
|
78
|
+
} else {
|
|
79
|
+
// No compression needed for string content
|
|
80
|
+
compressed.push(msg);
|
|
81
|
+
compressedSize += original;
|
|
82
|
+
messagesKept++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ratio = originalSize > 0 ? compressedSize / originalSize : 1.0;
|
|
87
|
+
|
|
88
|
+
const stats = {
|
|
89
|
+
original: originalSize,
|
|
90
|
+
compressed: compressedSize,
|
|
91
|
+
ratio,
|
|
92
|
+
reduction: Math.round((1 - ratio) * 100),
|
|
93
|
+
toolResultsOffloaded,
|
|
94
|
+
messagesKept,
|
|
95
|
+
messagesDropped: messages.length - messagesKept - (systemMsg ? 1 : 0),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
logger.debug({
|
|
99
|
+
...stats,
|
|
100
|
+
originalMsgs: messages.length,
|
|
101
|
+
compressedMsgs: compressed.length,
|
|
102
|
+
}, '[context-compressor] Compression complete');
|
|
103
|
+
|
|
104
|
+
return { compressed, stats };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Estimate message size in tokens (rough heuristic: chars / 4)
|
|
109
|
+
*/
|
|
110
|
+
function estimateMessageSize(msg) {
|
|
111
|
+
if (!msg) return 0;
|
|
112
|
+
|
|
113
|
+
let size = 0;
|
|
114
|
+
|
|
115
|
+
// Role + metadata overhead
|
|
116
|
+
size += 10;
|
|
117
|
+
|
|
118
|
+
// Content
|
|
119
|
+
if (typeof msg.content === 'string') {
|
|
120
|
+
size += Math.ceil(msg.content.length / 4);
|
|
121
|
+
} else if (Array.isArray(msg.content)) {
|
|
122
|
+
for (const block of msg.content) {
|
|
123
|
+
if (block?.type === 'text' && block.text) {
|
|
124
|
+
size += Math.ceil(block.text.length / 4);
|
|
125
|
+
} else if (block?.type === 'tool_result') {
|
|
126
|
+
// Tool results are verbose (code, logs, errors)
|
|
127
|
+
const content = Array.isArray(block.content)
|
|
128
|
+
? block.content.map(c => c?.text || '').join('')
|
|
129
|
+
: (block.content || '');
|
|
130
|
+
size += Math.ceil(content.length / 4);
|
|
131
|
+
} else if (block?.type === 'tool_use') {
|
|
132
|
+
// Tool use is compact (function name + args)
|
|
133
|
+
size += 50;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return size;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if compression is beneficial for this payload.
|
|
143
|
+
* Only compress if conversation is long enough to matter.
|
|
144
|
+
*/
|
|
145
|
+
function shouldCompress(payload) {
|
|
146
|
+
if (!payload?.messages || !Array.isArray(payload.messages)) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const msgCount = payload.messages.length;
|
|
151
|
+
|
|
152
|
+
// Count total tool_result blocks (not just messages with tool results)
|
|
153
|
+
let toolResultCount = 0;
|
|
154
|
+
for (const msg of payload.messages) {
|
|
155
|
+
if (Array.isArray(msg.content)) {
|
|
156
|
+
toolResultCount += msg.content.filter(c => c?.type === 'tool_result').length;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Compress if:
|
|
161
|
+
// 1. More than 10 messages (long conversation), OR
|
|
162
|
+
// 2. More than 3 tool_result blocks (verbose outputs)
|
|
163
|
+
return msgCount > 10 || toolResultCount > 3;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
compressMessages,
|
|
168
|
+
shouldCompress,
|
|
169
|
+
estimateMessageSize,
|
|
170
|
+
};
|