lynkr 9.14.1 → 9.14.3
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 +5 -1
- package/bin/cli.js +14 -6
- package/bin/lynkr-desktop-token.js +108 -0
- package/bin/lynkr-init.js +13 -1
- package/bin/lynkr-restart.js +125 -0
- package/bin/opencode-setup.js +175 -0
- package/bin/run.js +92 -0
- package/config/model-capabilities.json +20 -0
- package/config/model-capability-seeds.json +75 -0
- package/config/model-tiers.json +13 -1
- package/package.json +16 -22
- package/scripts/claude-desktop.js +212 -0
- package/scripts/ope-report.js +78 -0
- package/scripts/seed-capabilities.js +230 -0
- package/src/api/claude-desktop-gateway.js +123 -0
- package/src/api/health.js +0 -1
- package/src/api/mcp-broker.js +149 -0
- package/src/api/middleware/budget-enforcer.js +77 -7
- package/src/api/middleware/budget.js +36 -2
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +210 -15
- package/src/api/providers-handler.js +16 -1
- package/src/api/router.js +73 -106
- package/src/auth-mode.js +40 -4
- package/src/budget/index.js +86 -0
- package/src/cache/embeddings.js +125 -35
- package/src/cache/onnx-embedder.js +92 -0
- package/src/cache/prompt.js +0 -7
- package/src/clients/bedrock-utils.js +2 -1
- package/src/clients/codex-process.js +1 -1
- package/src/clients/codex-utils.js +0 -2
- package/src/clients/databricks.js +337 -46
- package/src/clients/gpt-utils.js +84 -8
- package/src/clients/health-probe.js +157 -0
- package/src/clients/openai-format.js +73 -23
- package/src/clients/provider-capabilities.js +27 -2
- package/src/clients/resilience.js +0 -1
- package/src/clients/responses-format.js +0 -56
- package/src/clients/routing.js +1 -1
- package/src/config/index.js +13 -2
- package/src/context/budget.js +1 -1
- package/src/context/compression.js +127 -19
- package/src/context/tool-result-compressor.js +3 -5
- package/src/dashboard/api.js +1 -1
- package/src/logger/oversized-error-stream.js +1 -1
- package/src/memory/extractor.js +3 -3
- package/src/memory/search.js +2 -2
- package/src/memory/store.js +9 -24
- package/src/memory/tools.js +14 -0
- package/src/observability/metrics.js +52 -3
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +216 -212
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +130 -13
- package/src/policy/safe-commands.js +0 -1
- package/src/routing/bandit.js +39 -0
- package/src/routing/calibration.js +21 -1
- package/src/routing/capabilities.js +88 -0
- package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
- package/src/routing/capability-seeds/family-heuristics.js +77 -0
- package/src/routing/capability-seeds/family.js +105 -0
- package/src/routing/capability-seeds/models-dev.js +64 -0
- package/src/routing/capability-seeds/normalize.js +160 -0
- package/src/routing/capability-seeds/registry.js +125 -0
- package/src/routing/capability-seeds/sources.js +69 -0
- package/src/routing/capability-seeds/swebench.js +66 -0
- package/src/routing/client-profiles.js +26 -0
- package/src/routing/complexity-analyzer.js +5 -8
- package/src/routing/confidence-scorer.js +2 -2
- package/src/routing/cost-optimizer.js +1 -2
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +38 -1
- package/src/routing/embedding-cache.js +0 -1
- package/src/routing/index.js +202 -86
- package/src/routing/intent-score.js +0 -1
- package/src/routing/knn-router.js +27 -5
- package/src/routing/latency-tracker.js +1 -1
- package/src/routing/model-registry.js +25 -0
- package/src/routing/model-slots.js +65 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/ope.js +255 -0
- package/src/routing/openai-model-slots.js +82 -0
- package/src/routing/regret-estimator.js +1 -1
- package/src/routing/shortfall.js +290 -0
- package/src/routing/stuck-detector.js +127 -0
- package/src/routing/telemetry.js +11 -2
- package/src/server.js +41 -3
- package/src/stores/file-store.js +5 -1
- package/src/tools/web-search-exec.js +182 -0
- package/src/training/trajectory-compressor.js +1 -1
- package/src/workers/pool.js +1 -1
- package/src/workers/worker.js +1 -1
- package/scripts/convert-routellm.py +0 -105
- package/src/diff/comments.js +0 -153
- package/src/policy/web-fallback.js +0 -33
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shortfall matching (item 1: HyDRA port, phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Selects the cheapest candidate whose capabilities cover the predicted
|
|
5
|
+
* requirement vector within tolerance τ:
|
|
6
|
+
*
|
|
7
|
+
* shortfall(m) = Σ w_k · max(0, req_k − cap_mk)
|
|
8
|
+
*
|
|
9
|
+
* All four heads share the [0,1] bandwidth, so v1 weights are uniform
|
|
10
|
+
* (band compensation is a no-op until heads diverge — noted for phase 2).
|
|
11
|
+
*
|
|
12
|
+
* Contracts:
|
|
13
|
+
* - Catalog-decoupled: candidates arrive as [{provider, model, tier}];
|
|
14
|
+
* capabilities resolve per MODEL FAMILY (provider never matters:
|
|
15
|
+
* zai/baidu/ollama servings of glm-5.2 share caps) via operator
|
|
16
|
+
* modelOverrides → seed snapshot/shipped → family heuristic → tier
|
|
17
|
+
* profile. Adding, removing, or repricing a model re-routes via config
|
|
18
|
+
* with zero retraining.
|
|
19
|
+
* - Self-contained config: enabled/tau/weights also live in
|
|
20
|
+
* config/model-capabilities.json — no env vars. The file is read once at
|
|
21
|
+
* boot (same lifecycle as config/model-tiers.json); restart to pick up
|
|
22
|
+
* edits.
|
|
23
|
+
* - Cost comes from the caller (model-registry) or per-1k blended estimate;
|
|
24
|
+
* unknown cost sorts last (conservative — never wins on cheapness alone).
|
|
25
|
+
* - No covering candidate (all shortfalls > τ) → minimal shortfall wins,
|
|
26
|
+
* tiebreak higher tier (correctness over cost, matches tier-fallback.js
|
|
27
|
+
* escalate-then-demote bias). Never returns null on valid input; returns
|
|
28
|
+
* null only on malformed input so callers fail open to legacy routing.
|
|
29
|
+
* - Pure except for config load (cached). Never throws.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const logger = require('../logger');
|
|
35
|
+
const { HEADS } = require('./capabilities');
|
|
36
|
+
|
|
37
|
+
const PROFILES_PATH = path.join(__dirname, '../../config/model-capabilities.json');
|
|
38
|
+
|
|
39
|
+
const DEFAULT_TAU = 0.24; // HyDRA iso-quality operating point
|
|
40
|
+
const TIER_PRIORITY = { SIMPLE: 1, MEDIUM: 2, COMPLEX: 3, REASONING: 4 };
|
|
41
|
+
|
|
42
|
+
let _profilesCache = null;
|
|
43
|
+
|
|
44
|
+
function _defaultWeights() {
|
|
45
|
+
const w = {};
|
|
46
|
+
for (const h of HEADS) w[h] = 1 / HEADS.length;
|
|
47
|
+
return w;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function _sanitizeCaps(raw) {
|
|
51
|
+
const caps = {};
|
|
52
|
+
for (const h of HEADS) {
|
|
53
|
+
const v = Number(raw?.[h]);
|
|
54
|
+
caps[h] = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0.5;
|
|
55
|
+
}
|
|
56
|
+
return caps;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadProfiles() {
|
|
60
|
+
if (_profilesCache) return _profilesCache;
|
|
61
|
+
try {
|
|
62
|
+
const raw = JSON.parse(fs.readFileSync(PROFILES_PATH, 'utf8'));
|
|
63
|
+
const tierProfiles = {};
|
|
64
|
+
const fileTiers = raw?.tierProfiles && typeof raw.tierProfiles === 'object'
|
|
65
|
+
? raw.tierProfiles
|
|
66
|
+
: {};
|
|
67
|
+
for (const [tier, caps] of Object.entries(fileTiers)) {
|
|
68
|
+
if (caps && typeof caps === 'object') tierProfiles[tier] = _sanitizeCaps(caps);
|
|
69
|
+
}
|
|
70
|
+
const modelOverrides = {};
|
|
71
|
+
const fileOverrides = raw?.modelOverrides && typeof raw.modelOverrides === 'object'
|
|
72
|
+
? raw.modelOverrides
|
|
73
|
+
: {};
|
|
74
|
+
for (const [key, caps] of Object.entries(fileOverrides)) {
|
|
75
|
+
if (caps && typeof caps === 'object') modelOverrides[String(key).toLowerCase()] = _sanitizeCaps(caps);
|
|
76
|
+
}
|
|
77
|
+
const tau = Number(raw?.tau);
|
|
78
|
+
const weights = _normalizeWeights(raw?.weights);
|
|
79
|
+
_profilesCache = {
|
|
80
|
+
tierProfiles,
|
|
81
|
+
modelOverrides,
|
|
82
|
+
enabled: raw?.enabled === true,
|
|
83
|
+
tau: Number.isFinite(tau) && tau >= 0 ? tau : DEFAULT_TAU,
|
|
84
|
+
weights,
|
|
85
|
+
};
|
|
86
|
+
} catch (err) {
|
|
87
|
+
logger.debug({ err: err.message }, '[Shortfall] profiles load failed — disabled with tier fallbacks');
|
|
88
|
+
_profilesCache = { tierProfiles: {}, modelOverrides: {}, enabled: false, tau: DEFAULT_TAU, weights: _defaultWeights() };
|
|
89
|
+
}
|
|
90
|
+
return _profilesCache;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Exposed for tests (cache reset / profile injection).
|
|
94
|
+
function _resetProfilesCache() {
|
|
95
|
+
_profilesCache = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _setProfilesForTests(profiles) {
|
|
99
|
+
const base = loadProfiles();
|
|
100
|
+
_profilesCache = {
|
|
101
|
+
tierProfiles: profiles?.tierProfiles ?? base.tierProfiles,
|
|
102
|
+
modelOverrides: profiles?.modelOverrides ?? base.modelOverrides,
|
|
103
|
+
enabled: profiles?.enabled ?? base.enabled,
|
|
104
|
+
tau: profiles?.tau ?? base.tau,
|
|
105
|
+
weights: profiles?.weights ?? base.weights,
|
|
106
|
+
};
|
|
107
|
+
return _profilesCache;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _normalizeWeights(raw) {
|
|
111
|
+
const w = _defaultWeights();
|
|
112
|
+
if (!raw || typeof raw !== 'object') return w;
|
|
113
|
+
let touched = false;
|
|
114
|
+
for (const h of HEADS) {
|
|
115
|
+
const v = Number(raw[h]);
|
|
116
|
+
if (Number.isFinite(v) && v >= 0) {
|
|
117
|
+
w[h] = v;
|
|
118
|
+
touched = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!touched) return _defaultWeights();
|
|
122
|
+
const sum = Object.values(w).reduce((a, b) => a + b, 0);
|
|
123
|
+
if (sum <= 0) return _defaultWeights();
|
|
124
|
+
for (const h of HEADS) w[h] = w[h] / sum;
|
|
125
|
+
return w;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getTau() {
|
|
129
|
+
return loadProfiles().tau;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getWeights() {
|
|
133
|
+
return { ...loadProfiles().weights };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isEnabled() {
|
|
137
|
+
return loadProfiles().enabled === true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve capabilities for one candidate. Precedence (first hit wins):
|
|
142
|
+
* 1. operator "provider:model" / "provider:*" override (manual, supreme,
|
|
143
|
+
* returned verbatim — not even the quant haircut applies)
|
|
144
|
+
* 2. seed snapshot / shipped seeds by normalized MODEL FAMILY
|
|
145
|
+
* (zai/baidu/ollama servings of glm-5.2 share one entry)
|
|
146
|
+
* 3. family-ladder heuristic (zero-network estimate, never frontier)
|
|
147
|
+
* 4. tier profile → tier-priority fallback (SIMPLE 0.2 … REASONING 0.9)
|
|
148
|
+
*
|
|
149
|
+
* Quantized self-hosted servings (Q4_K_M, gguf, …) take a small haircut at
|
|
150
|
+
* every level except 1: same brain, smaller body.
|
|
151
|
+
*
|
|
152
|
+
* @returns {{ caps, source }} — source is override|seed:snapshot|
|
|
153
|
+
* seed:shipped|family|tier|tier-fallback (telemetry provenance).
|
|
154
|
+
*/
|
|
155
|
+
function resolveCapabilitiesWithSource({ provider, model, tier }) {
|
|
156
|
+
const { tierProfiles, modelOverrides } = loadProfiles();
|
|
157
|
+
const key = `${String(provider || '').toLowerCase()}:${String(model || '').toLowerCase()}`;
|
|
158
|
+
const wild = `${String(provider || '').toLowerCase()}:*`;
|
|
159
|
+
const pick = modelOverrides[key] || modelOverrides[wild];
|
|
160
|
+
if (pick) return { caps: { ...pick }, source: 'override' };
|
|
161
|
+
|
|
162
|
+
let family = 'unknown';
|
|
163
|
+
let quant = false;
|
|
164
|
+
try {
|
|
165
|
+
const fam = require('./capability-seeds/family');
|
|
166
|
+
({ family, quant } = fam.normalizeFamily(provider, model));
|
|
167
|
+
} catch { /* family helpers unavailable — tier fallback below */ }
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const { resolveSeedCaps } = require('./capability-seeds/registry');
|
|
171
|
+
const seed = resolveSeedCaps(family);
|
|
172
|
+
if (seed) {
|
|
173
|
+
const caps = quant
|
|
174
|
+
? require('./capability-seeds/family').applyQuantHaircut(seed.caps)
|
|
175
|
+
: { ...seed.caps };
|
|
176
|
+
return { caps, source: seed.source };
|
|
177
|
+
}
|
|
178
|
+
} catch { /* seed layers unavailable — keep falling through */ }
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const { heuristicCaps } = require('./capability-seeds/family-heuristics');
|
|
182
|
+
const heur = heuristicCaps(family);
|
|
183
|
+
if (heur) {
|
|
184
|
+
const caps = quant
|
|
185
|
+
? require('./capability-seeds/family').applyQuantHaircut(heur)
|
|
186
|
+
: heur;
|
|
187
|
+
return { caps, source: 'family' };
|
|
188
|
+
}
|
|
189
|
+
} catch { /* heuristic unavailable — tier fallback below */ }
|
|
190
|
+
|
|
191
|
+
const tp = tierProfiles[tier];
|
|
192
|
+
if (tp) {
|
|
193
|
+
const caps = quant
|
|
194
|
+
? require('./capability-seeds/family').applyQuantHaircut(tp)
|
|
195
|
+
: { ...tp };
|
|
196
|
+
return { caps, source: 'tier' };
|
|
197
|
+
}
|
|
198
|
+
const p = (TIER_PRIORITY[tier] || 1) / 4;
|
|
199
|
+
const caps = {};
|
|
200
|
+
for (const h of HEADS) caps[h] = Math.round(p * 1000) / 1000;
|
|
201
|
+
return { caps, source: 'tier-fallback' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveCapabilities(candidate) {
|
|
205
|
+
return resolveCapabilitiesWithSource(candidate).caps;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function shortfall(req, caps, weights = null) {
|
|
209
|
+
const w = weights || _defaultWeights();
|
|
210
|
+
let s = 0;
|
|
211
|
+
for (const h of HEADS) {
|
|
212
|
+
const r = Math.max(0, Math.min(1, Number(req?.[h]) || 0));
|
|
213
|
+
const c = Math.max(0, Math.min(1, Number(caps?.[h]) || 0));
|
|
214
|
+
s += (w[h] ?? 0) * Math.max(0, r - c);
|
|
215
|
+
}
|
|
216
|
+
return Math.round(s * 10000) / 10000;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function _costValue(c) {
|
|
220
|
+
const n = Number(c);
|
|
221
|
+
return Number.isFinite(n) && n >= 0 ? n : Number.POSITIVE_INFINITY;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {object} req — requirement vector {reasoning, codegen, debugging, tool_use} in [0,1]
|
|
226
|
+
* @param {Array<{provider, model, tier, cost?}>} candidates — catalog-constrained set
|
|
227
|
+
* (callers pass getAllConfiguredModels() + model-registry costs)
|
|
228
|
+
* @param {object} [opts] — { tau, weights }
|
|
229
|
+
* @returns {null | { selected, shortfalls: Array<{provider, model, tier, shortfall, cost, source}>, tau }}
|
|
230
|
+
*/
|
|
231
|
+
function selectByShortfall(req, candidates, opts = {}) {
|
|
232
|
+
try {
|
|
233
|
+
if (!req || typeof req !== 'object') return null;
|
|
234
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
235
|
+
const tau = opts.tau ?? getTau();
|
|
236
|
+
const weights = opts.weights ?? getWeights();
|
|
237
|
+
|
|
238
|
+
const rows = candidates
|
|
239
|
+
.filter((c) => c && c.provider && c.model)
|
|
240
|
+
.map((c) => {
|
|
241
|
+
const { caps, source } = resolveCapabilitiesWithSource(c);
|
|
242
|
+
return {
|
|
243
|
+
provider: c.provider,
|
|
244
|
+
model: c.model,
|
|
245
|
+
tier: c.tier || 'MEDIUM',
|
|
246
|
+
cost: _costValue(c.cost),
|
|
247
|
+
shortfall: shortfall(req, caps, weights),
|
|
248
|
+
source,
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
if (rows.length === 0) return null;
|
|
252
|
+
|
|
253
|
+
const covering = rows.filter((r) => r.shortfall <= tau);
|
|
254
|
+
const pool = covering.length > 0 ? covering : rows;
|
|
255
|
+
pool.sort((a, b) => {
|
|
256
|
+
if (covering.length > 0) {
|
|
257
|
+
// Cheapest covering wins; cost tie (incl. all-unknown) breaks toward
|
|
258
|
+
// the LOWER tier — a covering lower tier is sufficient by definition,
|
|
259
|
+
// so prefer it over excess headroom (avoids over-provisioning).
|
|
260
|
+
if (a.cost !== b.cost) return a.cost - b.cost;
|
|
261
|
+
return (TIER_PRIORITY[a.tier] || 0) - (TIER_PRIORITY[b.tier] || 0);
|
|
262
|
+
}
|
|
263
|
+
// Nothing covers: minimal shortfall wins, tiebreak higher tier then cheaper.
|
|
264
|
+
if (a.shortfall !== b.shortfall) return a.shortfall - b.shortfall;
|
|
265
|
+
const tp = (TIER_PRIORITY[b.tier] || 0) - (TIER_PRIORITY[a.tier] || 0);
|
|
266
|
+
if (tp !== 0) return tp;
|
|
267
|
+
return a.cost - b.cost;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return { selected: pool[0], shortfalls: rows, tau };
|
|
271
|
+
} catch (err) {
|
|
272
|
+
logger.debug({ err: err.message }, '[Shortfall] select failed — failing open');
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = {
|
|
278
|
+
HEADS,
|
|
279
|
+
DEFAULT_TAU,
|
|
280
|
+
loadProfiles,
|
|
281
|
+
_resetProfilesCache,
|
|
282
|
+
_setProfilesForTests,
|
|
283
|
+
getTau,
|
|
284
|
+
getWeights,
|
|
285
|
+
isEnabled,
|
|
286
|
+
resolveCapabilities,
|
|
287
|
+
resolveCapabilitiesWithSource,
|
|
288
|
+
shortfall,
|
|
289
|
+
selectByShortfall,
|
|
290
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stuck-loop / no-progress detection (ROUTING-NOTES §4.10.5).
|
|
3
|
+
*
|
|
4
|
+
* The escalation ladder reacts to score drift, context overflow, risk
|
|
5
|
+
* keywords, and vision needs — but nothing watches for "the pinned model is
|
|
6
|
+
* thrashing": re-issuing the same tool call with the same input over and
|
|
7
|
+
* over, or repeating the same assistant text verbatim. A cheap-tier model
|
|
8
|
+
* stuck in that state burns turns indefinitely, because every frame of the
|
|
9
|
+
* loop is a mid-tool-exchange pin serve (which is unconditional — tool-call
|
|
10
|
+
* IDs aren't portable across providers, so switching mid-exchange would 400).
|
|
11
|
+
*
|
|
12
|
+
* The safe intervention is the one the pin path already uses for embedded
|
|
13
|
+
* text triggers: don't switch this turn — DROP THE PIN, so the next turn
|
|
14
|
+
* boundary re-routes fresh (and full routing, seeing the whole struggling
|
|
15
|
+
* conversation, escalates on its own signals).
|
|
16
|
+
*
|
|
17
|
+
* Detection is deliberately narrow to keep false positives near zero:
|
|
18
|
+
* - tool repetition: the last K assistant tool_use blocks are the SAME
|
|
19
|
+
* tool with the SAME input, K >= LYNKR_STUCK_TOOL_REPEATS (default 3).
|
|
20
|
+
* Agents legitimately re-run a tool (poll, retry-once); three identical
|
|
21
|
+
* consecutive calls is a loop.
|
|
22
|
+
* - text repetition: the last K assistant text blocks are identical after
|
|
23
|
+
* whitespace normalization, K >= LYNKR_STUCK_TEXT_REPEATS (default 3).
|
|
24
|
+
*
|
|
25
|
+
* Only the tail of the conversation is scanned (SCAN_WINDOW messages), so
|
|
26
|
+
* the check is O(1)-ish per request regardless of conversation length.
|
|
27
|
+
*
|
|
28
|
+
* @module routing/stuck-detector
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const SCAN_WINDOW = 20;
|
|
32
|
+
|
|
33
|
+
function _toolRepeats() {
|
|
34
|
+
const n = Number.parseInt(process.env.LYNKR_STUCK_TOOL_REPEATS, 10);
|
|
35
|
+
return Number.isNaN(n) ? 3 : Math.max(2, n);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _textRepeats() {
|
|
39
|
+
const n = Number.parseInt(process.env.LYNKR_STUCK_TEXT_REPEATS, 10);
|
|
40
|
+
return Number.isNaN(n) ? 3 : Math.max(2, n);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function _enabled() {
|
|
44
|
+
return process.env.LYNKR_STUCK_DETECTOR_ENABLED !== 'false';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract, oldest→newest, the assistant tool_use signatures and text blocks
|
|
49
|
+
* from the tail of the conversation.
|
|
50
|
+
*/
|
|
51
|
+
function _assistantTail(messages) {
|
|
52
|
+
const tail = messages.slice(-SCAN_WINDOW);
|
|
53
|
+
const toolSigs = [];
|
|
54
|
+
const texts = [];
|
|
55
|
+
for (const msg of tail) {
|
|
56
|
+
if (msg?.role !== 'assistant') continue;
|
|
57
|
+
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
58
|
+
for (const block of content) {
|
|
59
|
+
if (block?.type === 'tool_use') {
|
|
60
|
+
let inputSig;
|
|
61
|
+
try {
|
|
62
|
+
inputSig = JSON.stringify(block.input ?? null);
|
|
63
|
+
} catch {
|
|
64
|
+
inputSig = String(block.input);
|
|
65
|
+
}
|
|
66
|
+
toolSigs.push(`${block.name}::${inputSig}`);
|
|
67
|
+
} else if (block?.type === 'text' && typeof block.text === 'string') {
|
|
68
|
+
const normalized = block.text.replace(/\s+/g, ' ').trim();
|
|
69
|
+
if (normalized.length > 0) texts.push(normalized);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// String-content assistant messages count as text blocks too.
|
|
73
|
+
if (typeof msg.content === 'string') {
|
|
74
|
+
const normalized = msg.content.replace(/\s+/g, ' ').trim();
|
|
75
|
+
if (normalized.length > 0) texts.push(normalized);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { toolSigs, texts };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _trailingRun(items) {
|
|
82
|
+
if (items.length === 0) return 0;
|
|
83
|
+
const last = items[items.length - 1];
|
|
84
|
+
let run = 0;
|
|
85
|
+
for (let i = items.length - 1; i >= 0 && items[i] === last; i--) run++;
|
|
86
|
+
return run;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Detect a stuck loop in the conversation tail.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} payload — request payload with .messages
|
|
93
|
+
* @returns {{ stuck: boolean, reason?: 'tool_repetition'|'text_repetition',
|
|
94
|
+
* repeats?: number, signature?: string }}
|
|
95
|
+
*/
|
|
96
|
+
function detectStuckLoop(payload) {
|
|
97
|
+
if (!_enabled()) return { stuck: false };
|
|
98
|
+
const messages = payload?.messages;
|
|
99
|
+
if (!Array.isArray(messages) || messages.length < 4) return { stuck: false };
|
|
100
|
+
|
|
101
|
+
const { toolSigs, texts } = _assistantTail(messages);
|
|
102
|
+
|
|
103
|
+
const toolRun = _trailingRun(toolSigs);
|
|
104
|
+
if (toolRun >= _toolRepeats()) {
|
|
105
|
+
return {
|
|
106
|
+
stuck: true,
|
|
107
|
+
reason: 'tool_repetition',
|
|
108
|
+
repeats: toolRun,
|
|
109
|
+
// Truncated for logging — the full input may be huge or sensitive.
|
|
110
|
+
signature: toolSigs[toolSigs.length - 1].slice(0, 120),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const textRun = _trailingRun(texts);
|
|
115
|
+
if (textRun >= _textRepeats()) {
|
|
116
|
+
return {
|
|
117
|
+
stuck: true,
|
|
118
|
+
reason: 'text_repetition',
|
|
119
|
+
repeats: textRun,
|
|
120
|
+
signature: texts[texts.length - 1].slice(0, 120),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { stuck: false };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { detectStuckLoop };
|
package/src/routing/telemetry.js
CHANGED
|
@@ -175,6 +175,12 @@ function init() {
|
|
|
175
175
|
// providers that report none) — "not measured" is distinct from 0.
|
|
176
176
|
["cache_read_tokens", "INTEGER"],
|
|
177
177
|
["cache_creation_tokens", "INTEGER"],
|
|
178
|
+
// Off-policy evaluation — the bandit's context vector (12-dim JSON
|
|
179
|
+
// array) for the row's decision. Required by the doubly-robust
|
|
180
|
+
// estimator's regression term r̂(x, a); IPS/SNIPS work without it.
|
|
181
|
+
// NULL on rows where the bandit didn't run (deterministic decisions)
|
|
182
|
+
// or recorded before this column existed.
|
|
183
|
+
["context", "TEXT"],
|
|
178
184
|
];
|
|
179
185
|
for (const [col, type] of additiveCols) {
|
|
180
186
|
if (!existingCols.has(col)) {
|
|
@@ -237,7 +243,7 @@ function record(data) {
|
|
|
237
243
|
retry_count, circuit_breaker_state, quality_score, tokens_per_second,
|
|
238
244
|
cost_efficiency, request_text, response_text,
|
|
239
245
|
base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
|
|
240
|
-
cache_decision, cache_read_tokens, cache_creation_tokens
|
|
246
|
+
cache_decision, cache_read_tokens, cache_creation_tokens, context
|
|
241
247
|
) VALUES (
|
|
242
248
|
@request_id, @session_id, @timestamp, @complexity_score, @tier,
|
|
243
249
|
@agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
|
|
@@ -246,7 +252,7 @@ function record(data) {
|
|
|
246
252
|
@retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
|
|
247
253
|
@cost_efficiency, @request_text, @response_text,
|
|
248
254
|
@base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
|
|
249
|
-
@cache_decision, @cache_read_tokens, @cache_creation_tokens
|
|
255
|
+
@cache_decision, @cache_read_tokens, @cache_creation_tokens, @context
|
|
250
256
|
)`
|
|
251
257
|
);
|
|
252
258
|
if (!insert) return;
|
|
@@ -299,6 +305,9 @@ function record(data) {
|
|
|
299
305
|
: JSON.stringify(data.cache_decision)),
|
|
300
306
|
cache_read_tokens: data.cache_read_tokens ?? null,
|
|
301
307
|
cache_creation_tokens: data.cache_creation_tokens ?? null,
|
|
308
|
+
context: data.context == null
|
|
309
|
+
? null
|
|
310
|
+
: (typeof data.context === "string" ? data.context : JSON.stringify(data.context)),
|
|
302
311
|
});
|
|
303
312
|
} catch (err) {
|
|
304
313
|
logger.debug({ err: err.message }, "Telemetry record failed");
|
package/src/server.js
CHANGED
|
@@ -80,7 +80,11 @@ function createApp() {
|
|
|
80
80
|
|
|
81
81
|
// Phase 6.1 — per-tenant routing policies (LYNKR-Tenant-Id header).
|
|
82
82
|
// Runs before message handling so res.locals.tenantPolicy is populated.
|
|
83
|
-
|
|
83
|
+
// Must cover every AGENT_ENDPOINT, not just /v1/messages — mounting on a
|
|
84
|
+
// single path silently exempts OpenAI-compat clients from tenant policy
|
|
85
|
+
// (the same failure mode the comment above already warns about for other
|
|
86
|
+
// guards; see issue #100).
|
|
87
|
+
for (const p of AGENT_ENDPOINTS) app.use(p, tenantMiddleware);
|
|
84
88
|
|
|
85
89
|
// Phase 6.2 — hierarchical budget enforcement (LYNKR_BUDGET_ENFORCER=false to disable).
|
|
86
90
|
for (const p of AGENT_ENDPOINTS) app.use(p, budgetEnforcer);
|
|
@@ -105,7 +109,8 @@ function createApp() {
|
|
|
105
109
|
|
|
106
110
|
app.get("/metrics/circuit-breakers", (req, res) => {
|
|
107
111
|
const registry = getCircuitBreakerRegistry();
|
|
108
|
-
|
|
112
|
+
const { getHealthProber } = require("./clients/health-probe");
|
|
113
|
+
res.json({ breakers: registry.getAll(), healthProbe: getHealthProber().getStatus() });
|
|
109
114
|
});
|
|
110
115
|
|
|
111
116
|
app.get("/metrics/load-shedding", (req, res) => {
|
|
@@ -124,13 +129,23 @@ function createApp() {
|
|
|
124
129
|
|
|
125
130
|
app.get("/metrics/semantic-cache", (req, res) => {
|
|
126
131
|
const { getSemanticCache, isSemanticCacheEnabled } = require("./cache/semantic");
|
|
132
|
+
const { getEmbeddingStatus } = require("./cache/embeddings");
|
|
127
133
|
if (!isSemanticCacheEnabled()) {
|
|
128
134
|
return res.json({ enabled: false, message: "Semantic cache not enabled" });
|
|
129
135
|
}
|
|
130
136
|
const cache = getSemanticCache();
|
|
131
|
-
|
|
137
|
+
// embeddings.providerAvailable === false means matches are currently
|
|
138
|
+
// served from the non-semantic hash fallback — cache "hits" during a
|
|
139
|
+
// degraded window are approximate, not semantic.
|
|
140
|
+
res.json({ enabled: true, embeddings: getEmbeddingStatus(), ...cache.getStats() });
|
|
132
141
|
});
|
|
133
142
|
|
|
143
|
+
// MCP broker — exposes configured MCP servers to other clients over HTTP.
|
|
144
|
+
// Off by default; requires LYNKR_MCP_BROKER_ENABLED + a bearer token.
|
|
145
|
+
// Mounted BEFORE the main router so /v1/mcp/* never falls through to the
|
|
146
|
+
// OpenAI-compat surface.
|
|
147
|
+
app.use(require('./api/mcp-broker'));
|
|
148
|
+
|
|
134
149
|
app.use(router);
|
|
135
150
|
|
|
136
151
|
app.use('/dashboard', require('./dashboard/router'));
|
|
@@ -181,6 +196,29 @@ async function start() {
|
|
|
181
196
|
|
|
182
197
|
const app = createApp();
|
|
183
198
|
|
|
199
|
+
// Synthetic circuit-breaker health probing — recovers open circuits with a
|
|
200
|
+
// cheap background probe instead of letting the next live user request pay
|
|
201
|
+
// for testing a dead provider. Zero-cost while all breakers are closed.
|
|
202
|
+
try {
|
|
203
|
+
const { getHealthProber } = require("./clients/health-probe");
|
|
204
|
+
const prober = getHealthProber();
|
|
205
|
+
prober.start();
|
|
206
|
+
getShutdownManager().onShutdown(() => prober.stop());
|
|
207
|
+
} catch (err) {
|
|
208
|
+
logger.warn({ err: err.message }, "Health prober failed to start, circuit recovery falls back to live-request probing");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// OTel GenAI metrics export — no-op unless OTEL_EXPORTER_OTLP_ENDPOINT
|
|
212
|
+
// (or LYNKR_OTEL_ENDPOINT) is set. Zero-dependency OTLP/HTTP push.
|
|
213
|
+
try {
|
|
214
|
+
const { getOtelExporter } = require("./observability/otel");
|
|
215
|
+
const otel = getOtelExporter();
|
|
216
|
+
otel.start();
|
|
217
|
+
getShutdownManager().onShutdown(() => otel.stop());
|
|
218
|
+
} catch (err) {
|
|
219
|
+
logger.warn({ err: err.message }, "OTel exporter failed to start");
|
|
220
|
+
}
|
|
221
|
+
|
|
184
222
|
// Wait for Ollama if it's the configured provider or referenced in tier config
|
|
185
223
|
const provider = config.modelProvider?.type?.toLowerCase();
|
|
186
224
|
if (provider === "ollama" || config.tiersReferenceOllama()) {
|
package/src/stores/file-store.js
CHANGED
|
@@ -89,7 +89,11 @@ async function getFileContent(id) {
|
|
|
89
89
|
async function deleteFile(id) {
|
|
90
90
|
const entry = metadata.get(id);
|
|
91
91
|
if (!entry) return false;
|
|
92
|
-
try {
|
|
92
|
+
try {
|
|
93
|
+
await fsp.unlink(entry.storage_path);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
logger.debug({ err: err.message, fileId: id }, "Best-effort unlink failed during file delete");
|
|
96
|
+
}
|
|
93
97
|
metadata.delete(id);
|
|
94
98
|
persistMetadata();
|
|
95
99
|
return true;
|