lynkr 9.14.1 → 9.14.3

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 (96) 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 +16 -22
  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/health.js +0 -1
  17. package/src/api/mcp-broker.js +149 -0
  18. package/src/api/middleware/budget-enforcer.js +77 -7
  19. package/src/api/middleware/budget.js +36 -2
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +210 -15
  22. package/src/api/providers-handler.js +16 -1
  23. package/src/api/router.js +73 -106
  24. package/src/auth-mode.js +40 -4
  25. package/src/budget/index.js +86 -0
  26. package/src/cache/embeddings.js +125 -35
  27. package/src/cache/onnx-embedder.js +92 -0
  28. package/src/cache/prompt.js +0 -7
  29. package/src/clients/bedrock-utils.js +2 -1
  30. package/src/clients/codex-process.js +1 -1
  31. package/src/clients/codex-utils.js +0 -2
  32. package/src/clients/databricks.js +337 -46
  33. package/src/clients/gpt-utils.js +84 -8
  34. package/src/clients/health-probe.js +157 -0
  35. package/src/clients/openai-format.js +73 -23
  36. package/src/clients/provider-capabilities.js +27 -2
  37. package/src/clients/resilience.js +0 -1
  38. package/src/clients/responses-format.js +0 -56
  39. package/src/clients/routing.js +1 -1
  40. package/src/config/index.js +13 -2
  41. package/src/context/budget.js +1 -1
  42. package/src/context/compression.js +127 -19
  43. package/src/context/tool-result-compressor.js +3 -5
  44. package/src/dashboard/api.js +1 -1
  45. package/src/logger/oversized-error-stream.js +1 -1
  46. package/src/memory/extractor.js +3 -3
  47. package/src/memory/search.js +2 -2
  48. package/src/memory/store.js +9 -24
  49. package/src/memory/tools.js +14 -0
  50. package/src/observability/metrics.js +52 -3
  51. package/src/observability/otel.js +180 -0
  52. package/src/orchestrator/azure-responses-sse.js +223 -0
  53. package/src/orchestrator/index.js +216 -212
  54. package/src/orchestrator/passthrough-stream.js +7 -1
  55. package/src/orchestrator/sse-transformer.js +130 -13
  56. package/src/policy/safe-commands.js +0 -1
  57. package/src/routing/bandit.js +39 -0
  58. package/src/routing/calibration.js +21 -1
  59. package/src/routing/capabilities.js +88 -0
  60. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  61. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  62. package/src/routing/capability-seeds/family.js +105 -0
  63. package/src/routing/capability-seeds/models-dev.js +64 -0
  64. package/src/routing/capability-seeds/normalize.js +160 -0
  65. package/src/routing/capability-seeds/registry.js +125 -0
  66. package/src/routing/capability-seeds/sources.js +69 -0
  67. package/src/routing/capability-seeds/swebench.js +66 -0
  68. package/src/routing/client-profiles.js +26 -0
  69. package/src/routing/complexity-analyzer.js +5 -8
  70. package/src/routing/confidence-scorer.js +2 -2
  71. package/src/routing/cost-optimizer.js +1 -2
  72. package/src/routing/decide.js +149 -0
  73. package/src/routing/deescalator.js +38 -1
  74. package/src/routing/embedding-cache.js +0 -1
  75. package/src/routing/index.js +202 -86
  76. package/src/routing/intent-score.js +0 -1
  77. package/src/routing/knn-router.js +27 -5
  78. package/src/routing/latency-tracker.js +1 -1
  79. package/src/routing/model-registry.js +25 -0
  80. package/src/routing/model-slots.js +65 -0
  81. package/src/routing/model-tiers.js +2 -0
  82. package/src/routing/ope.js +255 -0
  83. package/src/routing/openai-model-slots.js +82 -0
  84. package/src/routing/regret-estimator.js +1 -1
  85. package/src/routing/shortfall.js +290 -0
  86. package/src/routing/stuck-detector.js +127 -0
  87. package/src/routing/telemetry.js +11 -2
  88. package/src/server.js +41 -3
  89. package/src/stores/file-store.js +5 -1
  90. package/src/tools/web-search-exec.js +182 -0
  91. package/src/training/trajectory-compressor.js +1 -1
  92. package/src/workers/pool.js +1 -1
  93. package/src/workers/worker.js +1 -1
  94. package/scripts/convert-routellm.py +0 -105
  95. package/src/diff/comments.js +0 -153
  96. package/src/policy/web-fallback.js +0 -33
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Seed per-family capability profiles from online benchmark data.
4
+ *
5
+ * Reads (all optional, all fail-soft): SWE-Bench Verified leaderboard,
6
+ * data/capability-benchmarks/*.json drop-ins, models.dev boolean flags.
7
+ * Maps scores to families, normalizes to 0-1 caps, and writes
8
+ * data/capability-seeds.snapshot.json for the shortfall resolver.
9
+ * Shipped seeds (config/model-capability-seeds.json) are only ever changed
10
+ * by human review of the printed diff — this script never touches them.
11
+ *
12
+ * Request-path routing never calls this (zero latency impact) and never
13
+ * needs it: unknown families fall back to heuristic → tier caps.
14
+ *
15
+ * Usage:
16
+ * node scripts/seed-capabilities.js [--refresh] [--model provider:model]
17
+ * [--dry-run] [--check] [--stale-days 90]
18
+ *
19
+ * --refresh fetch remote sources (default: cache-only)
20
+ * --model limit to one family (e.g. --model z.ai:glm-5.2)
21
+ * --dry-run print diff, write nothing
22
+ * --check exit 2 if shipped seeds are older than --stale-days (CI)
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const { fetchSweBench } = require('../src/routing/capability-seeds/swebench');
29
+ const { fetchModelsDevFlags, NO_TOOLCALL_TOOL_USE_CAP } = require('../src/routing/capability-seeds/models-dev');
30
+ const { loadBenchmarksDir } = require('../src/routing/capability-seeds/benchmarks-dir');
31
+ const { mapEntryToFamily, scoresToCaps, normalizeFamily } = require('../src/routing/capability-seeds/normalize');
32
+ const { SHIPPED_PATH, SNAPSHOT_PATH } = require('../src/routing/capability-seeds/registry');
33
+
34
+ const STALE_DAYS_DEFAULT = 90;
35
+
36
+ function _parseArgs(argv) {
37
+ const out = { refresh: false, model: null, dryRun: false, check: false, staleDays: STALE_DAYS_DEFAULT };
38
+ for (let i = 0; i < argv.length; i++) {
39
+ if (argv[i] === '--refresh') out.refresh = true;
40
+ else if (argv[i] === '--dry-run') out.dryRun = true;
41
+ else if (argv[i] === '--check') out.check = true;
42
+ else if (argv[i] === '--model') out.model = String(argv[++i] || '');
43
+ else if (argv[i] === '--stale-days') out.staleDays = Number(argv[++i]) || STALE_DAYS_DEFAULT;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function _readJson(p) {
49
+ try {
50
+ if (!fs.existsSync(p)) return null;
51
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ // Drop-in source name → normalized score slot.
58
+ function _slotFor(source) {
59
+ const s = String(source || '').toLowerCase();
60
+ if (s.includes('terminal')) return 'terminal';
61
+ if (s.includes('livecode') || s.includes('bigcode')) return 'livecode';
62
+ if (s.includes('arena')) return 'arena';
63
+ if (s.includes('swe')) return 'swe';
64
+ return null;
65
+ }
66
+
67
+ async function main() {
68
+ const args = _parseArgs(process.argv.slice(2));
69
+
70
+ if (args.check) {
71
+ const shipped = _readJson(SHIPPED_PATH);
72
+ const updatedAt = shipped?.updatedAt ? new Date(shipped.updatedAt).getTime() : NaN;
73
+ const ageDays = Number.isFinite(updatedAt) ? (Date.now() - updatedAt) / 86400000 : Infinity;
74
+ if (!(ageDays <= args.staleDays)) {
75
+ console.error(`STALE: shipped seeds updatedAt=${shipped?.updatedAt ?? 'missing'} (> ${args.staleDays}d). Run --refresh and review the diff.`);
76
+ process.exit(2);
77
+ }
78
+ console.log(`OK: shipped seeds fresh (${shipped.updatedAt}, ${ageDays.toFixed(0)}d old).`);
79
+ return;
80
+ }
81
+
82
+ const shipped = _readJson(SHIPPED_PATH);
83
+ const shippedSeeds = shipped?.seeds && typeof shipped.seeds === 'object' ? shipped.seeds : {};
84
+ const knownFamilies = Object.keys(shippedSeeds).filter((k) => !k.endsWith('*'));
85
+ const wildcardFamilies = Object.keys(shippedSeeds).filter((k) => k.endsWith('*'));
86
+
87
+ let onlyFamily = null;
88
+ if (args.model) {
89
+ const [provider, ...rest] = args.model.split(':');
90
+ const { family } = normalizeFamily(provider, rest.join(':') || provider);
91
+ onlyFamily = family;
92
+ console.log(`Limiting to family: ${onlyFamily}`);
93
+ }
94
+
95
+ // 1. Gather score entries per source.
96
+ const scored = {}; // family -> { swe, terminal, livecode, arena }
97
+ const unmapped = [];
98
+ const scaffolded = [];
99
+ const candidates = [];
100
+ const statuses = [];
101
+ const addScore = (family, slot, fraction, source) => {
102
+ if (onlyFamily && family !== onlyFamily) return;
103
+ scored[family] = scored[family] || {};
104
+ // Keep the max when several entries map to one family.
105
+ if (scored[family][slot] === undefined || fraction > scored[family][slot]) {
106
+ scored[family][slot] = fraction;
107
+ scored[family][`${slot}:source`] = source;
108
+ }
109
+ };
110
+ // Bare-model scores only: a "+" joins scaffold + model ("live-swe-agent +
111
+ // claude 4.5 opus") and measures the harness, not the weights. Those go to
112
+ // a separate review list and never seed caps.
113
+ const triage = (name, score, slot, source) => {
114
+ if (String(name).includes('+')) {
115
+ scaffolded.push({ source, name, score });
116
+ return;
117
+ }
118
+ const m = mapEntryToFamily(name, knownFamilies);
119
+ if (m.family) {
120
+ addScore(m.family, slot, score, source);
121
+ return;
122
+ }
123
+ const w = mapEntryToFamily(name, wildcardFamilies);
124
+ if (w.family) {
125
+ candidates.push({ entry: name, score, slot, prefix: w.family.replace(/\*$/, '') });
126
+ return;
127
+ }
128
+ unmapped.push({ source, name, score });
129
+ };
130
+
131
+ const swe = await fetchSweBench({ refresh: args.refresh });
132
+ statuses.push(`swebench[${swe.board || '?'}]: ${swe.status}${swe.cached ? ' (cache)' : ''}${swe.reason ? ` — ${swe.reason}` : ''}`);
133
+ for (const e of swe.entries || []) {
134
+ triage(e.name, e.resolved / 100, 'swe', `swebench-verified@${e.resolved}%`);
135
+ }
136
+
137
+ const dir = loadBenchmarksDir();
138
+ for (const f of dir.files || []) {
139
+ const slot = _slotFor(f.source);
140
+ statuses.push(`${f.source}: ${f.status}${f.reason ? ` — ${f.reason}` : ''}${f.status === 'ok' && !slot ? ' (unknown slot — skipped)' : ''}`);
141
+ if (f.status !== 'ok' || !slot) continue;
142
+ for (const e of f.entries || []) {
143
+ const fraction = f.scale === 'fraction' ? e.score : e.score / 100;
144
+ triage(e.name, fraction, slot, `${f.source}@${e.score}`);
145
+ }
146
+ }
147
+
148
+ const md = await fetchModelsDevFlags({ refresh: args.refresh });
149
+ statuses.push(`models.dev: ${md.status}${md.cached ? ' (cache)' : ''}${md.reason ? ` — ${md.reason}` : ''}`);
150
+
151
+ // 2. Normalize to caps.
152
+ const proposed = {};
153
+ for (const [family, slots] of Object.entries(scored)) {
154
+ const scores = {};
155
+ for (const k of ['swe', 'terminal', 'livecode', 'arena']) {
156
+ if (Number.isFinite(slots[k])) scores[k] = slots[k];
157
+ }
158
+ const caps = scoresToCaps(scores);
159
+ if (!caps) continue;
160
+ const sources = Object.keys(scores).map((k) => slots[`${k}:source`]).filter(Boolean);
161
+ const flag = md.flags?.[family];
162
+ if (flag && flag.toolCall === false && caps.tool_use > NO_TOOLCALL_TOOL_USE_CAP) {
163
+ caps.tool_use = NO_TOOLCALL_TOOL_USE_CAP;
164
+ sources.push('models.dev:no-tool_call-cap');
165
+ }
166
+ proposed[family] = { caps, sources, fetchedAt: new Date().toISOString().slice(0, 10) };
167
+ }
168
+
169
+ // 3. Diff vs snapshot + shipped.
170
+ const snapshot = _readJson(SNAPSHOT_PATH);
171
+ const prevSeeds = snapshot?.seeds && typeof snapshot.seeds === 'object' ? snapshot.seeds : {};
172
+ const moves = [];
173
+ for (const [family, entry] of Object.entries(proposed)) {
174
+ const prev = prevSeeds[family];
175
+ const shippedCaps = shippedSeeds[family]?.caps;
176
+ const sameAs = (a, b) => a && b && ['reasoning', 'codegen', 'debugging', 'tool_use'].every((h) => a[h] === b[h]);
177
+ if (!prev && !shippedCaps) moves.push({ family, type: 'added', caps: entry.caps });
178
+ else if (!sameAs(prev?.caps, entry.caps)) moves.push({ family, type: 'changed', from: prev?.caps ?? null, to: entry.caps });
179
+ }
180
+
181
+ console.log('Sources:');
182
+ for (const s of statuses) console.log(` - ${s}`);
183
+ console.log(`Families scored: ${Object.keys(proposed).length}, changed vs snapshot: ${moves.length}`);
184
+ for (const m of moves.slice(0, 30)) {
185
+ console.log(` ${m.type} ${m.family}: ${JSON.stringify(m.from ?? null)} → ${JSON.stringify(m.to ?? m.caps)}`);
186
+ }
187
+ if (moves.length > 30) console.log(` … and ${moves.length - 30} more`);
188
+ const topUnmapped = unmapped.sort((a, b) => b.score - a.score).slice(0, 10);
189
+ if (topUnmapped.length > 0) {
190
+ console.log('Unmapped (no family match — review, add alias/seed if real):');
191
+ for (const u of topUnmapped) console.log(` - [${u.source}] ${u.name} (${u.score})`);
192
+ }
193
+ const topCandidates = candidates.sort((a, b) => b.score - a.score).slice(0, 15);
194
+ if (topCandidates.length > 0) {
195
+ console.log('Candidate new seeds (matched a shipped wildcard — add an exact entry to graduate):');
196
+ for (const c of topCandidates) {
197
+ console.log(` - "${c.entry}" (${c.slot} ${c.score}) → under prefix "${c.prefix}"`);
198
+ }
199
+ }
200
+ const topScaffolded = scaffolded.sort((a, b) => b.score - a.score).slice(0, 5);
201
+ if (topScaffolded.length > 0) {
202
+ console.log('Scaffolded entries (harness+model composites — never seeded, shown for context):');
203
+ for (const u of topScaffolded) console.log(` - [${u.source}] ${u.name} (${u.score})`);
204
+ }
205
+
206
+ if (args.dryRun) {
207
+ console.log('dry-run: wrote nothing.');
208
+ return;
209
+ }
210
+ const next = {
211
+ version: 1,
212
+ fetchedAt: new Date().toISOString(),
213
+ seeds: { ...prevSeeds },
214
+ };
215
+ for (const [family, entry] of Object.entries(proposed)) next.seeds[family] = entry;
216
+ try {
217
+ fs.mkdirSync(path.dirname(SNAPSHOT_PATH), { recursive: true });
218
+ fs.writeFileSync(SNAPSHOT_PATH, `${JSON.stringify(next, null, 2)}\n`);
219
+ console.log(`Wrote ${SNAPSHOT_PATH} (${Object.keys(next.seeds).length} families).`);
220
+ console.log('Review the diff above; to promote entries to shipped seeds, copy them into config/model-capability-seeds.json and bump updatedAt.');
221
+ } catch (err) {
222
+ console.error(`Write failed: ${err.message}`);
223
+ process.exit(1);
224
+ }
225
+ }
226
+
227
+ main().catch((err) => {
228
+ console.error(`seed-capabilities failed: ${err?.message || err}`);
229
+ process.exit(1);
230
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Claude Desktop third-party gateway support.
3
+ *
4
+ * Claude Desktop has a native "3p" deployment mode in which it sends its
5
+ * normal Anthropic Messages API traffic to a local gateway URL instead of
6
+ * api.anthropic.com (this is the route Ollama's `ollama launch
7
+ * claude-desktop` integration uses). Lynkr already speaks the Messages API
8
+ * on /v1/messages, so the only missing surface is model discovery: Desktop
9
+ * lists the gateway's models and maps them into its model picker using two
10
+ * non-standard fields observed in Ollama's gateway implementation
11
+ * (internal/proxy/claude_desktop.go): `anthropic_family_tier` and
12
+ * `is_family_default`.
13
+ *
14
+ * This router serves that list, derived from Lynkr's TIER_* config so the
15
+ * Desktop model picker doubles as a tier selector:
16
+ *
17
+ * SIMPLE -> haiku family (default)
18
+ * MEDIUM -> sonnet family
19
+ * COMPLEX -> sonnet family (default)
20
+ * REASONING -> opus family (default)
21
+ *
22
+ * The selected id PINS routing: src/api/router.js resolves the request's
23
+ * `model` field against ../routing/model-slots.js and, when it matches one
24
+ * of these ids (anything but "Lynkr Auto" / claude-fable-5), skips content
25
+ * scoring entirely and forces that tier. Only "Lynkr Auto" and unrecognized
26
+ * ids fall through to Lynkr's normal per-message scoring, same as Claude
27
+ * Code traffic gets.
28
+ *
29
+ * Gating: GET /v1/models is already served in OpenAI format by
30
+ * openai-router.js (mounted after this router). We only intercept callers
31
+ * that identify as Anthropic API clients via the `anthropic-version` header
32
+ * (Claude Desktop always sends it) or an explicit ?format=anthropic. Set
33
+ * CLAUDE_DESKTOP_GATEWAY=0 to disable the intercept entirely.
34
+ *
35
+ * Profile installation lives in scripts/claude-desktop.js.
36
+ */
37
+
38
+ const express = require("express");
39
+ const config = require("../config");
40
+ const logger = require("../logger");
41
+ const { MODEL_SLOTS } = require("../routing/model-slots");
42
+
43
+ const router = express.Router();
44
+
45
+ // Fixed timestamp: Anthropic's list endpoint dates models by release, not by
46
+ // request time, and a stable value keeps repeated calls cache-friendly.
47
+ const CREATED_AT = "2026-01-01T00:00:00Z";
48
+
49
+ const DEFAULT_MAX_TOKENS = 32768;
50
+
51
+ // MODEL_SLOTS lives in ../routing/model-slots.js — shared with router.js,
52
+ // which resolves an incoming request's `model` field back to a tier so an
53
+ // explicit pick in Desktop's dropdown pins routing instead of only being
54
+ // advisory (see the model-id-pin block in router.js).
55
+
56
+ function gatewayEnabled() {
57
+ return process.env.CLAUDE_DESKTOP_GATEWAY !== "0";
58
+ }
59
+
60
+ function maxTokens() {
61
+ const raw = Number(process.env.CLAUDE_DESKTOP_GATEWAY_MAX_TOKENS);
62
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_TOKENS;
63
+ }
64
+
65
+ /**
66
+ * Build the Anthropic-shaped model list from configured tiers. Tiers that
67
+ * are unset in .env are skipped; if that leaves a family with no default,
68
+ * the first surviving entry of that family is promoted so Desktop always
69
+ * has a selectable default per family.
70
+ */
71
+ function buildGatewayModels() {
72
+ const entries = [];
73
+ for (const slot of MODEL_SLOTS) {
74
+ let label = slot.label;
75
+ if (!label) {
76
+ const configured = config.modelTiers?.[slot.tier];
77
+ if (!configured) continue; // tier unset in .env — skip this slot
78
+ const [provider, ...modelParts] = configured.split(":");
79
+ const model = modelParts.join(":") || provider;
80
+ label = `Lynkr ${slot.tier} (${model})`;
81
+ }
82
+ entries.push({
83
+ type: "model",
84
+ id: slot.id,
85
+ display_name: label,
86
+ created_at: slot.createdAt || CREATED_AT,
87
+ max_tokens: maxTokens(),
88
+ anthropic_family_tier: slot.family,
89
+ is_family_default: slot.isDefault,
90
+ });
91
+ }
92
+ return entries;
93
+ }
94
+
95
+ router.get("/models", (req, res, next) => {
96
+ const wantsAnthropic =
97
+ req.headers["anthropic-version"] || req.query.format === "anthropic";
98
+ if (!gatewayEnabled() || !wantsAnthropic) return next();
99
+
100
+ try {
101
+ const data = buildGatewayModels();
102
+ if (data.length === 0) return next(); // no tiers configured — fall through
103
+ logger.debug(
104
+ { modelCount: data.length, ua: req.headers["user-agent"] },
105
+ "[ClaudeDesktopGateway] Listed tier models (Anthropic format)"
106
+ );
107
+ res.json({
108
+ data,
109
+ first_id: data[0].id,
110
+ last_id: data[data.length - 1].id,
111
+ has_more: false,
112
+ });
113
+ } catch (error) {
114
+ logger.error(
115
+ { error: error.message },
116
+ "[ClaudeDesktopGateway] Failed to list models"
117
+ );
118
+ next(); // fall through to the OpenAI-format handler rather than 500
119
+ }
120
+ });
121
+
122
+ module.exports = router;
123
+ module.exports._buildGatewayModels = buildGatewayModels; // test hook
package/src/api/health.js CHANGED
@@ -4,7 +4,6 @@ try {
4
4
  } catch {
5
5
  Database = null;
6
6
  }
7
- const { invokeModel } = require("../clients/databricks");
8
7
  const logger = require("../logger");
9
8
  const config = require("../config");
10
9
  const { getHeadroomManager } = require("../headroom");
@@ -0,0 +1,149 @@
1
+ /**
2
+ * MCP broker (ROUTING-NOTES Track B / §1 gap: Lynkr was MCP-client-only).
3
+ *
4
+ * Exposes Lynkr's configured MCP servers to OTHER clients over HTTP, so one
5
+ * Lynkr install can act as the single MCP access point for a team/toolchain
6
+ * instead of every client wiring up (and holding credentials for) every
7
+ * server separately. The credential-injection property falls out of the
8
+ * existing registry design: downstream server env/credentials live in
9
+ * Lynkr's MCP config and never leave this process — callers see only tool
10
+ * names and results.
11
+ *
12
+ * Surface (all JSON):
13
+ * GET /v1/mcp/servers configured servers (id + tool count)
14
+ * GET /v1/mcp/tools aggregated tools/list across servers,
15
+ * namespaced "<server>:<tool>"
16
+ * POST /v1/mcp/tools/call { server, tool, arguments } → tool result
17
+ *
18
+ * Security: OFF by default. Enabling requires BOTH
19
+ * LYNKR_MCP_BROKER_ENABLED=true
20
+ * LYNKR_MCP_BROKER_TOKEN=<secret> (Bearer-checked on every call)
21
+ * Enabled-without-token fails CLOSED (503 + loud log) — an exposed tool
22
+ * surface must never be reachable unauthenticated (§4.4 rule: a live
23
+ * caller's input is never silently trusted). Optional allowlist:
24
+ * LYNKR_MCP_BROKER_SERVERS=id1,id2 (default: all configured)
25
+ *
26
+ * @module api/mcp-broker
27
+ */
28
+
29
+ const express = require('express');
30
+ const crypto = require('crypto');
31
+ const logger = require('../logger');
32
+ const mcp = require('../mcp');
33
+
34
+ const router = express.Router();
35
+
36
+ const CALL_TIMEOUT_MS = Number.parseInt(process.env.LYNKR_MCP_BROKER_CALL_TIMEOUT_MS, 10) || 60_000;
37
+
38
+ function _enabled() {
39
+ return process.env.LYNKR_MCP_BROKER_ENABLED === 'true';
40
+ }
41
+
42
+ function _allowedServers() {
43
+ const raw = process.env.LYNKR_MCP_BROKER_SERVERS;
44
+ if (!raw) return null; // null = all configured servers
45
+ return new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
46
+ }
47
+
48
+ function _timingSafeEqual(a, b) {
49
+ const ha = crypto.createHash('sha256').update(String(a)).digest();
50
+ const hb = crypto.createHash('sha256').update(String(b)).digest();
51
+ return crypto.timingSafeEqual(ha, hb);
52
+ }
53
+
54
+ function brokerAuth(req, res, next) {
55
+ if (!_enabled()) {
56
+ return res.status(404).json({ error: { type: 'not_found', message: 'MCP broker is not enabled' } });
57
+ }
58
+ const token = process.env.LYNKR_MCP_BROKER_TOKEN;
59
+ if (!token) {
60
+ // Fail closed: enabled-but-tokenless must never serve.
61
+ logger.error('[McpBroker] LYNKR_MCP_BROKER_ENABLED=true but LYNKR_MCP_BROKER_TOKEN is unset — refusing to serve. Set a token to activate the broker.');
62
+ return res.status(503).json({
63
+ error: { type: 'broker_misconfigured', message: 'MCP broker enabled without LYNKR_MCP_BROKER_TOKEN; refusing to serve unauthenticated' },
64
+ });
65
+ }
66
+ const auth = req.headers.authorization || '';
67
+ const presented = auth.startsWith('Bearer ') ? auth.slice(7) : null;
68
+ if (!presented || !_timingSafeEqual(presented, token)) {
69
+ return res.status(401).json({ error: { type: 'unauthorized', message: 'Invalid or missing bearer token' } });
70
+ }
71
+ next();
72
+ }
73
+
74
+ function _visibleServers() {
75
+ const allow = _allowedServers();
76
+ return mcp.listServers().filter((s) => !allow || allow.has(s.id));
77
+ }
78
+
79
+ async function _clientFor(serverId) {
80
+ const allow = _allowedServers();
81
+ if (allow && !allow.has(serverId)) {
82
+ const err = new Error(`server "${serverId}" is not allowlisted for the broker`);
83
+ err.status = 403;
84
+ throw err;
85
+ }
86
+ const server = mcp.getServer(serverId);
87
+ if (!server) {
88
+ const err = new Error(`unknown MCP server "${serverId}"`);
89
+ err.status = 404;
90
+ throw err;
91
+ }
92
+ return mcp.ensureClient(serverId);
93
+ }
94
+
95
+ router.get('/v1/mcp/servers', brokerAuth, async (_req, res) => {
96
+ const servers = _visibleServers().map((s) => ({
97
+ id: s.id,
98
+ description: s.description ?? null,
99
+ }));
100
+ res.json({ servers });
101
+ });
102
+
103
+ router.get('/v1/mcp/tools', brokerAuth, async (_req, res) => {
104
+ const results = [];
105
+ const errors = {};
106
+ await Promise.all(_visibleServers().map(async (server) => {
107
+ try {
108
+ const client = await mcp.ensureClient(server.id);
109
+ const listed = await client.request('tools/list', {});
110
+ for (const tool of listed?.tools ?? []) {
111
+ results.push({
112
+ name: `${server.id}:${tool.name}`,
113
+ server: server.id,
114
+ tool: tool.name,
115
+ description: tool.description ?? null,
116
+ input_schema: tool.inputSchema ?? tool.input_schema ?? null,
117
+ });
118
+ }
119
+ } catch (err) {
120
+ // Per-server failure is reported, not hidden — a dead server must not
121
+ // silently vanish from the tool list (§4.3: no invisible degradation).
122
+ errors[server.id] = err.message;
123
+ }
124
+ }));
125
+ res.json({ tools: results, ...(Object.keys(errors).length ? { server_errors: errors } : {}) });
126
+ });
127
+
128
+ router.post('/v1/mcp/tools/call', brokerAuth, async (req, res) => {
129
+ const { server, tool, arguments: args } = req.body || {};
130
+ if (!server || !tool) {
131
+ return res.status(400).json({ error: { type: 'invalid_request', message: 'body must include { server, tool, arguments? }' } });
132
+ }
133
+ try {
134
+ const client = await _clientFor(server);
135
+ const result = await Promise.race([
136
+ client.request('tools/call', { name: tool, arguments: args ?? {} }),
137
+ new Promise((_, reject) =>
138
+ setTimeout(() => reject(Object.assign(new Error('tool call timed out'), { status: 504 })), CALL_TIMEOUT_MS).unref?.()
139
+ ),
140
+ ]);
141
+ res.json({ server, tool, result });
142
+ } catch (err) {
143
+ const status = err.status ?? 502;
144
+ logger.warn({ server, tool, error: err.message }, '[McpBroker] tool call failed');
145
+ res.status(status).json({ error: { type: 'tool_call_failed', message: err.message, server, tool } });
146
+ }
147
+ });
148
+
149
+ module.exports = router;
@@ -21,18 +21,88 @@ function _readContext(req) {
21
21
  };
22
22
  }
23
23
 
24
+ // Default expected-output tokens when the caller didn't set max_tokens —
25
+ // same shape as LiteLLM's TPM floor: the estimate is soft admission control,
26
+ // trued up by the post-response recordSpend(), not a hard ceiling.
27
+ const DEFAULT_OUTPUT_TOKENS_ESTIMATE = 1024;
28
+
29
+ // Cached blended per-1k price across the configured tier models. Routing
30
+ // hasn't picked a model yet at middleware time, so the estimate prices at
31
+ // the average of what this install could plausibly serve. Refreshed every
32
+ // 60s (tier config can hot-reload).
33
+ let _priceCache = { at: 0, inputPer1k: 0, outputPer1k: 0 };
34
+ const PRICE_CACHE_TTL_MS = 60_000;
35
+
36
+ function _blendedTierPricing() {
37
+ const now = Date.now();
38
+ if (now - _priceCache.at < PRICE_CACHE_TTL_MS) return _priceCache;
39
+ try {
40
+ const { getModelTierSelector } = require('../../routing/model-tiers');
41
+ const { getModelRegistrySync } = require('../../routing/model-registry');
42
+ const registry = getModelRegistrySync && getModelRegistrySync();
43
+ const models = getModelTierSelector().getAllConfiguredModels();
44
+ let inputSum = 0;
45
+ let outputSum = 0;
46
+ let priced = 0;
47
+ for (const m of models) {
48
+ const cost = registry?.getCost?.(m.model);
49
+ if (!cost || cost.unknown) continue;
50
+ inputSum += cost.input ?? 0;
51
+ outputSum += cost.output ?? 0;
52
+ priced += 1;
53
+ }
54
+ _priceCache = {
55
+ at: now,
56
+ inputPer1k: priced > 0 ? inputSum / priced : 0,
57
+ outputPer1k: priced > 0 ? outputSum / priced : 0,
58
+ };
59
+ } catch {
60
+ _priceCache = { at: now, inputPer1k: 0, outputPer1k: 0 };
61
+ }
62
+ return _priceCache;
63
+ }
64
+
65
+ /**
66
+ * Pre-flight cost estimate for a request that hasn't been routed yet:
67
+ * estimated input tokens (4-chars≈1-token over system+tools+messages) plus
68
+ * expected output (caller's max_tokens, else a floor), priced at the blended
69
+ * average of the configured tier models. Falls back to a $0.01 nominal gate
70
+ * when nothing is priceable — never LESS strict than the old behavior.
71
+ *
72
+ * @param {object} body — request payload
73
+ * @returns {number} estimated USD cost
74
+ */
75
+ function estimateRequestCost(body) {
76
+ try {
77
+ const { countPayloadTokens } = require('../../utils/tokens');
78
+ const inputTokens = countPayloadTokens(body || {}).total || 0;
79
+ const outputTokens = Math.min(
80
+ typeof body?.max_tokens === 'number' && body.max_tokens > 0
81
+ ? body.max_tokens
82
+ : DEFAULT_OUTPUT_TOKENS_ESTIMATE,
83
+ 32_000,
84
+ );
85
+ const price = _blendedTierPricing();
86
+ const est = (inputTokens / 1000) * price.inputPer1k
87
+ + (outputTokens / 1000) * price.outputPer1k;
88
+ // Floor at the old nominal $0.01 so free/unpriced configs still gate
89
+ // exhausted accounts exactly as before.
90
+ return Math.max(0.01, est);
91
+ } catch {
92
+ return 0.01;
93
+ }
94
+ }
95
+
24
96
  /**
25
- * Express middleware. Estimates request cost via cost-optimizer and rejects
26
- * if the budget is already exceeded. Records spend after the response.
97
+ * Express middleware. Estimates request cost from the payload's token count
98
+ * and blended tier pricing, and rejects if the budget would be exceeded.
99
+ * Actual spend is recorded post-response (estimate → true-up pattern).
27
100
  */
28
101
  function budgetEnforcer(req, res, next) {
29
102
  if (process.env.LYNKR_BUDGET_ENFORCER === 'false') return next();
30
103
  const context = _readContext(req);
31
- // Cheap pre-check at $0; we use the request to record actual spend.
32
- // The actual ceiling check happens with an estimated $0.01 "minimum" so
33
- // exhausted accounts get rejected before we even route.
34
104
  const budget = getHierarchicalBudget();
35
- const check = budget.check(context, 0.01);
105
+ const check = budget.check(context, estimateRequestCost(req.body));
36
106
  if (!check.ok) {
37
107
  logger.warn({ exceeded: check.exceeded }, '[BudgetEnforcer] Budget exceeded');
38
108
  return res.status(429).json({
@@ -57,4 +127,4 @@ function recordSpend(context, amount) {
57
127
  getHierarchicalBudget().record(context, amount);
58
128
  }
59
129
 
60
- module.exports = { budgetEnforcer, recordSpend };
130
+ module.exports = { budgetEnforcer, recordSpend, estimateRequestCost };
@@ -30,6 +30,36 @@ function budgetMiddleware(req, res, next) {
30
30
  });
31
31
  }
32
32
 
33
+ // Token-aware (TPM) rate limiting — off unless LYNKR_TPM_LIMIT is set.
34
+ // Pre-flight gates on the window's actual consumption plus this request's
35
+ // cheap estimate; the true-up lands in the res 'finish' handler below.
36
+ try {
37
+ const { countPayloadTokens } = require('../../utils/tokens');
38
+ const estInput = countPayloadTokens(req.body || {}).total || 0;
39
+ const estOutput = typeof req.body?.max_tokens === 'number' && req.body.max_tokens > 0
40
+ ? req.body.max_tokens
41
+ : 1024;
42
+ const tokenCheck = budgetManager.checkTokenRate(userId, estInput + estOutput);
43
+ if (!tokenCheck.allowed) {
44
+ logger.warn({
45
+ userId,
46
+ limit: tokenCheck.limit,
47
+ current: tokenCheck.current,
48
+ estimated: tokenCheck.estimated,
49
+ }, 'Token rate limit (TPM) exceeded');
50
+ return res.status(429).json({
51
+ error: 'rate_limit_exceeded',
52
+ message: `Token rate limit exceeded: ${tokenCheck.limit} tokens per minute`,
53
+ limit: tokenCheck.limit,
54
+ current: tokenCheck.current,
55
+ resetInMs: tokenCheck.resetInMs,
56
+ retryAfter: Math.ceil(tokenCheck.resetInMs / 1000),
57
+ });
58
+ }
59
+ } catch (err) {
60
+ logger.debug({ err: err.message }, 'TPM check failed — allowing request');
61
+ }
62
+
33
63
  // Check budget
34
64
  const budgetCheck = budgetManager.checkBudget(userId);
35
65
  if (!budgetCheck.allowed) {
@@ -68,14 +98,18 @@ function budgetMiddleware(req, res, next) {
68
98
  try {
69
99
  const usage = res.locals.usage;
70
100
  if (!usage) return;
101
+ const tokensInput = usage.prompt_tokens || usage.input_tokens || 0;
102
+ const tokensOutput = usage.completion_tokens || usage.output_tokens || 0;
71
103
  budgetManager.recordUsage(userId, req.session?.id || null, {
72
- tokensInput: usage.prompt_tokens || usage.input_tokens || 0,
73
- tokensOutput: usage.completion_tokens || usage.output_tokens || 0,
104
+ tokensInput,
105
+ tokensOutput,
74
106
  costUsd: usage.cost_usd || 0,
75
107
  model: usage.model || null,
76
108
  endpoint: req.path,
77
109
  latencyMs: Date.now() - req.budgetInfo.startTime,
78
110
  });
111
+ // TPM true-up with actual consumption (no-op unless LYNKR_TPM_LIMIT set).
112
+ budgetManager.recordTokenUsage(userId, tokensInput + tokensOutput);
79
113
  } catch (err) {
80
114
  logger.warn({ err: err.message }, 'Failed to record usage after response');
81
115
  }
Binary file