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
|
@@ -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
|
};
|