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.
- 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/agents/context-manager.js +18 -2
- package/src/agents/definitions/loader.js +90 -0
- package/src/agents/executor.js +24 -2
- package/src/agents/index.js +9 -1
- package/src/agents/parallel-coordinator.js +2 -2
- package/src/agents/reflector.js +11 -1
- 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 +459 -146
- 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 +144 -88
- 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 +298 -10
- 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
package/src/routing/index.js
CHANGED
|
@@ -23,6 +23,7 @@ const { getAgenticDetector, AGENT_TYPES } = require('./agentic-detector');
|
|
|
23
23
|
const { getModelTierSelector, TIER_DEFINITIONS } = require('./model-tiers');
|
|
24
24
|
const { getCostOptimizer } = require('./cost-optimizer');
|
|
25
25
|
const { analyzeRisk } = require('./risk-classifier');
|
|
26
|
+
const { scoreIntent, intentScoreMode } = require('./intent-score');
|
|
26
27
|
|
|
27
28
|
// Phase 3-6 routing modules
|
|
28
29
|
const { getKnnRouter } = require('./knn-router');
|
|
@@ -40,6 +41,19 @@ const { getLatencyTracker } = require('./latency-tracker');
|
|
|
40
41
|
const contextValidator = require('./context-validator');
|
|
41
42
|
const { countPayloadTokens } = require('./tokenizer');
|
|
42
43
|
|
|
44
|
+
// Degradation registry — swallows silent fallbacks into a counted signal.
|
|
45
|
+
const degradation = require('./degradation');
|
|
46
|
+
|
|
47
|
+
// WS2.3 — de-escalation policy (evidence-gated tier demotion).
|
|
48
|
+
const deescalator = require('./deescalator');
|
|
49
|
+
try {
|
|
50
|
+
const shadow = require('./shadow-mode');
|
|
51
|
+
shadow.registerPolicy('deescalate-v1', deescalator.shadowDeescalate);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// shadow-mode is optional here — the policy just won't be available
|
|
54
|
+
logger.debug({ err: err.message }, '[Routing] Failed to register deescalate-v1 shadow policy');
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
// Local providers
|
|
44
58
|
const LOCAL_PROVIDERS = ['ollama', 'llamacpp', 'lmstudio'];
|
|
45
59
|
|
|
@@ -67,6 +81,7 @@ function _enabledProviders() {
|
|
|
67
81
|
if (config.azureAnthropic?.endpoint && config.azureAnthropic?.apiKey) out.push('azure-anthropic');
|
|
68
82
|
if (config.bedrock?.apiKey) out.push('bedrock');
|
|
69
83
|
if (config.openrouter?.apiKey) out.push('openrouter');
|
|
84
|
+
if (config.edenai?.apiKey) out.push('edenai');
|
|
70
85
|
if (config.openai?.apiKey) out.push('openai');
|
|
71
86
|
if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) out.push('azure-openai');
|
|
72
87
|
if (config.ollama?.endpoint) out.push('ollama');
|
|
@@ -108,6 +123,7 @@ function getBestCloudProvider() {
|
|
|
108
123
|
if (config.azureAnthropic?.endpoint && config.azureAnthropic?.apiKey) return 'azure-anthropic';
|
|
109
124
|
if (config.bedrock?.apiKey) return 'bedrock';
|
|
110
125
|
if (config.openrouter?.apiKey) return 'openrouter';
|
|
126
|
+
if (config.edenai?.apiKey) return 'edenai';
|
|
111
127
|
if (config.openai?.apiKey) return 'openai';
|
|
112
128
|
if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) return 'azure-openai';
|
|
113
129
|
|
|
@@ -140,41 +156,492 @@ function getBestLocalProvider() {
|
|
|
140
156
|
*/
|
|
141
157
|
const sessionAffinity = require('./session-affinity');
|
|
142
158
|
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// WS1 — sticky sessions
|
|
161
|
+
//
|
|
162
|
+
// The wrapper below implements cache-aware sticky routing. Routing decisions
|
|
163
|
+
// happen once per session (persisted so process restarts don't lose the pin)
|
|
164
|
+
// and are re-evaluated only at explicit triggers: compaction, guard
|
|
165
|
+
// escalation (risk/context/vision), or an economic downgrade that beats the
|
|
166
|
+
// estimated cost of the cold-cache re-read.
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
170
|
+
|
|
171
|
+
function _tierPriority(tier) {
|
|
172
|
+
const idx = TIER_ORDER.indexOf(tier);
|
|
173
|
+
return idx < 0 ? -1 : idx;
|
|
174
|
+
}
|
|
175
|
+
|
|
143
176
|
/**
|
|
144
|
-
*
|
|
177
|
+
* Run the cheap guards a pin must satisfy for the current turn:
|
|
178
|
+
* - Risk analysis: if `risk.level === 'high'`, the pin can't serve
|
|
179
|
+
* regardless of its model.
|
|
180
|
+
* - Context fit: pinned model's context window vs estimated prompt tokens.
|
|
181
|
+
* - Vision need: payload has images ⇒ pinned model must have vision.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} payload
|
|
184
|
+
* @param {{provider:string, model:string|null, tier:string|null}} pin
|
|
185
|
+
* @returns {{ok:boolean, reason:string|null, risk:object|null, promptTokensEst:number|null}}
|
|
186
|
+
*/
|
|
187
|
+
function _runPinGuards(payload, pin) {
|
|
188
|
+
let risk = null;
|
|
189
|
+
try {
|
|
190
|
+
risk = analyzeRisk(payload);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
degradation.record('risk', err);
|
|
193
|
+
}
|
|
194
|
+
if (risk?.level === 'high') {
|
|
195
|
+
return { ok: false, reason: 'risk', risk, promptTokensEst: null };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let promptTokensEst = null;
|
|
199
|
+
try {
|
|
200
|
+
if (pin.model) {
|
|
201
|
+
promptTokensEst = countPayloadTokens(payload, pin.model);
|
|
202
|
+
const ctxResult = contextValidator.validate(pin.model, promptTokensEst);
|
|
203
|
+
if (!ctxResult.ok) {
|
|
204
|
+
return { ok: false, reason: 'context', risk, promptTokensEst };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
degradation.record('context_validate', err);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (_payloadHasImages(payload)) {
|
|
212
|
+
try {
|
|
213
|
+
const { getModelRegistrySync } = require('./model-registry');
|
|
214
|
+
const registry = getModelRegistrySync();
|
|
215
|
+
const modelInfo = pin.model ? registry.getCost(pin.model) : null;
|
|
216
|
+
if (!modelInfo?.vision) {
|
|
217
|
+
return { ok: false, reason: 'vision', risk, promptTokensEst };
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
degradation.record('vision_guard', err);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { ok: true, reason: null, risk, promptTokensEst };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* True when the fresh decision may replace the pin from a cost perspective.
|
|
229
|
+
* The rule (per WS1 plan):
|
|
230
|
+
* - Fresh model must be strictly cheaper than the pin's model per
|
|
231
|
+
* cost-optimizer.estimateCost (per-1000-token estimate).
|
|
232
|
+
* - Estimated prompt tokens must be below LYNKR_SWITCH_MAX_PROMPT_TOKENS
|
|
233
|
+
* (default 20k) — beyond that the cold-cache re-read dominates the
|
|
234
|
+
* savings.
|
|
235
|
+
* - Fresh must be ≥25% cheaper.
|
|
236
|
+
*
|
|
237
|
+
* Returns false (i.e. suppress the switch) when either bound fails.
|
|
238
|
+
*
|
|
239
|
+
* @param {number|null} promptTokensEst
|
|
240
|
+
* @param {string|null} pinModel
|
|
241
|
+
* @param {string|null} freshModel
|
|
242
|
+
* @returns {boolean}
|
|
243
|
+
*/
|
|
244
|
+
function _economicDowngradeAllowed(promptTokensEst, pinModel, freshModel) {
|
|
245
|
+
if (!pinModel || !freshModel || pinModel === freshModel) return true;
|
|
246
|
+
const maxPromptTokens = Number(process.env.LYNKR_SWITCH_MAX_PROMPT_TOKENS) || 20000;
|
|
247
|
+
if (promptTokensEst != null && promptTokensEst >= maxPromptTokens) return false;
|
|
248
|
+
try {
|
|
249
|
+
const optimizer = getCostOptimizer();
|
|
250
|
+
const pinCost = optimizer.estimateCost(pinModel, 1000);
|
|
251
|
+
const freshCost = optimizer.estimateCost(freshModel, 1000);
|
|
252
|
+
if (!pinCost?.totalEstimate || !freshCost?.totalEstimate) return true;
|
|
253
|
+
return freshCost.totalEstimate <= pinCost.totalEstimate * 0.75;
|
|
254
|
+
} catch (err) {
|
|
255
|
+
degradation.record('cost_optimize', err);
|
|
256
|
+
return true; // fail-open: if we can't compare, let the fresh decision through
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function _pinToDecision(pin, { reason, risk }) {
|
|
261
|
+
return {
|
|
262
|
+
provider: pin.provider,
|
|
263
|
+
model: pin.model,
|
|
264
|
+
tier: pin.tier,
|
|
265
|
+
method: 'session_pin',
|
|
266
|
+
reason,
|
|
267
|
+
score: null,
|
|
268
|
+
analysis: null,
|
|
269
|
+
embeddingsResult: null,
|
|
270
|
+
agenticResult: null,
|
|
271
|
+
costOptimized: false,
|
|
272
|
+
risk: risk ?? null,
|
|
273
|
+
knnResult: null,
|
|
274
|
+
base_tier: null,
|
|
275
|
+
escalations: [],
|
|
276
|
+
escalation_source: null,
|
|
277
|
+
pinned: true,
|
|
278
|
+
switch_reason: null,
|
|
279
|
+
propensity: 1.0,
|
|
280
|
+
candidates: [{ provider: pin.provider, model: pin.model }],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Provider routing with cache-aware session pinning.
|
|
286
|
+
*
|
|
287
|
+
* Fast path: when a session has a pin and the cheap guards pass, we skip
|
|
288
|
+
* complexity analysis, kNN, and the bandit entirely and serve the pin.
|
|
289
|
+
* Slow path: no pin, compaction, or a guard forces a re-decide.
|
|
145
290
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
291
|
+
* The pin evaluation itself lives in `checkSessionPin` so this path and the
|
|
292
|
+
* OAuth intent path in `src/api/router.js` use the same trigger rules.
|
|
293
|
+
* Gated by `LYNKR_STICKY_SESSIONS !== 'false'`.
|
|
149
294
|
*/
|
|
150
295
|
async function determineProviderSmart(payload, options = {}) {
|
|
151
|
-
const
|
|
296
|
+
const pinCheck = checkSessionPin(payload, options);
|
|
297
|
+
|
|
298
|
+
// Bypass (no session / forceProvider / feature-off) or no pin yet →
|
|
299
|
+
// straight to fresh routing, then persist the outcome for the next turn.
|
|
300
|
+
if (pinCheck.reason === 'bypass' || pinCheck.reason === 'no_pin') {
|
|
301
|
+
const fresh = await _determineProviderSmartInner(payload, options);
|
|
302
|
+
if (pinCheck.sessionId && fresh?.provider) {
|
|
303
|
+
writeSessionPin(pinCheck.sessionId, fresh, payload);
|
|
304
|
+
}
|
|
305
|
+
return fresh;
|
|
306
|
+
}
|
|
152
307
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
308
|
+
// Pin serves — either mid-tool-exchange (unconditional) or guards passed.
|
|
309
|
+
if (pinCheck.serve) {
|
|
310
|
+
// WS1.5 — upward-drift escape hatch. Only on guards_passed turns:
|
|
311
|
+
// tool_history serves must NEVER switch (tool-call IDs break across
|
|
312
|
+
// providers), and drift-checking them would risk exactly that.
|
|
313
|
+
if (pinCheck.reason === 'guards_passed') {
|
|
314
|
+
const drift = await checkPinScoreDrift(pinCheck.pin, payload);
|
|
315
|
+
if (drift.drift) {
|
|
316
|
+
logger.info({
|
|
317
|
+
sessionId: pinCheck.sessionId,
|
|
318
|
+
pinnedTier: pinCheck.pin.tier,
|
|
319
|
+
freshScore: drift.freshScore,
|
|
320
|
+
ceiling: drift.ceiling,
|
|
321
|
+
}, '[Routing] Pin score drift — re-deciding');
|
|
322
|
+
const fresh = await _determineProviderSmartInner(payload, options);
|
|
323
|
+
fresh.pinned = false;
|
|
324
|
+
fresh.switch_reason = 'score_drift';
|
|
325
|
+
if (_tierPriority(fresh.tier) >= _tierPriority(pinCheck.pin.tier)) {
|
|
326
|
+
writeSessionPin(pinCheck.sessionId, fresh, payload);
|
|
327
|
+
}
|
|
328
|
+
return fresh;
|
|
329
|
+
}
|
|
167
330
|
}
|
|
331
|
+
const reasonLabel = pinCheck.reason === 'tool_history'
|
|
332
|
+
? 'tool_history_provider_pin'
|
|
333
|
+
: 'guards_passed';
|
|
334
|
+
logger.debug({
|
|
335
|
+
sessionId: pinCheck.sessionId,
|
|
336
|
+
provider: pinCheck.pin.provider,
|
|
337
|
+
tier: pinCheck.pin.tier,
|
|
338
|
+
reason: reasonLabel,
|
|
339
|
+
}, '[Routing] Serving session pin');
|
|
340
|
+
return _pinToDecision(pinCheck.pin, { reason: reasonLabel, risk: null });
|
|
168
341
|
}
|
|
169
342
|
|
|
170
|
-
|
|
343
|
+
// Pin exists but a trigger fired. Run fresh routing.
|
|
344
|
+
const pin = pinCheck.pin;
|
|
345
|
+
const fresh = await _determineProviderSmartInner(payload, options);
|
|
346
|
+
fresh.pinned = false;
|
|
347
|
+
|
|
348
|
+
// Compaction: prompt cache was reset upstream, so switching is free EXCEPT
|
|
349
|
+
// when the switch would be to a cheaper model that doesn't pay for the
|
|
350
|
+
// cold-cache re-read at the current prompt size. The economic guard only
|
|
351
|
+
// fires on the compaction path — guard escalations are mandatory.
|
|
352
|
+
if (pinCheck.reason === 'compaction') {
|
|
353
|
+
fresh.switch_reason = 'compaction';
|
|
354
|
+
const promptTokensEst = _tryCountTokens(payload, pin.model || fresh.model);
|
|
355
|
+
if (!_economicDowngradeAllowed(promptTokensEst, pin.model, fresh.model)) {
|
|
356
|
+
logger.debug({
|
|
357
|
+
sessionId: pinCheck.sessionId,
|
|
358
|
+
pinModel: pin.model,
|
|
359
|
+
freshModel: fresh.model,
|
|
360
|
+
promptTokensEst,
|
|
361
|
+
}, '[Routing] Economic downgrade suppressed — staying on pin');
|
|
362
|
+
const served = _pinToDecision(pin, { reason: 'economic_suppressed', risk: null });
|
|
363
|
+
writeSessionPin(pinCheck.sessionId, pin, payload);
|
|
364
|
+
return served;
|
|
365
|
+
}
|
|
366
|
+
writeSessionPin(pinCheck.sessionId, fresh, payload);
|
|
367
|
+
return fresh;
|
|
368
|
+
}
|
|
171
369
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
370
|
+
// Vision-only escalation: use the vision-capable model for THIS turn but
|
|
371
|
+
// don't overwrite the pin — the next non-image turn should fall back to
|
|
372
|
+
// the cheaper pinned model.
|
|
373
|
+
if (pinCheck.reason === 'vision') {
|
|
374
|
+
fresh.switch_reason = 'guard_escalation';
|
|
375
|
+
logger.debug({
|
|
376
|
+
sessionId: pinCheck.sessionId,
|
|
377
|
+
pinModel: pin.model,
|
|
378
|
+
freshModel: fresh.model,
|
|
379
|
+
}, '[Routing] Vision guard fired — pinExempt, not re-pinning');
|
|
380
|
+
return fresh;
|
|
175
381
|
}
|
|
176
382
|
|
|
177
|
-
|
|
383
|
+
// Risk/context escalation: re-pin upward when the fresh tier is at least
|
|
384
|
+
// as capable as the pinned tier (typical) — downgrades from a guard fail
|
|
385
|
+
// are rare and we leave the old pin alone in that case.
|
|
386
|
+
fresh.switch_reason = 'guard_escalation';
|
|
387
|
+
if (_tierPriority(fresh.tier) >= _tierPriority(pin.tier)) {
|
|
388
|
+
writeSessionPin(pinCheck.sessionId, fresh, payload);
|
|
389
|
+
}
|
|
390
|
+
return fresh;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function _tryCountTokens(payload, model) {
|
|
394
|
+
if (!model) return null;
|
|
395
|
+
try {
|
|
396
|
+
return countPayloadTokens(payload, model);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
degradation.record('context_validate', err);
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// WS1.5 — upward-drift margin. A pinned session re-decides when the latest
|
|
404
|
+
// user message scores this many points ABOVE the pinned tier's calibrated
|
|
405
|
+
// ceiling. 15 ≈ half a tier band: enough that score jitter on borderline
|
|
406
|
+
// messages doesn't thrash the pin, small enough that "Hi" → "refactor the
|
|
407
|
+
// whole repo" escapes a SIMPLE pin on the very turn the task escalates.
|
|
408
|
+
const PIN_DRIFT_MARGIN = Number(process.env.LYNKR_PIN_DRIFT_MARGIN) || 15;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* WS1.5 — detect upward complexity drift on a pinned session.
|
|
412
|
+
*
|
|
413
|
+
* WS1's re-decide triggers (compaction, risk, context, vision, economics)
|
|
414
|
+
* all miss the most common real-world case: the session OPENED trivially
|
|
415
|
+
* ("Hi" → pinned SIMPLE) and then the real task arrived ("plan a refactor
|
|
416
|
+
* of the whole repo"). Nothing about that turn trips a guard, so the pin
|
|
417
|
+
* held the session on the SIMPLE-tier model forever.
|
|
418
|
+
*
|
|
419
|
+
* This scores ONLY the latest user message (cheap heuristic pass — no
|
|
420
|
+
* embeddings, no kNN, no bandit) and compares it against the pinned
|
|
421
|
+
* tier's calibrated score ceiling. Exceeding ceiling + PIN_DRIFT_MARGIN
|
|
422
|
+
* means the conversation has outgrown its pin → caller falls through to
|
|
423
|
+
* full routing and re-pins upward.
|
|
424
|
+
*
|
|
425
|
+
* Deliberately one-directional: downward drift stays pinned (the economic
|
|
426
|
+
* downgrade rule owns that case, where switching costs a cold cache read).
|
|
427
|
+
*
|
|
428
|
+
* @param {{tier:string|null}} pin
|
|
429
|
+
* @param {object} payload — full request payload
|
|
430
|
+
* @returns {Promise<{drift:boolean, freshScore:number|null, ceiling:number|null}>}
|
|
431
|
+
*/
|
|
432
|
+
async function checkPinScoreDrift(pin, payload) {
|
|
433
|
+
const none = { drift: false, freshScore: null, ceiling: null };
|
|
434
|
+
try {
|
|
435
|
+
const tier = pin?.tier;
|
|
436
|
+
if (!tier || tier === 'REASONING') return none; // already at the top
|
|
437
|
+
const idx = _tierPriority(tier);
|
|
438
|
+
if (idx < 0) return none;
|
|
439
|
+
|
|
440
|
+
const msgs = payload?.messages;
|
|
441
|
+
if (!Array.isArray(msgs)) return none;
|
|
442
|
+
const lastUser = [...msgs].reverse().find(m => m?.role === 'user');
|
|
443
|
+
if (!lastUser) return none;
|
|
444
|
+
const rawText = typeof lastUser.content === 'string'
|
|
445
|
+
? lastUser.content
|
|
446
|
+
: Array.isArray(lastUser.content)
|
|
447
|
+
? lastUser.content.filter(b => b?.type === 'text').map(b => b.text || '').join(' ')
|
|
448
|
+
: '';
|
|
449
|
+
const text = rawText.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
450
|
+
if (!text) return none; // tool-result-only turn etc. — nothing to score
|
|
451
|
+
|
|
452
|
+
// Force-cloud patterns are absolute overrides ("refactor the entire
|
|
453
|
+
// codebase", "security audit", "architecture review" — always cloud,
|
|
454
|
+
// regardless of score). Full routing honours them, but a pinned turn
|
|
455
|
+
// never reaches full routing — live incident (2026-07-07): "refactor
|
|
456
|
+
// the entire codebase give me a plan" scored 28, missed the drift
|
|
457
|
+
// threshold by 1, and rode a SIMPLE pin instead of force-routing to
|
|
458
|
+
// cloud. Treat a force-cloud match on the TYPED text as drift so the
|
|
459
|
+
// caller falls through to full routing, where the pattern fires
|
|
460
|
+
// properly. (force_local is deliberately NOT checked here — downward
|
|
461
|
+
// moves stay pinned per the economics rule.)
|
|
462
|
+
if (shouldForceCloud({ messages: [{ role: 'user', content: text }] })) {
|
|
463
|
+
return { drift: true, freshScore: 100, ceiling: null, forced: 'force_cloud' };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Agentic-autonomous is a trigger of the same rank as force-cloud, and
|
|
467
|
+
// its anchor score alone can't clear ceiling+margin — without this
|
|
468
|
+
// escape, autonomous asks are trapped by any pin.
|
|
469
|
+
try {
|
|
470
|
+
const agentic = getAgenticDetector().detect(
|
|
471
|
+
{ messages: [{ role: 'user', content: text }], tools: payload.tools },
|
|
472
|
+
{ clientProfile: payload._clientProfile || null },
|
|
473
|
+
);
|
|
474
|
+
if (agentic?.agentType === 'AUTONOMOUS') {
|
|
475
|
+
return { drift: true, freshScore: 100, ceiling: null, forced: 'agentic_autonomous' };
|
|
476
|
+
}
|
|
477
|
+
} catch { /* detector failure never blocks the pin path */ }
|
|
478
|
+
|
|
479
|
+
// Heuristic-only score of the isolated message. Mirrors the intent
|
|
480
|
+
// scorer's shape (single message + tools + client profile) so the
|
|
481
|
+
// number is comparable to the score that produced the pin.
|
|
482
|
+
// Pins hold anchor scores, so drift must use the same scorer or
|
|
483
|
+
// ceiling+margin comparisons are meaningless.
|
|
484
|
+
let freshScore = null;
|
|
485
|
+
if (intentScoreMode() !== 'legacy') {
|
|
486
|
+
const intent = await scoreIntent({ messages: [{ role: 'user', content: text }] });
|
|
487
|
+
if (intent && Number.isFinite(intent.score)) freshScore = intent.score;
|
|
488
|
+
}
|
|
489
|
+
if (freshScore === null) {
|
|
490
|
+
const analysis = await analyzeComplexity({
|
|
491
|
+
messages: [{ role: 'user', content: text }],
|
|
492
|
+
tools: payload.tools,
|
|
493
|
+
_clientProfile: payload._clientProfile,
|
|
494
|
+
}, {});
|
|
495
|
+
freshScore = analysis?.score;
|
|
496
|
+
}
|
|
497
|
+
if (typeof freshScore !== 'number') return none;
|
|
498
|
+
|
|
499
|
+
// Calibrated ceiling for the pinned tier (falls back to defaults).
|
|
500
|
+
const selector = getModelTierSelector();
|
|
501
|
+
const ranges = selector.ranges || {};
|
|
502
|
+
const ceiling = Array.isArray(ranges[tier]) ? ranges[tier][1]
|
|
503
|
+
: TIER_DEFINITIONS[tier]?.range?.[1];
|
|
504
|
+
if (typeof ceiling !== 'number') return none;
|
|
505
|
+
|
|
506
|
+
return { drift: freshScore > ceiling + PIN_DRIFT_MARGIN, freshScore, ceiling };
|
|
507
|
+
} catch (err) {
|
|
508
|
+
degradation.record('tier_select', err);
|
|
509
|
+
return none;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Sticky-session pin check used by both `determineProviderSmart` (full
|
|
515
|
+
* pipeline) and the OAuth intent path in `src/api/router.js`. The two paths
|
|
516
|
+
* decide provider differently but agree on WHEN to reuse a pin, so this
|
|
517
|
+
* central function keeps that policy in one place.
|
|
518
|
+
*
|
|
519
|
+
* Returns:
|
|
520
|
+
* { serve: true, pin, reason, sessionId } — reuse the pinned decision
|
|
521
|
+
* { serve: false, pin?, reason?, sessionId } — run full routing
|
|
522
|
+
*
|
|
523
|
+
* Refreshes the pin's ts/messageCount on serve so an active session doesn't
|
|
524
|
+
* TTL-expire mid-conversation. Never throws.
|
|
525
|
+
*
|
|
526
|
+
* @param {object} payload
|
|
527
|
+
* @param {object} [options]
|
|
528
|
+
* @returns {{serve:boolean, pin?:object, reason:string, sessionId:string|null}}
|
|
529
|
+
*/
|
|
530
|
+
function checkSessionPin(payload, options = {}) {
|
|
531
|
+
const sessionId = payload?._sessionId || null;
|
|
532
|
+
const stickyEnabled = process.env.LYNKR_STICKY_SESSIONS !== 'false';
|
|
533
|
+
if (!stickyEnabled || !sessionId || options.forceProvider) {
|
|
534
|
+
return { serve: false, sessionId: null, reason: 'bypass' };
|
|
535
|
+
}
|
|
536
|
+
const pin = sessionAffinity.getPin(sessionId);
|
|
537
|
+
if (!pin) return { serve: false, sessionId, reason: 'no_pin' };
|
|
538
|
+
|
|
539
|
+
const messageCount = Array.isArray(payload?.messages) ? payload.messages.length : 0;
|
|
540
|
+
// Opener-only conversations (≤2 messages) never consume pins either —
|
|
541
|
+
// a visible pin may belong to a different conversation with the same
|
|
542
|
+
// opener, and a full route on frame 1-2 costs one cached embed.
|
|
543
|
+
if (messageCount <= 2) {
|
|
544
|
+
return { serve: false, pin, sessionId, reason: 'opener_conversation' };
|
|
545
|
+
}
|
|
546
|
+
// Tool-less requests are harness side traffic (title generation,
|
|
547
|
+
// summarization, memory extraction) replaying the conversation. They may
|
|
548
|
+
// be SERVED from the pin but must not refresh its ts/messageCount — a
|
|
549
|
+
// side request's message count includes wrapper turns, and persisting it
|
|
550
|
+
// makes the next real turn look compacted (phantom re-route).
|
|
551
|
+
const refreshOk = Array.isArray(payload?.tools) && payload.tools.length > 0;
|
|
552
|
+
|
|
553
|
+
if (sessionAffinity.payloadHasToolHistory(payload)) {
|
|
554
|
+
// Text typed during a tool loop arrives merged with the pending
|
|
555
|
+
// tool_result, where the pin serves unconditionally (id linkage forbids
|
|
556
|
+
// switching mid-exchange). If that embedded text trips a trigger, drop
|
|
557
|
+
// the pin so the next turn boundary re-routes.
|
|
558
|
+
try {
|
|
559
|
+
const { extractCleanUserText } = require('./intent-score');
|
|
560
|
+
const lastMsg = payload.messages[payload.messages.length - 1];
|
|
561
|
+
const embedded = extractCleanUserText({ messages: [lastMsg] });
|
|
562
|
+
if (embedded) {
|
|
563
|
+
const trippedForce = shouldForceCloud({ messages: [{ role: 'user', content: embedded }] });
|
|
564
|
+
const agentic = trippedForce ? null : getAgenticDetector().detect(
|
|
565
|
+
{ messages: [{ role: 'user', content: embedded }], tools: payload.tools },
|
|
566
|
+
{ clientProfile: payload._clientProfile || null },
|
|
567
|
+
);
|
|
568
|
+
if (trippedForce || agentic?.agentType === 'AUTONOMOUS') {
|
|
569
|
+
sessionAffinity.removePin(sessionId);
|
|
570
|
+
logger.info({
|
|
571
|
+
sessionId,
|
|
572
|
+
trigger: trippedForce ? 'force_cloud' : 'agentic_autonomous',
|
|
573
|
+
pinnedTier: pin.tier,
|
|
574
|
+
}, '[Routing] Trigger text embedded in tool exchange — pin dropped, next boundary re-routes');
|
|
575
|
+
return { serve: true, pin, reason: 'tool_history_pin_dropped', sessionId };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
} catch { /* never block the pin-serve path */ }
|
|
579
|
+
if (refreshOk) {
|
|
580
|
+
sessionAffinity.setPin(sessionId, pin, {
|
|
581
|
+
// Monotonic within a pin's lifetime: refreshes must never SHRINK the
|
|
582
|
+
// recorded conversation size, or a short colliding session blinds
|
|
583
|
+
// the new_conversation guard for everyone after it.
|
|
584
|
+
messageCount: Math.max(messageCount, pin.messageCount ?? 0),
|
|
585
|
+
promptTokensEst: pin.promptTokensEst,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
return { serve: true, pin, reason: 'tool_history', sessionId };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const repin = sessionAffinity.shouldRepin(pin, payload);
|
|
592
|
+
if (repin.repin) return { serve: false, pin, sessionId, reason: repin.reason };
|
|
593
|
+
|
|
594
|
+
const guards = _runPinGuards(payload, pin);
|
|
595
|
+
if (!guards.ok) return { serve: false, pin, sessionId, reason: guards.reason };
|
|
596
|
+
|
|
597
|
+
if (refreshOk) {
|
|
598
|
+
sessionAffinity.setPin(sessionId, pin, {
|
|
599
|
+
messageCount: Math.max(messageCount, pin.messageCount ?? 0),
|
|
600
|
+
promptTokensEst: guards.promptTokensEst ?? pin.promptTokensEst,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return { serve: true, pin, reason: 'guards_passed', sessionId };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Write-through helper: persist a fresh routing decision as the session's
|
|
608
|
+
* new pin. No-op when sessionId is missing or the decision has no provider.
|
|
609
|
+
*
|
|
610
|
+
* Risk-forced decisions are NEVER pinned. Risk analysis runs on every turn
|
|
611
|
+
* (both in `_runPinGuards` and inner routing), so escalating THIS turn is
|
|
612
|
+
* already guaranteed without a pin — pinning it only creates a one-way
|
|
613
|
+
* ratchet where a single phantom risk hit (live incidents: harness
|
|
614
|
+
* suggestion-mode wrapper text, replayed repo transcripts) locks the whole
|
|
615
|
+
* conversation onto the expensive tier. If the next turn is genuinely
|
|
616
|
+
* risky, risk fires again then.
|
|
617
|
+
*
|
|
618
|
+
* @param {string|null} sessionId
|
|
619
|
+
* @param {object} decision
|
|
620
|
+
* @param {object} payload
|
|
621
|
+
*/
|
|
622
|
+
function writeSessionPin(sessionId, decision, payload) {
|
|
623
|
+
if (!sessionId || !decision?.provider) return;
|
|
624
|
+
const method = decision.method || '';
|
|
625
|
+
if (method === 'risk' || method.startsWith('risk+') || decision.escalation_source === 'risk') {
|
|
626
|
+
logger.debug({
|
|
627
|
+
sessionId,
|
|
628
|
+
provider: decision.provider,
|
|
629
|
+
tier: decision.tier,
|
|
630
|
+
method,
|
|
631
|
+
}, '[Routing] Risk-forced decision — pin write skipped');
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const messageCount = Array.isArray(payload?.messages) ? payload.messages.length : 0;
|
|
635
|
+
// Opener-only sessions (≤2 messages) never pin: a bare opener has nothing
|
|
636
|
+
// to stabilize, and identical openers share a fingerprint for the 6h TTL,
|
|
637
|
+
// so any pin written here leaks to unrelated sessions.
|
|
638
|
+
if (messageCount <= 2) {
|
|
639
|
+
logger.debug({ sessionId, tier: decision.tier, messageCount },
|
|
640
|
+
'[Routing] Opener-only session — pin write skipped');
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const promptTokensEst = _tryCountTokens(payload, decision.model);
|
|
644
|
+
sessionAffinity.setPin(sessionId, decision, { messageCount, promptTokensEst });
|
|
178
645
|
}
|
|
179
646
|
|
|
180
647
|
async function _determineProviderSmartInner(payload, options = {}) {
|
|
@@ -188,10 +655,36 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
188
655
|
try {
|
|
189
656
|
risk = analyzeRisk(payload);
|
|
190
657
|
} catch (err) {
|
|
191
|
-
|
|
658
|
+
degradation.record('risk', err);
|
|
192
659
|
risk = null;
|
|
193
660
|
}
|
|
194
661
|
|
|
662
|
+
// WS5.5 — hoist query-text + embedding capture to the top so every
|
|
663
|
+
// decision-return path (static, risk-forced, force-local/cloud,
|
|
664
|
+
// autonomous-agentic, kNN-driven main path) attaches `_queryEmbedding`
|
|
665
|
+
// consistently. Without this, risk-forced short-circuits skip the kNN
|
|
666
|
+
// block entirely and the feedback loop can never add those outcomes as
|
|
667
|
+
// exemplars — which is exactly the path that most needs learning (e.g.
|
|
668
|
+
// "encrypt" trigrams that keep landing high-risk on trivial prompts).
|
|
669
|
+
// Extraction is cheap (string parse); embedding is one HTTP call to
|
|
670
|
+
// Ollama (~200ms). Both are best-effort and fall through as null.
|
|
671
|
+
let queryText = null;
|
|
672
|
+
let queryEmbedding = null;
|
|
673
|
+
if (config.routing?.knnEnabled !== false) {
|
|
674
|
+
try {
|
|
675
|
+
const msgs = payload?.messages;
|
|
676
|
+
const lastMsg = Array.isArray(msgs) ? msgs[msgs.length - 1]?.content : null;
|
|
677
|
+
queryText = typeof lastMsg === 'string' ? lastMsg
|
|
678
|
+
: Array.isArray(lastMsg) ? lastMsg.filter(b => b?.type === 'text').map(b => b.text || '').join(' ')
|
|
679
|
+
: null;
|
|
680
|
+
if (queryText) {
|
|
681
|
+
queryEmbedding = await getKnnRouter().embed(queryText);
|
|
682
|
+
}
|
|
683
|
+
} catch (err) {
|
|
684
|
+
degradation.record('knn', err);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
195
688
|
// If tier routing is disabled, use static configuration
|
|
196
689
|
if (!config.modelTiers?.enabled) {
|
|
197
690
|
return {
|
|
@@ -200,6 +693,10 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
200
693
|
method: 'static',
|
|
201
694
|
reason: 'tier_routing_disabled',
|
|
202
695
|
risk,
|
|
696
|
+
propensity: 1.0,
|
|
697
|
+
candidates: [{ provider: primaryProvider, model: null }],
|
|
698
|
+
_queryEmbedding: queryEmbedding,
|
|
699
|
+
_queryText: queryText,
|
|
203
700
|
};
|
|
204
701
|
}
|
|
205
702
|
|
|
@@ -218,6 +715,19 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
218
715
|
reason: 'high_risk_forced_tier',
|
|
219
716
|
score: 100,
|
|
220
717
|
risk,
|
|
718
|
+
base_tier: null,
|
|
719
|
+
escalations: [{
|
|
720
|
+
source: 'risk',
|
|
721
|
+
fromTier: null,
|
|
722
|
+
toTier: 'COMPLEX',
|
|
723
|
+
fromModel: null,
|
|
724
|
+
toModel: modelSelection.model,
|
|
725
|
+
}],
|
|
726
|
+
escalation_source: 'risk',
|
|
727
|
+
propensity: 1.0,
|
|
728
|
+
candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
|
|
729
|
+
_queryEmbedding: queryEmbedding,
|
|
730
|
+
_queryText: queryText,
|
|
221
731
|
};
|
|
222
732
|
routingMetrics.record(decision);
|
|
223
733
|
logger.debug({
|
|
@@ -228,7 +738,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
228
738
|
}, '[Routing] High risk → forcing tier');
|
|
229
739
|
return decision;
|
|
230
740
|
} catch (err) {
|
|
231
|
-
|
|
741
|
+
degradation.record('tier_select', err);
|
|
232
742
|
}
|
|
233
743
|
}
|
|
234
744
|
|
|
@@ -247,11 +757,15 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
247
757
|
reason: 'force_local_pattern',
|
|
248
758
|
score: 0,
|
|
249
759
|
risk,
|
|
760
|
+
propensity: 1.0,
|
|
761
|
+
candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
|
|
762
|
+
_queryEmbedding: queryEmbedding,
|
|
763
|
+
_queryText: queryText,
|
|
250
764
|
};
|
|
251
765
|
routingMetrics.record(decision);
|
|
252
766
|
return decision;
|
|
253
767
|
} catch (err) {
|
|
254
|
-
|
|
768
|
+
degradation.record('tier_select', err);
|
|
255
769
|
}
|
|
256
770
|
}
|
|
257
771
|
const provider = getBestLocalProvider();
|
|
@@ -262,12 +776,47 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
262
776
|
reason: 'force_local_pattern',
|
|
263
777
|
score: 0,
|
|
264
778
|
risk,
|
|
779
|
+
propensity: 1.0,
|
|
780
|
+
candidates: [{ provider, model: null }],
|
|
781
|
+
_queryEmbedding: queryEmbedding,
|
|
782
|
+
_queryText: queryText,
|
|
265
783
|
};
|
|
266
784
|
routingMetrics.record(decision);
|
|
267
785
|
return decision;
|
|
268
786
|
}
|
|
269
787
|
|
|
270
788
|
if (shouldForceCloud(payload) && isFallbackEnabled()) {
|
|
789
|
+
// When tier routing is enabled, force-cloud means the COMPLEX tier's
|
|
790
|
+
// configured model — NOT getBestCloudProvider()'s credential-priority
|
|
791
|
+
// list. That list starts with databricks-if-credentialed, and installs
|
|
792
|
+
// running pure tier routing carry DUMMY databricks values to pass
|
|
793
|
+
// startup validation (live incident 2026-07-07: an "architecture
|
|
794
|
+
// review" force-cloud routed to the dummy base http://localhost:8081 —
|
|
795
|
+
// Lynkr proxying to itself — and hung). Mirrors the force_local branch,
|
|
796
|
+
// which got the same tier-aware fix long ago.
|
|
797
|
+
if (config.modelTiers?.enabled) {
|
|
798
|
+
try {
|
|
799
|
+
const selector = getModelTierSelector();
|
|
800
|
+
const modelSelection = selector.selectModel('COMPLEX', null);
|
|
801
|
+
const decision = {
|
|
802
|
+
provider: modelSelection.provider,
|
|
803
|
+
model: modelSelection.model,
|
|
804
|
+
tier: 'COMPLEX',
|
|
805
|
+
method: 'force',
|
|
806
|
+
reason: 'force_cloud_pattern',
|
|
807
|
+
score: 100,
|
|
808
|
+
risk,
|
|
809
|
+
propensity: 1.0,
|
|
810
|
+
candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
|
|
811
|
+
_queryEmbedding: queryEmbedding,
|
|
812
|
+
_queryText: queryText,
|
|
813
|
+
};
|
|
814
|
+
routingMetrics.record(decision);
|
|
815
|
+
return decision;
|
|
816
|
+
} catch (err) {
|
|
817
|
+
degradation.record('tier_select', err);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
271
820
|
const provider = getBestCloudProvider();
|
|
272
821
|
const decision = {
|
|
273
822
|
provider,
|
|
@@ -276,6 +825,10 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
276
825
|
reason: 'force_cloud_pattern',
|
|
277
826
|
score: 100,
|
|
278
827
|
risk,
|
|
828
|
+
propensity: 1.0,
|
|
829
|
+
candidates: [{ provider, model: null }],
|
|
830
|
+
_queryEmbedding: queryEmbedding,
|
|
831
|
+
_queryText: queryText,
|
|
279
832
|
};
|
|
280
833
|
routingMetrics.record(decision);
|
|
281
834
|
return decision;
|
|
@@ -285,9 +838,33 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
285
838
|
const useWeightedScoring = config.routing?.weightedScoring ?? false;
|
|
286
839
|
const analysis = await analyzeComplexity(payload, { weighted: useWeightedScoring, workspace: options.workspace });
|
|
287
840
|
|
|
288
|
-
//
|
|
841
|
+
// WS7 — replace the full-payload lexical score with the anchor score of
|
|
842
|
+
// cleaned user text (payload-invariant). Envelope signals escalate via
|
|
843
|
+
// triggers, never via this score. LYNKR_INTENT_SCORE_MODE=legacy opts out.
|
|
844
|
+
let intentScored = false;
|
|
845
|
+
try {
|
|
846
|
+
const intent = await scoreIntent(payload);
|
|
847
|
+
if (intent && Number.isFinite(intent.score)) {
|
|
848
|
+
analysis.lexicalScore = analysis.score;
|
|
849
|
+
analysis.score = intent.score;
|
|
850
|
+
analysis.scoreMode = intent.mode; // 'anchor' | 'lexical' (clean-text fallback)
|
|
851
|
+
analysis.anchorClass = intent.class ?? null;
|
|
852
|
+
intentScored = true;
|
|
853
|
+
logger.debug({
|
|
854
|
+
score: intent.score,
|
|
855
|
+
mode: intent.mode,
|
|
856
|
+
class: intent.class,
|
|
857
|
+
lexicalScore: analysis.lexicalScore,
|
|
858
|
+
}, '[Routing] WS7 intent score');
|
|
859
|
+
}
|
|
860
|
+
} catch (err) {
|
|
861
|
+
degradation.record('intent_score', err);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Phase 4 embeddings adjustment: legacy path only — it reads the full
|
|
865
|
+
// payload and would reintroduce envelope noise into an anchor score.
|
|
289
866
|
let embeddingsResult = null;
|
|
290
|
-
if (options.useEmbeddings !== false && config.ollama?.embeddingsModel) {
|
|
867
|
+
if (!intentScored && options.useEmbeddings !== false && config.ollama?.embeddingsModel) {
|
|
291
868
|
try {
|
|
292
869
|
embeddingsResult = await analyzeWithEmbeddings(payload);
|
|
293
870
|
if (embeddingsResult?.adjustment) {
|
|
@@ -297,7 +874,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
297
874
|
analysis.embeddingsAdjustment = embeddingsResult.adjustment;
|
|
298
875
|
}
|
|
299
876
|
} catch (err) {
|
|
300
|
-
|
|
877
|
+
degradation.record('embeddings', err);
|
|
301
878
|
}
|
|
302
879
|
}
|
|
303
880
|
|
|
@@ -306,12 +883,20 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
306
883
|
if (config.routing?.agenticDetection !== false) {
|
|
307
884
|
try {
|
|
308
885
|
const detector = getAgenticDetector();
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
886
|
+
// WS3.2 — thread the client profile so the detector subtracts the
|
|
887
|
+
// harness's baseline tool loadout before scoring tool-count signals.
|
|
888
|
+
const clientProfile = payload?._clientProfile
|
|
889
|
+
|| options.clientProfile
|
|
890
|
+
|| null;
|
|
891
|
+
agenticResult = detector.detect(payload, { clientProfile });
|
|
892
|
+
|
|
893
|
+
// Agentic boost: legacy mode only. In anchor mode the score must stay
|
|
894
|
+
// payload-invariant; agentic escalation happens via minTier instead.
|
|
312
895
|
if (agenticResult.isAgentic) {
|
|
313
|
-
|
|
314
|
-
|
|
896
|
+
if (!intentScored) {
|
|
897
|
+
analysis.score = Math.min(100, analysis.score + agenticResult.scoreBoost);
|
|
898
|
+
analysis.agenticBoost = agenticResult.scoreBoost;
|
|
899
|
+
}
|
|
315
900
|
analysis.agentType = agenticResult.agentType;
|
|
316
901
|
|
|
317
902
|
logger.debug({
|
|
@@ -320,48 +905,100 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
320
905
|
newScore: analysis.score,
|
|
321
906
|
}, '[Routing] Agentic workflow detected, boosting score');
|
|
322
907
|
|
|
323
|
-
// Force cloud for autonomous workflows
|
|
908
|
+
// Force cloud for autonomous workflows. AUTONOMOUS carries
|
|
909
|
+
// minTier=REASONING, but this early return used to bypass tier
|
|
910
|
+
// mapping entirely and pick from getBestCloudProvider()'s
|
|
911
|
+
// credential list — the same dormant landmine as the force-cloud
|
|
912
|
+
// branch (dummy databricks credentials → self-proxy). When tier
|
|
913
|
+
// routing is on, honour the agent type's declared minTier and
|
|
914
|
+
// serve the REASONING tier's configured model.
|
|
324
915
|
if (agenticResult.agentType === 'AUTONOMOUS' && isFallbackEnabled()) {
|
|
916
|
+
if (config.modelTiers?.enabled) {
|
|
917
|
+
try {
|
|
918
|
+
const selector = getModelTierSelector();
|
|
919
|
+
const modelSelection = selector.selectModel('REASONING', null);
|
|
920
|
+
const decision = {
|
|
921
|
+
provider: modelSelection.provider,
|
|
922
|
+
model: modelSelection.model,
|
|
923
|
+
tier: 'REASONING',
|
|
924
|
+
method: 'agentic',
|
|
925
|
+
reason: 'autonomous_workflow',
|
|
926
|
+
// Triggers present a score consistent with the tier they
|
|
927
|
+
// force (like force_cloud/risk) — score-comparing consumers
|
|
928
|
+
// (intent window decay, pin ceilings) would otherwise treat
|
|
929
|
+
// a REASONING decision as trivial.
|
|
930
|
+
score: 100,
|
|
931
|
+
agenticResult,
|
|
932
|
+
risk,
|
|
933
|
+
propensity: 1.0,
|
|
934
|
+
candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
|
|
935
|
+
_queryEmbedding: queryEmbedding,
|
|
936
|
+
_queryText: queryText,
|
|
937
|
+
};
|
|
938
|
+
routingMetrics.record(decision);
|
|
939
|
+
return decision;
|
|
940
|
+
} catch (err) {
|
|
941
|
+
degradation.record('tier_select', err);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
325
944
|
const provider = getBestCloudProvider();
|
|
326
945
|
const decision = {
|
|
327
946
|
provider,
|
|
328
947
|
method: 'agentic',
|
|
329
948
|
reason: 'autonomous_workflow',
|
|
330
|
-
score:
|
|
949
|
+
score: 100, // trigger score matches forced tier — see comment above
|
|
331
950
|
agenticResult,
|
|
332
951
|
risk,
|
|
952
|
+
propensity: 1.0,
|
|
953
|
+
candidates: [{ provider, model: null }],
|
|
954
|
+
_queryEmbedding: queryEmbedding,
|
|
955
|
+
_queryText: queryText,
|
|
333
956
|
};
|
|
334
957
|
routingMetrics.record(decision);
|
|
335
958
|
return decision;
|
|
336
959
|
}
|
|
337
960
|
}
|
|
338
961
|
} catch (err) {
|
|
339
|
-
|
|
962
|
+
degradation.record('agentic', err);
|
|
340
963
|
}
|
|
341
964
|
}
|
|
342
965
|
|
|
343
966
|
// Tier-based model selection
|
|
344
967
|
let selectedModel = null;
|
|
345
968
|
let tier = null;
|
|
969
|
+
// baseTier is the tier chosen by pure complexity analysis, before any
|
|
970
|
+
// guard/override. escalations[] accumulates every deviation from base_tier
|
|
971
|
+
// (agentic minTier, context, vision, kNN-ambiguous) for telemetry.
|
|
972
|
+
let baseTier = null;
|
|
973
|
+
const escalations = [];
|
|
346
974
|
if (config.modelTiers?.enabled) {
|
|
347
975
|
try {
|
|
348
976
|
const selector = getModelTierSelector();
|
|
349
977
|
tier = selector.getTier(analysis.score);
|
|
978
|
+
baseTier = tier;
|
|
350
979
|
|
|
351
980
|
// Check if agentic detection requires a higher tier
|
|
352
981
|
if (agenticResult?.minTier) {
|
|
353
982
|
const agenticTierPriority = TIER_DEFINITIONS[agenticResult.minTier]?.priority || 0;
|
|
354
983
|
const currentTierPriority = TIER_DEFINITIONS[tier]?.priority || 0;
|
|
355
984
|
if (agenticTierPriority > currentTierPriority) {
|
|
985
|
+
const fromTier = tier;
|
|
356
986
|
tier = agenticResult.minTier;
|
|
357
|
-
|
|
987
|
+
escalations.push({
|
|
988
|
+
source: 'agentic_min_tier',
|
|
989
|
+
fromTier,
|
|
990
|
+
toTier: tier,
|
|
991
|
+
fromModel: null,
|
|
992
|
+
toModel: null,
|
|
993
|
+
});
|
|
994
|
+
logger.debug({ from: fromTier, to: tier }, '[Routing] Upgrading tier for agentic workflow');
|
|
358
995
|
}
|
|
359
996
|
}
|
|
360
997
|
|
|
361
998
|
// Select model for the tier (will be applied after provider selection)
|
|
362
999
|
analysis.tier = tier;
|
|
363
1000
|
} catch (err) {
|
|
364
|
-
|
|
1001
|
+
degradation.record('tier_select', err);
|
|
365
1002
|
}
|
|
366
1003
|
}
|
|
367
1004
|
|
|
@@ -378,6 +1015,46 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
378
1015
|
selectedModel = modelSelection.model;
|
|
379
1016
|
logger.debug({ tier, provider, model: selectedModel }, '[Routing] Using tier config');
|
|
380
1017
|
|
|
1018
|
+
// WS2.3 — evidence-based de-escalation.
|
|
1019
|
+
//
|
|
1020
|
+
// The check is intentionally gated by evidence, not a feature flag: the
|
|
1021
|
+
// deescalator only returns a lower tier when the lower tier has >=30
|
|
1022
|
+
// rows of this request_type at avg quality >=70 with error rate <5% in
|
|
1023
|
+
// the last 7 days. On a fresh install with no telemetry, this never
|
|
1024
|
+
// fires. On a mature install with proven data, it demotes safely.
|
|
1025
|
+
//
|
|
1026
|
+
// Never applied when risk=high (upstream forces COMPLEX) or when any
|
|
1027
|
+
// upward escalation has already fired (agentic minTier, context, vision,
|
|
1028
|
+
// kNN) — those signals dominate.
|
|
1029
|
+
let demotedFrom = null;
|
|
1030
|
+
if (risk?.level !== 'high' && escalations.length === 0) {
|
|
1031
|
+
try {
|
|
1032
|
+
const requestType = analysis?.breakdown?.taskType?.reason
|
|
1033
|
+
?? analysis?.taskType
|
|
1034
|
+
?? null;
|
|
1035
|
+
const demoted = deescalator.suggestDemotion({
|
|
1036
|
+
tier,
|
|
1037
|
+
requestType,
|
|
1038
|
+
analysis,
|
|
1039
|
+
});
|
|
1040
|
+
if (demoted && demoted !== tier) {
|
|
1041
|
+
const demotedSelection = selector.selectModel(demoted, null);
|
|
1042
|
+
logger.debug({
|
|
1043
|
+
from: `${tier}:${provider}:${selectedModel}`,
|
|
1044
|
+
to: `${demoted}:${demotedSelection.provider}:${demotedSelection.model}`,
|
|
1045
|
+
requestType,
|
|
1046
|
+
}, '[Routing] De-escalation — demoting tier on evidence');
|
|
1047
|
+
demotedFrom = tier;
|
|
1048
|
+
provider = demotedSelection.provider;
|
|
1049
|
+
selectedModel = demotedSelection.model;
|
|
1050
|
+
tier = demoted;
|
|
1051
|
+
method = method + '+deescalated';
|
|
1052
|
+
}
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
degradation.record('tier_select', err);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
381
1058
|
// Phase 1.2 — cost-optimizer override.
|
|
382
1059
|
// Only kick in when:
|
|
383
1060
|
// - feature flag enabled (default true, disable with LYNKR_COST_OPTIMIZE=false)
|
|
@@ -407,7 +1084,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
407
1084
|
}
|
|
408
1085
|
}
|
|
409
1086
|
} catch (err) {
|
|
410
|
-
|
|
1087
|
+
degradation.record('cost_optimize', err);
|
|
411
1088
|
}
|
|
412
1089
|
}
|
|
413
1090
|
|
|
@@ -420,6 +1097,8 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
420
1097
|
if (!ctxResult.ok) {
|
|
421
1098
|
const capable = selector.findContextCapable(estimatedTokens, tier);
|
|
422
1099
|
if (capable) {
|
|
1100
|
+
const fromTier = tier;
|
|
1101
|
+
const fromModel = selectedModel;
|
|
423
1102
|
logger.info({
|
|
424
1103
|
from: `${provider}:${selectedModel}`,
|
|
425
1104
|
to: `${capable.provider}:${capable.model}`,
|
|
@@ -431,6 +1110,13 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
431
1110
|
selectedModel = capable.model;
|
|
432
1111
|
if (capable.tier) tier = capable.tier;
|
|
433
1112
|
method = method + '+context_escalated';
|
|
1113
|
+
escalations.push({
|
|
1114
|
+
source: 'context',
|
|
1115
|
+
fromTier,
|
|
1116
|
+
toTier: tier,
|
|
1117
|
+
fromModel,
|
|
1118
|
+
toModel: selectedModel,
|
|
1119
|
+
});
|
|
434
1120
|
} else {
|
|
435
1121
|
logger.warn({
|
|
436
1122
|
model: selectedModel,
|
|
@@ -440,7 +1126,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
440
1126
|
}
|
|
441
1127
|
}
|
|
442
1128
|
} catch (err) {
|
|
443
|
-
|
|
1129
|
+
degradation.record('context_validate', err);
|
|
444
1130
|
}
|
|
445
1131
|
|
|
446
1132
|
// Phase 1.4 — vision capability guard.
|
|
@@ -455,6 +1141,8 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
455
1141
|
if (!modelInfo?.vision) {
|
|
456
1142
|
const visionModel = selector.findVisionCapable(tier);
|
|
457
1143
|
if (visionModel) {
|
|
1144
|
+
const fromTier = tier;
|
|
1145
|
+
const fromModel = selectedModel;
|
|
458
1146
|
logger.info({
|
|
459
1147
|
from: `${provider}:${selectedModel}`,
|
|
460
1148
|
to: `${visionModel.provider}:${visionModel.model}`,
|
|
@@ -464,28 +1152,41 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
464
1152
|
selectedModel = visionModel.model;
|
|
465
1153
|
if (visionModel.tier !== tier) tier = visionModel.tier;
|
|
466
1154
|
method = method + '+vision_guard';
|
|
1155
|
+
escalations.push({
|
|
1156
|
+
source: 'vision_guard',
|
|
1157
|
+
fromTier,
|
|
1158
|
+
toTier: tier,
|
|
1159
|
+
fromModel,
|
|
1160
|
+
toModel: selectedModel,
|
|
1161
|
+
});
|
|
467
1162
|
} else {
|
|
468
1163
|
logger.warn({ model: selectedModel }, '[Routing] Vision guard — no vision-capable model found, request may fail');
|
|
469
1164
|
}
|
|
470
1165
|
}
|
|
471
1166
|
} catch (err) {
|
|
472
|
-
|
|
1167
|
+
degradation.record('vision_guard', err);
|
|
473
1168
|
}
|
|
474
1169
|
}
|
|
475
1170
|
|
|
476
1171
|
// Phase 3.1 — kNN routing hint.
|
|
477
1172
|
// If the index has enough entries, query it with the last user message.
|
|
478
1173
|
// A high-confidence kNN suggestion overrides the heuristic selection.
|
|
1174
|
+
//
|
|
1175
|
+
// WS5.5 — capture the query embedding at decision time so the feedback
|
|
1176
|
+
// path can turn a conclusive outcome into a new kNN exemplar without
|
|
1177
|
+
// paying for a second embedding call. The embedding is only computed
|
|
1178
|
+
// once regardless of whether the kNN query runs (sparse index / no
|
|
1179
|
+
// embedder → we skip the search but still keep the embedding for later
|
|
1180
|
+
// add() from feedback).
|
|
1181
|
+
// queryText + queryEmbedding are already captured at the top of the
|
|
1182
|
+
// function (WS5.5). Reuse them here so we don't re-embed the same text
|
|
1183
|
+
// — router.embed() is cache-backed but the extra call is still wasteful.
|
|
479
1184
|
let knnResult = null;
|
|
480
|
-
if (config.routing?.knnEnabled !== false) {
|
|
1185
|
+
if (config.routing?.knnEnabled !== false && queryEmbedding) {
|
|
481
1186
|
try {
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
: Array.isArray(lastMsg) ? lastMsg.filter(b => b?.type === 'text').map(b => b.text || '').join(' ')
|
|
486
|
-
: null;
|
|
487
|
-
if (queryText) {
|
|
488
|
-
knnResult = await getKnnRouter().query(queryText);
|
|
1187
|
+
const router = getKnnRouter();
|
|
1188
|
+
knnResult = await router.query(queryText);
|
|
1189
|
+
{
|
|
489
1190
|
// Confidence thresholds (env-configurable; defaults 0.7 high / 0.4 low):
|
|
490
1191
|
const KNN_HIGH = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_HIGH) || 0.7;
|
|
491
1192
|
const KNN_LOW = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_LOW) || 0.4;
|
|
@@ -500,39 +1201,80 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
500
1201
|
selectedModel = knnResult.model;
|
|
501
1202
|
method = method + '+knn';
|
|
502
1203
|
} else if (knnResult && knnResult.confidence > KNN_LOW && knnResult.confidence <= KNN_HIGH) {
|
|
503
|
-
// Ambiguous signal — neighbors are split, we can't trust any single
|
|
504
|
-
// recommendation.
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
1204
|
+
// Ambiguous signal — neighbors are split, we can't trust any single
|
|
1205
|
+
// model recommendation. Historically this always escalated one tier
|
|
1206
|
+
// "for safety", but that's a one-way ratchet: on a system where
|
|
1207
|
+
// cheap tiers are NOT failing, the bump is pure over-provisioning.
|
|
1208
|
+
//
|
|
1209
|
+
// Evidence-based leash: escalate only when telemetry shows
|
|
1210
|
+
// underProvisionedPct >= 2% (cheap tiers actually failing lately).
|
|
1211
|
+
// Otherwise keep the current tier. Fail-open to legacy escalation
|
|
1212
|
+
// if telemetry lookup itself fails, so we never regress safety.
|
|
1213
|
+
let shouldEscalate = false;
|
|
1214
|
+
try {
|
|
1215
|
+
const acc = telemetry.getRoutingAccuracy?.();
|
|
1216
|
+
const under = acc?.underProvisionedPct ?? 0;
|
|
1217
|
+
shouldEscalate = under >= 2;
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
degradation.record('knn', err);
|
|
1220
|
+
shouldEscalate = true;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (!shouldEscalate) {
|
|
1224
|
+
method = method + '+knn_ambiguous_kept';
|
|
1225
|
+
logger.debug({
|
|
1226
|
+
tier,
|
|
1227
|
+
provider,
|
|
1228
|
+
model: selectedModel,
|
|
1229
|
+
confidence: knnResult.confidence.toFixed(3),
|
|
1230
|
+
}, '[Routing] kNN ambiguous — leash held, keeping tier');
|
|
1231
|
+
} else {
|
|
1232
|
+
const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
1233
|
+
const currentIdx = TIER_ORDER.indexOf(tier);
|
|
1234
|
+
if (currentIdx >= 0 && currentIdx < TIER_ORDER.length - 1) {
|
|
1235
|
+
const upgradedTier = TIER_ORDER[currentIdx + 1];
|
|
1236
|
+
try {
|
|
1237
|
+
const upgraded = selector.selectModel(upgradedTier, null);
|
|
1238
|
+
const fromTier = tier;
|
|
1239
|
+
const fromModel = selectedModel;
|
|
1240
|
+
logger.debug({
|
|
1241
|
+
from: `${tier}:${provider}:${selectedModel}`,
|
|
1242
|
+
to: `${upgradedTier}:${upgraded.provider}:${upgraded.model}`,
|
|
1243
|
+
confidence: knnResult.confidence.toFixed(3),
|
|
1244
|
+
}, '[Routing] kNN ambiguous — escalating tier for safety');
|
|
1245
|
+
provider = upgraded.provider;
|
|
1246
|
+
selectedModel = upgraded.model;
|
|
1247
|
+
tier = upgradedTier;
|
|
1248
|
+
method = method + '+knn_ambiguous_escalate';
|
|
1249
|
+
escalations.push({
|
|
1250
|
+
source: 'knn_ambiguous',
|
|
1251
|
+
fromTier,
|
|
1252
|
+
toTier: tier,
|
|
1253
|
+
fromModel,
|
|
1254
|
+
toModel: selectedModel,
|
|
1255
|
+
});
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
degradation.record('knn', err);
|
|
1258
|
+
}
|
|
524
1259
|
}
|
|
525
1260
|
}
|
|
526
1261
|
}
|
|
527
1262
|
}
|
|
528
1263
|
} catch (err) {
|
|
529
|
-
|
|
1264
|
+
degradation.record('knn', err);
|
|
530
1265
|
}
|
|
531
1266
|
}
|
|
532
1267
|
|
|
533
1268
|
// Phase 4.1 — LinUCB bandit intra-tier selection.
|
|
534
1269
|
// When there are two candidates (heuristic vs kNN), the bandit picks the
|
|
535
1270
|
// one with the highest estimated UCB score for the current context.
|
|
1271
|
+
//
|
|
1272
|
+
// WS4.2 — capture propensity + candidates + context so the outcome row can
|
|
1273
|
+
// support off-policy evaluation. banditContext is stashed on the decision
|
|
1274
|
+
// (underscored → won't leak through headers, see WS4.2 verification).
|
|
1275
|
+
let banditPropensity = null;
|
|
1276
|
+
let banditCandidates = null;
|
|
1277
|
+
let banditContext = null;
|
|
536
1278
|
if (config.routing?.banditEnabled !== false && knnResult && knnResult.model) {
|
|
537
1279
|
try {
|
|
538
1280
|
// Build candidates: current selection and kNN alternative if different.
|
|
@@ -572,20 +1314,26 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
572
1314
|
...TASK_TYPES.map((_, i) => i === taskIdx ? 1 : 0),
|
|
573
1315
|
];
|
|
574
1316
|
const picked = bandit.pick(tier, allCandidates, ctx);
|
|
575
|
-
if (picked
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
1317
|
+
if (picked) {
|
|
1318
|
+
banditCandidates = allCandidates;
|
|
1319
|
+
banditPropensity = picked.propensity ?? null;
|
|
1320
|
+
banditContext = ctx;
|
|
1321
|
+
if (picked.model !== selectedModel) {
|
|
1322
|
+
logger.debug({
|
|
1323
|
+
from: `${provider}:${selectedModel}`,
|
|
1324
|
+
to: `${picked.provider}:${picked.model}`,
|
|
1325
|
+
ucb: picked.ucb?.toFixed(4),
|
|
1326
|
+
explored: picked.explored,
|
|
1327
|
+
propensity: picked.propensity,
|
|
1328
|
+
}, '[Routing] Bandit override');
|
|
1329
|
+
provider = picked.provider;
|
|
1330
|
+
selectedModel = picked.model;
|
|
1331
|
+
method = method + (picked.explored ? '+bandit_explore' : '+bandit');
|
|
1332
|
+
}
|
|
585
1333
|
}
|
|
586
1334
|
}
|
|
587
1335
|
} catch (err) {
|
|
588
|
-
|
|
1336
|
+
degradation.record('bandit', err);
|
|
589
1337
|
}
|
|
590
1338
|
}
|
|
591
1339
|
|
|
@@ -607,7 +1355,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
607
1355
|
method = method + '+deadline';
|
|
608
1356
|
}
|
|
609
1357
|
} catch (err) {
|
|
610
|
-
|
|
1358
|
+
degradation.record('deadline', err);
|
|
611
1359
|
}
|
|
612
1360
|
}
|
|
613
1361
|
|
|
@@ -630,7 +1378,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
630
1378
|
method = overridden.method;
|
|
631
1379
|
}
|
|
632
1380
|
} catch (err) {
|
|
633
|
-
|
|
1381
|
+
degradation.record('tenant', err);
|
|
634
1382
|
}
|
|
635
1383
|
}
|
|
636
1384
|
|
|
@@ -649,8 +1397,43 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
649
1397
|
costOptimized,
|
|
650
1398
|
risk,
|
|
651
1399
|
knnResult,
|
|
1400
|
+
base_tier: baseTier,
|
|
1401
|
+
escalations,
|
|
1402
|
+
// Upward escalations take precedence in the source label; a demotion is
|
|
1403
|
+
// only surfaced when nothing else escalated (guarded by the wire above,
|
|
1404
|
+
// but re-checked here for clarity).
|
|
1405
|
+
escalation_source: escalations[0]?.source
|
|
1406
|
+
?? (demotedFrom ? 'deescalation' : null),
|
|
1407
|
+
demoted_from: demotedFrom,
|
|
652
1408
|
};
|
|
653
1409
|
|
|
1410
|
+
// WS4.2 — propensity/candidates for off-policy evaluation from telemetry.
|
|
1411
|
+
// Bandit picks populate both. If a deterministic downstream override
|
|
1412
|
+
// (deadline / tenant) then swapped the served model out of the bandit's
|
|
1413
|
+
// candidate set, the bandit's propensity no longer describes the served
|
|
1414
|
+
// choice — collapse to propensity=1.0 with a single candidate. Otherwise
|
|
1415
|
+
// deterministic branches (bandit didn't run at all) always collapse.
|
|
1416
|
+
// _banditContext is underscored so it never leaks to response headers;
|
|
1417
|
+
// WS5 will consume it in the feedback path to call bandit.update().
|
|
1418
|
+
const banditPickedServed = banditCandidates
|
|
1419
|
+
&& banditCandidates.some(c => c.provider === provider && c.model === selectedModel);
|
|
1420
|
+
if (banditPickedServed) {
|
|
1421
|
+
decision.propensity = banditPropensity ?? 1.0;
|
|
1422
|
+
decision.candidates = banditCandidates;
|
|
1423
|
+
decision._banditContext = banditContext;
|
|
1424
|
+
} else {
|
|
1425
|
+
decision.propensity = 1.0;
|
|
1426
|
+
decision.candidates = [{ provider, model: selectedModel }];
|
|
1427
|
+
decision._banditContext = null;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// WS5.5 — attach the query embedding + raw query text so the feedback
|
|
1431
|
+
// path can turn conclusive outcomes into new kNN exemplars without
|
|
1432
|
+
// re-embedding. Underscored so it doesn't leak into headers or JSON
|
|
1433
|
+
// serialisation of the decision.
|
|
1434
|
+
decision._queryEmbedding = queryEmbedding;
|
|
1435
|
+
decision._queryText = queryText;
|
|
1436
|
+
|
|
654
1437
|
// Phase 4.4 — shadow-mode policy comparison (fire-and-forget).
|
|
655
1438
|
const shadowFn = getShadowPolicy();
|
|
656
1439
|
if (shadowFn) {
|
|
@@ -744,13 +1527,37 @@ function getRoutingHeaders(decision) {
|
|
|
744
1527
|
* Phase 3: Metrics access
|
|
745
1528
|
*/
|
|
746
1529
|
function getRoutingStats() {
|
|
747
|
-
|
|
1530
|
+
const base = routingMetrics.getStats() || {};
|
|
1531
|
+
let escalations = null;
|
|
1532
|
+
try {
|
|
1533
|
+
escalations = telemetry.getEscalationStats?.() ?? null;
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
degradation.record('tenant', err); // reuse a bucket; not worth a new one
|
|
1536
|
+
}
|
|
1537
|
+
return {
|
|
1538
|
+
...base,
|
|
1539
|
+
degradation: degradation.getCounts(),
|
|
1540
|
+
escalations,
|
|
1541
|
+
};
|
|
748
1542
|
}
|
|
749
1543
|
|
|
750
1544
|
module.exports = {
|
|
751
1545
|
// Main routing function
|
|
752
1546
|
determineProviderSmart,
|
|
753
1547
|
|
|
1548
|
+
// WS1: sticky-session pin helpers (shared with OAuth intent path)
|
|
1549
|
+
checkSessionPin,
|
|
1550
|
+
writeSessionPin,
|
|
1551
|
+
// WS1.5: upward-drift detection for pinned sessions
|
|
1552
|
+
checkPinScoreDrift,
|
|
1553
|
+
|
|
1554
|
+
// WS1: test-only internals (exercised by test/sticky-routing.test.js)
|
|
1555
|
+
_internals: {
|
|
1556
|
+
economicDowngradeAllowed: _economicDowngradeAllowed,
|
|
1557
|
+
runPinGuards: _runPinGuards,
|
|
1558
|
+
tierPriority: _tierPriority,
|
|
1559
|
+
},
|
|
1560
|
+
|
|
754
1561
|
// Helpers
|
|
755
1562
|
isFallbackEnabled,
|
|
756
1563
|
getFallbackProvider,
|
|
@@ -785,4 +1592,7 @@ module.exports = {
|
|
|
785
1592
|
telemetry,
|
|
786
1593
|
scoreResponseQuality,
|
|
787
1594
|
getLatencyTracker,
|
|
1595
|
+
|
|
1596
|
+
// WS2.3 — de-escalation
|
|
1597
|
+
deescalator,
|
|
788
1598
|
};
|