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.
@@ -73,61 +73,53 @@ function compressToolDescriptions(tools, mode = null) {
73
73
  // Normalize to Anthropic shape: OpenAI-format callers (OpenWorker, aisuite,
74
74
  // any OpenAI SDK) send {type:"function", function:{name, description, parameters}}.
75
75
  // Unwrap once here so the compression logic below can assume input_schema exists.
76
- tools = tools.map(tool =>
77
- tool?.type === "function" && tool.function
78
- ? {
79
- name: tool.function.name,
80
- description: tool.function.description,
81
- input_schema: tool.function.parameters || { type: "object", properties: {} },
82
- }
83
- : tool
84
- );
85
-
86
- return tools.map(tool => {
87
- const input_schema = tool.input_schema || { type: "object", properties: {} };
88
- const compressed = {
89
- name: tool.name,
90
- input_schema: {
91
- type: input_schema.type || "object",
92
- properties: {},
93
- required: input_schema.required || [],
94
- }
76
+ // Every other key on the function object (strict, and any future keywords)
77
+ // passes through untouched — an allowlist here silently drops enforceable
78
+ // constraints (issue #116).
79
+ tools = tools.map(tool => {
80
+ if (tool?.type !== "function" || !tool.function) return tool;
81
+ const { parameters, ...kept } = tool.function;
82
+ return {
83
+ ...kept,
84
+ input_schema: parameters || { type: "object", properties: {} },
95
85
  };
86
+ });
96
87
 
97
- // Add minimal description only if it exists
98
- if (tool.description) {
99
- compressed.description = compressText(tool.description, 50);
88
+ return tools.map(tool => {
89
+ // Copy everything through; compression below targets description TEXT
90
+ // only, never schema shape (issue #116: an allowlist rebuild silently
91
+ // dropped strict/pattern/const/oneOf and misleveled additionalProperties).
92
+ const out = { ...tool };
93
+ if (typeof out.description === 'string' && out.description) {
94
+ out.description = compressText(out.description, 50);
100
95
  }
101
-
102
- // Compress property descriptions
103
- if (input_schema.properties) {
104
- for (const [key, value] of Object.entries(input_schema.properties)) {
105
- compressed.input_schema.properties[key] = {
106
- type: value.type,
107
- };
108
-
109
- // Only include description if it's critical
110
- if (value.description && !isObviousFromName(key)) {
111
- compressed.input_schema.properties[key].description = compressText(value.description, 30);
112
- }
113
-
114
- // Preserve enum, format, and other critical constraints
115
- if (value.enum) compressed.input_schema.properties[key].enum = value.enum;
116
- if (value.format) compressed.input_schema.properties[key].format = value.format;
117
- if (value.items) compressed.input_schema.properties[key].items = value.items;
118
- if (value.additionalProperties !== undefined) {
119
- compressed.input_schema.properties[key].additionalProperties = value.additionalProperties;
120
- }
121
- }
96
+ if (out.input_schema && typeof out.input_schema === 'object') {
97
+ out.input_schema = compressSchemaDescriptions(out.input_schema, null);
122
98
  }
99
+ return out;
100
+ });
101
+ }
123
102
 
124
- // Preserve additionalProperties if set
125
- if (input_schema.additionalProperties !== undefined) {
126
- compressed.input_schema.additionalProperties = input_schema.additionalProperties;
103
+ /**
104
+ * Recursively compress `description` strings in a JSON Schema, preserving
105
+ * every other keyword byte-for-byte in shape. Descriptions obvious from the
106
+ * property name are dropped, the rest truncated — matching the historical
107
+ * minimal-mode behavior without an allowlist that lags the spec.
108
+ */
109
+ function compressSchemaDescriptions(node, key) {
110
+ if (Array.isArray(node)) return node.map((v) => compressSchemaDescriptions(v, key));
111
+ if (node && typeof node === 'object') {
112
+ const out = {};
113
+ for (const [k, v] of Object.entries(node)) {
114
+ if (k === 'description' && typeof v === 'string') {
115
+ if (!isObviousFromName(key)) out[k] = compressText(v, 30);
116
+ } else {
117
+ out[k] = compressSchemaDescriptions(v, k);
118
+ }
127
119
  }
128
-
129
- return compressed;
130
- });
120
+ return out;
121
+ }
122
+ return node;
131
123
  }
132
124
 
133
125
  /**
@@ -59,9 +59,25 @@ function _db() {
59
59
  // Additive migration for cache-aware routing (Phase 1): JSON blob
60
60
  // holding {warmPrefixTokens, provider, model, lastRequestAt, ttlMs},
61
61
  // updated after every upstream response that reports cache usage.
62
+ // NOTE (2026-09-18): superseded by the dedicated session_cache_state
63
+ // table below. The column stays (and old blobs stay readable nowhere —
64
+ // loadCacheState reads the new table), but NOTHING writes it anymore:
65
+ // sharing the pins table let cache-only rows (no tier) be served as
66
+ // routing pins, permanently locking sessions with no escalation path.
62
67
  if (!cols.has("cache_state")) {
63
68
  db.exec("ALTER TABLE session_pins ADD COLUMN cache_state TEXT");
64
69
  }
70
+ db.exec(`
71
+ CREATE TABLE IF NOT EXISTS session_cache_state (
72
+ session_id TEXT PRIMARY KEY,
73
+ warm_prefix_tokens INTEGER NOT NULL,
74
+ provider TEXT NOT NULL,
75
+ model TEXT,
76
+ last_request_at INTEGER NOT NULL,
77
+ ttl_ms INTEGER NOT NULL
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_cache_state_ts ON session_cache_state(last_request_at);
80
+ `);
65
81
  schemaEnsured = true;
66
82
  } catch (err) {
67
83
  degradation.record("feedback", err);
@@ -168,9 +184,10 @@ function save(sessionId, pin) {
168
184
 
169
185
  /**
170
186
  * Persist per-session prompt-cache state (Phase 1, cache-aware routing).
171
- * Piggybacks on the session_pins row; creates a minimal row when the session
172
- * has no pin yet (possible when sticky sessions are disabled but tracking
173
- * is on). Best-effort like everything else in this module.
187
+ * Lives in its OWN table (session_cache_state), deliberately separate from
188
+ * session_pins: the old piggyback design wrote tier-less rows that got
189
+ * served as routing pins, locking sessions with no escalation path.
190
+ * Best-effort like everything else in this module.
174
191
  *
175
192
  * @param {string} sessionId
176
193
  * @param {{warmPrefixTokens:number, provider:string, model:string|null, lastRequestAt:number, ttlMs:number}} state
@@ -180,21 +197,25 @@ function saveCacheState(sessionId, state) {
180
197
  const db = _db();
181
198
  if (!db) return;
182
199
  try {
183
- const json = JSON.stringify(state);
184
- const res = _stmt(
200
+ _stmt(
185
201
  db,
186
- "cache_state_update",
187
- "UPDATE session_pins SET cache_state = ? WHERE session_id = ?"
188
- ).run(json, sessionId);
189
- if (res.changes === 0) {
190
- _stmt(
191
- db,
192
- "cache_state_insert",
193
- `INSERT INTO session_pins (session_id, provider, model, ts, cache_state)
194
- VALUES (?, ?, ?, ?, ?)
195
- ON CONFLICT(session_id) DO UPDATE SET cache_state = excluded.cache_state`
196
- ).run(sessionId, state.provider, state.model ?? null, Date.now(), json);
197
- }
202
+ "cache_state_upsert",
203
+ `INSERT INTO session_cache_state (session_id, warm_prefix_tokens, provider, model, last_request_at, ttl_ms)
204
+ VALUES (?, ?, ?, ?, ?, ?)
205
+ ON CONFLICT(session_id) DO UPDATE SET
206
+ warm_prefix_tokens = excluded.warm_prefix_tokens,
207
+ provider = excluded.provider,
208
+ model = excluded.model,
209
+ last_request_at = excluded.last_request_at,
210
+ ttl_ms = excluded.ttl_ms`
211
+ ).run(
212
+ sessionId,
213
+ state.warmPrefixTokens ?? 0,
214
+ state.provider,
215
+ state.model ?? null,
216
+ state.lastRequestAt ?? Date.now(),
217
+ state.ttlMs ?? 5 * 60 * 1000
218
+ );
198
219
  } catch (err) {
199
220
  degradation.record("feedback", err);
200
221
  }
@@ -216,11 +237,16 @@ function loadCacheState(sessionId) {
216
237
  const row = _stmt(
217
238
  db,
218
239
  "cache_state_load",
219
- "SELECT cache_state FROM session_pins WHERE session_id = ?"
240
+ "SELECT warm_prefix_tokens, provider, model, last_request_at, ttl_ms FROM session_cache_state WHERE session_id = ?"
220
241
  ).get(sessionId);
221
- if (!row?.cache_state) return null;
222
- const parsed = JSON.parse(row.cache_state);
223
- return parsed && typeof parsed === "object" ? parsed : null;
242
+ if (!row) return null;
243
+ return {
244
+ warmPrefixTokens: Number(row.warm_prefix_tokens) || 0,
245
+ provider: row.provider,
246
+ model: row.model ?? null,
247
+ lastRequestAt: Number(row.last_request_at) || 0,
248
+ ttlMs: Number(row.ttl_ms) || 5 * 60 * 1000,
249
+ };
224
250
  } catch (err) {
225
251
  degradation.record("feedback", err);
226
252
  return null;
@@ -47,6 +47,7 @@ const PROVIDER_CACHE_DEFAULTS = {
47
47
  moonshot: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
48
48
  openrouter: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
49
49
  edenai: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
50
+ orcarouter: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
50
51
  // DeepSeek direct (via aggregators today, kept for model-level matches):
51
52
  deepseek: { readMult: 0.02, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
52
53
 
@@ -61,12 +61,23 @@ function _cfg() {
61
61
  * @param {number} [args.outputTokensPerTurn] - est. output tokens per turn.
62
62
  * @param {number|null} [args.expectedRemainingTurns] - median remaining
63
63
  * turns; falls back to the conservative config default when null.
64
+ * @param {number|null} [args.sessionBurnPressure] - 0..1 quota pressure from
65
+ * quota-ledger.js (sustained expensive serving on a flat subscription).
66
+ * Enables a parallel QUOTA break-even leg: while dollar math prices the
67
+ * rebuild in USD, quota math prices it in ledger units (model weight ×
68
+ * tokens, Haiku = 1.0). Either leg clearing allows the switch, so high
69
+ * burn opens descents the dollar leg alone would hold. Null/0 disables
70
+ * the quota leg entirely — pure dollar behavior, byte-for-byte backward
71
+ * compatible. Upgrades never route through this gate, so pressure can
72
+ * only accelerate descents, never block capability.
64
73
  * @param {Object} [args.deps] - test injection: {resolveCacheEconomics}.
65
74
  * @returns {{
66
75
  * switchAllowed: boolean,
67
76
  * reason: string,
68
77
  * breakEvenTurns: number|null,
69
78
  * expectedRemainingTurns: number,
79
+ * quotaBreakEvenTurns: number|null,
80
+ * burnPressureApplied: number,
70
81
  * warmPrefixTokens: number,
71
82
  * switchOnceUsd: number|null,
72
83
  * stayPerTurnUsd: number|null,
@@ -81,18 +92,24 @@ function evaluateSwitch({
81
92
  newTokensPerTurn,
82
93
  outputTokensPerTurn,
83
94
  expectedRemainingTurns,
95
+ sessionBurnPressure = null,
84
96
  deps = {},
85
97
  } = {}) {
86
98
  const cfg = _cfg();
87
99
  const remaining = Number.isFinite(expectedRemainingTurns) && expectedRemainingTurns > 0
88
100
  ? expectedRemainingTurns
89
101
  : cfg.defaultRemainingTurns;
102
+ const burnPressure = Number.isFinite(sessionBurnPressure)
103
+ ? Math.max(0, Math.min(1, sessionBurnPressure))
104
+ : 0;
90
105
 
91
106
  const base = {
92
107
  switchAllowed: true,
93
108
  reason: 'no_cache_state',
94
109
  breakEvenTurns: null,
95
110
  expectedRemainingTurns: remaining,
111
+ quotaBreakEvenTurns: null,
112
+ burnPressureApplied: burnPressure,
96
113
  warmPrefixTokens: cacheState?.warmPrefixTokens ?? 0,
97
114
  switchOnceUsd: null,
98
115
  stayPerTurnUsd: null,
@@ -174,19 +191,46 @@ function evaluateSwitch({
174
191
  const breakEvenTurns = switchOnce / savingsPerTurn;
175
192
  const allowed = breakEvenTurns <= remaining;
176
193
 
194
+ // Quota leg (flat subscriptions): re-run the same break-even in ledger
195
+ // units when the session is burning. Model weight = (input+output $/M) /
196
+ // Haiku baseline — quota burn tracks price ratios, which is exactly what
197
+ // the ledger records. A rebuild that never pays in dollars can still pay
198
+ // in runway when every held turn burns 5x the units a descent would.
199
+ let quotaBreakEvenTurns = null;
200
+ let quotaAllowed = false;
201
+ if (burnPressure > 0) {
202
+ const wCur = (econCur.inputPerM + econCur.outputPerM) / 6;
203
+ const wTgt = (econTgt.inputPerM + econTgt.outputPerM) / 6;
204
+ if (wCur > wTgt) {
205
+ const perTurnTokens = (W + N + O) / 1000;
206
+ const quotaSavingsPerTurn = perTurnTokens * (wCur - wTgt);
207
+ if (quotaSavingsPerTurn > 0) {
208
+ quotaBreakEvenTurns = ((W + N) / 1000) * wTgt / quotaSavingsPerTurn;
209
+ quotaAllowed = quotaBreakEvenTurns <= remaining;
210
+ }
211
+ }
212
+ }
213
+ const finalAllowed = allowed || quotaAllowed;
214
+
177
215
  logger.debug({
178
216
  current: `${current.provider}:${current.model}`,
179
217
  target: `${target.provider}:${target.model}`,
180
218
  warmPrefixTokens: W,
181
219
  breakEvenTurns: Number(breakEvenTurns.toFixed(2)),
182
220
  expectedRemainingTurns: remaining,
221
+ quotaBreakEvenTurns: quotaBreakEvenTurns === null ? null : Number(quotaBreakEvenTurns.toFixed(2)),
222
+ burnPressureApplied: burnPressure,
183
223
  allowed,
224
+ quotaAllowed,
184
225
  }, '[CacheSwitchCost] break-even evaluated');
185
226
 
186
227
  return {
187
228
  ...priced,
188
- switchAllowed: allowed,
189
- reason: allowed ? 'break_even_cleared' : 'break_even_blocked',
229
+ quotaBreakEvenTurns,
230
+ switchAllowed: finalAllowed,
231
+ reason: finalAllowed
232
+ ? (allowed ? 'break_even_cleared' : 'quota_break_even_cleared')
233
+ : 'break_even_blocked',
190
234
  breakEvenTurns,
191
235
  };
192
236
  }
@@ -104,16 +104,28 @@ const FORCE_REASONING_PATTERNS = [
104
104
  /\b(prove|proof|formal\s+proof|verify|verification)\b/i,
105
105
  /\b(from\s+first\s+principles)\b/i,
106
106
  /\b(reason\s+(through|about|from)\s+(the|this))/i,
107
+ // Frontier planning: asking FOR a plan/strategy/design of technical work
108
+ // (not asking about one). Two-sided lookahead — a plan word AND a technical
109
+ // scope word must both be present, so "plan my vacation", "lesson plan"
110
+ // and "business plan" stay quiet while "give me a plan to refactor X",
111
+ // "draft an RFC to redesign Y" and "migration strategy for Z" escalate.
112
+ // Deliberately excludes bare "give me a plan" (nothing technical to plan)
113
+ // and generic nouns (module/api/database) that misfire on non-technical
114
+ // asks. Known overlap: "explain the deployment plan" fires — accepted,
115
+ // deployment planning benefits from the strong model anyway.
116
+ /(?=.*\b(plans?|roadmaps?|strateg(y|ies)|blueprints?|proposals?|rfcs?|design\s+docs?)\b)(?=.*\b(refactor\w*|implement\w*|migrat\w+|architect\w+|redesign\w*|restructur\w+|rewrit\w+|overhaul\w*|codebases?|infrastructure|pipelines?|deploy\w*|systems?)\b)/i,
107
117
  ];
108
118
 
109
119
  // Force COMPLEX patterns - route to COMPLEX (GLM in config B) regardless of
110
120
  // score. Architecture/refactor/code-review are substantial but not
111
121
  // security-critical, so GLM is appropriate.
112
122
  const FORCE_CLOUD_PATTERNS = [
113
- /\b(architect(ure)?\s+(review|design|diagram)|system\s+design)\b/i,
123
+ /\b(architect\w*\s+(review|design|diagram)|system\s+design)\b/i,
114
124
  /\b(refactor\s+(entire|whole|all|the\s+entire)|complete\s+rewrite)\b/i,
115
125
  /\b(code\s+review|pr\s+review|pull\s+request\s+review)\b/i,
116
- /\b(debug(ging)?\s+(complex|difficult|hard|tricky))\b/i,
126
+ // "debug this tricky race" reads naturally but the adjectives rarely sit
127
+ // adjacent to the verb, so up to two words may intervene.
128
+ /\bdebug(?:ging)?\s+(?:\w+\s+){0,2}(complex|difficult|hard|tricky)\b/i,
117
129
  /\b(production\s+(issue|bug|incident|outage))\b/i,
118
130
  ];
119
131
 
@@ -655,8 +667,13 @@ function calculateWeightedScore(payload, content) {
655
667
  const avgLength = content.length / Math.max(sentences.length, 1);
656
668
  dimensions.promptComplexity = Math.min(avgLength / 2, 100);
657
669
 
658
- // 3. Technical depth (keyword density)
659
- const techMatches = (content.match(PATTERNS.technical) || []).length;
670
+ // 3. Technical depth (keyword density). NOTE: uses a local global-flag
671
+ // copy — the shared PATTERNS.technical must stay flag-less because .test()
672
+ // callers above depend on stateless matching (a shared /g regex carries
673
+ // lastIndex between calls). String.match with a non-global regex returns
674
+ // at most one hit, which used to cap this dimension at 15 regardless of
675
+ // density and starved COMPLEX-tier scores of their strongest signal.
676
+ const techMatches = (content.match(new RegExp(PATTERNS.technical.source, 'gi')) || []).length;
660
677
  dimensions.technicalDepth = Math.min(techMatches * 15, 100);
661
678
 
662
679
  // 4. Domain specificity (how many domains are touched)
@@ -94,6 +94,7 @@ function _enabledProviders() {
94
94
  if (config.openai?.apiKey) out.push('openai');
95
95
  if (config.atlas?.apiKey) out.push('atlas');
96
96
  if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) out.push('azure-openai');
97
+ if (config.orcarouter?.apiKey) out.push('orcarouter');
97
98
  if (config.ollama?.endpoint) out.push('ollama');
98
99
  if (config.llamacpp?.endpoint) out.push('llamacpp');
99
100
  if (config.lmstudio?.endpoint) out.push('lmstudio');
@@ -137,6 +138,7 @@ function getBestCloudProvider() {
137
138
  if (config.openai?.apiKey) return 'openai';
138
139
  if (config.atlas?.apiKey) return 'atlas';
139
140
  if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) return 'azure-openai';
141
+ if (config.orcarouter?.apiKey) return 'orcarouter';
140
142
 
141
143
  return getFallbackProvider();
142
144
  }
@@ -803,6 +805,17 @@ function checkSessionPin(payload, options = {}) {
803
805
  const guards = _runPinGuards(payload, pin);
804
806
  if (!guards.ok) return { serve: false, pin, sessionId, reason: guards.reason };
805
807
 
808
+ // Tier-less pins (legacy cache-only rows predating the session_cache_state
809
+ // split) must not drive tier decisions: drift math on a null tier never
810
+ // fires, so serving them locks the session with no escalation path.
811
+ // Exempted when ANY tool blocks exist anywhere in the payload: an
812
+ // unresolved tool_use breaks if the provider switches, so provider
813
+ // stickiness wins there (same safety rationale as the tool_history path
814
+ // above, which returns before reaching this check).
815
+ if (!pin.tier && !_payloadHasAnyToolBlocks(payload)) {
816
+ return { serve: false, pin, sessionId, reason: 'tierless_pin' };
817
+ }
818
+
806
819
  if (refreshOk) {
807
820
  sessionAffinity.setPin(sessionId, pin, {
808
821
  messageCount: Math.max(messageCount, pin.messageCount ?? 0),
@@ -1093,6 +1106,11 @@ async function _determineProviderSmartInner(payload, options = {}) {
1093
1106
  analysis.score = intent.score;
1094
1107
  analysis.scoreMode = intent.mode; // 'anchor' | 'lexical' (clean-text fallback)
1095
1108
  analysis.anchorClass = intent.class ?? null;
1109
+ // Jev verdict rides alongside the score so the shortfall floor,
1110
+ // risk corroboration and telemetry can consume it downstream.
1111
+ // Null unless the Jev leg ran (pinned serves, side frames and
1112
+ // classifier skips leave no verdict — same as classifierResult).
1113
+ if (intent.jev && typeof intent.jev === 'object') analysis.jev = intent.jev;
1096
1114
  intentScored = true;
1097
1115
  logger.debug({
1098
1116
  score: intent.score,
@@ -1211,12 +1229,47 @@ async function _determineProviderSmartInner(payload, options = {}) {
1211
1229
  // (agentic minTier, context, vision, kNN-ambiguous) for telemetry.
1212
1230
  let baseTier = null;
1213
1231
  const escalations = [];
1232
+ let _jevOverrode = false;
1214
1233
  if (config.modelTiers?.enabled) {
1215
1234
  try {
1216
1235
  const selector = getModelTierSelector();
1217
1236
  tier = selector.getTier(analysis.score);
1218
1237
  baseTier = tier;
1219
1238
 
1239
+ // Jev tier override: a high-confidence measured verdict replaces the
1240
+ // score-band tier in either direction (score bands stay the fallback
1241
+ // for Jev-null/low-confidence). Runs BEFORE agentic floors and risk
1242
+ // lifts so structural guarantees can still lift above the verdict —
1243
+ // never below it. Score is set to the tier midpoint so drift math,
1244
+ // badges and telemetry stay coherent with the decided tier.
1245
+ try {
1246
+ const { jevTierOverride } = require('./jev-router');
1247
+ const _ov = jevTierOverride({
1248
+ baseTier,
1249
+ jevTier: analysis.jev?.tier,
1250
+ jevConfidence: analysis.jev?.confidence,
1251
+ });
1252
+ if (_ov) {
1253
+ escalations.push({
1254
+ source: 'jev_override',
1255
+ fromTier: tier,
1256
+ toTier: _ov.tier,
1257
+ fromModel: null,
1258
+ toModel: null,
1259
+ });
1260
+ logger.debug({
1261
+ fromTier: tier,
1262
+ toTier: _ov.tier,
1263
+ jevConfidence: analysis.jev?.confidence ?? null,
1264
+ }, '[Routing] Jev tier override');
1265
+ tier = _ov.tier;
1266
+ if (typeof _ov.score === 'number') analysis.score = _ov.score;
1267
+ _jevOverrode = true;
1268
+ }
1269
+ } catch (err) {
1270
+ degradation.record('tier_select', err);
1271
+ }
1272
+
1220
1273
  // Check if agentic detection requires a higher tier
1221
1274
  if (agenticResult?.minTier) {
1222
1275
  const agenticTierPriority = TIER_DEFINITIONS[agenticResult.minTier]?.priority || 0;
@@ -1235,6 +1288,30 @@ async function _determineProviderSmartInner(payload, options = {}) {
1235
1288
  }
1236
1289
  }
1237
1290
 
1291
+ // Jev risk corroboration (escalate-only): a high Noul on auth /
1292
+ // secrets / production / destructive content lifts SIMPLE/MEDIUM one
1293
+ // band when the keyword analyzer stayed quiet. Never clears a keyword
1294
+ // hit (that path returned far above), never jumps two bands, never
1295
+ // fires without a verdict (fail-soft null skips silently). Mirrors the
1296
+ // kNN-ambiguous escalate pattern with a ledger entry.
1297
+ try {
1298
+ const { jevRiskLift } = require('./jev-router');
1299
+ const _lifted = jevRiskLift({ tier, risky: analysis.jev?.risky, riskLevel: risk?.level });
1300
+ if (_lifted) {
1301
+ escalations.push({
1302
+ source: 'jev_risk',
1303
+ fromTier: tier,
1304
+ toTier: _lifted,
1305
+ fromModel: null,
1306
+ toModel: null,
1307
+ });
1308
+ logger.debug({ fromTier: tier, toTier: _lifted, risky: analysis.jev.risky }, '[Routing] Jev risk corroboration — one-band lift');
1309
+ tier = _lifted;
1310
+ }
1311
+ } catch (err) {
1312
+ degradation.record('tier_select', err);
1313
+ }
1314
+
1238
1315
  // Select model for the tier (will be applied after provider selection)
1239
1316
  analysis.tier = tier;
1240
1317
  } catch (err) {
@@ -1245,7 +1322,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
1245
1322
  // Apply routing decision based on tier config (TIER_* env vars take precedence
1246
1323
  // but Phase 1.2 lets the cost-optimizer pick a cheaper qualifying model when safe).
1247
1324
  let provider;
1248
- let method = 'tier_config';
1325
+ let method = _jevOverrode ? 'tier_config+jev_override' : 'tier_config';
1249
1326
  let costOptimized = false;
1250
1327
 
1251
1328
  const selector = getModelTierSelector();
@@ -1340,6 +1417,51 @@ async function _determineProviderSmartInner(payload, options = {}) {
1340
1417
  degradation.record('shortfall', err);
1341
1418
  }
1342
1419
 
1420
+ // Jev shortfall floor: a high-confidence Jev tier is a measured verdict,
1421
+ // not a heuristic guess — cheapest-covering must not demote below it.
1422
+ // Floor target is the stronger of (scored legacy pick, Jev verdict):
1423
+ // score-derived highs are never talked down by the judge, and judged
1424
+ // highs are never talked down by the price optimizer (see jevFloorTarget).
1425
+ // De-escalation below still runs after this, so evidence gates apply like
1426
+ // any other decision.
1427
+ try {
1428
+ const { jevFloorTarget } = require('./jev-router');
1429
+ const _floorTier = shortfallInfo && shortfallInfo.legacy
1430
+ ? jevFloorTarget({
1431
+ selectedTier: tier,
1432
+ legacyTier: shortfallInfo.legacy.tier,
1433
+ jevTier: analysis.jev?.tier,
1434
+ jevConfidence: analysis.jev?.confidence,
1435
+ })
1436
+ : null;
1437
+ if (_floorTier) {
1438
+ const _floorSel = selector.selectModel(_floorTier, null);
1439
+ if (_floorSel && _floorSel.provider && _floorSel.model) {
1440
+ const _fromTier = tier;
1441
+ const _fromModel = selectedModel;
1442
+ provider = _floorSel.provider;
1443
+ selectedModel = _floorSel.model;
1444
+ tier = _floorTier;
1445
+ analysis.tier = tier;
1446
+ method = method + '+jev_floor_hold';
1447
+ escalations.push({
1448
+ source: 'jev_floor',
1449
+ fromTier: _fromTier,
1450
+ toTier: tier,
1451
+ fromModel: _fromModel,
1452
+ toModel: selectedModel,
1453
+ });
1454
+ logger.info({
1455
+ from: `${_fromTier}:${_fromModel}`,
1456
+ to: `${tier}:${selectedModel}`,
1457
+ jevConfidence: analysis.jev?.confidence ?? null,
1458
+ }, '[Routing] Jev floor hold — shortfall demotion refused');
1459
+ }
1460
+ }
1461
+ } catch (err) {
1462
+ degradation.record('shortfall', err);
1463
+ }
1464
+
1343
1465
  // WS2.3 — evidence-based de-escalation.
1344
1466
  //
1345
1467
  // The check is intentionally gated by evidence, not a feature flag: the
@@ -338,9 +338,8 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
338
338
  const anchorIdx = TIER_ORDER.indexOf(anchorTier);
339
339
  const classifierIdx = TIER_ORDER.indexOf(classifierTier);
340
340
 
341
- // Midpoints of each tier band (from model-tiers.js defaults):
342
- // SIMPLE 0-19 → 10, MEDIUM 20-50 → 35, COMPLEX 51-75 → 63, REASONING 76-100 → 88
343
- const TIER_MIDPOINT = { SIMPLE: 10, MEDIUM: 35, COMPLEX: 63, REASONING: 88 };
341
+ // Midpoints live in model-tiers.js (single source of truth).
342
+ const { TIER_MIDPOINT } = require('./model-tiers');
344
343
 
345
344
  if (classifierIdx < anchorIdx) {
346
345
  // Classifier says LOWER tier — trust it. Fixes over-routing.
@@ -410,24 +409,47 @@ async function scoreIntent(payload, opts = {}) {
410
409
  const [lo, hi] = CLASS_BANDS[cls];
411
410
  const anchorScore = Math.max(lo, Math.min(hi, blendScore(sims)));
412
411
 
413
- // LLM classifier — second opinion. Skipped in tests (opts.skipClassifier)
414
- // to keep unit tests hermetic. Runs live otherwise.
412
+ // Jev routing judge — second opinion. Skipped in tests (opts.skipClassifier)
413
+ // to keep unit tests hermetic (or stubbed via opts.jevFetchFn). Runs live
414
+ // otherwise. Replaces the qwen difficulty classifier in the same result
415
+ // shape ({tier, confidence}) so _reconcile semantics are unchanged;
416
+ // Jev extras (probabilities, risky, model, criteriaHash) ride along
417
+ // for telemetry and the shortfall floor. Fail-soft null preserves the
418
+ // anchor-only path exactly.
419
+ //
420
+ // Hermeticity note: suites asserting deterministic routing scrub
421
+ // TYPESAFE_API_KEY (no key → Jev leg returns null before any fetch),
422
+ // the same posture as the repo's live-Ollama tolerance. Never gate
423
+ // on NODE_ENV — .env pins it to production in every context.
415
424
  let classifierResult = null;
425
+ let jevResult = null;
416
426
  if (!opts.skipClassifier) {
417
427
  try {
418
- const { classifyDifficulty } = require('./difficulty-classifier');
419
- classifierResult = await classifyDifficulty(text, {
420
- forceMatched: opts.forceMatched,
421
- riskLevel: opts.riskLevel,
422
- // Condensed prior turns, threaded by the router's window loop.
423
- // Without it a short follow-up ("Who kills him ?") is
424
- // unclassifiable in isolation.
428
+ const { classifyJev } = require('./jev-router');
429
+ const msgs = Array.isArray(payload?.messages) ? payload.messages : [];
430
+ const toolDefs = Array.isArray(payload?.tools) ? payload.tools : [];
431
+ const toolResults = msgs.filter((m) =>
432
+ m?.role === 'user' && Array.isArray(m.content) && m.content.some((c) => c?.type === 'tool_result')
433
+ ).length;
434
+ const jevFetchFn = opts.jevFetchFn;
435
+ jevResult = await classifyJev(text, {
425
436
  context: typeof payload?._conversationContext === 'string'
426
437
  ? payload._conversationContext
427
438
  : null,
439
+ signals: {
440
+ message_count: msgs.length,
441
+ tools_attached: toolDefs.length,
442
+ effective_tools: toolDefs.length,
443
+ has_tool_history: toolResults > 0,
444
+ session_turn: Math.max(1, Math.ceil(msgs.length / 2)),
445
+ },
446
+ fetchFn: jevFetchFn,
428
447
  });
448
+ if (jevResult) {
449
+ classifierResult = { tier: jevResult.tier, confidence: jevResult.confidence };
450
+ }
429
451
  } catch (err) {
430
- logger.debug({ err: err.message }, '[IntentScore] classifier failed — anchor only');
452
+ logger.debug({ err: err.message }, '[IntentScore] Jev classifier failed — anchor only');
431
453
  }
432
454
  }
433
455
 
@@ -453,6 +475,19 @@ async function scoreIntent(payload, opts = {}) {
453
475
  classifierTier: classifierResult?.tier ?? null,
454
476
  classifierConfidence: classifierResult?.confidence ?? null,
455
477
  reconciled,
478
+ // Jev verdict details for telemetry + shortfall floor downstream.
479
+ // Null unless the Jev leg ran (same conditions as classifierResult,
480
+ // plus LRU-cached repeats which still carry the original extras).
481
+ jev: jevResult ? {
482
+ tier: jevResult.tier,
483
+ confidence: jevResult.confidence,
484
+ probabilities: jevResult.probabilities ?? null,
485
+ risky: jevResult.risky ?? null,
486
+ model: jevResult.model ?? null,
487
+ criteriaHash: jevResult.criteriaHash ?? null,
488
+ latencyMs: jevResult.latencyMs ?? null,
489
+ cached: !!jevResult.cached,
490
+ } : null,
456
491
  });
457
492
  }
458
493
  }