lynkr 9.14.12 → 9.14.13
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/config/difficulty-anchors.json +12 -2
- package/config/model-capability-seeds.json +4 -0
- package/package.json +3 -3
- package/scripts/validate-intent-anchors.js +2 -2
- package/src/api/router.js +370 -16
- package/src/clients/databricks.js +64 -23
- package/src/clients/openai-format.js +37 -2
- package/src/clients/openrouter-utils.js +20 -0
- package/src/orchestrator/index.js +47 -10
- package/src/prompts/system.js +41 -49
- package/src/routing/affinity-store.js +47 -21
- package/src/routing/cache-switch-cost.js +46 -2
- package/src/routing/complexity-analyzer.js +21 -4
- package/src/routing/index.js +121 -1
- package/src/routing/intent-score.js +48 -13
- package/src/routing/jev-router.js +272 -0
- package/src/routing/model-tiers.js +13 -0
- package/src/routing/passthrough-route.js +286 -0
- package/src/routing/quota-ledger.js +110 -0
- package/src/routing/risk-analyzer.js +17 -1
- package/src/routing/telemetry.js +47 -2
|
@@ -46,6 +46,13 @@ const TIER_DEFINITIONS = {
|
|
|
46
46
|
},
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
// Midpoint of each tier band: the score a decision carries when only the
|
|
50
|
+
// tier is known (reconcile caps, Jev overrides, badges). Single source of
|
|
51
|
+
// truth — intent-score and jev-router import this rather than keeping
|
|
52
|
+
// private copies that can drift (midpoints must stay inside the bands
|
|
53
|
+
// above, including calibrated overrides).
|
|
54
|
+
const TIER_MIDPOINT = { SIMPLE: 10, MEDIUM: 35, COMPLEX: 63, REASONING: 88 };
|
|
55
|
+
|
|
49
56
|
class ModelTierSelector {
|
|
50
57
|
constructor() {
|
|
51
58
|
this.tierConfig = null;
|
|
@@ -147,6 +154,11 @@ class ModelTierSelector {
|
|
|
147
154
|
getTier(complexityScore) {
|
|
148
155
|
const score = Math.max(0, Math.min(100, complexityScore || 0));
|
|
149
156
|
const ranges = this.ranges || this._defaultRanges();
|
|
157
|
+
// First match wins in TIER_DEFINITIONS order (SIMPLE, MEDIUM, COMPLEX,
|
|
158
|
+
// REASONING). Calibrated ranges can overlap (observed: MEDIUM [20,50] vs
|
|
159
|
+
// COMPLEX [30,75]) — in the overlap the EARLIER tier wins, so the
|
|
160
|
+
// effective COMPLEX floor is 51, not 30. Keep this in mind when reading
|
|
161
|
+
// data/calibrated-thresholds.json.
|
|
150
162
|
for (const tier of Object.keys(TIER_DEFINITIONS)) {
|
|
151
163
|
const [lo, hi] = ranges[tier];
|
|
152
164
|
if (score >= lo && score <= hi) return tier;
|
|
@@ -521,4 +533,5 @@ module.exports = {
|
|
|
521
533
|
getModelTierSelector,
|
|
522
534
|
reloadCalibratedThresholds,
|
|
523
535
|
TIER_DEFINITIONS,
|
|
536
|
+
TIER_MIDPOINT,
|
|
524
537
|
};
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-driven model selection for the OAuth subscription passthrough.
|
|
3
|
+
*
|
|
4
|
+
* Context: when a picked tier resolves to `azure-anthropic` for a first-party
|
|
5
|
+
* subscription client, src/api/router.js forwards the request byte-for-byte
|
|
6
|
+
* to api.anthropic.com — serving whatever model the CLIENT chose. Tier picks
|
|
7
|
+
* (COMPLEX/sonnet, REASONING/opus, force keywords) were computed and then
|
|
8
|
+
* silently discarded, so escalation could never fire.
|
|
9
|
+
*
|
|
10
|
+
* This module decides the model the passthrough should ACTUALLY serve. The
|
|
11
|
+
* subscription is not model-locked (the same OAuth token serves Haiku, Sonnet
|
|
12
|
+
* and Opus), so rewriting `body.model` to a tier-configured Claude model is
|
|
13
|
+
* accepted upstream. Only the `model` field is ever rewritten — messages,
|
|
14
|
+
* tools, system prompt, thinking blocks and cache_control breakpoints pass
|
|
15
|
+
* through verbatim, which is what keeps Anthropic's prefix cache usable
|
|
16
|
+
* across the switch (same bytes, new model = one cold-prefix turn, then warm).
|
|
17
|
+
*
|
|
18
|
+
* Rules (first match wins):
|
|
19
|
+
* 1. Kill-switch: LYNKR_PASSTHROUGH_MODEL_ROUTING=false → verbatim always.
|
|
20
|
+
* 2. Side-channel tiers (suggestion/autocomplete/bare/drift frames routed to
|
|
21
|
+
* static SIMPLE) → verbatim. Never spend Opus on harness wrapper text.
|
|
22
|
+
* 3. Unknown client model or unrankable tier model → verbatim (fail-closed).
|
|
23
|
+
* 4. Tier model outranks client model → upgrade to the tier model, with one
|
|
24
|
+
* exception (rule 4a): stepping DOWN from a higher pin (pin Opus, fresh
|
|
25
|
+
* verdict Sonnet) consults the downgrade gate for the pin→tier move. A
|
|
26
|
+
* warm Opus prefix survives a Sonnet verdict when break-even blocks;
|
|
27
|
+
* genuine upgrades (no higher pin, or pin at/below the tier) fire
|
|
28
|
+
* ungated — correctness beats one cold turn, same policy as the normal
|
|
29
|
+
* flow's hard triggers (cache-switch-cost.js scope: escalations are
|
|
30
|
+
* never gated). Without 4a every Opus→Sonnet transition destroyed the
|
|
31
|
+
* warm prefix with no math.
|
|
32
|
+
* 5. Pinned (previously served) model outranks client model → downgrade
|
|
33
|
+
* gate: hold the pin (pin_hold) ONLY while real warmth is at stake —
|
|
34
|
+
* cacheState present, fresh (not TTL-cold), recorded for this same
|
|
35
|
+
* model, and warmPrefixTokens >= HOLD_MIN_PREFIX_TOKENS (2000). Otherwise
|
|
36
|
+
* allow the downgrade (nothing warm worth protecting). This is the
|
|
37
|
+
* normal flow's break-even philosophy ported to a flat subscription:
|
|
38
|
+
* dollars can't justify holding (marginal cost is $0), but a large warm
|
|
39
|
+
* prefix is paid latency on every switch, so a large fresh one protects
|
|
40
|
+
* the pin while a cold/small/absent one doesn't.
|
|
41
|
+
* 6. Otherwise → verbatim. A client that explicitly chose Opus keeps Opus
|
|
42
|
+
* even on a SIMPLE-scored turn.
|
|
43
|
+
*
|
|
44
|
+
* Pure functions, no I/O. Never throws.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const FAMILY_RANK = [
|
|
48
|
+
{ re: /opus/i, rank: 3 },
|
|
49
|
+
{ re: /sonnet/i, rank: 2 },
|
|
50
|
+
{ re: /haiku/i, rank: 1 },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Rank a Claude model id by family. Works on short aliases
|
|
55
|
+
* (claude-sonnet-4-5), dated ids (claude-haiku-4-5-20251001) and
|
|
56
|
+
* provider-prefixed forms. Returns null for anything unrecognized so
|
|
57
|
+
* callers fail closed to verbatim passthrough.
|
|
58
|
+
*/
|
|
59
|
+
function familyRank(modelId) {
|
|
60
|
+
if (!modelId || typeof modelId !== 'string') return null;
|
|
61
|
+
for (const f of FAMILY_RANK) {
|
|
62
|
+
if (f.re.test(modelId)) return f.rank;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRoutingEnabled() {
|
|
68
|
+
// Default ON. Set LYNKR_PASSTHROUGH_MODEL_ROUTING=false to restore pure
|
|
69
|
+
// byte-for-byte passthrough (pre-routing behavior).
|
|
70
|
+
return process.env.LYNKR_PASSTHROUGH_MODEL_ROUTING !== 'false';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isSideTier(tier) {
|
|
74
|
+
if (!tier || typeof tier !== 'object') return false;
|
|
75
|
+
return tier.pinned === false && String(tier.method || '').startsWith('side_request');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Warm-prefix floor for the downgrade hold (rule 5). Deliberately a
|
|
79
|
+
// constant, not an env knob: one fewer dial, same protection. At/above this
|
|
80
|
+
// many warm tokens the pin holds across lower-scored turns; below it (or
|
|
81
|
+
// cold/absent/stale) the tier falls straight back.
|
|
82
|
+
const HOLD_MIN_PREFIX_TOKENS = 2000;
|
|
83
|
+
|
|
84
|
+
function holdMinPrefixTokens() {
|
|
85
|
+
return HOLD_MIN_PREFIX_TOKENS;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the effective tier model for dispatch (pure).
|
|
90
|
+
*
|
|
91
|
+
* A tier object can arrive with a weaker model than its label promises:
|
|
92
|
+
* model-swapping overrides (kNN/bandit) keep the tier label while serving
|
|
93
|
+
* cheaper, the no_user_messages fallback historically carried null, and
|
|
94
|
+
* stale pins replay both shapes indefinitely. On the orchestrator path
|
|
95
|
+
* those dynamics have dollar logic behind them; on the flat-fee
|
|
96
|
+
* subscription passthrough a demotion saves $0 and only risks quality —
|
|
97
|
+
* so here the label wins ties: effective model = max(tierModel,
|
|
98
|
+
* configured(tier)) by family rank. Promotions (stronger-than-configured,
|
|
99
|
+
* e.g. a held Opus on a COMPLEX tier) always survive.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} args
|
|
102
|
+
* @param {string|null} args.tierName - tier label ('COMPLEX', ...)
|
|
103
|
+
* @param {string|null} args.tierModel - model carried on the tier object
|
|
104
|
+
* @param {function|null} args.selectModelFn - (tier) => {model} | null
|
|
105
|
+
* @returns {{model:string|null, resolved:boolean}} resolved=true when the
|
|
106
|
+
* configured model replaced the carried one.
|
|
107
|
+
*/
|
|
108
|
+
function resolveTierModel({ tierName = null, tierModel = null, selectModelFn = null } = {}) {
|
|
109
|
+
try {
|
|
110
|
+
let configured = null;
|
|
111
|
+
try {
|
|
112
|
+
configured = (typeof selectModelFn === 'function' && tierName)
|
|
113
|
+
? selectModelFn(tierName)?.model || null
|
|
114
|
+
: null;
|
|
115
|
+
} catch { configured = null; }
|
|
116
|
+
const rT = familyRank(tierModel);
|
|
117
|
+
const rC = familyRank(configured);
|
|
118
|
+
if (rC !== null && (rT === null || rC > rT)) {
|
|
119
|
+
return { model: configured, resolved: true };
|
|
120
|
+
}
|
|
121
|
+
return { model: tierModel ?? null, resolved: false };
|
|
122
|
+
} catch {
|
|
123
|
+
return { model: tierModel ?? null, resolved: false };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Downgrade gate (rule 5): is there enough live warmth to justify holding
|
|
129
|
+
* the pinned model instead of following the fresh lower tier down?
|
|
130
|
+
* Mirrors cache-switch-cost.js staleness/TTL handling: stale-model state,
|
|
131
|
+
* TTL-cold state, absent state and small prefixes all allow the downgrade
|
|
132
|
+
* without further math.
|
|
133
|
+
*
|
|
134
|
+
* Above the trivial floor, the decision is dollar break-even via the SAME
|
|
135
|
+
* evaluator the normal flow uses: holding Opus to "save" a re-read is only
|
|
136
|
+
* rational when the re-read costs more than the per-turn premium over the
|
|
137
|
+
* session's expected remaining turns. With Opus ~5x Haiku on output
|
|
138
|
+
* (registry: $25/M vs $5/M) the break-even typically clears in ~1-2 turns,
|
|
139
|
+
* so holds are rare by design — surviving only when the session is nearly
|
|
140
|
+
* over (nothing to amortize a rebuild over), quota pressure has shortened
|
|
141
|
+
* the horizon to ~nothing, or pricing is unknown (fail toward the hold).
|
|
142
|
+
*
|
|
143
|
+
* @param {string|null} pinModel - currently serving (pinned) model
|
|
144
|
+
* @param {object|null} cacheState - sessionAffinity.getCacheState(sessionId)
|
|
145
|
+
* @param {object} [opts]
|
|
146
|
+
* @param {string|null} [opts.downgradeModel] - model a downgrade would serve
|
|
147
|
+
* @param {number|null} [opts.remainingTurns] - expected turns left (null → evaluator default)
|
|
148
|
+
* @param {number|null} [opts.sessionBurnPressure] - 0..1 quota pressure,
|
|
149
|
+
+ * threaded into evaluateSwitch (null/0 = untouched horizon)
|
|
150
|
+
* @param {function|null} [opts.evaluateSwitch] - injectable evaluator (tests)
|
|
151
|
+
*/
|
|
152
|
+
function shouldHoldForCache(pinModel, cacheState, opts = {}) {
|
|
153
|
+
if (!cacheState || typeof cacheState !== 'object') return null;
|
|
154
|
+
if (cacheState.cold) return { hold: false, reason: 'downgrade_cache_cold' };
|
|
155
|
+
if (cacheState.model && pinModel && cacheState.model !== pinModel) {
|
|
156
|
+
return { hold: false, reason: 'downgrade_cache_stale' };
|
|
157
|
+
}
|
|
158
|
+
const warm = Number(cacheState.warmPrefixTokens) || 0;
|
|
159
|
+
if (warm < holdMinPrefixTokens()) {
|
|
160
|
+
return { hold: false, reason: 'downgrade_prefix_small', warmPrefixTokens: warm };
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const evaluate = opts.evaluateSwitch
|
|
164
|
+
|| require('./cache-switch-cost').evaluateSwitch;
|
|
165
|
+
const ev = evaluate({
|
|
166
|
+
cacheState,
|
|
167
|
+
current: { provider: cacheState.provider || 'azure-anthropic', model: pinModel },
|
|
168
|
+
target: { provider: cacheState.provider || 'azure-anthropic', model: opts.downgradeModel || null },
|
|
169
|
+
expectedRemainingTurns: opts.remainingTurns ?? null,
|
|
170
|
+
...(opts.sessionBurnPressure != null ? { sessionBurnPressure: opts.sessionBurnPressure } : {}),
|
|
171
|
+
});
|
|
172
|
+
if (ev && ev.switchAllowed) {
|
|
173
|
+
return {
|
|
174
|
+
hold: false,
|
|
175
|
+
reason: 'downgrade_break_even_cleared',
|
|
176
|
+
warmPrefixTokens: warm,
|
|
177
|
+
breakEvenTurns: ev.breakEvenTurns ?? null,
|
|
178
|
+
expectedRemainingTurns: ev.expectedRemainingTurns ?? null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
hold: true,
|
|
183
|
+
reason: 'hold_break_even_blocked',
|
|
184
|
+
warmPrefixTokens: warm,
|
|
185
|
+
breakEvenTurns: ev && ev.breakEvenTurns !== undefined ? ev.breakEvenTurns : null,
|
|
186
|
+
expectedRemainingTurns: ev && ev.expectedRemainingTurns !== undefined ? ev.expectedRemainingTurns : null,
|
|
187
|
+
};
|
|
188
|
+
} catch {
|
|
189
|
+
return { hold: true, reason: 'hold_evaluator_failed', warmPrefixTokens: warm };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {object} args
|
|
195
|
+
* @param {string|null} args.tierModel - model from the picked tier's config
|
|
196
|
+
* @param {string|null} args.clientModel - req.body.model (what the client asked for)
|
|
197
|
+
* @param {string|null} [args.pinModel] - model recorded on the session pin (previously served)
|
|
198
|
+
* @param {string|null} [args.tierMethod] - tier.method (side-channel detection)
|
|
199
|
+
* @param {boolean|null} [args.tierPinned] - tier.pinned flag
|
|
200
|
+
* @param {object|null} [args.cacheState] - sessionAffinity.getCacheState(sessionId):
|
|
201
|
+
* {warmPrefixTokens, provider, model, lastRequestAt, ttlMs, cold} or null.
|
|
202
|
+
* Caller (router fork) loads it; kept out of here so this stays pure.
|
|
203
|
+
* @param {number|null} [args.remainingTurns] - expected turns left, for the
|
|
204
|
+
* break-even gate (null → evaluator default). Caller loads via telemetry.
|
|
205
|
+
* @param {number|null} [args.sessionBurnPressure] - 0..1 quota pressure,
|
|
206
|
+
* threaded into both gate calls (null/0 = untouched horizon).
|
|
207
|
+
* @param {function|null} [args.evaluateSwitch] - injectable evaluator (tests).
|
|
208
|
+
* @returns {{model:string|null, action:'verbatim'|'upgrade'|'pin_hold', reason:string, warmPrefixTokens:number|null}}
|
|
209
|
+
*/
|
|
210
|
+
function decidePassthroughModel({ tierModel = null, clientModel = null, pinModel = null, tierMethod = null, tierPinned = null, cacheState = null, remainingTurns = null, sessionBurnPressure = null, evaluateSwitch = null } = {}) {
|
|
211
|
+
try {
|
|
212
|
+
if (!isRoutingEnabled()) {
|
|
213
|
+
return { model: clientModel, action: 'verbatim', reason: 'routing_disabled', warmPrefixTokens: null };
|
|
214
|
+
}
|
|
215
|
+
if (isSideTier({ method: tierMethod, pinned: tierPinned })) {
|
|
216
|
+
return { model: clientModel, action: 'verbatim', reason: 'side_request', warmPrefixTokens: null };
|
|
217
|
+
}
|
|
218
|
+
const clientRank = familyRank(clientModel);
|
|
219
|
+
if (clientRank === null) {
|
|
220
|
+
return { model: clientModel, action: 'verbatim', reason: 'unknown_client_model', warmPrefixTokens: null };
|
|
221
|
+
}
|
|
222
|
+
const tierRank = familyRank(tierModel);
|
|
223
|
+
const pinRank = familyRank(pinModel);
|
|
224
|
+
if (tierRank !== null && tierRank > clientRank) {
|
|
225
|
+
// Rule 4a — the upgrade branch can also be a step DOWN from the pin
|
|
226
|
+
// (pin Opus, fresh verdict Sonnet, client Haiku). Consult the gate for
|
|
227
|
+
// the pin→tier move instead of destroying warmth unexamined.
|
|
228
|
+
if (pinRank !== null && pinRank > tierRank) {
|
|
229
|
+
const gate = shouldHoldForCache(pinModel, cacheState, {
|
|
230
|
+
downgradeModel: tierModel,
|
|
231
|
+
remainingTurns,
|
|
232
|
+
sessionBurnPressure,
|
|
233
|
+
...(evaluateSwitch ? { evaluateSwitch } : {}),
|
|
234
|
+
});
|
|
235
|
+
if (gate && gate.hold) {
|
|
236
|
+
return { model: pinModel, action: 'pin_hold', reason: gate.reason, warmPrefixTokens: gate.warmPrefixTokens ?? null };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { model: tierModel, action: 'upgrade', reason: 'tier_outranks_client', warmPrefixTokens: null };
|
|
240
|
+
}
|
|
241
|
+
if (pinRank !== null && pinRank > clientRank) {
|
|
242
|
+
const gate = shouldHoldForCache(pinModel, cacheState, {
|
|
243
|
+
downgradeModel: clientModel,
|
|
244
|
+
remainingTurns,
|
|
245
|
+
sessionBurnPressure,
|
|
246
|
+
...(evaluateSwitch ? { evaluateSwitch } : {}),
|
|
247
|
+
});
|
|
248
|
+
if (gate && gate.hold) {
|
|
249
|
+
return { model: pinModel, action: 'pin_hold', reason: gate.reason, warmPrefixTokens: gate.warmPrefixTokens ?? null };
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
model: clientModel,
|
|
253
|
+
action: 'verbatim',
|
|
254
|
+
reason: gate ? gate.reason : 'downgrade_no_cache_state',
|
|
255
|
+
warmPrefixTokens: (gate && gate.warmPrefixTokens) ?? null,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return { model: clientModel, action: 'verbatim', reason: 'no_upgrade', warmPrefixTokens: null };
|
|
259
|
+
} catch {
|
|
260
|
+
return { model: clientModel ?? null, action: 'verbatim', reason: 'error_fail_closed', warmPrefixTokens: null };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Badge text for a passthrough turn (pure — unit-tested, used by router.js).
|
|
266
|
+
* Three marked variants plus the plain no-op:
|
|
267
|
+
* upgrade → +route (model rewritten up)
|
|
268
|
+
* pin_hold → +hold (model rewritten to the held pin, with gate reason)
|
|
269
|
+
* verbatim + downgrade_* reason → −stepdown (gate-approved descent)
|
|
270
|
+
* otherwise → plain passthrough (true no-op: no_upgrade, side_request…)
|
|
271
|
+
*/
|
|
272
|
+
function buildPassthroughBadge({ action = null, reason = null, routeModel = null, clientModel = null, tierName = null, servedModel = null } = {}) {
|
|
273
|
+
if (action === 'upgrade' && routeModel) {
|
|
274
|
+
return `*[Lynkr] subscription-passthrough+route → ${routeModel} (client: ${clientModel || '—'}) · ${tierName || '—'}*`;
|
|
275
|
+
}
|
|
276
|
+
if (action === 'pin_hold' && routeModel) {
|
|
277
|
+
return `*[Lynkr] subscription-passthrough+hold → ${routeModel} (client: ${clientModel || '—'}) · ${tierName || '—'} (${reason || 'cache'})*`;
|
|
278
|
+
}
|
|
279
|
+
if (action === 'verbatim' && typeof reason === 'string' && reason.startsWith('downgrade_')) {
|
|
280
|
+
return `*[Lynkr] subscription-passthrough−stepdown → ${servedModel || '—'} (azure-anthropic) · ${tierName || '—'} (${reason})*`;
|
|
281
|
+
}
|
|
282
|
+
return `*[Lynkr] subscription-passthrough → ${servedModel || '—'} (azure-anthropic)*`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = { familyRank, isRoutingEnabled, isSideTier, decidePassthroughModel,
|
|
286
|
+
resolveTierModel, shouldHoldForCache, holdMinPrefixTokens, buildPassthroughBadge };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription quota ledger (Phase 4 — quota-aware descents).
|
|
3
|
+
*
|
|
4
|
+
* Flat-fee subscriptions have no dollar price per token, but they do have a
|
|
5
|
+
* quota bucket: sustained Opus serving drains weekly runway several times
|
|
6
|
+
* faster than Haiku. This module tracks per-session burn in QUOTA UNITS so
|
|
7
|
+
* the downgrade gate can shorten its horizon under pressure (see
|
|
8
|
+
* cache-switch-cost.js `sessionBurnPressure`): high burn → fewer effective
|
|
9
|
+
* remaining turns → holds collapse → descents clear sooner.
|
|
10
|
+
*
|
|
11
|
+
* Units: 1 quota unit = 1000 Haiku-class tokens (input+output blended).
|
|
12
|
+
* Model weight = (inputPerM + outputPerM) / (haiku input+output), resolved
|
|
13
|
+
* via cache-economics with a fail-to-middle default (2.0) for unpriced ids —
|
|
14
|
+
* unknown models should exert middling pressure, never zero, never Opus.
|
|
15
|
+
* Today's registry: haiku 1.0, sonnet-5 2.0, opus-4-8 5.0.
|
|
16
|
+
*
|
|
17
|
+
* Pressure = min(1, windowBurn / BURN_WINDOW_QUOTA) over the trailing window
|
|
18
|
+
* (default 10 turns). BURN_WINDOW_QUOTA is a heuristic constant: ~ten sustained
|
|
19
|
+
* full-context Opus turns. It is deliberately coarse — pressure only shortens
|
|
20
|
+
* the hold horizon, it never forces a descent past the gate's own math, and
|
|
21
|
+
* upgrades/tool-loops/compaction-floors bypass the gate entirely.
|
|
22
|
+
*
|
|
23
|
+
* Pure except for the in-memory session map (no I/O, no DB). Never throws.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const BURN_WINDOW_TURNS = 10;
|
|
27
|
+
// ~10 sustained 10k-token Opus turns (10 × 10 × 5.0). Sustained peaks pin
|
|
28
|
+
// pressure at 1; occasional upgrades barely register. Heuristic — see above.
|
|
29
|
+
const BURN_WINDOW_QUOTA = 500;
|
|
30
|
+
const MAX_SESSIONS = 500;
|
|
31
|
+
const UNKNOWN_MODEL_WEIGHT = 2.0;
|
|
32
|
+
const HAIKU_BASELINE_PER_M = 6; // claude-haiku-4-5-20251001: 1 input + 5 output
|
|
33
|
+
|
|
34
|
+
const _windows = new Map(); // sessionId -> number[] (recent turn burns)
|
|
35
|
+
|
|
36
|
+
function _evictIfNeeded() {
|
|
37
|
+
while (_windows.size > MAX_SESSIONS) {
|
|
38
|
+
const oldest = _windows.keys().next();
|
|
39
|
+
if (oldest.done) break;
|
|
40
|
+
_windows.delete(oldest.value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Quota weight for a model id: output+input price relative to Haiku baseline.
|
|
46
|
+
* @param {string|null} model
|
|
47
|
+
* @param {function|null} [resolveFn] - (provider, model) => {inputPerM, outputPerM} (tests)
|
|
48
|
+
*/
|
|
49
|
+
function modelWeight(model, resolveFn = null) {
|
|
50
|
+
try {
|
|
51
|
+
const resolve = resolveFn || require('./cache-economics').resolveCacheEconomics;
|
|
52
|
+
const econ = resolve('azure-anthropic', model);
|
|
53
|
+
if (!econ || typeof econ.inputPerM !== 'number' || typeof econ.outputPerM !== 'number') {
|
|
54
|
+
return UNKNOWN_MODEL_WEIGHT;
|
|
55
|
+
}
|
|
56
|
+
if (econ.unknownPricing) return UNKNOWN_MODEL_WEIGHT;
|
|
57
|
+
return (econ.inputPerM + econ.outputPerM) / HAIKU_BASELINE_PER_M;
|
|
58
|
+
} catch {
|
|
59
|
+
return UNKNOWN_MODEL_WEIGHT;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Record one served turn.
|
|
65
|
+
* @param {string|null} sessionId
|
|
66
|
+
* @param {object} usage - {model, inputTokens, outputTokens}
|
|
67
|
+
* @param {function|null} [resolveFn] - test injection for modelWeight
|
|
68
|
+
*/
|
|
69
|
+
function record(sessionId, usage = {}, resolveFn = null) {
|
|
70
|
+
if (!sessionId) return;
|
|
71
|
+
const input = Number(usage.inputTokens) || 0;
|
|
72
|
+
const output = Number(usage.outputTokens) || 0;
|
|
73
|
+
if (input <= 0 && output <= 0) return;
|
|
74
|
+
const burn = ((input + output) / 1000) * modelWeight(usage.model, resolveFn);
|
|
75
|
+
let window = _windows.get(sessionId);
|
|
76
|
+
if (!window) {
|
|
77
|
+
window = [];
|
|
78
|
+
_windows.set(sessionId, window);
|
|
79
|
+
}
|
|
80
|
+
window.push(burn);
|
|
81
|
+
while (window.length > BURN_WINDOW_TURNS) window.shift();
|
|
82
|
+
_evictIfNeeded();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Current burn pressure for a session: 0 (idle/cheap) → 1 (sustained peaks).
|
|
87
|
+
*/
|
|
88
|
+
function pressure(sessionId) {
|
|
89
|
+
const window = _windows.get(sessionId);
|
|
90
|
+
if (!window || window.length === 0) return 0;
|
|
91
|
+
const burn = window.reduce((a, b) => a + b, 0);
|
|
92
|
+
return Math.max(0, Math.min(1, burn / BURN_WINDOW_QUOTA));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Test/ops helpers. */
|
|
96
|
+
function _reset() { _windows.clear(); }
|
|
97
|
+
function _windowBurn(sessionId) {
|
|
98
|
+
const window = _windows.get(sessionId);
|
|
99
|
+
return window ? window.reduce((a, b) => a + b, 0) : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
record,
|
|
104
|
+
pressure,
|
|
105
|
+
modelWeight,
|
|
106
|
+
BURN_WINDOW_TURNS,
|
|
107
|
+
BURN_WINDOW_QUOTA,
|
|
108
|
+
_reset,
|
|
109
|
+
_windowBurn,
|
|
110
|
+
};
|
|
@@ -125,9 +125,22 @@ function findHits(keywords, haystack) {
|
|
|
125
125
|
* not what the harness injected; genuinely risky asks live in the typed
|
|
126
126
|
* text and still fire. (The intent scorers have stripped these blocks
|
|
127
127
|
* since WS3 — this brings the risk scan in line.)
|
|
128
|
+
*
|
|
129
|
+
* Bare-line MCP boilerplate (HARNESS_BOILERPLATE_LINE) gets the same
|
|
130
|
+
* treatment: auth notices also arrive as PLAIN text outside any tags.
|
|
128
131
|
* @param {string} text
|
|
129
132
|
* @returns {string}
|
|
130
133
|
*/
|
|
134
|
+
// Bare-line harness boilerplate that escapes the tag stripping above: MCP
|
|
135
|
+
// auth notices also arrive as PLAIN text (tool results, echoed banners),
|
|
136
|
+
// where the same credential-flavored words detonate the keyword scan —
|
|
137
|
+
// live 2026-09-18: "Can you pull sprint stories…" + an "Authentication
|
|
138
|
+
// successful. Connected to claude.ai BT1_MCP" notice routed REASONING on
|
|
139
|
+
// instructionHits ["authentication"] the user never typed. Full-LINE match
|
|
140
|
+
// only: these are fixed harness templates, never user prose — typed risky
|
|
141
|
+
// asks ("disable the authentication check") span the line differently and
|
|
142
|
+
// keep firing. Tool_use inputs are untouched (real activity still counts).
|
|
143
|
+
const HARNESS_BOILERPLATE_LINE = /^\s*(authentication successful[.!]?(\s+connected to .+)?|connected to \S+ MCP\.?|(\d+\s+)?MCP servers? need authentication\b.*|run \/mcp\b.*)\s*$/i;
|
|
131
144
|
function stripSystemReminders(text) {
|
|
132
145
|
if (typeof text !== 'string' || !text) return '';
|
|
133
146
|
return text
|
|
@@ -139,7 +152,10 @@ function stripSystemReminders(text) {
|
|
|
139
152
|
// "Hi", force-escalated to COMPLEX on it) and <user_instructions>
|
|
140
153
|
// carries AGENTS.md contents the user never typed this turn.
|
|
141
154
|
.replace(/<environment_context>[\s\S]*?<\/environment_context>/g, ' ')
|
|
142
|
-
.replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, ' ')
|
|
155
|
+
.replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, ' ')
|
|
156
|
+
.split('\n')
|
|
157
|
+
.filter((line) => !HARNESS_BOILERPLATE_LINE.test(line))
|
|
158
|
+
.join('\n');
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
/**
|
package/src/routing/telemetry.js
CHANGED
|
@@ -181,6 +181,14 @@ function init() {
|
|
|
181
181
|
// NULL on rows where the bandit didn't run (deterministic decisions)
|
|
182
182
|
// or recorded before this column existed.
|
|
183
183
|
["context", "TEXT"],
|
|
184
|
+
// Jev routing judge — shadow verdict recorded alongside every scored
|
|
185
|
+
// decision so heuristic/Jev eras stay separable. NULL when Jev didn't
|
|
186
|
+
// run (pinned serves, side frames, no key) or predates this column.
|
|
187
|
+
["jev_verdict", "TEXT"],
|
|
188
|
+
["jev_confidence", "REAL"],
|
|
189
|
+
["jev_probabilities", "TEXT"],
|
|
190
|
+
["jev_model", "TEXT"],
|
|
191
|
+
["criteria_hash", "TEXT"],
|
|
184
192
|
];
|
|
185
193
|
for (const [col, type] of additiveCols) {
|
|
186
194
|
if (!existingCols.has(col)) {
|
|
@@ -243,7 +251,8 @@ function record(data) {
|
|
|
243
251
|
retry_count, circuit_breaker_state, quality_score, tokens_per_second,
|
|
244
252
|
cost_efficiency, request_text, response_text,
|
|
245
253
|
base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
|
|
246
|
-
cache_decision, cache_read_tokens, cache_creation_tokens, context
|
|
254
|
+
cache_decision, cache_read_tokens, cache_creation_tokens, context,
|
|
255
|
+
jev_verdict, jev_confidence, jev_probabilities, jev_model, criteria_hash
|
|
247
256
|
) VALUES (
|
|
248
257
|
@request_id, @session_id, @timestamp, @complexity_score, @tier,
|
|
249
258
|
@agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
|
|
@@ -252,7 +261,8 @@ function record(data) {
|
|
|
252
261
|
@retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
|
|
253
262
|
@cost_efficiency, @request_text, @response_text,
|
|
254
263
|
@base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
|
|
255
|
-
@cache_decision, @cache_read_tokens, @cache_creation_tokens, @context
|
|
264
|
+
@cache_decision, @cache_read_tokens, @cache_creation_tokens, @context,
|
|
265
|
+
@jev_verdict, @jev_confidence, @jev_probabilities, @jev_model, @criteria_hash
|
|
256
266
|
)`
|
|
257
267
|
);
|
|
258
268
|
if (!insert) return;
|
|
@@ -308,6 +318,13 @@ function record(data) {
|
|
|
308
318
|
context: data.context == null
|
|
309
319
|
? null
|
|
310
320
|
: (typeof data.context === "string" ? data.context : JSON.stringify(data.context)),
|
|
321
|
+
jev_verdict: data.jev_verdict ?? null,
|
|
322
|
+
jev_confidence: data.jev_confidence ?? null,
|
|
323
|
+
jev_probabilities: data.jev_probabilities === null || data.jev_probabilities === undefined
|
|
324
|
+
? null
|
|
325
|
+
: (typeof data.jev_probabilities === "string" ? data.jev_probabilities : JSON.stringify(data.jev_probabilities)),
|
|
326
|
+
jev_model: data.jev_model ?? null,
|
|
327
|
+
criteria_hash: data.criteria_hash ?? null,
|
|
311
328
|
});
|
|
312
329
|
} catch (err) {
|
|
313
330
|
logger.debug({ err: err.message }, "Telemetry record failed");
|
|
@@ -1136,8 +1153,36 @@ function getAnalytics(opts = {}) {
|
|
|
1136
1153
|
}
|
|
1137
1154
|
}
|
|
1138
1155
|
|
|
1156
|
+
/**
|
|
1157
|
+
* Map a Jev verdict (decision.jev or analysis.jev shape) onto the
|
|
1158
|
+
* jev_* telemetry columns. Null-safe: anything missing yields all-null
|
|
1159
|
+
* fields so call sites spread this unconditionally.
|
|
1160
|
+
*/
|
|
1161
|
+
function jevFields(src) {
|
|
1162
|
+
const j = (src && typeof src === 'object')
|
|
1163
|
+
? (src.jev && typeof src.jev === 'object' ? src.jev
|
|
1164
|
+
: (src.analysis && typeof src.analysis === 'object' && src.analysis.jev ? src.analysis.jev : null))
|
|
1165
|
+
: null;
|
|
1166
|
+
if (!j) {
|
|
1167
|
+
return {
|
|
1168
|
+
jev_verdict: null, jev_confidence: null, jev_probabilities: null,
|
|
1169
|
+
jev_model: null, criteria_hash: null,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
return {
|
|
1173
|
+
jev_verdict: j.tier ?? null,
|
|
1174
|
+
jev_confidence: j.confidence ?? null,
|
|
1175
|
+
jev_probabilities: j.probabilities === null || j.probabilities === undefined
|
|
1176
|
+
? null
|
|
1177
|
+
: (typeof j.probabilities === 'string' ? j.probabilities : JSON.stringify(j.probabilities)),
|
|
1178
|
+
jev_model: j.model ?? null,
|
|
1179
|
+
criteria_hash: j.criteriaHash ?? j.criteria_hash ?? null,
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1139
1183
|
module.exports = {
|
|
1140
1184
|
record,
|
|
1185
|
+
jevFields,
|
|
1141
1186
|
query,
|
|
1142
1187
|
getStats: getStatsCached,
|
|
1143
1188
|
getProviderStats: getProviderStatsCached,
|