lynkr 9.14.11 → 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/README.md +1 -0
- package/bin/cli.js +1 -0
- package/bin/lynkr-connect-orcarouter.js +246 -0
- package/bin/lynkr-init.js +15 -1
- package/config/difficulty-anchors.json +12 -2
- package/config/model-capability-seeds.json +5 -0
- package/package.json +3 -3
- package/scripts/validate-intent-anchors.js +2 -2
- package/src/api/openai-router.js +22 -4
- package/src/api/providers-handler.js +33 -10
- package/src/api/router.js +370 -16
- package/src/clients/databricks.js +196 -22
- package/src/clients/openai-format.js +37 -2
- package/src/clients/openrouter-utils.js +20 -0
- package/src/clients/orcarouter-catalog.js +330 -0
- package/src/clients/orcarouter-credentials.js +428 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/config/index.js +63 -2
- package/src/dashboard/api.js +1 -0
- package/src/orchestrator/index.js +49 -10
- package/src/orchestrator/sse-transformer.js +3 -0
- package/src/prompts/system.js +41 -49
- package/src/routing/affinity-store.js +47 -21
- package/src/routing/cache-economics.js +1 -0
- package/src/routing/cache-switch-cost.js +46 -2
- package/src/routing/complexity-analyzer.js +21 -4
- package/src/routing/index.js +123 -1
- package/src/routing/intent-score.js +48 -13
- package/src/routing/jev-router.js +272 -0
- package/src/routing/model-tiers.js +15 -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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev (TypeSafe System One) routing judge.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the qwen-based difficulty classifier as the second opinion in
|
|
5
|
+
* intent scoring: one typed Choice over the four tiers (+ a parallel
|
|
6
|
+
* risk Noul, free — questions share state) instead of JSON coaxed through
|
|
7
|
+
* a thinking block. Native per-option probabilities replace self-reported
|
|
8
|
+
* confidence; 0.5s vs up-to-10s timeout; no local prefill.
|
|
9
|
+
*
|
|
10
|
+
* Design constraints (operator policy):
|
|
11
|
+
* - No new env vars. Endpoint, pinned model, timeout, cache size and the
|
|
12
|
+
* promotion cut are constants below. Only TYPESAFE_API_KEY is read from
|
|
13
|
+
* env (pre-existing); absent key disables the client. Rollback is a
|
|
14
|
+
* revert, not a flag — a hardcoded kill-switch you can only flip with a
|
|
15
|
+
* code edit is theater; the fail-soft nulls are the real safety.
|
|
16
|
+
* - Fail-soft: any failure (no key, timeout, 429/529, malformed body)
|
|
17
|
+
* returns null and callers fall back exactly as if no classifier ran.
|
|
18
|
+
* Single attempt, no retries in the hot path.
|
|
19
|
+
* - Pinned model version (jev-1.13.0 observed live), never jev-latest:
|
|
20
|
+
* routing must not ride alias drift. Re-run the difficulty evals on
|
|
21
|
+
* any bump. Criteria text is hashed into telemetry so prompt tweaks
|
|
22
|
+
* don't silently invalidate history either.
|
|
23
|
+
*
|
|
24
|
+
* Pure except for fetch (injectable) and the LRU cache. Never throws.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
const logger = require('../logger');
|
|
29
|
+
|
|
30
|
+
const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
|
|
31
|
+
const JEV_MODEL = 'jev-1.13.0';
|
|
32
|
+
const JEV_TIMEOUT_MS = 3000;
|
|
33
|
+
const JEV_CACHE_CAPACITY = 500;
|
|
34
|
+
// Promotion cut for letting a Jev tier override the heuristic (Phase 2).
|
|
35
|
+
// Matches the existing upward-gate in intent-score _reconcile (0.8);
|
|
36
|
+
// ratify against shadow agreement-vs-confidence data when available.
|
|
37
|
+
const JEV_PROMOTE_CONFIDENCE = 0.8;
|
|
38
|
+
// Risk corroboration cut: Noul at/above this corroborates upward only
|
|
39
|
+
// (may escalate, never clears a keyword hit).
|
|
40
|
+
const JEV_RISK_CUT = 0.85;
|
|
41
|
+
|
|
42
|
+
const VALID_TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
43
|
+
|
|
44
|
+
// v1 criteria — carries the hard-won disambiguators from the qwen prompt
|
|
45
|
+
// generations (casual follow-ups, arithmetic, verify-file-exists).
|
|
46
|
+
const TIER_CRITERIA_V1 = {
|
|
47
|
+
SIMPLE: 'Greetings, confirmations, casual follow-ups about people, stories or everyday facts, trivial lookups and arithmetic. Any tiny model handles. NOT reasoning: who kills him?, 12+21, prove me wrong lol.',
|
|
48
|
+
MEDIUM: 'One specific mechanical task or focused explanation. A mid-size model suffices. Examples: list the exports from this file, explain this regex, verify the file exists before reading it.',
|
|
49
|
+
COMPLEX: 'Multi-file design, systemic refactor, architecture review, debugging that requires broad code understanding. Needs a strong general model.',
|
|
50
|
+
REASONING: 'Formal proof, correctness verification, security audit, novel algorithm design, reasoning from first principles. Needs a frontier reasoning model.',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const TIER_INSTRUCTIONS = 'Which difficulty tier should serve this coding-assistant request? Judge the TASK the model must perform, not the vocabulary.';
|
|
54
|
+
const RISK_INSTRUCTIONS = 'This request involves auth, secrets, credentials, production systems, deployments, or destructive/data-loss operations.';
|
|
55
|
+
|
|
56
|
+
const CRITERIA_HASH = crypto
|
|
57
|
+
.createHash('sha256')
|
|
58
|
+
.update(JSON.stringify(TIER_CRITERIA_V1))
|
|
59
|
+
.digest('hex')
|
|
60
|
+
.slice(0, 12);
|
|
61
|
+
|
|
62
|
+
// Defensive caps (far below the ~32k-token request budget; condensed states
|
|
63
|
+
// never approach them — the guard exists so a pathological upstream payload
|
|
64
|
+
// can't turn a 0.5s judgment into a 30k-token one).
|
|
65
|
+
const MAX_REQUEST_CHARS = 4000;
|
|
66
|
+
const MAX_CONTEXT_CHARS = 500;
|
|
67
|
+
|
|
68
|
+
const _cache = new Map(); // key -> result (LRU-ish: delete+re-set on hit)
|
|
69
|
+
|
|
70
|
+
function _evictIfNeeded() {
|
|
71
|
+
while (_cache.size > JEV_CACHE_CAPACITY) {
|
|
72
|
+
const oldest = _cache.keys().next();
|
|
73
|
+
if (oldest.done) break;
|
|
74
|
+
_cache.delete(oldest.value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getApiKey() {
|
|
79
|
+
const raw = process.env.TYPESAFE_API_KEY;
|
|
80
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
81
|
+
const key = raw.trim().replace(/^["']|["']$/g, '').split(/\s/)[0];
|
|
82
|
+
return key || null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function _stripReminders(s) {
|
|
86
|
+
return typeof s === 'string'
|
|
87
|
+
? s.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
|
|
88
|
+
: s;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _cacheKey(text, context, signals) {
|
|
92
|
+
return crypto
|
|
93
|
+
.createHash('sha256')
|
|
94
|
+
.update(`${text || ''}\0${context || ''}\0${JSON.stringify(signals || {})}\0${CRITERIA_HASH}`)
|
|
95
|
+
.digest('hex');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the Jev state: current ask in full, condensed context, precomputed
|
|
100
|
+
* signals. Signals over transcripts: the counts/flags carry the routing
|
|
101
|
+
* information at ~30 tokens instead of ~6k.
|
|
102
|
+
*/
|
|
103
|
+
function buildJevState({ text, context = null, signals = {} } = {}) {
|
|
104
|
+
let current = _stripReminders(text || '');
|
|
105
|
+
if (current.length > MAX_REQUEST_CHARS) current = current.slice(0, MAX_REQUEST_CHARS);
|
|
106
|
+
let ctx = context ? _stripReminders(context) : null;
|
|
107
|
+
if (ctx && ctx.length > MAX_CONTEXT_CHARS) ctx = ctx.slice(0, MAX_CONTEXT_CHARS);
|
|
108
|
+
return {
|
|
109
|
+
current_request: current,
|
|
110
|
+
conversation_context: ctx || undefined,
|
|
111
|
+
signals: {
|
|
112
|
+
message_count: Number(signals.message_count) || 0,
|
|
113
|
+
tools_attached: Number(signals.tools_attached) || 0,
|
|
114
|
+
effective_tools: Number(signals.effective_tools) || 0,
|
|
115
|
+
has_tool_history: !!signals.has_tool_history,
|
|
116
|
+
session_turn: Number(signals.session_turn) || 0,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function _normalizeTier(t) {
|
|
122
|
+
if (!t || typeof t !== 'string') return null;
|
|
123
|
+
const up = t.trim().toUpperCase();
|
|
124
|
+
return VALID_TIERS.includes(up) ? up : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _num01(v) {
|
|
128
|
+
const n = Number(v);
|
|
129
|
+
return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One batched evaluation: Choice(tier) + Noul(risky) sharing state.
|
|
134
|
+
* @returns {null | {tier, confidence, probabilities, risky, model, latencyMs, criteriaHash}}
|
|
135
|
+
*/
|
|
136
|
+
async function evaluateJev(state, { fetchFn = null, endpoint = JEV_ENDPOINT, apiKey = null } = {}) {
|
|
137
|
+
const key = apiKey || getApiKey();
|
|
138
|
+
if (!key) return null;
|
|
139
|
+
const fetchImpl = fetchFn || globalThis.fetch;
|
|
140
|
+
if (typeof fetchImpl !== 'function') return null;
|
|
141
|
+
const started = Date.now();
|
|
142
|
+
try {
|
|
143
|
+
const body = {
|
|
144
|
+
state,
|
|
145
|
+
model: JEV_MODEL,
|
|
146
|
+
questions: {
|
|
147
|
+
tier: { type: 'choice', instructions: TIER_INSTRUCTIONS, criteria: TIER_CRITERIA_V1 },
|
|
148
|
+
risky: { type: 'noul', instructions: RISK_INSTRUCTIONS },
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
const resp = await fetchImpl(endpoint, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify(body),
|
|
155
|
+
signal: AbortSignal.timeout(JEV_TIMEOUT_MS),
|
|
156
|
+
});
|
|
157
|
+
if (!resp || !resp.ok) return null;
|
|
158
|
+
const data = await resp.json();
|
|
159
|
+
const t = data?.answers?.tier;
|
|
160
|
+
const tier = _normalizeTier(t?.choice);
|
|
161
|
+
if (!tier) return null;
|
|
162
|
+
return {
|
|
163
|
+
tier,
|
|
164
|
+
confidence: _num01(t?.confidence) ?? 0,
|
|
165
|
+
probabilities: (t?.probabilities && typeof t.probabilities === 'object') ? t.probabilities : null,
|
|
166
|
+
risky: _num01(data?.answers?.risky?.noul),
|
|
167
|
+
model: typeof data?.model === 'string' ? data.model : JEV_MODEL,
|
|
168
|
+
latencyMs: Date.now() - started,
|
|
169
|
+
criteriaHash: CRITERIA_HASH,
|
|
170
|
+
};
|
|
171
|
+
} catch (err) {
|
|
172
|
+
logger.debug({ err: err?.message || String(err) }, '[Jev] evaluation failed — fail-soft null');
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* LRU-cached classify() matching the qwen classifyDifficulty return shape
|
|
179
|
+
* ({tier, confidence}) plus Jev extras, so intent-score swaps one call.
|
|
180
|
+
*/
|
|
181
|
+
async function classifyJev(text, { context = null, signals = {}, fetchFn = null } = {}) {
|
|
182
|
+
const clean = _stripReminders(text || '');
|
|
183
|
+
if (!clean) return null;
|
|
184
|
+
const cacheKey = _cacheKey(clean, context, signals);
|
|
185
|
+
const hit = _cache.get(cacheKey);
|
|
186
|
+
if (hit) {
|
|
187
|
+
_cache.delete(cacheKey);
|
|
188
|
+
_cache.set(cacheKey, hit);
|
|
189
|
+
return { ...hit, cached: true };
|
|
190
|
+
}
|
|
191
|
+
const state = buildJevState({ text: clean, context, signals });
|
|
192
|
+
const result = await evaluateJev(state, { fetchFn });
|
|
193
|
+
if (result) {
|
|
194
|
+
_cache.set(cacheKey, result);
|
|
195
|
+
_evictIfNeeded();
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _clearCache() {
|
|
201
|
+
_cache.clear();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const _TIER_PRI = { SIMPLE: 1, MEDIUM: 2, COMPLEX: 3, REASONING: 4 };
|
|
205
|
+
function _pri(tier) {
|
|
206
|
+
return _TIER_PRI[tier] || 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Tier midpoints live in model-tiers.js (single source of truth, shared
|
|
210
|
+
// with intent-score reconcile) so a decided tier maps to a coherent numeric
|
|
211
|
+
// score for drift math, badges and telemetry.
|
|
212
|
+
const { TIER_MIDPOINT } = require('./model-tiers');
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Jev tier override (pure): when a high-confidence Jev verdict disagrees
|
|
216
|
+
* with the score-derived base tier, the verdict wins either direction.
|
|
217
|
+
* Score bands remain the fallback (Jev null/low-confidence). Force paths
|
|
218
|
+
* already returned upstream; agentic floors and risk lifts apply after.
|
|
219
|
+
* @returns {null | {tier, score}} — score is the tier midpoint for coherence.
|
|
220
|
+
*/
|
|
221
|
+
function jevTierOverride({ baseTier, jevTier, jevConfidence } = {}) {
|
|
222
|
+
if (!baseTier || !_pri(baseTier)) return null;
|
|
223
|
+
if (!jevTier || !_pri(jevTier)) return null;
|
|
224
|
+
if (typeof jevConfidence !== 'number' || jevConfidence < JEV_PROMOTE_CONFIDENCE) return null;
|
|
225
|
+
if (_pri(jevTier) === _pri(baseTier)) return null;
|
|
226
|
+
return { tier: jevTier, score: TIER_MIDPOINT[jevTier] ?? null };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Shortfall floor target (pure; called by the router after shortfall).
|
|
231
|
+
* A high-confidence Jev tier is a measured verdict — cheapest-covering must
|
|
232
|
+
* not demote below the stronger of (scored legacy pick, Jev verdict).
|
|
233
|
+
* @returns {string|null} floor tier, or null when no floor applies.
|
|
234
|
+
*/
|
|
235
|
+
function jevFloorTarget({ selectedTier, legacyTier, jevTier, jevConfidence } = {}) {
|
|
236
|
+
if (typeof jevConfidence !== 'number' || jevConfidence < JEV_PROMOTE_CONFIDENCE) return null;
|
|
237
|
+
if (!jevTier || !_pri(jevTier)) return null;
|
|
238
|
+
const floor = _pri(legacyTier) >= _pri(jevTier) ? legacyTier : jevTier;
|
|
239
|
+
if (!floor || !( _pri(selectedTier) < _pri(floor))) return null;
|
|
240
|
+
return floor;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Risk corroboration lift (pure; escalate-only, one band).
|
|
245
|
+
* @returns {string|null} lifted tier, or null when no lift applies.
|
|
246
|
+
*/
|
|
247
|
+
function jevRiskLift({ tier, risky, riskLevel } = {}) {
|
|
248
|
+
if (tier !== 'SIMPLE' && tier !== 'MEDIUM') return null;
|
|
249
|
+
if (typeof risky !== 'number' || risky < JEV_RISK_CUT) return null;
|
|
250
|
+
if (riskLevel === 'high') return null; // keyword path owns high risk
|
|
251
|
+
return tier === 'SIMPLE' ? 'MEDIUM' : 'COMPLEX';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = {
|
|
255
|
+
JEV_ENDPOINT,
|
|
256
|
+
JEV_MODEL,
|
|
257
|
+
JEV_TIMEOUT_MS,
|
|
258
|
+
JEV_PROMOTE_CONFIDENCE,
|
|
259
|
+
JEV_RISK_CUT,
|
|
260
|
+
TIER_CRITERIA_V1,
|
|
261
|
+
CRITERIA_HASH,
|
|
262
|
+
VALID_TIERS,
|
|
263
|
+
getApiKey,
|
|
264
|
+
buildJevState,
|
|
265
|
+
evaluateJev,
|
|
266
|
+
classifyJev,
|
|
267
|
+
jevFloorTarget,
|
|
268
|
+
jevRiskLift,
|
|
269
|
+
jevTierOverride,
|
|
270
|
+
TIER_MIDPOINT,
|
|
271
|
+
_clearCache,
|
|
272
|
+
};
|
|
@@ -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;
|
|
@@ -354,6 +366,8 @@ class ModelTierSelector {
|
|
|
354
366
|
return config.baidu?.model || null;
|
|
355
367
|
case 'fireworks':
|
|
356
368
|
return config.fireworks?.model || null;
|
|
369
|
+
case 'orcarouter':
|
|
370
|
+
return config.orcarouter?.model || null;
|
|
357
371
|
case 'codex':
|
|
358
372
|
return config.codex?.model || null;
|
|
359
373
|
case 'vertex':
|
|
@@ -519,4 +533,5 @@ module.exports = {
|
|
|
519
533
|
getModelTierSelector,
|
|
520
534
|
reloadCalibratedThresholds,
|
|
521
535
|
TIER_DEFINITIONS,
|
|
536
|
+
TIER_MIDPOINT,
|
|
522
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 };
|