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.
Files changed (61) hide show
  1. package/README.md +5 -1
  2. package/bin/cli.js +14 -6
  3. package/bin/lynkr-desktop-token.js +108 -0
  4. package/bin/lynkr-init.js +13 -1
  5. package/bin/lynkr-restart.js +125 -0
  6. package/bin/opencode-setup.js +175 -0
  7. package/bin/run.js +92 -0
  8. package/config/model-capabilities.json +20 -0
  9. package/config/model-capability-seeds.json +75 -0
  10. package/config/model-tiers.json +13 -1
  11. package/package.json +10 -4
  12. package/scripts/claude-desktop.js +212 -0
  13. package/scripts/ope-report.js +78 -0
  14. package/scripts/seed-capabilities.js +230 -0
  15. package/src/api/claude-desktop-gateway.js +123 -0
  16. package/src/api/mcp-broker.js +149 -0
  17. package/src/api/middleware/budget-enforcer.js +77 -7
  18. package/src/api/middleware/budget.js +36 -2
  19. package/src/api/openai-router.js +125 -4
  20. package/src/api/providers-handler.js +16 -0
  21. package/src/api/router.js +67 -103
  22. package/src/auth-mode.js +40 -4
  23. package/src/budget/index.js +86 -0
  24. package/src/cache/embeddings.js +125 -35
  25. package/src/cache/onnx-embedder.js +92 -0
  26. package/src/clients/databricks.js +285 -21
  27. package/src/clients/gpt-utils.js +83 -7
  28. package/src/clients/health-probe.js +157 -0
  29. package/src/clients/provider-capabilities.js +27 -0
  30. package/src/config/index.js +13 -1
  31. package/src/context/compression.js +127 -19
  32. package/src/dashboard/api.js +1 -0
  33. package/src/observability/metrics.js +52 -0
  34. package/src/observability/otel.js +180 -0
  35. package/src/orchestrator/azure-responses-sse.js +223 -0
  36. package/src/orchestrator/index.js +219 -136
  37. package/src/orchestrator/passthrough-stream.js +7 -1
  38. package/src/orchestrator/sse-transformer.js +62 -3
  39. package/src/routing/bandit.js +39 -0
  40. package/src/routing/capabilities.js +88 -0
  41. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  42. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  43. package/src/routing/capability-seeds/family.js +105 -0
  44. package/src/routing/capability-seeds/models-dev.js +64 -0
  45. package/src/routing/capability-seeds/normalize.js +160 -0
  46. package/src/routing/capability-seeds/registry.js +125 -0
  47. package/src/routing/capability-seeds/sources.js +69 -0
  48. package/src/routing/capability-seeds/swebench.js +66 -0
  49. package/src/routing/decide.js +149 -0
  50. package/src/routing/deescalator.js +37 -0
  51. package/src/routing/index.js +202 -86
  52. package/src/routing/model-registry.js +25 -0
  53. package/src/routing/model-slots.js +65 -0
  54. package/src/routing/model-tiers.js +2 -0
  55. package/src/routing/ope.js +255 -0
  56. package/src/routing/openai-model-slots.js +82 -0
  57. package/src/routing/shortfall.js +290 -0
  58. package/src/routing/stuck-detector.js +127 -0
  59. package/src/routing/telemetry.js +11 -2
  60. package/src/server.js +41 -3
  61. package/src/tools/web-search-exec.js +182 -0
@@ -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
- const demotedSelection = selector.selectModel(demoted, null);
1204
- logger.debug({
1205
- from: `${tier}:${provider}:${selectedModel}`,
1206
- to: `${demoted}:${demotedSelection.provider}:${demotedSelection.model}`,
1207
- requestType,
1208
- }, '[Routing] De-escalation — demoting tier on evidence');
1209
- demotedFrom = tier;
1210
- provider = demotedSelection.provider;
1211
- selectedModel = demotedSelection.model;
1212
- tier = demoted;
1213
- method = method + '+deescalated';
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
- let banditPropensity = null;
1445
- let banditCandidates = null;
1446
- let banditContext = null;
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
- // Build candidates: current selection and kNN alternative if different.
1450
- //
1451
- // Tier-aware filter: only treat the kNN suggestion as a real candidate
1452
- // if it matches a (provider, model) combo configured in ANY TIER_*
1453
- // entry. The bandit is allowed to explore freely across the user's
1454
- // configured tiers (e.g. swap a SIMPLE request to the COMPLEX-tier
1455
- // model), but is forbidden from picking a credentialed-but-untiered
1456
- // model (e.g. an Azure OpenAI deployment whose endpoint is set in .env
1457
- // for some other use, but not referenced by any TIER_*). This keeps
1458
- // tier routing as the source of truth for what's eligible while
1459
- // preserving cross-tier bandit exploration.
1460
- const allCandidates = [{ provider, model: selectedModel }];
1461
- if (knnResult.model !== selectedModel) {
1462
- const configured = require('./model-tiers').getModelTierSelector().getAllConfiguredModels();
1463
- const inConfig = configured.some(
1464
- m => m.provider === knnResult.provider && m.model === knnResult.model
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
- // Bandit picks populate both. If a deterministic downstream override
1581
- // (deadline / tenant) then swapped the served model out of the bandit's
1582
- // candidate set, the bandit's propensity no longer describes the served
1583
- // choice — collapse to propensity=1.0 with a single candidate. Otherwise
1584
- // deterministic branches (bandit didn't run at all) always collapse.
1585
- // _banditContext is underscored so it never leaks to response headers;
1586
- // WS5 will consume it in the feedback path to call bandit.update().
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':