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.
- 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 +16 -22
- 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/health.js +0 -1
- 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/middleware/session.js +0 -0
- package/src/api/openai-router.js +210 -15
- package/src/api/providers-handler.js +16 -1
- package/src/api/router.js +73 -106
- 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/cache/prompt.js +0 -7
- package/src/clients/bedrock-utils.js +2 -1
- package/src/clients/codex-process.js +1 -1
- package/src/clients/codex-utils.js +0 -2
- package/src/clients/databricks.js +337 -46
- package/src/clients/gpt-utils.js +84 -8
- package/src/clients/health-probe.js +157 -0
- package/src/clients/openai-format.js +73 -23
- package/src/clients/provider-capabilities.js +27 -2
- package/src/clients/resilience.js +0 -1
- package/src/clients/responses-format.js +0 -56
- package/src/clients/routing.js +1 -1
- package/src/config/index.js +13 -2
- package/src/context/budget.js +1 -1
- package/src/context/compression.js +127 -19
- package/src/context/tool-result-compressor.js +3 -5
- package/src/dashboard/api.js +1 -1
- package/src/logger/oversized-error-stream.js +1 -1
- package/src/memory/extractor.js +3 -3
- package/src/memory/search.js +2 -2
- package/src/memory/store.js +9 -24
- package/src/memory/tools.js +14 -0
- package/src/observability/metrics.js +52 -3
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +216 -212
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +130 -13
- package/src/policy/safe-commands.js +0 -1
- package/src/routing/bandit.js +39 -0
- package/src/routing/calibration.js +21 -1
- 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/client-profiles.js +26 -0
- package/src/routing/complexity-analyzer.js +5 -8
- package/src/routing/confidence-scorer.js +2 -2
- package/src/routing/cost-optimizer.js +1 -2
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +38 -1
- package/src/routing/embedding-cache.js +0 -1
- package/src/routing/index.js +202 -86
- package/src/routing/intent-score.js +0 -1
- package/src/routing/knn-router.js +27 -5
- package/src/routing/latency-tracker.js +1 -1
- 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/regret-estimator.js +1 -1
- 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/stores/file-store.js +5 -1
- package/src/tools/web-search-exec.js +182 -0
- package/src/training/trajectory-compressor.js +1 -1
- package/src/workers/pool.js +1 -1
- package/src/workers/worker.js +1 -1
- package/scripts/convert-routellm.py +0 -105
- package/src/diff/comments.js +0 -153
- 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
|
@@ -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
|
|
26
|
-
*
|
|
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,
|
|
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
|
|
73
|
-
tokensOutput
|
|
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
|