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.
@@ -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 };