lynkr 9.14.2 → 9.14.7
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 +10 -4
- 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/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/openai-router.js +125 -4
- package/src/api/providers-handler.js +16 -0
- package/src/api/router.js +67 -103
- 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/clients/databricks.js +285 -21
- package/src/clients/gpt-utils.js +83 -7
- package/src/clients/health-probe.js +157 -0
- package/src/clients/provider-capabilities.js +27 -0
- package/src/config/index.js +13 -1
- package/src/context/compression.js +127 -19
- package/src/dashboard/api.js +1 -0
- package/src/observability/metrics.js +52 -0
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +219 -136
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +62 -3
- package/src/routing/bandit.js +39 -0
- 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/decide.js +149 -0
- package/src/routing/deescalator.js +37 -0
- package/src/routing/index.js +202 -86
- 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/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/tools/web-search-exec.js +182 -0
package/src/routing/index.js
CHANGED
|
@@ -29,6 +29,7 @@ const { scoreIntent, intentScoreMode } = require('./intent-score');
|
|
|
29
29
|
// Phase 3-6 routing modules
|
|
30
30
|
const { getKnnRouter } = require('./knn-router');
|
|
31
31
|
const { getBandit } = require('./bandit');
|
|
32
|
+
const { buildCandidates, buildContextVector, decide, stampPropensity } = require('./decide');
|
|
32
33
|
const { getShadowPolicy, compareAndLog: shadowCompareAndLog } = require('./shadow-mode');
|
|
33
34
|
const { chooseFastest } = require('./deadline');
|
|
34
35
|
const { applyTenantOverrides } = require('./tenant-policy');
|
|
@@ -391,6 +392,11 @@ function _pinToDecision(pin, { reason, risk }) {
|
|
|
391
392
|
async function determineProviderSmart(payload, options = {}) {
|
|
392
393
|
const pinCheck = checkSessionPin(payload, options);
|
|
393
394
|
|
|
395
|
+
// Thread the session identity into fresh routing without mutating the
|
|
396
|
+
// caller's options object. Used by the de-escalation holdout so a session
|
|
397
|
+
// deterministically lands on one side of the holdout on every turn.
|
|
398
|
+
options = pinCheck.sessionId ? { ...options, _sessionId: pinCheck.sessionId } : options;
|
|
399
|
+
|
|
394
400
|
// Bypass (no session / forceProvider / feature-off) or no pin yet →
|
|
395
401
|
// straight to fresh routing, then persist the outcome for the next turn.
|
|
396
402
|
if (pinCheck.reason === 'bypass' || pinCheck.reason === 'no_pin') {
|
|
@@ -680,6 +686,27 @@ function checkSessionPin(payload, options = {}) {
|
|
|
680
686
|
const refreshOk = Array.isArray(payload?.tools) && payload.tools.length > 0;
|
|
681
687
|
|
|
682
688
|
if (sessionAffinity.payloadHasToolHistory(payload)) {
|
|
689
|
+
// Stuck-loop detection: the pinned model re-issuing the same tool call
|
|
690
|
+
// (or the same text) over and over. Switching mid-exchange is forbidden
|
|
691
|
+
// (tool-call IDs aren't provider-portable), so the intervention is the
|
|
692
|
+
// same safe pattern as the embedded-text triggers below — serve the pin
|
|
693
|
+
// this turn but DROP it, so the next turn boundary re-routes fresh.
|
|
694
|
+
try {
|
|
695
|
+
const { detectStuckLoop } = require('./stuck-detector');
|
|
696
|
+
const stuck = detectStuckLoop(payload);
|
|
697
|
+
if (stuck.stuck) {
|
|
698
|
+
sessionAffinity.removePin(sessionId);
|
|
699
|
+
logger.warn({
|
|
700
|
+
sessionId,
|
|
701
|
+
trigger: stuck.reason,
|
|
702
|
+
repeats: stuck.repeats,
|
|
703
|
+
signature: stuck.signature,
|
|
704
|
+
pinnedTier: pin.tier,
|
|
705
|
+
pinnedModel: pin.model,
|
|
706
|
+
}, '[Routing] Stuck loop detected — pin dropped, next boundary re-routes fresh');
|
|
707
|
+
return { serve: true, pin, reason: 'tool_history_pin_dropped', sessionId };
|
|
708
|
+
}
|
|
709
|
+
} catch { /* never block the pin-serve path */ }
|
|
683
710
|
// Text typed during a tool loop arrives merged with the pending
|
|
684
711
|
// tool_result, where the pin serves unconditionally (id linkage forbids
|
|
685
712
|
// switching mid-exchange). If that embedded text trips a trigger, drop
|
|
@@ -705,6 +732,32 @@ function checkSessionPin(payload, options = {}) {
|
|
|
705
732
|
}
|
|
706
733
|
}
|
|
707
734
|
} catch { /* never block the pin-serve path */ }
|
|
735
|
+
// Context-overflow escape hatch. Every frame of a tool loop is an
|
|
736
|
+
// unconditional pin serve, so a long agentic session could NEVER escape
|
|
737
|
+
// to a larger-context model — the guards that check context fit only run
|
|
738
|
+
// on non-tool turns, which a busy agent may not produce for hours (live
|
|
739
|
+
// incident: an 884k-token session stayed pinned to a 128k model for 43
|
|
740
|
+
// straight requests, with 93.7% of history amputated on every one).
|
|
741
|
+
// Same safe pattern as the triggers above: serve the pin THIS turn
|
|
742
|
+
// (tool-call IDs aren't provider-portable), drop it so the next boundary
|
|
743
|
+
// re-routes — and fresh routing's context guard picks a model that fits.
|
|
744
|
+
try {
|
|
745
|
+
if (pin.model) {
|
|
746
|
+
const promptTokensEst = countPayloadTokens(payload, pin.model);
|
|
747
|
+
const ctxResult = contextValidator.validate(pin.model, promptTokensEst);
|
|
748
|
+
if (!ctxResult.ok) {
|
|
749
|
+
sessionAffinity.removePin(sessionId);
|
|
750
|
+
logger.warn({
|
|
751
|
+
sessionId,
|
|
752
|
+
trigger: 'context_overflow',
|
|
753
|
+
pinnedModel: pin.model,
|
|
754
|
+
pinnedTier: pin.tier,
|
|
755
|
+
promptTokensEst,
|
|
756
|
+
}, '[Routing] Session outgrew pinned model\'s context window — pin dropped, next boundary re-routes to a context-capable model');
|
|
757
|
+
return { serve: true, pin, reason: 'tool_history_pin_dropped', sessionId };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
} catch { /* never block the pin-serve path */ }
|
|
708
761
|
if (refreshOk) {
|
|
709
762
|
sessionAffinity.setPin(sessionId, pin, {
|
|
710
763
|
// Monotonic within a pin's lifetime: refreshes must never SHRINK the
|
|
@@ -1177,6 +1230,91 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1177
1230
|
selectedModel = modelSelection.model;
|
|
1178
1231
|
logger.debug({ tier, provider, model: selectedModel }, '[Routing] Using tier config');
|
|
1179
1232
|
|
|
1233
|
+
// Item 1 — capability-decoupled shortfall matching (HyDRA port).
|
|
1234
|
+
// Shadow-computes whenever possible (weighted mode only); serves only when
|
|
1235
|
+
// config/model-capabilities.json has enabled:true. Never overrides risk-high (returned earlier
|
|
1236
|
+
// upstream), static mode (tier null), or legacy scorer mode. Downstream
|
|
1237
|
+
// guards (de-escalation, context, vision, kNN, bandit, deadline, tenant)
|
|
1238
|
+
// still run after and dominate — shortfall only replaces the tier_config pick.
|
|
1239
|
+
let shortfallInfo = null;
|
|
1240
|
+
try {
|
|
1241
|
+
if (tier && config.modelTiers?.enabled && risk?.level !== 'high'
|
|
1242
|
+
&& analysis?.mode === 'weighted' && analysis?.breakdown) {
|
|
1243
|
+
const { buildRequirementVector } = require('./capabilities');
|
|
1244
|
+
const sf = require('./shortfall');
|
|
1245
|
+
const req = buildRequirementVector({ dimensions: analysis.breakdown, agenticResult });
|
|
1246
|
+
// Candidates constrained to the user's TIER_* (same eligibility rule
|
|
1247
|
+
// as the bandit in decide.js) with tier labels attached for capability
|
|
1248
|
+
// resolution. Dedupe identical provider:model keeping the highest tier.
|
|
1249
|
+
const seen = new Map();
|
|
1250
|
+
for (const t of ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING']) {
|
|
1251
|
+
for (const m of selector.getModelsForTier(t)) {
|
|
1252
|
+
const key = `${m.provider}:${m.model}`;
|
|
1253
|
+
const prev = seen.get(key);
|
|
1254
|
+
if (!prev || (TIER_DEFINITIONS[t]?.priority || 0) > (TIER_DEFINITIONS[prev.tier]?.priority || 0)) {
|
|
1255
|
+
seen.set(key, { provider: m.provider, model: m.model, tier: t });
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
let registry = null;
|
|
1260
|
+
try { registry = require('./model-registry').getModelRegistrySync(); } catch { registry = null; }
|
|
1261
|
+
let optimizer = null;
|
|
1262
|
+
try { optimizer = require('./cost-optimizer').getCostOptimizer(); } catch { optimizer = null; }
|
|
1263
|
+
const candidates = [...seen.values()].map((c) => {
|
|
1264
|
+
let cost = Number.POSITIVE_INFINITY; // unknown price never wins on cheapness
|
|
1265
|
+
try {
|
|
1266
|
+
const info = registry?.getCost?.(c.model);
|
|
1267
|
+
if (info && !info.unknown) {
|
|
1268
|
+
const est = optimizer?.estimateCost?.(c.model, 1000);
|
|
1269
|
+
cost = Number.isFinite(est?.totalEstimate)
|
|
1270
|
+
? est.totalEstimate
|
|
1271
|
+
: (Number(info.input) || 0) + (Number(info.output) || 0);
|
|
1272
|
+
}
|
|
1273
|
+
} catch { /* keep Infinity */ }
|
|
1274
|
+
return { ...c, cost };
|
|
1275
|
+
});
|
|
1276
|
+
const result = sf.selectByShortfall(req, candidates);
|
|
1277
|
+
if (result) {
|
|
1278
|
+
const agreed = result.selected.provider === provider && result.selected.model === selectedModel;
|
|
1279
|
+
shortfallInfo = {
|
|
1280
|
+
req,
|
|
1281
|
+
tau: result.tau,
|
|
1282
|
+
selected: result.selected,
|
|
1283
|
+
agreed,
|
|
1284
|
+
legacy: { provider, model: selectedModel, tier },
|
|
1285
|
+
};
|
|
1286
|
+
logger.debug({
|
|
1287
|
+
req,
|
|
1288
|
+
tau: result.tau,
|
|
1289
|
+
legacy: `${tier}:${provider}:${selectedModel}`,
|
|
1290
|
+
shortfall: `${result.selected.tier}:${result.selected.provider}:${result.selected.model}`,
|
|
1291
|
+
agreed,
|
|
1292
|
+
}, '[Routing] Shortfall shadow compare');
|
|
1293
|
+
if (sf.isEnabled() && !agreed) {
|
|
1294
|
+
const fromTier = tier;
|
|
1295
|
+
const fromModel = selectedModel;
|
|
1296
|
+
provider = result.selected.provider;
|
|
1297
|
+
selectedModel = result.selected.model;
|
|
1298
|
+
tier = result.selected.tier;
|
|
1299
|
+
analysis.tier = tier;
|
|
1300
|
+
method = method + '+shortfall';
|
|
1301
|
+
if ((TIER_DEFINITIONS[tier]?.priority || 0) > (TIER_DEFINITIONS[fromTier]?.priority || 0)) {
|
|
1302
|
+
escalations.push({
|
|
1303
|
+
source: 'shortfall',
|
|
1304
|
+
fromTier,
|
|
1305
|
+
toTier: tier,
|
|
1306
|
+
fromModel,
|
|
1307
|
+
toModel: selectedModel,
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
logger.info({ from: `${fromTier}:${fromModel}`, to: `${tier}:${selectedModel}` }, '[Routing] Shortfall override');
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
degradation.record('shortfall', err);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1180
1318
|
// WS2.3 — evidence-based de-escalation.
|
|
1181
1319
|
//
|
|
1182
1320
|
// The check is intentionally gated by evidence, not a feature flag: the
|
|
@@ -1200,17 +1338,31 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1200
1338
|
analysis,
|
|
1201
1339
|
});
|
|
1202
1340
|
if (demoted && demoted !== tier) {
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1341
|
+
// Percentage holdout (LYNKR_DEESCALATION_HOLDOUT_PCT, default 10):
|
|
1342
|
+
// a deterministic slice of sessions is served at the original tier
|
|
1343
|
+
// even though the evidence supports demotion. Their telemetry rows
|
|
1344
|
+
// (method '+deescalation_holdout') are the running baseline that
|
|
1345
|
+
// proves demoted sessions aren't quietly doing worse.
|
|
1346
|
+
if (deescalator.isHeldOut(options._sessionId)) {
|
|
1347
|
+
method = method + '+deescalation_holdout';
|
|
1348
|
+
logger.debug({
|
|
1349
|
+
tier,
|
|
1350
|
+
wouldDemoteTo: demoted,
|
|
1351
|
+
requestType,
|
|
1352
|
+
}, '[Routing] De-escalation — evidence supports demotion, session held out as baseline');
|
|
1353
|
+
} else {
|
|
1354
|
+
const demotedSelection = selector.selectModel(demoted, null);
|
|
1355
|
+
logger.debug({
|
|
1356
|
+
from: `${tier}:${provider}:${selectedModel}`,
|
|
1357
|
+
to: `${demoted}:${demotedSelection.provider}:${demotedSelection.model}`,
|
|
1358
|
+
requestType,
|
|
1359
|
+
}, '[Routing] De-escalation — demoting tier on evidence');
|
|
1360
|
+
demotedFrom = tier;
|
|
1361
|
+
provider = demotedSelection.provider;
|
|
1362
|
+
selectedModel = demotedSelection.model;
|
|
1363
|
+
tier = demoted;
|
|
1364
|
+
method = method + '+deescalated';
|
|
1365
|
+
}
|
|
1214
1366
|
}
|
|
1215
1367
|
} catch (err) {
|
|
1216
1368
|
degradation.record('tier_select', err);
|
|
@@ -1441,65 +1593,29 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1441
1593
|
// WS4.2 — capture propensity + candidates + context so the outcome row can
|
|
1442
1594
|
// support off-policy evaluation. banditContext is stashed on the decision
|
|
1443
1595
|
// (underscored → won't leak through headers, see WS4.2 verification).
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1596
|
+
//
|
|
1597
|
+
// The candidate-building / context-vector / pick logic lives in decide.js
|
|
1598
|
+
// so the off-policy evaluator can replay it against logged rows.
|
|
1599
|
+
let banditResult = null;
|
|
1447
1600
|
if (config.routing?.banditEnabled !== false && knnResult && knnResult.model) {
|
|
1448
1601
|
try {
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
);
|
|
1466
|
-
if (inConfig) {
|
|
1467
|
-
allCandidates.push({ provider: knnResult.provider, model: knnResult.model });
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
if (allCandidates.length > 1) {
|
|
1472
|
-
const bandit = getBandit();
|
|
1473
|
-
const TASK_TYPES = ['code_gen', 'summarization', 'reasoning', 'factoid', 'chat', 'other'];
|
|
1474
|
-
const inferredTask = (analysis.breakdown?.taskType?.reason || 'other').toLowerCase();
|
|
1475
|
-
const taskIdx = Math.max(0, TASK_TYPES.findIndex(t => inferredTask.includes(t)));
|
|
1476
|
-
const ctx = [
|
|
1477
|
-
(analysis.score || 0) / 100,
|
|
1478
|
-
Math.log(Math.max(1, analysis.breakdown?.tokenCount || 0) + 1) / 15,
|
|
1479
|
-
((payload?.tools?.length ?? 0) > 0) ? 1 : 0,
|
|
1480
|
-
options.streaming ? 1 : 0,
|
|
1481
|
-
risk?.level === 'high' ? 1 : risk?.level === 'medium' ? 0.5 : 0,
|
|
1482
|
-
agenticResult?.isAgentic ? 1 : 0,
|
|
1483
|
-
...TASK_TYPES.map((_, i) => i === taskIdx ? 1 : 0),
|
|
1484
|
-
];
|
|
1485
|
-
const picked = bandit.pick(tier, allCandidates, ctx);
|
|
1486
|
-
if (picked) {
|
|
1487
|
-
banditCandidates = allCandidates;
|
|
1488
|
-
banditPropensity = picked.propensity ?? null;
|
|
1489
|
-
banditContext = ctx;
|
|
1490
|
-
if (picked.model !== selectedModel) {
|
|
1491
|
-
logger.debug({
|
|
1492
|
-
from: `${provider}:${selectedModel}`,
|
|
1493
|
-
to: `${picked.provider}:${picked.model}`,
|
|
1494
|
-
ucb: picked.ucb?.toFixed(4),
|
|
1495
|
-
explored: picked.explored,
|
|
1496
|
-
propensity: picked.propensity,
|
|
1497
|
-
}, '[Routing] Bandit override');
|
|
1498
|
-
provider = picked.provider;
|
|
1499
|
-
selectedModel = picked.model;
|
|
1500
|
-
method = method + (picked.explored ? '+bandit_explore' : '+bandit');
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1602
|
+
const allCandidates = buildCandidates(
|
|
1603
|
+
{ provider, model: selectedModel },
|
|
1604
|
+
{ provider: knnResult.provider, model: knnResult.model },
|
|
1605
|
+
);
|
|
1606
|
+
const ctx = buildContextVector({ analysis, payload, options, risk, agenticResult });
|
|
1607
|
+
banditResult = decide(tier, allCandidates, ctx);
|
|
1608
|
+
if (banditResult && banditResult.model !== selectedModel) {
|
|
1609
|
+
logger.debug({
|
|
1610
|
+
from: `${provider}:${selectedModel}`,
|
|
1611
|
+
to: `${banditResult.provider}:${banditResult.model}`,
|
|
1612
|
+
ucb: banditResult.ucb?.toFixed(4),
|
|
1613
|
+
explored: banditResult.explored,
|
|
1614
|
+
propensity: banditResult.propensity,
|
|
1615
|
+
}, '[Routing] Bandit override');
|
|
1616
|
+
provider = banditResult.provider;
|
|
1617
|
+
selectedModel = banditResult.model;
|
|
1618
|
+
method = method + (banditResult.explored ? '+bandit_explore' : '+bandit');
|
|
1503
1619
|
}
|
|
1504
1620
|
} catch (err) {
|
|
1505
1621
|
degradation.record('bandit', err);
|
|
@@ -1568,6 +1684,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1568
1684
|
knnResult,
|
|
1569
1685
|
base_tier: baseTier,
|
|
1570
1686
|
escalations,
|
|
1687
|
+
shortfall: shortfallInfo,
|
|
1571
1688
|
// Upward escalations take precedence in the source label; a demotion is
|
|
1572
1689
|
// only surfaced when nothing else escalated (guarded by the wire above,
|
|
1573
1690
|
// but re-checked here for clarity).
|
|
@@ -1577,24 +1694,13 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1577
1694
|
});
|
|
1578
1695
|
|
|
1579
1696
|
// WS4.2 — propensity/candidates for off-policy evaluation from telemetry.
|
|
1580
|
-
//
|
|
1581
|
-
// (deadline / tenant)
|
|
1582
|
-
// candidate set
|
|
1583
|
-
//
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
1586
|
-
|
|
1587
|
-
const banditPickedServed = banditCandidates
|
|
1588
|
-
&& banditCandidates.some(c => c.provider === provider && c.model === selectedModel);
|
|
1589
|
-
if (banditPickedServed) {
|
|
1590
|
-
decision.propensity = banditPropensity ?? 1.0;
|
|
1591
|
-
decision.candidates = banditCandidates;
|
|
1592
|
-
decision._banditContext = banditContext;
|
|
1593
|
-
} else {
|
|
1594
|
-
decision.propensity = 1.0;
|
|
1595
|
-
decision.candidates = [{ provider, model: selectedModel }];
|
|
1596
|
-
decision._banditContext = null;
|
|
1597
|
-
}
|
|
1697
|
+
// Collapse rule lives in decide.js (stampPropensity): if a deterministic
|
|
1698
|
+
// downstream override (deadline / tenant) swapped the served model out of
|
|
1699
|
+
// the bandit's candidate set — or the bandit never ran — the row collapses
|
|
1700
|
+
// to propensity=1.0 with a single candidate. _banditContext is underscored
|
|
1701
|
+
// so it never leaks to response headers; the feedback path consumes it to
|
|
1702
|
+
// call bandit.update().
|
|
1703
|
+
stampPropensity(decision, { provider, model: selectedModel }, banditResult);
|
|
1598
1704
|
|
|
1599
1705
|
// WS5.5 — attach the query embedding + raw query text so the feedback
|
|
1600
1706
|
// path can turn conclusive outcomes into new kNN exemplars without
|
|
@@ -1658,6 +1764,16 @@ function getRoutingHeaders(decision) {
|
|
|
1658
1764
|
|
|
1659
1765
|
if (decision.model) {
|
|
1660
1766
|
headers['X-Lynkr-Model'] = decision.model;
|
|
1767
|
+
// Served-model context window (workweave pattern): the client can't know
|
|
1768
|
+
// the real window behind a virtual model name when tier routing serves
|
|
1769
|
+
// different models per request — this header is the authoritative per-
|
|
1770
|
+
// turn answer, intended as the client's compaction budget. Omitted (never
|
|
1771
|
+
// guessed) when the registry doesn't know the model.
|
|
1772
|
+
const { contextWindowFor } = require('./model-registry');
|
|
1773
|
+
const contextWindow = contextWindowFor(decision.model);
|
|
1774
|
+
if (contextWindow) {
|
|
1775
|
+
headers['X-Lynkr-Context-Window'] = String(contextWindow);
|
|
1776
|
+
}
|
|
1661
1777
|
}
|
|
1662
1778
|
|
|
1663
1779
|
if (decision.agenticResult?.isAgentic) {
|
|
@@ -537,10 +537,35 @@ function getModelRegistrySync() {
|
|
|
537
537
|
return instance;
|
|
538
538
|
}
|
|
539
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Real context window (tokens) for a model, or null when unknown.
|
|
542
|
+
*
|
|
543
|
+
* Null on unknown is deliberate: consumers surface this to CLIENTS (the
|
|
544
|
+
* X-Lynkr-Context-Window response header that harnesses may use as their
|
|
545
|
+
* compaction budget), and advertising a guessed window is worse than
|
|
546
|
+
* advertising none — a client that compacts against a fabricated number
|
|
547
|
+
* either wastes context or overruns the real window.
|
|
548
|
+
*
|
|
549
|
+
* @param {string} modelName
|
|
550
|
+
* @returns {number|null}
|
|
551
|
+
*/
|
|
552
|
+
function contextWindowFor(modelName) {
|
|
553
|
+
if (!modelName) return null;
|
|
554
|
+
try {
|
|
555
|
+
const registry = getModelRegistrySync();
|
|
556
|
+
const info = registry?.getCost?.(modelName);
|
|
557
|
+
if (!info || info.unknown) return null;
|
|
558
|
+
return Number.isFinite(info.context) && info.context > 0 ? info.context : null;
|
|
559
|
+
} catch {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
540
564
|
module.exports = {
|
|
541
565
|
ModelRegistry,
|
|
542
566
|
getModelRegistry,
|
|
543
567
|
getModelRegistrySync,
|
|
544
568
|
DATABRICKS_FALLBACK,
|
|
545
569
|
DEFAULT_COST,
|
|
570
|
+
contextWindowFor,
|
|
546
571
|
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model id → tier mapping for Claude Desktop's model picker.
|
|
3
|
+
*
|
|
4
|
+
* Claude Desktop VALIDATES model ids against a fixed set — arbitrary ids
|
|
5
|
+
* (e.g. "lynkr-simple") make it show "model list hasn't loaded". These five
|
|
6
|
+
* slots mirror ollama's internal/proxy/claude_desktop_models.go, the ids
|
|
7
|
+
* Desktop currently accepts.
|
|
8
|
+
*
|
|
9
|
+
* Shared by:
|
|
10
|
+
* - src/api/claude-desktop-gateway.js — advertises these as the Anthropic-
|
|
11
|
+
* format model list Desktop's picker renders.
|
|
12
|
+
* - src/api/router.js — resolves an incoming request's `model` field back
|
|
13
|
+
* to a tier, so an explicit pick in Desktop's dropdown pins routing
|
|
14
|
+
* instead of only being advisory.
|
|
15
|
+
*
|
|
16
|
+
* @module routing/model-slots
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const MODEL_SLOTS = [
|
|
20
|
+
{ id: "claude-fable-5", family: "fable", createdAt: "2026-06-09T00:00:00Z", isDefault: true, tier: null, label: "Lynkr Auto (tier routing)" },
|
|
21
|
+
{ id: "claude-opus-5", family: "opus", createdAt: "2026-07-24T00:00:00Z", isDefault: true, tier: "REASONING" },
|
|
22
|
+
{ id: "claude-sonnet-5", family: "sonnet", createdAt: "2026-06-30T00:00:00Z", isDefault: true, tier: "COMPLEX" },
|
|
23
|
+
{ id: "claude-sonnet-4-6", family: "sonnet", createdAt: "2025-11-18T00:00:00Z", isDefault: false, tier: "MEDIUM" },
|
|
24
|
+
{ id: "claude-haiku-4-5-20251001", family: "haiku", createdAt: "2025-10-01T00:00:00Z", isDefault: true, tier: "SIMPLE" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Protocol-agnostic virtual tier ids. Unlike MODEL_SLOTS (whose ids must be
|
|
29
|
+
* real Claude names because Claude Desktop validates its picker against a
|
|
30
|
+
* fixed set), these are for clients whose model list Lynkr fully controls —
|
|
31
|
+
* e.g. the opencode provider entry written by `lynkr opencode`. Each id pins
|
|
32
|
+
* its tier explicitly, which also lets the client carry an HONEST per-tier
|
|
33
|
+
* context window in its own config (the tier's configured model is knowable
|
|
34
|
+
* at setup time), so client-side compaction budgets match the real serving
|
|
35
|
+
* model instead of one blended guess.
|
|
36
|
+
*
|
|
37
|
+
* "lynkr-auto" = no pin (full content-based tier routing).
|
|
38
|
+
*/
|
|
39
|
+
const VIRTUAL_TIER_IDS = {
|
|
40
|
+
'lynkr-auto': null,
|
|
41
|
+
'lynkr-simple': 'SIMPLE',
|
|
42
|
+
'lynkr-medium': 'MEDIUM',
|
|
43
|
+
'lynkr-complex': 'COMPLEX',
|
|
44
|
+
'lynkr-reasoning': 'REASONING',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a client-supplied model id to the tier it pins.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} modelId
|
|
51
|
+
* @returns {string|null} one of SIMPLE/MEDIUM/COMPLEX/REASONING, or null if
|
|
52
|
+
* the id is unrecognized or maps to "Auto" (no pin — caller should fall
|
|
53
|
+
* through to content-based scoring).
|
|
54
|
+
*/
|
|
55
|
+
function resolveTierForModelId(modelId) {
|
|
56
|
+
if (!modelId) return null;
|
|
57
|
+
const normalized = String(modelId).toLowerCase();
|
|
58
|
+
if (Object.prototype.hasOwnProperty.call(VIRTUAL_TIER_IDS, normalized)) {
|
|
59
|
+
return VIRTUAL_TIER_IDS[normalized];
|
|
60
|
+
}
|
|
61
|
+
const slot = MODEL_SLOTS.find((s) => s.id === modelId);
|
|
62
|
+
return slot?.tier || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { MODEL_SLOTS, VIRTUAL_TIER_IDS, resolveTierForModelId };
|
|
@@ -353,6 +353,8 @@ class ModelTierSelector {
|
|
|
353
353
|
return config.moonshot?.model || null;
|
|
354
354
|
case 'baidu':
|
|
355
355
|
return config.baidu?.model || null;
|
|
356
|
+
case 'fireworks':
|
|
357
|
+
return config.fireworks?.model || null;
|
|
356
358
|
case 'codex':
|
|
357
359
|
return config.codex?.model || null;
|
|
358
360
|
case 'vertex':
|