lynkr 9.7.3 → 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.
- package/README.md +29 -19
- package/bin/cli.js +11 -0
- package/bin/lynkr-init.js +14 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +24 -3
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +450 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +455 -142
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +51 -9
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +120 -85
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +48 -11
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +897 -87
- package/src/routing/intent-score.js +339 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +286 -13
- package/src/routing/verifier.js +267 -0
- package/src/server.js +66 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- 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 =
|
|
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
|
|
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 (
|
|
640
|
-
const totalWeight =
|
|
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 /
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|