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,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Off-policy evaluation (ROUTING-NOTES §3 + §4.10.3).
|
|
3
|
+
*
|
|
4
|
+
* Estimates how a CANDIDATE routing policy would have performed, using only
|
|
5
|
+
* logged decisions made by the LIVE policy — no live traffic, no A/B test.
|
|
6
|
+
* This consumes exactly what WS4.2 has been logging on every telemetry row
|
|
7
|
+
* since it shipped: `propensity` (the live policy's probability of the
|
|
8
|
+
* logged choice), `candidates` (the choice set), `quality_score` (reward),
|
|
9
|
+
* and — as of the same change that added this module — `context` (the
|
|
10
|
+
* bandit's 12-dim feature vector, needed by the doubly-robust term).
|
|
11
|
+
*
|
|
12
|
+
* Four estimators, in increasing order of sophistication:
|
|
13
|
+
*
|
|
14
|
+
* IPS importance sampling: reweight logged rewards by
|
|
15
|
+
* π(a|x) / p. Unbiased but high-variance when propensities are
|
|
16
|
+
* small.
|
|
17
|
+
* SNIPS self-normalized IPS: divide by the sum of weights instead of n.
|
|
18
|
+
* Slightly biased, far lower variance (Swaminathan & Joachims).
|
|
19
|
+
* DR doubly robust (Dudík/Langford/Li 2011): a regression baseline
|
|
20
|
+
* r̂(x, a) — LinUCB's own per-arm ridge model, reused — plus the
|
|
21
|
+
* importance-weighted residual. Unbiased if EITHER the propensities
|
|
22
|
+
* OR the regression model is right.
|
|
23
|
+
* WDR weighted DR: DR with SNIPS-style self-normalized weights on the
|
|
24
|
+
* correction term. The production target named by real systems'
|
|
25
|
+
* own code ("the IPS/DR/WDR estimators").
|
|
26
|
+
*
|
|
27
|
+
* A candidate policy is a plain function:
|
|
28
|
+
* policyFn({ tier, context, candidates }) →
|
|
29
|
+
* { probs: Map<candidateKey, number> } — action probabilities summing
|
|
30
|
+
* to 1 over the row's candidate set (deterministic policies return
|
|
31
|
+
* probability 1 on one candidate).
|
|
32
|
+
*
|
|
33
|
+
* Rewards are quality_score rescaled to [0, 1] (same scale the bandit and
|
|
34
|
+
* its r̂ train on). Rows are only usable when they carry propensity,
|
|
35
|
+
* candidates (≥1), and quality_score; the DR term additionally needs
|
|
36
|
+
* context and falls back to the pure IPS term on rows without it.
|
|
37
|
+
*
|
|
38
|
+
* @module routing/ope
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const { getBandit } = require('./bandit');
|
|
42
|
+
|
|
43
|
+
/** Canonical key for a candidate. */
|
|
44
|
+
function candKey(c) {
|
|
45
|
+
return `${c.provider}:${c.model}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Propensity floor: a logged propensity below this is clamped, bounding any
|
|
49
|
+
// single row's importance weight (the same reason workweave's Monte-Carlo
|
|
50
|
+
// propensity floors at 1/trials — one row must not dominate the estimate).
|
|
51
|
+
const PROPENSITY_FLOOR = 1e-3;
|
|
52
|
+
|
|
53
|
+
function _parseRow(row) {
|
|
54
|
+
if (row == null) return null;
|
|
55
|
+
const propensity = typeof row.propensity === 'number' ? row.propensity : null;
|
|
56
|
+
const quality = typeof row.quality_score === 'number' ? row.quality_score : null;
|
|
57
|
+
if (propensity == null || quality == null) return null;
|
|
58
|
+
|
|
59
|
+
let candidates = row.candidates;
|
|
60
|
+
if (typeof candidates === 'string') {
|
|
61
|
+
try {
|
|
62
|
+
candidates = JSON.parse(candidates);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
68
|
+
|
|
69
|
+
let context = row.context ?? null;
|
|
70
|
+
if (typeof context === 'string') {
|
|
71
|
+
try {
|
|
72
|
+
context = JSON.parse(context);
|
|
73
|
+
} catch {
|
|
74
|
+
context = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!Array.isArray(context)) context = null;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
tier: row.tier ?? null,
|
|
81
|
+
served: { provider: row.provider, model: row.model ?? null },
|
|
82
|
+
reward: Math.max(0, Math.min(1, quality / 100)),
|
|
83
|
+
propensity: Math.max(PROPENSITY_FLOOR, Math.min(1, propensity)),
|
|
84
|
+
candidates,
|
|
85
|
+
context,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Evaluate a candidate policy against logged telemetry rows.
|
|
91
|
+
*
|
|
92
|
+
* @param {Array<object>} rows — telemetry rows (raw DB rows or equivalents)
|
|
93
|
+
* @param {Function} policyFn — ({ tier, context, candidates }) → { probs: Map }
|
|
94
|
+
* @param {object} [deps]
|
|
95
|
+
* @param {object} [deps.bandit] — override the reward model (tests)
|
|
96
|
+
* @returns {{
|
|
97
|
+
* n: number, usable: number, drRows: number,
|
|
98
|
+
* ips: number|null, snips: number|null, dr: number|null, wdr: number|null,
|
|
99
|
+
* effectiveSampleSize: number|null,
|
|
100
|
+
* loggedMeanReward: number|null,
|
|
101
|
+
* }}
|
|
102
|
+
*/
|
|
103
|
+
function evaluatePolicy(rows, policyFn, deps = {}) {
|
|
104
|
+
const bandit = deps.bandit || getBandit();
|
|
105
|
+
|
|
106
|
+
let usable = 0;
|
|
107
|
+
let drRows = 0;
|
|
108
|
+
let ipsSum = 0;
|
|
109
|
+
let weightSum = 0;
|
|
110
|
+
let weightSqSum = 0;
|
|
111
|
+
let drSum = 0;
|
|
112
|
+
let wdrBaselineSum = 0;
|
|
113
|
+
let wdrCorrectionSum = 0;
|
|
114
|
+
let loggedRewardSum = 0;
|
|
115
|
+
|
|
116
|
+
for (const raw of rows) {
|
|
117
|
+
const row = _parseRow(raw);
|
|
118
|
+
if (!row) continue;
|
|
119
|
+
usable += 1;
|
|
120
|
+
loggedRewardSum += row.reward;
|
|
121
|
+
|
|
122
|
+
let probs;
|
|
123
|
+
try {
|
|
124
|
+
const out = policyFn({ tier: row.tier, context: row.context, candidates: row.candidates });
|
|
125
|
+
probs = out?.probs;
|
|
126
|
+
} catch {
|
|
127
|
+
probs = null;
|
|
128
|
+
}
|
|
129
|
+
if (!probs) continue;
|
|
130
|
+
|
|
131
|
+
// π(a_logged | x): the candidate policy's probability of the action the
|
|
132
|
+
// live policy actually served.
|
|
133
|
+
const piLogged = probs.get(candKey(row.served)) ?? 0;
|
|
134
|
+
const w = piLogged / row.propensity;
|
|
135
|
+
|
|
136
|
+
ipsSum += w * row.reward;
|
|
137
|
+
weightSum += w;
|
|
138
|
+
weightSqSum += w * w;
|
|
139
|
+
|
|
140
|
+
// DR terms: baseline = Σ_a π(a|x)·r̂(x,a); correction = w·(r − r̂(x,a_logged)).
|
|
141
|
+
// Rows without a context (bandit didn't run) or without a usable r̂ fall
|
|
142
|
+
// back to r̂ = 0, which reduces the row's DR contribution to pure IPS.
|
|
143
|
+
let baseline = 0;
|
|
144
|
+
let rhatLogged = 0;
|
|
145
|
+
if (row.context && row.tier) {
|
|
146
|
+
drRows += 1;
|
|
147
|
+
for (const c of row.candidates) {
|
|
148
|
+
const p = probs.get(candKey(c)) ?? 0;
|
|
149
|
+
if (p === 0) continue;
|
|
150
|
+
const rhat = bandit.estimateReward(row.tier, c.provider, c.model, row.context);
|
|
151
|
+
if (rhat != null) baseline += p * rhat;
|
|
152
|
+
}
|
|
153
|
+
const rhatServed = bandit.estimateReward(row.tier, row.served.provider, row.served.model, row.context);
|
|
154
|
+
if (rhatServed != null) rhatLogged = rhatServed;
|
|
155
|
+
}
|
|
156
|
+
drSum += baseline + w * (row.reward - rhatLogged);
|
|
157
|
+
wdrBaselineSum += baseline;
|
|
158
|
+
wdrCorrectionSum += w * (row.reward - rhatLogged);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (usable === 0) {
|
|
162
|
+
return {
|
|
163
|
+
n: rows.length, usable: 0, drRows: 0,
|
|
164
|
+
ips: null, snips: null, dr: null, wdr: null,
|
|
165
|
+
effectiveSampleSize: null, loggedMeanReward: null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Effective sample size — Kish's approximation. A small ESS relative to
|
|
170
|
+
// `usable` means a few high-weight rows dominate: treat the estimate with
|
|
171
|
+
// suspicion regardless of its value.
|
|
172
|
+
const ess = weightSqSum > 0 ? (weightSum * weightSum) / weightSqSum : 0;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
n: rows.length,
|
|
176
|
+
usable,
|
|
177
|
+
drRows,
|
|
178
|
+
ips: ipsSum / usable,
|
|
179
|
+
snips: weightSum > 0 ? ipsSum / weightSum : null,
|
|
180
|
+
dr: drSum / usable,
|
|
181
|
+
// WDR: baseline averaged per-row (deterministic, no weights) plus the
|
|
182
|
+
// self-normalized correction — variance control on exactly the term
|
|
183
|
+
// that needs it.
|
|
184
|
+
wdr: weightSum > 0
|
|
185
|
+
? wdrBaselineSum / usable + wdrCorrectionSum / weightSum
|
|
186
|
+
: wdrBaselineSum / usable,
|
|
187
|
+
effectiveSampleSize: ess,
|
|
188
|
+
loggedMeanReward: loggedRewardSum / usable,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Reference policies — useful baselines to evaluate out of the box.
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/** Always pick the first candidate (the tier-config/heuristic selection). */
|
|
197
|
+
function firstCandidatePolicy({ candidates }) {
|
|
198
|
+
const probs = new Map();
|
|
199
|
+
candidates.forEach((c, i) => probs.set(candKey(c), i === 0 ? 1 : 0));
|
|
200
|
+
return { probs };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Always pick the last candidate (the kNN suggestion when present). */
|
|
204
|
+
function lastCandidatePolicy({ candidates }) {
|
|
205
|
+
const probs = new Map();
|
|
206
|
+
const lastIdx = candidates.length - 1;
|
|
207
|
+
candidates.forEach((c, i) => probs.set(candKey(c), i === lastIdx ? 1 : 0));
|
|
208
|
+
return { probs };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Uniform-random over the candidate set. */
|
|
212
|
+
function uniformPolicy({ candidates }) {
|
|
213
|
+
const probs = new Map();
|
|
214
|
+
const p = 1 / candidates.length;
|
|
215
|
+
candidates.forEach((c) => probs.set(candKey(c), p));
|
|
216
|
+
return { probs };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The current LinUCB policy replayed greedily (no exploration, mean+bonus
|
|
221
|
+
* argmax at TODAY'S learned weights). Comparing this against the logged
|
|
222
|
+
* rewards answers: "has the bandit's learning actually converged on
|
|
223
|
+
* something better than what it served while learning?"
|
|
224
|
+
*/
|
|
225
|
+
function currentBanditGreedyPolicy({ tier, context, candidates }, deps = {}) {
|
|
226
|
+
const bandit = deps.bandit || getBandit();
|
|
227
|
+
const probs = new Map();
|
|
228
|
+
if (!context || !tier || candidates.length < 2) {
|
|
229
|
+
return firstCandidatePolicy({ candidates });
|
|
230
|
+
}
|
|
231
|
+
let best = null;
|
|
232
|
+
let bestVal = -Infinity;
|
|
233
|
+
for (const c of candidates) {
|
|
234
|
+
const est = bandit.estimateReward(tier, c.provider, c.model, context);
|
|
235
|
+
const val = est ?? -1; // unknown arms lose to any known arm
|
|
236
|
+
if (val > bestVal) {
|
|
237
|
+
bestVal = val;
|
|
238
|
+
best = c;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
candidates.forEach((c) => probs.set(candKey(c), c === best ? 1 : 0));
|
|
242
|
+
return { probs };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = {
|
|
246
|
+
evaluatePolicy,
|
|
247
|
+
candKey,
|
|
248
|
+
PROPENSITY_FLOOR,
|
|
249
|
+
policies: {
|
|
250
|
+
firstCandidate: firstCandidatePolicy,
|
|
251
|
+
lastCandidate: lastCandidatePolicy,
|
|
252
|
+
uniform: uniformPolicy,
|
|
253
|
+
currentBanditGreedy: currentBanditGreedyPolicy,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model + reasoning-effort → tier mapping for Codex/ChatGPT desktop's model
|
|
3
|
+
* picker (the "5.6 Sol Light" dropdown in the app UI).
|
|
4
|
+
*
|
|
5
|
+
* This is the OpenAI/Responses-API-shaped counterpart to ./model-slots.js.
|
|
6
|
+
* That module works because Claude Desktop lets a gateway advertise
|
|
7
|
+
* completely made-up model ids ("claude-fable-5" etc.) via its own /v1/models
|
|
8
|
+
* list. Codex has no such gateway hook — its dropdown is populated from
|
|
9
|
+
* OpenAI's own real model catalog, and it always sends a real OpenAI model
|
|
10
|
+
* id (e.g. "gpt-5.6-sol", "gpt-5.6-mini") plus a `reasoning.effort` field
|
|
11
|
+
* (OpenAI's documented enum: minimal | low | medium | high). So instead of
|
|
12
|
+
* inventing ids, this pins on (model, effort) combinations Codex's dropdown
|
|
13
|
+
* can actually produce.
|
|
14
|
+
*
|
|
15
|
+
* Verification status: CONFIRMED live (2026-08-28) via a temp diagnostic log
|
|
16
|
+
* (since removed) that captured a real Codex Desktop request. A screenshot
|
|
17
|
+
* of the picker showed model options "5.6 Sol / 5.6 Terra / 5.6 Luna / 5.5 /
|
|
18
|
+
* 5.2" (five peer entries, no visible size/tier hint — an earlier version of
|
|
19
|
+
* this file guessed "luna" meant a cheap/distilled variant; that guess had
|
|
20
|
+
* no evidence and has been removed) and an "Effort" row labeled "Light".
|
|
21
|
+
* The captured request, sent with that exact picker state, carried
|
|
22
|
+
* `reasoning: {"effort":"low","summary":"detailed","context":"all_turns"}`
|
|
23
|
+
* — so the UI label "Light" is confirmed to be OpenAI's documented public
|
|
24
|
+
* enum value "low" verbatim, not a distinct string. resolveTierForOpenAIModel()
|
|
25
|
+
* stays conservative regardless: any unrecognized model or effort value
|
|
26
|
+
* returns null (no pin), falling through to normal content-based scoring.
|
|
27
|
+
*
|
|
28
|
+
* Scope note (also observed live in the same captured session): the pin
|
|
29
|
+
* only governs the FIRST model call of a turn. Multi-step agentic turns
|
|
30
|
+
* (tool calls, follow-up steps) re-score by content on each subsequent
|
|
31
|
+
* step — this is inherent to the shared `_forceProvider` mechanism
|
|
32
|
+
* (orchestrator/index.js deletes it after the first read), identical to
|
|
33
|
+
* how router.js's own Claude-Desktop-picker pin already behaves. Not a bug
|
|
34
|
+
* introduced here; a pre-existing property of the shared pin plumbing.
|
|
35
|
+
*
|
|
36
|
+
* Shared by:
|
|
37
|
+
* - src/api/openai-router.js — resolves an incoming /chat/completions or
|
|
38
|
+
* /responses request's `model` + `reasoning.effort` fields to a tier,
|
|
39
|
+
* mirroring router.js's model-id-pin block (which openai-router.js's
|
|
40
|
+
* handlers never go through — they call orchestrator.processMessage()
|
|
41
|
+
* directly).
|
|
42
|
+
*
|
|
43
|
+
* @module routing/openai-model-slots
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
// OpenAI's publicly documented naming convention for distilled/cheap model
|
|
47
|
+
// variants — NOT confirmed present in this app's specific dropdown (its
|
|
48
|
+
// real options are Sol/Terra/Luna/5.5/5.2, no mini/nano seen), kept only
|
|
49
|
+
// because it's a well-established public-catalog convention that costs
|
|
50
|
+
// nothing to check if some other Codex build or client does send one.
|
|
51
|
+
const SMALL_MODEL_PATTERN = /-(mini|nano)\b/i;
|
|
52
|
+
|
|
53
|
+
// reasoning.effort → tier. Confirmed live 2026-08-28: Codex Desktop's
|
|
54
|
+
// "Light" picker label sends the literal wire value "low", not "light" —
|
|
55
|
+
// OpenAI's publicly documented Responses API enum, verbatim. See the module
|
|
56
|
+
// doc comment above for the captured request that confirmed this.
|
|
57
|
+
const EFFORT_TIER = {
|
|
58
|
+
minimal: "SIMPLE",
|
|
59
|
+
low: "MEDIUM",
|
|
60
|
+
medium: "COMPLEX",
|
|
61
|
+
high: "REASONING",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a Codex-style model id + reasoning effort to a Lynkr tier.
|
|
66
|
+
*
|
|
67
|
+
* @param {string|null|undefined} model - e.g. "gpt-5.6-sol"
|
|
68
|
+
* @param {string|null|undefined} effort - e.g. "low" (from body.reasoning.effort)
|
|
69
|
+
* @returns {string|null} one of SIMPLE/MEDIUM/COMPLEX/REASONING, or null when
|
|
70
|
+
* unrecognized — caller should fall through to normal content scoring.
|
|
71
|
+
*/
|
|
72
|
+
function resolveTierForOpenAIModel(model, effort) {
|
|
73
|
+
if (typeof model === "string" && SMALL_MODEL_PATTERN.test(model)) {
|
|
74
|
+
return "SIMPLE";
|
|
75
|
+
}
|
|
76
|
+
if (typeof effort === "string" && EFFORT_TIER[effort.toLowerCase()]) {
|
|
77
|
+
return EFFORT_TIER[effort.toLowerCase()];
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { EFFORT_TIER, SMALL_MODEL_PATTERN, resolveTierForOpenAIModel };
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shortfall matching (item 1: HyDRA port, phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Selects the cheapest candidate whose capabilities cover the predicted
|
|
5
|
+
* requirement vector within tolerance τ:
|
|
6
|
+
*
|
|
7
|
+
* shortfall(m) = Σ w_k · max(0, req_k − cap_mk)
|
|
8
|
+
*
|
|
9
|
+
* All four heads share the [0,1] bandwidth, so v1 weights are uniform
|
|
10
|
+
* (band compensation is a no-op until heads diverge — noted for phase 2).
|
|
11
|
+
*
|
|
12
|
+
* Contracts:
|
|
13
|
+
* - Catalog-decoupled: candidates arrive as [{provider, model, tier}];
|
|
14
|
+
* capabilities resolve per MODEL FAMILY (provider never matters:
|
|
15
|
+
* zai/baidu/ollama servings of glm-5.2 share caps) via operator
|
|
16
|
+
* modelOverrides → seed snapshot/shipped → family heuristic → tier
|
|
17
|
+
* profile. Adding, removing, or repricing a model re-routes via config
|
|
18
|
+
* with zero retraining.
|
|
19
|
+
* - Self-contained config: enabled/tau/weights also live in
|
|
20
|
+
* config/model-capabilities.json — no env vars. The file is read once at
|
|
21
|
+
* boot (same lifecycle as config/model-tiers.json); restart to pick up
|
|
22
|
+
* edits.
|
|
23
|
+
* - Cost comes from the caller (model-registry) or per-1k blended estimate;
|
|
24
|
+
* unknown cost sorts last (conservative — never wins on cheapness alone).
|
|
25
|
+
* - No covering candidate (all shortfalls > τ) → minimal shortfall wins,
|
|
26
|
+
* tiebreak higher tier (correctness over cost, matches tier-fallback.js
|
|
27
|
+
* escalate-then-demote bias). Never returns null on valid input; returns
|
|
28
|
+
* null only on malformed input so callers fail open to legacy routing.
|
|
29
|
+
* - Pure except for config load (cached). Never throws.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const logger = require('../logger');
|
|
35
|
+
const { HEADS } = require('./capabilities');
|
|
36
|
+
|
|
37
|
+
const PROFILES_PATH = path.join(__dirname, '../../config/model-capabilities.json');
|
|
38
|
+
|
|
39
|
+
const DEFAULT_TAU = 0.24; // HyDRA iso-quality operating point
|
|
40
|
+
const TIER_PRIORITY = { SIMPLE: 1, MEDIUM: 2, COMPLEX: 3, REASONING: 4 };
|
|
41
|
+
|
|
42
|
+
let _profilesCache = null;
|
|
43
|
+
|
|
44
|
+
function _defaultWeights() {
|
|
45
|
+
const w = {};
|
|
46
|
+
for (const h of HEADS) w[h] = 1 / HEADS.length;
|
|
47
|
+
return w;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function _sanitizeCaps(raw) {
|
|
51
|
+
const caps = {};
|
|
52
|
+
for (const h of HEADS) {
|
|
53
|
+
const v = Number(raw?.[h]);
|
|
54
|
+
caps[h] = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0.5;
|
|
55
|
+
}
|
|
56
|
+
return caps;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadProfiles() {
|
|
60
|
+
if (_profilesCache) return _profilesCache;
|
|
61
|
+
try {
|
|
62
|
+
const raw = JSON.parse(fs.readFileSync(PROFILES_PATH, 'utf8'));
|
|
63
|
+
const tierProfiles = {};
|
|
64
|
+
const fileTiers = raw?.tierProfiles && typeof raw.tierProfiles === 'object'
|
|
65
|
+
? raw.tierProfiles
|
|
66
|
+
: {};
|
|
67
|
+
for (const [tier, caps] of Object.entries(fileTiers)) {
|
|
68
|
+
if (caps && typeof caps === 'object') tierProfiles[tier] = _sanitizeCaps(caps);
|
|
69
|
+
}
|
|
70
|
+
const modelOverrides = {};
|
|
71
|
+
const fileOverrides = raw?.modelOverrides && typeof raw.modelOverrides === 'object'
|
|
72
|
+
? raw.modelOverrides
|
|
73
|
+
: {};
|
|
74
|
+
for (const [key, caps] of Object.entries(fileOverrides)) {
|
|
75
|
+
if (caps && typeof caps === 'object') modelOverrides[String(key).toLowerCase()] = _sanitizeCaps(caps);
|
|
76
|
+
}
|
|
77
|
+
const tau = Number(raw?.tau);
|
|
78
|
+
const weights = _normalizeWeights(raw?.weights);
|
|
79
|
+
_profilesCache = {
|
|
80
|
+
tierProfiles,
|
|
81
|
+
modelOverrides,
|
|
82
|
+
enabled: raw?.enabled === true,
|
|
83
|
+
tau: Number.isFinite(tau) && tau >= 0 ? tau : DEFAULT_TAU,
|
|
84
|
+
weights,
|
|
85
|
+
};
|
|
86
|
+
} catch (err) {
|
|
87
|
+
logger.debug({ err: err.message }, '[Shortfall] profiles load failed — disabled with tier fallbacks');
|
|
88
|
+
_profilesCache = { tierProfiles: {}, modelOverrides: {}, enabled: false, tau: DEFAULT_TAU, weights: _defaultWeights() };
|
|
89
|
+
}
|
|
90
|
+
return _profilesCache;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Exposed for tests (cache reset / profile injection).
|
|
94
|
+
function _resetProfilesCache() {
|
|
95
|
+
_profilesCache = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _setProfilesForTests(profiles) {
|
|
99
|
+
const base = loadProfiles();
|
|
100
|
+
_profilesCache = {
|
|
101
|
+
tierProfiles: profiles?.tierProfiles ?? base.tierProfiles,
|
|
102
|
+
modelOverrides: profiles?.modelOverrides ?? base.modelOverrides,
|
|
103
|
+
enabled: profiles?.enabled ?? base.enabled,
|
|
104
|
+
tau: profiles?.tau ?? base.tau,
|
|
105
|
+
weights: profiles?.weights ?? base.weights,
|
|
106
|
+
};
|
|
107
|
+
return _profilesCache;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _normalizeWeights(raw) {
|
|
111
|
+
const w = _defaultWeights();
|
|
112
|
+
if (!raw || typeof raw !== 'object') return w;
|
|
113
|
+
let touched = false;
|
|
114
|
+
for (const h of HEADS) {
|
|
115
|
+
const v = Number(raw[h]);
|
|
116
|
+
if (Number.isFinite(v) && v >= 0) {
|
|
117
|
+
w[h] = v;
|
|
118
|
+
touched = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!touched) return _defaultWeights();
|
|
122
|
+
const sum = Object.values(w).reduce((a, b) => a + b, 0);
|
|
123
|
+
if (sum <= 0) return _defaultWeights();
|
|
124
|
+
for (const h of HEADS) w[h] = w[h] / sum;
|
|
125
|
+
return w;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getTau() {
|
|
129
|
+
return loadProfiles().tau;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getWeights() {
|
|
133
|
+
return { ...loadProfiles().weights };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isEnabled() {
|
|
137
|
+
return loadProfiles().enabled === true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve capabilities for one candidate. Precedence (first hit wins):
|
|
142
|
+
* 1. operator "provider:model" / "provider:*" override (manual, supreme,
|
|
143
|
+
* returned verbatim — not even the quant haircut applies)
|
|
144
|
+
* 2. seed snapshot / shipped seeds by normalized MODEL FAMILY
|
|
145
|
+
* (zai/baidu/ollama servings of glm-5.2 share one entry)
|
|
146
|
+
* 3. family-ladder heuristic (zero-network estimate, never frontier)
|
|
147
|
+
* 4. tier profile → tier-priority fallback (SIMPLE 0.2 … REASONING 0.9)
|
|
148
|
+
*
|
|
149
|
+
* Quantized self-hosted servings (Q4_K_M, gguf, …) take a small haircut at
|
|
150
|
+
* every level except 1: same brain, smaller body.
|
|
151
|
+
*
|
|
152
|
+
* @returns {{ caps, source }} — source is override|seed:snapshot|
|
|
153
|
+
* seed:shipped|family|tier|tier-fallback (telemetry provenance).
|
|
154
|
+
*/
|
|
155
|
+
function resolveCapabilitiesWithSource({ provider, model, tier }) {
|
|
156
|
+
const { tierProfiles, modelOverrides } = loadProfiles();
|
|
157
|
+
const key = `${String(provider || '').toLowerCase()}:${String(model || '').toLowerCase()}`;
|
|
158
|
+
const wild = `${String(provider || '').toLowerCase()}:*`;
|
|
159
|
+
const pick = modelOverrides[key] || modelOverrides[wild];
|
|
160
|
+
if (pick) return { caps: { ...pick }, source: 'override' };
|
|
161
|
+
|
|
162
|
+
let family = 'unknown';
|
|
163
|
+
let quant = false;
|
|
164
|
+
try {
|
|
165
|
+
const fam = require('./capability-seeds/family');
|
|
166
|
+
({ family, quant } = fam.normalizeFamily(provider, model));
|
|
167
|
+
} catch { /* family helpers unavailable — tier fallback below */ }
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const { resolveSeedCaps } = require('./capability-seeds/registry');
|
|
171
|
+
const seed = resolveSeedCaps(family);
|
|
172
|
+
if (seed) {
|
|
173
|
+
const caps = quant
|
|
174
|
+
? require('./capability-seeds/family').applyQuantHaircut(seed.caps)
|
|
175
|
+
: { ...seed.caps };
|
|
176
|
+
return { caps, source: seed.source };
|
|
177
|
+
}
|
|
178
|
+
} catch { /* seed layers unavailable — keep falling through */ }
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const { heuristicCaps } = require('./capability-seeds/family-heuristics');
|
|
182
|
+
const heur = heuristicCaps(family);
|
|
183
|
+
if (heur) {
|
|
184
|
+
const caps = quant
|
|
185
|
+
? require('./capability-seeds/family').applyQuantHaircut(heur)
|
|
186
|
+
: heur;
|
|
187
|
+
return { caps, source: 'family' };
|
|
188
|
+
}
|
|
189
|
+
} catch { /* heuristic unavailable — tier fallback below */ }
|
|
190
|
+
|
|
191
|
+
const tp = tierProfiles[tier];
|
|
192
|
+
if (tp) {
|
|
193
|
+
const caps = quant
|
|
194
|
+
? require('./capability-seeds/family').applyQuantHaircut(tp)
|
|
195
|
+
: { ...tp };
|
|
196
|
+
return { caps, source: 'tier' };
|
|
197
|
+
}
|
|
198
|
+
const p = (TIER_PRIORITY[tier] || 1) / 4;
|
|
199
|
+
const caps = {};
|
|
200
|
+
for (const h of HEADS) caps[h] = Math.round(p * 1000) / 1000;
|
|
201
|
+
return { caps, source: 'tier-fallback' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveCapabilities(candidate) {
|
|
205
|
+
return resolveCapabilitiesWithSource(candidate).caps;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function shortfall(req, caps, weights = null) {
|
|
209
|
+
const w = weights || _defaultWeights();
|
|
210
|
+
let s = 0;
|
|
211
|
+
for (const h of HEADS) {
|
|
212
|
+
const r = Math.max(0, Math.min(1, Number(req?.[h]) || 0));
|
|
213
|
+
const c = Math.max(0, Math.min(1, Number(caps?.[h]) || 0));
|
|
214
|
+
s += (w[h] ?? 0) * Math.max(0, r - c);
|
|
215
|
+
}
|
|
216
|
+
return Math.round(s * 10000) / 10000;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function _costValue(c) {
|
|
220
|
+
const n = Number(c);
|
|
221
|
+
return Number.isFinite(n) && n >= 0 ? n : Number.POSITIVE_INFINITY;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {object} req — requirement vector {reasoning, codegen, debugging, tool_use} in [0,1]
|
|
226
|
+
* @param {Array<{provider, model, tier, cost?}>} candidates — catalog-constrained set
|
|
227
|
+
* (callers pass getAllConfiguredModels() + model-registry costs)
|
|
228
|
+
* @param {object} [opts] — { tau, weights }
|
|
229
|
+
* @returns {null | { selected, shortfalls: Array<{provider, model, tier, shortfall, cost, source}>, tau }}
|
|
230
|
+
*/
|
|
231
|
+
function selectByShortfall(req, candidates, opts = {}) {
|
|
232
|
+
try {
|
|
233
|
+
if (!req || typeof req !== 'object') return null;
|
|
234
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
235
|
+
const tau = opts.tau ?? getTau();
|
|
236
|
+
const weights = opts.weights ?? getWeights();
|
|
237
|
+
|
|
238
|
+
const rows = candidates
|
|
239
|
+
.filter((c) => c && c.provider && c.model)
|
|
240
|
+
.map((c) => {
|
|
241
|
+
const { caps, source } = resolveCapabilitiesWithSource(c);
|
|
242
|
+
return {
|
|
243
|
+
provider: c.provider,
|
|
244
|
+
model: c.model,
|
|
245
|
+
tier: c.tier || 'MEDIUM',
|
|
246
|
+
cost: _costValue(c.cost),
|
|
247
|
+
shortfall: shortfall(req, caps, weights),
|
|
248
|
+
source,
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
if (rows.length === 0) return null;
|
|
252
|
+
|
|
253
|
+
const covering = rows.filter((r) => r.shortfall <= tau);
|
|
254
|
+
const pool = covering.length > 0 ? covering : rows;
|
|
255
|
+
pool.sort((a, b) => {
|
|
256
|
+
if (covering.length > 0) {
|
|
257
|
+
// Cheapest covering wins; cost tie (incl. all-unknown) breaks toward
|
|
258
|
+
// the LOWER tier — a covering lower tier is sufficient by definition,
|
|
259
|
+
// so prefer it over excess headroom (avoids over-provisioning).
|
|
260
|
+
if (a.cost !== b.cost) return a.cost - b.cost;
|
|
261
|
+
return (TIER_PRIORITY[a.tier] || 0) - (TIER_PRIORITY[b.tier] || 0);
|
|
262
|
+
}
|
|
263
|
+
// Nothing covers: minimal shortfall wins, tiebreak higher tier then cheaper.
|
|
264
|
+
if (a.shortfall !== b.shortfall) return a.shortfall - b.shortfall;
|
|
265
|
+
const tp = (TIER_PRIORITY[b.tier] || 0) - (TIER_PRIORITY[a.tier] || 0);
|
|
266
|
+
if (tp !== 0) return tp;
|
|
267
|
+
return a.cost - b.cost;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return { selected: pool[0], shortfalls: rows, tau };
|
|
271
|
+
} catch (err) {
|
|
272
|
+
logger.debug({ err: err.message }, '[Shortfall] select failed — failing open');
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = {
|
|
278
|
+
HEADS,
|
|
279
|
+
DEFAULT_TAU,
|
|
280
|
+
loadProfiles,
|
|
281
|
+
_resetProfilesCache,
|
|
282
|
+
_setProfilesForTests,
|
|
283
|
+
getTau,
|
|
284
|
+
getWeights,
|
|
285
|
+
isEnabled,
|
|
286
|
+
resolveCapabilities,
|
|
287
|
+
resolveCapabilitiesWithSource,
|
|
288
|
+
shortfall,
|
|
289
|
+
selectByShortfall,
|
|
290
|
+
};
|