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,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
+ };