lynkr 9.7.2 → 9.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +29 -19
  2. package/bin/cli.js +11 -0
  3. package/bin/lynkr-init.js +14 -1
  4. package/bin/lynkr-usage.js +78 -0
  5. package/bin/wrap.js +60 -35
  6. package/config/difficulty-anchors.json +22 -0
  7. package/package.json +24 -3
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/calibrate-thresholds.js +38 -157
  10. package/scripts/compact-dictionary.js +204 -0
  11. package/scripts/test-deduplication.js +448 -0
  12. package/scripts/ws7-anchor-replay.js +108 -0
  13. package/skills/lynkr/SKILL.md +195 -0
  14. package/src/agents/context-manager.js +18 -2
  15. package/src/agents/definitions/loader.js +90 -0
  16. package/src/agents/executor.js +24 -2
  17. package/src/agents/index.js +9 -1
  18. package/src/agents/parallel-coordinator.js +2 -2
  19. package/src/agents/reflector.js +11 -1
  20. package/src/api/middleware/loop-guard.js +87 -0
  21. package/src/api/middleware/request-logging.js +5 -64
  22. package/src/api/middleware/session.js +0 -0
  23. package/src/api/openai-router.js +120 -101
  24. package/src/api/providers-handler.js +27 -2
  25. package/src/api/router.js +450 -125
  26. package/src/budget/index.js +2 -19
  27. package/src/cache/semantic.js +9 -0
  28. package/src/clients/databricks.js +459 -146
  29. package/src/clients/gpt-utils.js +11 -105
  30. package/src/clients/openai-format.js +10 -3
  31. package/src/clients/openrouter-utils.js +49 -24
  32. package/src/clients/prompt-cache-injection.js +1 -0
  33. package/src/clients/provider-capabilities.js +1 -1
  34. package/src/clients/responses-format.js +34 -3
  35. package/src/clients/routing.js +15 -0
  36. package/src/config/index.js +36 -2
  37. package/src/context/gcf.js +275 -0
  38. package/src/context/tool-result-compressor.js +51 -9
  39. package/src/dashboard/api.js +1 -0
  40. package/src/logger/index.js +14 -1
  41. package/src/memory/search.js +12 -40
  42. package/src/memory/tools.js +3 -24
  43. package/src/orchestrator/bypass.js +4 -2
  44. package/src/orchestrator/index.js +144 -88
  45. package/src/routing/affinity-store.js +194 -0
  46. package/src/routing/agentic-detector.js +36 -6
  47. package/src/routing/bandit.js +25 -6
  48. package/src/routing/calibration.js +212 -0
  49. package/src/routing/client-profiles.js +292 -0
  50. package/src/routing/complexity-analyzer.js +48 -11
  51. package/src/routing/deescalator.js +148 -0
  52. package/src/routing/degradation.js +109 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +897 -87
  55. package/src/routing/intent-score.js +339 -0
  56. package/src/routing/interaction.js +3 -0
  57. package/src/routing/knn-router.js +70 -21
  58. package/src/routing/model-registry.js +28 -7
  59. package/src/routing/model-tiers.js +25 -2
  60. package/src/routing/reward-pipeline.js +68 -2
  61. package/src/routing/risk-analyzer.js +30 -1
  62. package/src/routing/risk-classifier.js +6 -2
  63. package/src/routing/session-affinity.js +162 -34
  64. package/src/routing/telemetry.js +298 -10
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +66 -21
  67. package/src/sessions/cleanup.js +17 -0
  68. package/src/tools/index.js +1 -15
  69. package/src/tools/smart-selection.js +10 -0
  70. package/src/tools/web-client.js +3 -3
  71. package/.eslintrc.cjs +0 -12
  72. package/benchmark-configs/litellm_config.yaml +0 -86
  73. package/benchmark-configs/lynkr.env +0 -48
  74. package/benchmark-configs/portkey-config.json +0 -60
  75. package/benchmark-configs/portkey-docker.sh +0 -23
  76. package/benchmark-tier-routing.js +0 -449
  77. package/funding.json +0 -110
  78. package/src/api/middleware/validation.js +0 -261
  79. package/src/routing/drift-monitor.js +0 -113
  80. package/src/workers/helpers.js +0 -185
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Client Profiles (WS3.1).
3
+ *
4
+ * Client harnesses like Claude Code, Cursor, and Codex attach a large
5
+ * baseline tool loadout to EVERY request (Claude Code sends ~11 tools
6
+ * unconditionally). The old agentic detector inflated `tool_count` and
7
+ * `agentic_tool` signals from those baselines, mis-classifying a trivial
8
+ * "hi" as an agentic COMPLEX-tier workload.
9
+ *
10
+ * The `slice(0, 3)` hack in `src/api/router.js` worked around that in one
11
+ * specific spot for one specific client — but it also discarded real MCP
12
+ * tools the user had configured, and did nothing for Cursor / Codex.
13
+ *
14
+ * This module fixes the root cause. Given a request's `user-agent` header
15
+ * and its tools list, `detectClient` returns the matching profile (or null),
16
+ * and `effectiveTools` subtracts the baseline so the agentic detector scores
17
+ * only tools the user actually added beyond the harness default.
18
+ *
19
+ * @module routing/client-profiles
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const logger = require('../logger');
25
+
26
+ /**
27
+ * Seed profiles. Baseline tool lists must exactly match the tool NAMES the
28
+ * client sends (case-sensitive) — the agentic detector reads
29
+ * `t.name || t.function?.name`, so these are the tools that ship in the
30
+ * harness's default `tools` array.
31
+ *
32
+ * Claude Code's default loadout was verified against the live proxy request
33
+ * shape at time of writing (2026-07). If a future release adds/renames a
34
+ * tool, add it here (or override via data/client-profiles.json).
35
+ */
36
+ const PROFILES = {
37
+ 'claude-code': {
38
+ name: 'claude-code',
39
+ baselineTools: new Set([
40
+ 'Task', 'Bash', 'Glob', 'Grep', 'Read', 'Edit', 'Write',
41
+ 'NotebookEdit', 'WebFetch', 'WebSearch', 'TodoWrite',
42
+ 'BashOutput', 'KillShell', 'SlashCommand',
43
+ ]),
44
+ detect: {
45
+ // Claude Code CLI actually sends `user-agent: claude-cli/x.y.z` (not
46
+ // claude-code/x.y.z as the tests assumed). Match every Anthropic-side
47
+ // subscription client — same set as auth-mode.js's
48
+ // SUBSCRIPTION_UA_PREFIXES, kept in sync manually since the two files
49
+ // want slightly different match rules.
50
+ headerPatterns: [
51
+ /claude[-_](cli|code|vscode)/i,
52
+ /anthropic-cli/i,
53
+ ],
54
+ minToolFingerprintMatch: 0.8,
55
+ },
56
+ },
57
+ 'cursor': {
58
+ name: 'cursor',
59
+ baselineTools: new Set([
60
+ // Cursor's default agent loadout
61
+ 'read_file', 'write_file', 'edit_file', 'delete_file',
62
+ 'run_terminal_command', 'grep_search', 'file_search', 'codebase_search',
63
+ 'web_search', 'list_dir',
64
+ ]),
65
+ detect: {
66
+ headerPatterns: [/cursor/i],
67
+ minToolFingerprintMatch: 0.8,
68
+ },
69
+ },
70
+ 'lynkr-injected': {
71
+ name: 'lynkr-injected',
72
+ baselineTools: new Set([
73
+ // Lynkr's own IDE_SAFE_TOOLS loadout (src/clients/standard-tools.js),
74
+ // injected by the OpenAI-compat routers when a client sends no tools.
75
+ // These are Lynkr plumbing, not user intent: without this profile the
76
+ // agentic detector counts its own injected tools and floors every
77
+ // tool-less client (e.g. goose's summarizer side-requests) at
78
+ // ITERATIVE/COMPLEX. Matches claude-code's names plus the four
79
+ // (MultiEdit, LS, NotebookRead, WebAgent) that keep it from
80
+ // fingerprinting as claude-code.
81
+ 'Write', 'Read', 'Edit', 'Bash', 'Glob', 'Grep', 'MultiEdit', 'LS',
82
+ 'NotebookRead', 'TodoWrite', 'Task', 'WebSearch', 'WebFetch',
83
+ 'WebAgent', 'NotebookEdit',
84
+ ]),
85
+ detect: {
86
+ headerPatterns: [],
87
+ minToolFingerprintMatch: 0.8,
88
+ },
89
+ },
90
+ 'goose': {
91
+ name: 'goose',
92
+ baselineTools: new Set([
93
+ // Goose CLI 1.41 default loadout — captured live 2026-07-13 against
94
+ // the OpenAI-compatible endpoint. Goose sends NO user-agent header,
95
+ // so detection relies entirely on this tool fingerprint. The apps__*,
96
+ // extensionmanager__* and todo__* names come from goose's default
97
+ // extensions; the rest are core tools that attach to every request.
98
+ 'analyze', 'delegate', 'edit', 'load', 'load_skill', 'read_image',
99
+ 'shell', 'tree', 'write',
100
+ 'apps__create_app', 'apps__delete_app', 'apps__iterate_app',
101
+ 'apps__list_apps',
102
+ 'extensionmanager__list_resources', 'extensionmanager__manage_extensions',
103
+ 'extensionmanager__read_resource',
104
+ 'extensionmanager__search_available_extensions',
105
+ 'todo__todo_write',
106
+ ]),
107
+ detect: {
108
+ headerPatterns: [/goose/i],
109
+ // 0.5 rather than the usual 0.8: users can disable goose's default
110
+ // extensions (apps, extensionmanager, todo), which removes up to 9 of
111
+ // the 18 baseline names while the 9 core tools still identify goose.
112
+ // A false-positive match on another harness with this many identical
113
+ // tool names would subtract only those shared names — harmless.
114
+ minToolFingerprintMatch: 0.5,
115
+ },
116
+ },
117
+ 'openai-codex': {
118
+ name: 'openai-codex',
119
+ baselineTools: new Set([
120
+ // Legacy Codex CLI (and Lynkr's injected fallback names)
121
+ 'shell', 'apply_patch', 'read_file', 'write_file',
122
+ // Codex v0.142+ (Rust CLI) default loadout — captured live 2026-07-11.
123
+ // All of these attach to EVERY request, including "Hi": counting them
124
+ // as agentic intent floored every Codex turn at MEDIUM.
125
+ 'exec_command', 'write_stdin', 'update_plan', 'request_user_input',
126
+ 'view_image', 'get_goal', 'create_goal', 'update_goal',
127
+ 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource',
128
+ 'list_available_plugins_to_install', 'request_plugin_install',
129
+ ]),
130
+ detect: {
131
+ headerPatterns: [/codex/i, /openai-cli/i],
132
+ minToolFingerprintMatch: 0.8,
133
+ },
134
+ },
135
+ };
136
+
137
+ // Union of every baseline tool across all known profiles — used for the
138
+ // "no-profile-but-looks-like-a-harness" heuristic guard.
139
+ let _knownBaselineToolsCache = null;
140
+ function _knownBaselineTools() {
141
+ if (_knownBaselineToolsCache) return _knownBaselineToolsCache;
142
+ const s = new Set();
143
+ for (const p of Object.values(PROFILES)) {
144
+ for (const t of p.baselineTools) s.add(t);
145
+ }
146
+ _knownBaselineToolsCache = s;
147
+ return s;
148
+ }
149
+
150
+ // User overrides / additions from data/client-profiles.json — loaded once
151
+ // at first use. Shape: { profileName: { baselineTools: [...], detect: {...} } }
152
+ let _userProfilesLoaded = false;
153
+ function _loadUserProfiles() {
154
+ if (_userProfilesLoaded) return;
155
+ _userProfilesLoaded = true;
156
+ const overridePath = path.join(process.cwd(), 'data', 'client-profiles.json');
157
+ if (!fs.existsSync(overridePath)) return;
158
+ try {
159
+ const raw = JSON.parse(fs.readFileSync(overridePath, 'utf8'));
160
+ for (const [name, def] of Object.entries(raw || {})) {
161
+ if (!def || typeof def !== 'object') continue;
162
+ const baseline = Array.isArray(def.baselineTools)
163
+ ? new Set(def.baselineTools)
164
+ : PROFILES[name]?.baselineTools ?? new Set();
165
+ const patterns = Array.isArray(def.headerPatterns)
166
+ ? def.headerPatterns.map((p) => new RegExp(p, 'i'))
167
+ : PROFILES[name]?.detect?.headerPatterns ?? [];
168
+ PROFILES[name] = {
169
+ name,
170
+ baselineTools: baseline,
171
+ detect: {
172
+ headerPatterns: patterns,
173
+ minToolFingerprintMatch: Number.isFinite(def.minToolFingerprintMatch)
174
+ ? def.minToolFingerprintMatch
175
+ : 0.8,
176
+ },
177
+ };
178
+ }
179
+ _knownBaselineToolsCache = null; // recompute after overrides
180
+ logger.debug({ count: Object.keys(raw).length }, '[ClientProfiles] Loaded user overrides');
181
+ } catch (err) {
182
+ logger.debug({ err: err.message }, '[ClientProfiles] Failed to load user overrides');
183
+ }
184
+ }
185
+
186
+ function _toolName(t) {
187
+ return (t && (t.name || t.function?.name)) || null;
188
+ }
189
+
190
+ /**
191
+ * Detect the client harness that issued this request.
192
+ *
193
+ * Match order:
194
+ * 1. user-agent header regex — strongest signal, cheap.
195
+ * 2. Tool-set fingerprint — ≥ minToolFingerprintMatch (default 80%) of the
196
+ * profile's baseline tool names must be present in payload.tools.
197
+ *
198
+ * @param {Object} args
199
+ * @param {Object} [args.headers]
200
+ * @param {Object} [args.payload]
201
+ * @returns {Object|null} The matched profile or null.
202
+ */
203
+ function detectClient({ headers = {}, payload = {} } = {}) {
204
+ _loadUserProfiles();
205
+
206
+ const ua = String(headers['user-agent'] || headers['User-Agent'] || '');
207
+ if (ua) {
208
+ for (const profile of Object.values(PROFILES)) {
209
+ for (const pattern of profile.detect.headerPatterns) {
210
+ if (pattern.test(ua)) return profile;
211
+ }
212
+ }
213
+ }
214
+
215
+ const tools = Array.isArray(payload.tools) ? payload.tools : [];
216
+ if (tools.length === 0) return null;
217
+
218
+ const presentNames = new Set(tools.map(_toolName).filter(Boolean));
219
+ let best = null;
220
+ let bestRatio = 0;
221
+ for (const profile of Object.values(PROFILES)) {
222
+ const baseline = profile.baselineTools;
223
+ if (baseline.size === 0) continue;
224
+ let hits = 0;
225
+ for (const name of baseline) {
226
+ if (presentNames.has(name)) hits++;
227
+ }
228
+ const ratio = hits / baseline.size;
229
+ if (ratio >= profile.detect.minToolFingerprintMatch && ratio > bestRatio) {
230
+ best = profile;
231
+ bestRatio = ratio;
232
+ }
233
+ }
234
+ return best;
235
+ }
236
+
237
+ /**
238
+ * Return the tools in `payload.tools` that are NOT part of `profile`'s
239
+ * baseline loadout — i.e., the tools the user actually configured beyond
240
+ * the harness default. If profile is null, returns the raw list.
241
+ *
242
+ * The array preserves the original tool objects (not just names) so the
243
+ * agentic detector can still inspect `t.name || t.function?.name`.
244
+ *
245
+ * @param {Object} payload
246
+ * @param {Object|null} profile
247
+ * @returns {Array}
248
+ */
249
+ function effectiveTools(payload, profile) {
250
+ const tools = Array.isArray(payload?.tools) ? payload.tools : [];
251
+ if (!profile) return tools.slice();
252
+ const baseline = profile.baselineTools;
253
+ return tools.filter((t) => {
254
+ const name = _toolName(t);
255
+ return name && !baseline.has(name);
256
+ });
257
+ }
258
+
259
+ /**
260
+ * True when every tool name in the payload is present in some known profile's
261
+ * baseline. Used by the agentic detector's "unknown harness" guard so a
262
+ * Claude-Code-alike client that omits its user-agent doesn't inflate the
263
+ * agentic score.
264
+ *
265
+ * @param {Object} payload
266
+ * @returns {boolean}
267
+ */
268
+ function allToolsAreBaseline(payload) {
269
+ _loadUserProfiles();
270
+ const tools = Array.isArray(payload?.tools) ? payload.tools : [];
271
+ if (tools.length === 0) return false;
272
+ const known = _knownBaselineTools();
273
+ for (const t of tools) {
274
+ const name = _toolName(t);
275
+ if (!name || !known.has(name)) return false;
276
+ }
277
+ return true;
278
+ }
279
+
280
+ /** Test helper — clear cached override state (unit tests reload from disk). */
281
+ function _resetForTests() {
282
+ _userProfilesLoaded = false;
283
+ _knownBaselineToolsCache = null;
284
+ }
285
+
286
+ module.exports = {
287
+ PROFILES,
288
+ detectClient,
289
+ effectiveTools,
290
+ allToolsAreBaseline,
291
+ _resetForTests,
292
+ };
@@ -15,6 +15,26 @@
15
15
  const logger = require('../logger');
16
16
  const config = require('../config');
17
17
  const codeGraph = require('../tools/code-graph');
18
+ const clientProfiles = require('./client-profiles');
19
+
20
+ /**
21
+ * Return the tools the user actually added beyond the client harness's
22
+ * baseline loadout. Same intent as the agentic detector's guard: Claude
23
+ * Code / Cursor / Codex all attach a fat baseline tools list to every
24
+ * request, and counting those inflates the complexity score for trivial
25
+ * turns. If no profile is attached, fall back to the raw list, but zero
26
+ * it out when every tool is a known-harness-baseline name AND there are
27
+ * enough of them to look like a harness attaching its default set
28
+ * (mirrors the "unknown_harness_guard" in agentic-detector.js).
29
+ */
30
+ function _effectiveTools(payload) {
31
+ const raw = Array.isArray(payload?.tools) ? payload.tools : [];
32
+ if (raw.length === 0) return raw;
33
+ const profile = payload?._clientProfile || null;
34
+ if (profile) return clientProfiles.effectiveTools(payload, profile);
35
+ if (raw.length >= 10 && clientProfiles.allToolsAreBaseline(payload)) return [];
36
+ return raw;
37
+ }
18
38
 
19
39
  // ============================================================================
20
40
  // PHASE 1: Basic Scoring Patterns
@@ -422,10 +442,14 @@ function scoreTokens(payload) {
422
442
  }
423
443
 
424
444
  /**
425
- * Score based on tool count (0-20 points)
445
+ * Score based on tool count (0-20 points). Uses the effective tool list —
446
+ * i.e. tools the user actually added beyond the client harness's baseline
447
+ * loadout — so a trivial "hi" with 11 Claude Code tools attached doesn't
448
+ * pick up 16 points of "heavy tools" complexity for something the user
449
+ * didn't ask for.
426
450
  */
427
451
  function scoreTools(payload) {
428
- const toolCount = Array.isArray(payload?.tools) ? payload.tools.length : 0;
452
+ const toolCount = _effectiveTools(payload).length;
429
453
 
430
454
  if (toolCount === 0) return 0; // No tools
431
455
  if (toolCount <= 3) return 4; // Few tools - local can handle
@@ -627,21 +651,22 @@ function calculateWeightedScore(payload, content) {
627
651
  }
628
652
  dimensions.domainSpecificity = Math.min(domainScore, 100);
629
653
 
630
- // 5. Tool count
631
- const toolCount = payload?.tools?.length ?? 0;
654
+ // 5. Tool count — effective tools only (harness baseline subtracted)
655
+ const effTools = _effectiveTools(payload);
656
+ const toolCount = effTools.length;
632
657
  dimensions.toolCount = toolCount === 0 ? 0 :
633
658
  toolCount <= 3 ? 20 :
634
659
  toolCount <= 6 ? 40 :
635
660
  toolCount <= 10 ? 60 :
636
661
  toolCount <= 15 ? 80 : 100;
637
662
 
638
- // 6. Tool complexity (weighted by tool types)
639
- if (payload?.tools?.length > 0) {
640
- const totalWeight = payload.tools.reduce((sum, t) => {
663
+ // 6. Tool complexity (weighted by tool types) — same effective list
664
+ if (effTools.length > 0) {
665
+ const totalWeight = effTools.reduce((sum, t) => {
641
666
  const name = t.name || t.function?.name || '';
642
667
  return sum + (TOOL_COMPLEXITY_WEIGHTS[name] || TOOL_COMPLEXITY_WEIGHTS.default);
643
668
  }, 0);
644
- const avgWeight = totalWeight / payload.tools.length;
669
+ const avgWeight = totalWeight / effTools.length;
645
670
  dimensions.toolComplexity = avgWeight * 100;
646
671
  } else {
647
672
  dimensions.toolComplexity = 0;
@@ -831,7 +856,7 @@ async function analyzeComplexity(payload, options = {}) {
831
856
  recommendation,
832
857
  breakdown: {
833
858
  tokens: { score: tokenScore, estimated: estimateTokens(payload) },
834
- tools: { score: toolScore, count: payload?.tools?.length ?? 0 },
859
+ tools: { score: toolScore, count: _effectiveTools(payload).length },
835
860
  taskType: taskTypeResult,
836
861
  codeComplexity: codeComplexityResult,
837
862
  reasoning: reasoningResult,
@@ -878,7 +903,15 @@ async function analyzeComplexity(payload, options = {}) {
878
903
  */
879
904
  function shouldForceLocal(payload) {
880
905
  const content = extractContent(payload);
881
- return FORCE_LOCAL_PATTERNS.some(pattern => pattern.test(content));
906
+ const matched = FORCE_LOCAL_PATTERNS.find(pattern => pattern.test(content));
907
+ if (matched) {
908
+ // Deliberately loud: a force_local misfire routed a subagent's opening
909
+ // frames to the weakest tier (live 2026-07-09, pattern unidentified at
910
+ // the time because the wrap gateway had no file logging). This line is
911
+ // how the next misfire names itself.
912
+ logger.debug({ pattern: matched.source.slice(0, 50), text: content.slice(0, 100) }, '[Force] force_local matched');
913
+ }
914
+ return !!matched;
882
915
  }
883
916
 
884
917
  /**
@@ -886,7 +919,11 @@ function shouldForceLocal(payload) {
886
919
  */
887
920
  function shouldForceCloud(payload) {
888
921
  const content = extractContent(payload);
889
- return FORCE_CLOUD_PATTERNS.some(pattern => pattern.test(content));
922
+ const matched = FORCE_CLOUD_PATTERNS.find(pattern => pattern.test(content));
923
+ if (matched) {
924
+ logger.debug({ pattern: matched.source.slice(0, 50), text: content.slice(0, 100) }, '[Force] force_cloud matched');
925
+ }
926
+ return !!matched;
890
927
  }
891
928
 
892
929
  // ============================================================================
@@ -0,0 +1,148 @@
1
+ /**
2
+ * De-escalation policy (WS2.3).
3
+ *
4
+ * The routing pipeline today only ratchets *up* — risk / agentic minTier /
5
+ * context / vision / kNN-ambiguous all escalate; nothing ever demotes. That
6
+ * means once a request_type has been escalated for ANY reason (even a
7
+ * one-off), it stays over-provisioned forever.
8
+ *
9
+ * This module supplies the missing signal: for a given (tier, request_type),
10
+ * has the *lower* tier historically served ≥ N similar requests at quality
11
+ * ≥ Q with error rate < E? If yes, the caller may demote — cheaper AND
12
+ * evidence-backed.
13
+ *
14
+ * The check is a plain SELECT against routing_telemetry (cached 60s) so
15
+ * enabling it in-line does not add DB pressure at request rate.
16
+ *
17
+ * @module routing/deescalator
18
+ */
19
+
20
+ const logger = require('../logger');
21
+ const telemetry = require('./telemetry');
22
+
23
+ const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
24
+
25
+ // Fixed thresholds. Chosen conservatively so a demotion only fires when the
26
+ // lower tier has demonstrable evidence: a full-week window, a meaningful
27
+ // sample count, high average quality, and a low error rate.
28
+ const MIN_SAMPLES = 30;
29
+ const MIN_QUALITY = 70;
30
+ const MAX_ERROR_RATE = 0.05;
31
+ const WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
32
+ const CACHE_TTL_MS = 60 * 1000;
33
+
34
+ /** @type {Map<string, {value: string|null, ts: number}>} */
35
+ const _cache = new Map();
36
+
37
+ function _cacheKey(tier, requestType) {
38
+ return `${tier}::${requestType || '<none>'}`;
39
+ }
40
+
41
+ function _lowerTier(tier) {
42
+ const idx = TIER_ORDER.indexOf(tier);
43
+ if (idx <= 0) return null;
44
+ return TIER_ORDER[idx - 1];
45
+ }
46
+
47
+ /**
48
+ * Return the demoted tier if evidence supports it; null otherwise.
49
+ *
50
+ * @param {Object} args
51
+ * @param {string} args.tier - The tier the router currently plans to serve.
52
+ * @param {string|null} args.requestType - Complexity analyzer's request_type.
53
+ * @param {Object} [args.analysis] - Passed through for callers that want it;
54
+ * currently unused by the rule, kept for signature stability.
55
+ * @param {Object} [args.deps] - Test-injectable dependencies.
56
+ * @param {Function} [args.deps.getQualityByTierAndType] - Override the telemetry query.
57
+ * @param {Function} [args.deps.now] - Override Date.now() for tests.
58
+ * @returns {string|null} The lower tier, or null if demotion is not warranted.
59
+ */
60
+ function suggestDemotion({ tier, requestType, analysis, deps = {} } = {}) {
61
+ if (!tier || !requestType) return null;
62
+ const lower = _lowerTier(tier);
63
+ if (!lower) return null;
64
+
65
+ const now = typeof deps.now === 'function' ? deps.now() : Date.now();
66
+ const key = _cacheKey(tier, requestType);
67
+ const cached = _cache.get(key);
68
+ if (cached && now - cached.ts < CACHE_TTL_MS) {
69
+ return cached.value;
70
+ }
71
+
72
+ const query = deps.getQualityByTierAndType || telemetry.getQualityByTierAndType;
73
+ if (typeof query !== 'function') return null;
74
+
75
+ let rows = null;
76
+ try {
77
+ rows = query({
78
+ since: now - WINDOW_MS,
79
+ until: now,
80
+ tiers: [lower],
81
+ });
82
+ } catch (err) {
83
+ logger.debug({ err: err.message }, '[Deescalator] telemetry query failed');
84
+ return null;
85
+ }
86
+
87
+ const match = Array.isArray(rows)
88
+ ? rows.find((r) => r.tier === lower && r.request_type === requestType)
89
+ : null;
90
+
91
+ const ok = match
92
+ && match.count >= MIN_SAMPLES
93
+ && (match.avg_quality ?? 0) >= MIN_QUALITY
94
+ && (match.error_rate ?? 1) < MAX_ERROR_RATE;
95
+
96
+ const value = ok ? lower : null;
97
+ _cache.set(key, { value, ts: now });
98
+ return value;
99
+ }
100
+
101
+ /** Test helper — clear the memoized decisions. */
102
+ function _clearCache() {
103
+ _cache.clear();
104
+ }
105
+
106
+ /**
107
+ * Shadow-mode policy. Wraps the live routing decision by delegating to
108
+ * `determineProviderSmart` and then applying `suggestDemotion` on the result.
109
+ * Registered with shadow-mode.js under name 'deescalate-v1' so operators can
110
+ * evaluate the projected cost/quality delta before enabling live demotion.
111
+ */
112
+ async function shadowDeescalate(payload) {
113
+ // Late require to avoid the circular src/routing/index.js ↔ deescalator.js
114
+ // reference (index.js registers the shadow policy at module load).
115
+ const { determineProviderSmart } = require('./index');
116
+ const base = await determineProviderSmart(payload, { _shadow: true });
117
+ if (!base?.tier) return base;
118
+ const requestType = base?.analysis?.breakdown?.taskType?.reason
119
+ ?? base?.analysis?.taskType
120
+ ?? null;
121
+ const demoted = suggestDemotion({
122
+ tier: base.tier,
123
+ requestType,
124
+ analysis: base.analysis,
125
+ });
126
+ if (!demoted) return base;
127
+ try {
128
+ const { getModelTierSelector } = require('./model-tiers');
129
+ const selected = getModelTierSelector().selectModel(demoted, null);
130
+ return {
131
+ ...base,
132
+ provider: selected.provider,
133
+ model: selected.model,
134
+ tier: demoted,
135
+ method: (base.method || 'tier_config') + '+deescalated_shadow',
136
+ _shadowDemotedFrom: base.tier,
137
+ };
138
+ } catch {
139
+ return base;
140
+ }
141
+ }
142
+
143
+ module.exports = {
144
+ suggestDemotion,
145
+ shadowDeescalate,
146
+ TIER_ORDER,
147
+ _clearCache,
148
+ };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Degradation Registry
3
+ *
4
+ * Tracks silent-fallback occurrences across the routing pipeline. Every catch
5
+ * block in the routing path that used to `logger.debug` and fall through now
6
+ * calls `record(component, err)` here instead, giving us:
7
+ *
8
+ * - A counter per subsystem (visible via getRoutingStats)
9
+ * - A warn-once-per-hour policy per component (so the first failure surfaces
10
+ * in ops, but a wedged subsystem doesn't spam)
11
+ * - The last error message and timestamp for postmortem
12
+ *
13
+ * @module routing/degradation
14
+ */
15
+
16
+ const logger = require("../logger");
17
+
18
+ // TODO(prometheus): expose these counters as Prometheus gauges once the
19
+ // project ships a prom-client registry (no such registry today; the WS0 plan
20
+ // authorised skipping this — getRoutingStats().degradation is the interim
21
+ // signal).
22
+
23
+ const KNOWN_COMPONENTS = new Set([
24
+ "risk",
25
+ "embeddings",
26
+ "agentic",
27
+ "tier_select",
28
+ "cost_optimize",
29
+ "context_validate",
30
+ "vision_guard",
31
+ "knn",
32
+ "bandit",
33
+ "deadline",
34
+ "tenant",
35
+ "feedback",
36
+ "calibration",
37
+ ]);
38
+
39
+ const WARN_INTERVAL_MS = 60 * 60 * 1000;
40
+
41
+ /** @type {Map<string, {count:number, lastError:string|null, lastAt:number, lastWarnedAt:number}>} */
42
+ const counters = new Map();
43
+
44
+ function _entry(component) {
45
+ let e = counters.get(component);
46
+ if (!e) {
47
+ e = { count: 0, lastError: null, lastAt: 0, lastWarnedAt: 0 };
48
+ counters.set(component, e);
49
+ }
50
+ return e;
51
+ }
52
+
53
+ /**
54
+ * Record a degradation event. Warns once per hour per component, otherwise
55
+ * increments the counter silently at debug level.
56
+ *
57
+ * @param {string} component - One of KNOWN_COMPONENTS. Unknown names are
58
+ * accepted (kept for forward compatibility) but log a debug notice.
59
+ * @param {Error|{message?:string}|string} err
60
+ */
61
+ function record(component, err) {
62
+ if (!component) return;
63
+ if (!KNOWN_COMPONENTS.has(component)) {
64
+ logger.debug({ component }, "[Degradation] Unknown component recorded");
65
+ }
66
+
67
+ const message = err == null
68
+ ? "unknown"
69
+ : typeof err === "string"
70
+ ? err
71
+ : (err.message || String(err));
72
+
73
+ const now = Date.now();
74
+ const e = _entry(component);
75
+ e.count += 1;
76
+ e.lastError = message;
77
+ e.lastAt = now;
78
+
79
+ if (now - e.lastWarnedAt >= WARN_INTERVAL_MS) {
80
+ e.lastWarnedAt = now;
81
+ logger.warn({ component, err: message, count: e.count }, "[Degradation] Subsystem failed, falling through");
82
+ } else {
83
+ logger.debug({ component, err: message, count: e.count }, "[Degradation] Subsystem failed, falling through");
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Snapshot of per-component counters, safe to serialize into stats responses.
89
+ * @returns {Object<string, {count:number, lastError:string|null, lastAt:number}>}
90
+ */
91
+ function getCounts() {
92
+ const out = {};
93
+ for (const [name, e] of counters.entries()) {
94
+ out[name] = { count: e.count, lastError: e.lastError, lastAt: e.lastAt };
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Test helper — reset all counters. */
100
+ function _clear() {
101
+ counters.clear();
102
+ }
103
+
104
+ module.exports = {
105
+ record,
106
+ getCounts,
107
+ KNOWN_COMPONENTS,
108
+ _clear,
109
+ };