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
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared plumbing for capability-seed sources (online benchmark data).
3
+ *
4
+ * Every adapter is optional and fail-soft: no network, bad payload, or
5
+ * unknown shape → { status:'skipped', reason } — never throws, never blocks
6
+ * the script, and never touches request-path routing (this only runs in
7
+ * scripts/seed-capabilities.js). Fetched payloads are cached under
8
+ * data/capability-sources-cache.json with a 7-day TTL so refreshes are
9
+ * cheap and CI stays hermetic (tests use fixtures, never the network).
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const CACHE_PATH = path.join(__dirname, '../../../data/capability-sources-cache.json');
16
+ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
17
+ const FETCH_TIMEOUT_MS = 20000;
18
+
19
+ function _readCache() {
20
+ try {
21
+ if (!fs.existsSync(CACHE_PATH)) return {};
22
+ return JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ function _writeCache(cache) {
29
+ try {
30
+ fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true });
31
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
32
+ } catch {
33
+ // cache is best-effort; a failed write must not fail seeding
34
+ }
35
+ }
36
+
37
+ function getCached(source) {
38
+ const cache = _readCache();
39
+ const entry = cache[source];
40
+ if (!entry || typeof entry !== 'object') return null;
41
+ if (Date.now() - (entry.fetchedAt || 0) > CACHE_TTL_MS) return null;
42
+ return entry.payload ?? null;
43
+ }
44
+
45
+ function setCached(source, payload) {
46
+ const cache = _readCache();
47
+ cache[source] = { fetchedAt: Date.now(), payload };
48
+ _writeCache(cache);
49
+ }
50
+
51
+ async function fetchJson(url, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
52
+ const response = await fetch(url, {
53
+ signal: AbortSignal.timeout(timeoutMs),
54
+ headers: { Accept: 'application/json' },
55
+ });
56
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
57
+ return response.json();
58
+ }
59
+
60
+ const skipped = (reason) => ({ status: 'skipped', reason });
61
+
62
+ module.exports = {
63
+ CACHE_PATH,
64
+ CACHE_TTL_MS,
65
+ getCached,
66
+ setCached,
67
+ fetchJson,
68
+ skipped,
69
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * SWE-Bench Verified adapter: per-model % resolved from the public
3
+ * leaderboard raw data.
4
+ *
5
+ * Source: https://raw.githubusercontent.com/SWE-bench/swe-bench.github.io/master/data/leaderboards.json
6
+ * Shape: { leaderboards: [{ name: "Verified"|"Lite"|..., results: [{ name, resolved, date, ... }] }] }
7
+ * Entry names look like "Claude 4.5 Opus (high) · mini-SWE-agent · 2026-02-17"
8
+ * (model + effort qualifier + scaffold + date). We keep the best resolved%
9
+ * per cleaned model name; family mapping happens in normalize.js
10
+ * (token-overlap against known families, unmapped surfaced for review).
11
+ *
12
+ * Fail-soft: any fetch/parse problem → { status:'skipped', reason }.
13
+ */
14
+
15
+ const { fetchJson, getCached, setCached, skipped } = require('./sources');
16
+
17
+ const LEADERBOARDS_URL = 'https://raw.githubusercontent.com/SWE-bench/swe-bench.github.io/master/data/leaderboards.json';
18
+ const BOARD = 'Verified';
19
+
20
+ function cleanName(name) {
21
+ return String(name || '')
22
+ .split('·')[0] // drop scaffold + date segments
23
+ .replace(/\((high|medium|low)\)/gi, '') // effort qualifier (we keep max across them)
24
+ .trim()
25
+ .replace(/\s+/g, ' ')
26
+ .toLowerCase();
27
+ }
28
+
29
+ /**
30
+ * @param {object} [opts] — { refresh:boolean } (false = cache-only)
31
+ * @returns {Promise<{ status, board?, entries?:Array<{name,resolved,date}>, reason? }>}
32
+ */
33
+ async function fetchSweBench({ refresh = true } = {}) {
34
+ if (!refresh) {
35
+ const cached = getCached('swebench');
36
+ if (cached) return { status: 'ok', board: BOARD, entries: cached, cached: true };
37
+ return skipped('no cache (run with --refresh)');
38
+ }
39
+ try {
40
+ const data = await fetchJson(LEADERBOARDS_URL);
41
+ const boards = Array.isArray(data?.leaderboards) ? data.leaderboards : [];
42
+ const verified = boards.find((b) => String(b?.name || '').toLowerCase() === BOARD.toLowerCase());
43
+ if (!verified || !Array.isArray(verified.results)) {
44
+ return skipped(`board "${BOARD}" not found in payload`);
45
+ }
46
+ const best = new Map();
47
+ for (const r of verified.results) {
48
+ const name = cleanName(r?.name);
49
+ const resolved = Number(r?.resolved);
50
+ if (!name || !Number.isFinite(resolved)) continue;
51
+ const prev = best.get(name);
52
+ if (!prev || resolved > prev.resolved) {
53
+ best.set(name, { name, resolved, date: r?.date ?? null });
54
+ }
55
+ }
56
+ const entries = [...best.values()];
57
+ setCached('swebench', entries);
58
+ return { status: 'ok', board: BOARD, entries };
59
+ } catch (err) {
60
+ const cached = getCached('swebench');
61
+ if (cached) return { status: 'ok', board: BOARD, entries: cached, cached: true, stale: err.message };
62
+ return skipped(`fetch failed and no cache: ${err.message}`);
63
+ }
64
+ }
65
+
66
+ module.exports = { fetchSweBench, cleanName, LEADERBOARDS_URL, BOARD };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Routing decision core — the harness-shaped, standalone pieces of the
3
+ * per-request model decision, extracted from routing/index.js so they can be
4
+ * consumed by more than one caller:
5
+ *
6
+ * - routing/index.js (the live request path, unchanged behavior)
7
+ * - the off-policy evaluator (WS4 follow-up), which must re-derive a
8
+ * candidate policy's π(a|x) on logged rows using the exact same context
9
+ * vector and candidate-eligibility rules the live policy used
10
+ * - a future remote decision endpoint, which needs decide-shaped logic
11
+ * that doesn't reach into request/response plumbing
12
+ *
13
+ * Deliberately excluded: session pins, the escalation ladder, kNN confidence
14
+ * branching, deadline/tenant overrides. Those are decision *inputs and
15
+ * post-passes* owned by the caller; this module owns only "given a context
16
+ * and a candidate set, pick one and report the probability of that pick."
17
+ *
18
+ * Nothing here catches errors — callers keep their existing
19
+ * degradation.record() wrapping so failure accounting stays where it was.
20
+ */
21
+
22
+ const { getBandit } = require('./bandit');
23
+
24
+ /**
25
+ * Task-type one-hot vocabulary. Order is load-bearing: it defines feature
26
+ * indices 6..11 of the context vector, and every persisted bandit arm was
27
+ * trained against this order. Do not reorder or insert — append only, and
28
+ * only together with a bandit-state migration.
29
+ */
30
+ const TASK_TYPES = ['code_gen', 'summarization', 'reasoning', 'factoid', 'chat', 'other'];
31
+
32
+ /**
33
+ * Build the 12-dim context vector the bandit (and any off-policy estimator
34
+ * replaying its decisions) scores against.
35
+ *
36
+ * Layout: [score, log-tokens, has-tools, streaming, risk, agentic,
37
+ * ...one-hot task type (6)].
38
+ *
39
+ * @param {object} args
40
+ * @param {object} args.analysis — complexity analysis ({ score, breakdown })
41
+ * @param {object} [args.payload] — request payload (for tools presence)
42
+ * @param {object} [args.options] — routing options (for streaming flag)
43
+ * @param {object} [args.risk] — risk analysis ({ level })
44
+ * @param {object} [args.agenticResult] — agentic detection ({ isAgentic })
45
+ * @returns {number[]} 12-dim feature vector
46
+ */
47
+ function buildContextVector({ analysis, payload, options, risk, agenticResult }) {
48
+ const inferredTask = (analysis?.breakdown?.taskType?.reason || 'other').toLowerCase();
49
+ const taskIdx = Math.max(0, TASK_TYPES.findIndex(t => inferredTask.includes(t)));
50
+ return [
51
+ (analysis?.score || 0) / 100,
52
+ Math.log(Math.max(1, analysis?.breakdown?.tokenCount || 0) + 1) / 15,
53
+ ((payload?.tools?.length ?? 0) > 0) ? 1 : 0,
54
+ options?.streaming ? 1 : 0,
55
+ risk?.level === 'high' ? 1 : risk?.level === 'medium' ? 0.5 : 0,
56
+ agenticResult?.isAgentic ? 1 : 0,
57
+ ...TASK_TYPES.map((_, i) => i === taskIdx ? 1 : 0),
58
+ ];
59
+ }
60
+
61
+ /**
62
+ * Build the bandit's candidate set: the current selection plus the kNN
63
+ * alternative, if it differs AND is configured in some TIER_* entry.
64
+ *
65
+ * Tier-aware filter: the bandit may explore freely across the user's
66
+ * configured tiers (e.g. swap a SIMPLE request to the COMPLEX-tier model),
67
+ * but never pick a credentialed-but-untiered model (e.g. an Azure deployment
68
+ * present in .env for another purpose but referenced by no TIER_*). Tier
69
+ * routing stays the source of truth for eligibility.
70
+ *
71
+ * @param {{ provider: string, model: string }} current
72
+ * @param {{ provider: string, model: string }|null} alternative
73
+ * @returns {Array<{ provider: string, model: string }>}
74
+ */
75
+ function buildCandidates(current, alternative) {
76
+ const candidates = [{ provider: current.provider, model: current.model }];
77
+ if (alternative && alternative.model && alternative.model !== current.model) {
78
+ const configured = require('./model-tiers').getModelTierSelector().getAllConfiguredModels();
79
+ const inConfig = configured.some(
80
+ m => m.provider === alternative.provider && m.model === alternative.model
81
+ );
82
+ if (inConfig) {
83
+ candidates.push({ provider: alternative.provider, model: alternative.model });
84
+ }
85
+ }
86
+ return candidates;
87
+ }
88
+
89
+ /**
90
+ * The decision core: given a tier, a candidate set, and a context vector,
91
+ * have the bandit pick one and report the pick's propensity.
92
+ *
93
+ * Returns null when there's nothing to adjudicate (fewer than 2 candidates)
94
+ * or the bandit declined — callers keep their pre-existing selection.
95
+ *
96
+ * @param {string} tier
97
+ * @param {Array<{ provider, model }>} candidates
98
+ * @param {number[]} ctx — from buildContextVector
99
+ * @returns {null | { provider, model, ucb, explored, propensity, candidates, context }}
100
+ */
101
+ function decide(tier, candidates, ctx) {
102
+ if (!candidates || candidates.length < 2) return null;
103
+ const picked = getBandit().pick(tier, candidates, ctx);
104
+ if (!picked) return null;
105
+ return { ...picked, candidates, context: ctx };
106
+ }
107
+
108
+ /**
109
+ * Stamp propensity/candidates/_banditContext onto a built decision (WS4.2).
110
+ *
111
+ * Collapse rule: the bandit's propensity only describes the served choice if
112
+ * the served (provider, model) is still one of the bandit's candidates. If a
113
+ * deterministic downstream override (deadline / tenant) swapped the served
114
+ * model out of that set — or the bandit never ran — collapse to
115
+ * propensity=1.0 with a single-entry candidate list, so off-policy
116
+ * estimators treat the row as a deterministic decision.
117
+ *
118
+ * _banditContext is underscored so it never leaks to response headers; the
119
+ * feedback path consumes it to call bandit.update().
120
+ *
121
+ * @param {object} decision — mutated in place (matches prior inline behavior)
122
+ * @param {{ provider: string, model: string }} served
123
+ * @param {null | { propensity, candidates, context }} banditResult — from decide()
124
+ * @returns {object} the same decision, for chaining
125
+ */
126
+ function stampPropensity(decision, served, banditResult) {
127
+ const banditPickedServed = banditResult?.candidates
128
+ && banditResult.candidates.some(
129
+ c => c.provider === served.provider && c.model === served.model
130
+ );
131
+ if (banditPickedServed) {
132
+ decision.propensity = banditResult.propensity ?? 1.0;
133
+ decision.candidates = banditResult.candidates;
134
+ decision._banditContext = banditResult.context;
135
+ } else {
136
+ decision.propensity = 1.0;
137
+ decision.candidates = [{ provider: served.provider, model: served.model }];
138
+ decision._banditContext = null;
139
+ }
140
+ return decision;
141
+ }
142
+
143
+ module.exports = {
144
+ TASK_TYPES,
145
+ buildContextVector,
146
+ buildCandidates,
147
+ decide,
148
+ stampPropensity,
149
+ };
@@ -103,6 +103,42 @@ function _clearCache() {
103
103
  _cache.clear();
104
104
  }
105
105
 
106
+ /**
107
+ * Percentage-based demotion holdout (ROUTING-NOTES §4.10.4).
108
+ *
109
+ * Even when the evidence supports demotion, a configurable slice of sessions
110
+ * is deliberately excluded and served at the original tier. Those held-out
111
+ * rows are a continuously-running baseline: comparing their outcomes against
112
+ * demoted rows (method suffix '+deescalated' vs '+deescalation_holdout' in
113
+ * routing telemetry) proves the demotion rule is still net-positive over
114
+ * time, instead of trusting a one-time calibration.
115
+ *
116
+ * The bucket is a deterministic hash of the session key, so a session lands
117
+ * on the same side of the holdout on every turn — cohorts stay clean and a
118
+ * conversation never flip-flops tiers because of the holdout itself.
119
+ *
120
+ * LYNKR_DEESCALATION_HOLDOUT_PCT: 0–100, default 10. 0 disables the holdout.
121
+ * Read at call time (not module load) so tests and live re-config work.
122
+ *
123
+ * @param {string|null} sessionKey — session id/fingerprint; null → never held out
124
+ * @returns {boolean} true when this session must NOT be demoted (baseline cohort)
125
+ */
126
+ function isHeldOut(sessionKey) {
127
+ if (!sessionKey) return false;
128
+ const raw = Number.parseInt(process.env.LYNKR_DEESCALATION_HOLDOUT_PCT, 10);
129
+ const pct = Number.isNaN(raw) ? 10 : Math.min(100, Math.max(0, raw));
130
+ if (pct === 0) return false;
131
+ // FNV-1a over the session key → stable bucket in 0..99.
132
+ let hash = 0x811c9dc5;
133
+ const s = String(sessionKey);
134
+ for (let i = 0; i < s.length; i++) {
135
+ hash ^= s.charCodeAt(i);
136
+ hash = Math.imul(hash, 0x01000193);
137
+ }
138
+ const bucket = (hash >>> 0) % 100;
139
+ return bucket < pct;
140
+ }
141
+
106
142
  /**
107
143
  * Shadow-mode policy. Wraps the live routing decision by delegating to
108
144
  * `determineProviderSmart` and then applying `suggestDemotion` on the result.
@@ -143,6 +179,7 @@ async function shadowDeescalate(payload) {
143
179
  module.exports = {
144
180
  suggestDemotion,
145
181
  shadowDeescalate,
182
+ isHeldOut,
146
183
  TIER_ORDER,
147
184
  _clearCache,
148
185
  };