lynkr 9.9.1 → 9.11.0
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 +50 -9
- package/bin/cli.js +2 -0
- package/bin/lynkr-init.js +11 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/public/dashboard.html +29 -0
- package/scripts/validate-difficulty-classifier.js +27 -6
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +309 -160
- package/src/clients/databricks.js +215 -71
- package/src/clients/gpt-utils.js +8 -1
- package/src/clients/ollama-utils.js +19 -10
- package/src/clients/prompt-cache-injection.js +93 -30
- package/src/config/index.js +82 -43
- package/src/context/compression.js +27 -8
- package/src/context/distill.js +2 -1
- package/src/dashboard/api.js +5 -1
- package/src/memory/distiller.js +350 -0
- package/src/memory/extractor.js +0 -8
- package/src/memory/format.js +17 -2
- package/src/memory/index.js +10 -0
- package/src/memory/retriever.js +7 -2
- package/src/memory/skills-cache.js +180 -0
- package/src/memory/tencentdb-launcher.js +459 -0
- package/src/memory/wiki.js +171 -0
- package/src/orchestrator/index.js +202 -1237
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +85 -4
- package/src/routing/agentic-detector.js +9 -1
- package/src/routing/cache-economics.js +123 -0
- package/src/routing/cache-switch-cost.js +194 -0
- package/src/routing/calibration.js +8 -1
- package/src/routing/complexity-analyzer.js +7 -1
- package/src/routing/context-compressor.js +170 -0
- package/src/routing/difficulty-classifier.js +161 -17
- package/src/routing/index.js +196 -56
- package/src/routing/intent-score.js +20 -2
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +68 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/routing/telemetry.js +139 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -49
- package/src/tools/code-graph.js +6 -4
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- package/src/tools/workspace.js +0 -204
|
@@ -17,22 +17,29 @@ const path = require("path");
|
|
|
17
17
|
const { classifyDifficulty, _clearCacheForTests } = require("../src/routing/difficulty-classifier");
|
|
18
18
|
|
|
19
19
|
const EVAL_FILE = path.join(__dirname, "../data/difficulty-eval.jsonl");
|
|
20
|
-
const RESULTS_FILE = path.join(__dirname, "../data/difficulty-eval-results.jsonl");
|
|
21
20
|
const TIERS = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
|
|
22
21
|
|
|
23
22
|
async function main() {
|
|
23
|
+
// --file=data/xyz.jsonl runs an alternate eval set (e.g. the follow-up
|
|
24
|
+
// slice with conversation context). Results land next to the input file.
|
|
25
|
+
const fileArg = process.argv.find((a) => a.startsWith("--file="));
|
|
26
|
+
const evalFile = fileArg ? path.resolve(fileArg.slice(7)) : EVAL_FILE;
|
|
27
|
+
const resultsFile = evalFile.replace(/\.jsonl$/, "-results.jsonl");
|
|
28
|
+
|
|
24
29
|
_clearCacheForTests();
|
|
25
|
-
const lines = fs.readFileSync(
|
|
30
|
+
const lines = fs.readFileSync(evalFile, "utf8").split("\n").filter(Boolean);
|
|
26
31
|
const rows = lines.map(l => JSON.parse(l));
|
|
27
|
-
console.log(`Loaded ${rows.length} eval rows`);
|
|
32
|
+
console.log(`Loaded ${rows.length} eval rows from ${path.basename(evalFile)}`);
|
|
28
33
|
|
|
29
34
|
// Persist incrementally so a crash mid-run preserves partial data.
|
|
30
|
-
const resultsFd = fs.openSync(
|
|
35
|
+
const resultsFd = fs.openSync(resultsFile, "w");
|
|
31
36
|
const results = [];
|
|
32
37
|
const t0 = Date.now();
|
|
33
38
|
let done = 0;
|
|
34
39
|
for (const row of rows) {
|
|
35
|
-
|
|
40
|
+
// context is threaded the same way production does (router window loop
|
|
41
|
+
// → scoreIntent → classifyDifficulty); absent for standalone prompts.
|
|
42
|
+
const r = await classifyDifficulty(row.text, { context: row.context });
|
|
36
43
|
const record = {
|
|
37
44
|
...row,
|
|
38
45
|
predicted: r?.tier ?? null,
|
|
@@ -47,7 +54,7 @@ async function main() {
|
|
|
47
54
|
}
|
|
48
55
|
}
|
|
49
56
|
fs.closeSync(resultsFd);
|
|
50
|
-
console.log(`\nDone in ${((Date.now() - t0) / 1000).toFixed(0)}s (results saved to ${
|
|
57
|
+
console.log(`\nDone in ${((Date.now() - t0) / 1000).toFixed(0)}s (results saved to ${resultsFile})`);
|
|
51
58
|
|
|
52
59
|
// Overall + per-tier accuracy
|
|
53
60
|
const perTier = {};
|
|
@@ -108,6 +115,20 @@ async function main() {
|
|
|
108
115
|
console.log(`SIMPLE→REASONING: ${simpleToReasoning}`);
|
|
109
116
|
console.log(`MEDIUM→COMPLEX: ${confusion.MEDIUM?.COMPLEX || 0}`);
|
|
110
117
|
|
|
118
|
+
// Confidence histogram — validates whether the confidence gate in
|
|
119
|
+
// _reconcile carries signal. A degenerate distribution (everything ≥0.9)
|
|
120
|
+
// means confidence is decorative and the band cap is the only real guard.
|
|
121
|
+
const buckets = { "<0.6": 0, "0.6-0.8": 0, "0.8-0.9": 0, "0.9-1.0": 0 };
|
|
122
|
+
for (const r of results) {
|
|
123
|
+
if (r.confidence == null) continue;
|
|
124
|
+
if (r.confidence < 0.6) buckets["<0.6"]++;
|
|
125
|
+
else if (r.confidence < 0.8) buckets["0.6-0.8"]++;
|
|
126
|
+
else if (r.confidence < 0.9) buckets["0.8-0.9"]++;
|
|
127
|
+
else buckets["0.9-1.0"]++;
|
|
128
|
+
}
|
|
129
|
+
console.log(`\n=== Confidence histogram ===`);
|
|
130
|
+
for (const [b, n] of Object.entries(buckets)) console.log(` ${b}: ${n}`);
|
|
131
|
+
|
|
111
132
|
// List misclassifications (cap at 20 per tier)
|
|
112
133
|
console.log(`\n=== Misclassifications (up to 5 per tier) ===`);
|
|
113
134
|
for (const t of TIERS) {
|
|
@@ -401,7 +401,6 @@ router.get("/config", (req, res) => {
|
|
|
401
401
|
fallback_provider: config.modelProvider?.fallbackProvider || null,
|
|
402
402
|
fallback_enabled: config.modelProvider?.fallbackEnabled || false,
|
|
403
403
|
tier_routing_enabled: config.modelTiers?.enabled || false,
|
|
404
|
-
tool_execution_mode: config.toolExecutionMode || "server",
|
|
405
404
|
configured_providers: providers.map(p => p.name),
|
|
406
405
|
memory_enabled: config.memory?.enabled || false,
|
|
407
406
|
smart_tool_selection: config.smartToolSelection?.enabled || false
|
package/src/api/router.js
CHANGED
|
@@ -160,6 +160,47 @@ async function pickTierByIntent(body) {
|
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// Condense a message's text for classifier context: text blocks only,
|
|
164
|
+
// reminders stripped, whitespace collapsed, hard-capped. Best-effort.
|
|
165
|
+
const _condenseText = (msg, max = 140) => {
|
|
166
|
+
if (!msg) return '';
|
|
167
|
+
let text = '';
|
|
168
|
+
if (typeof msg.content === 'string') text = msg.content;
|
|
169
|
+
else if (Array.isArray(msg.content)) {
|
|
170
|
+
text = msg.content
|
|
171
|
+
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
|
172
|
+
.map((b) => b.text)
|
|
173
|
+
.join(' ');
|
|
174
|
+
}
|
|
175
|
+
return stripReminders(text).replace(/\s+/g, ' ').trim().slice(0, max);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Conversation context for the difficulty classifier: the nearest prior
|
|
179
|
+
// assistant reply and the user turn before it. A bare follow-up ("Who
|
|
180
|
+
// kills him ?") is unclassifiable in isolation — the single-message
|
|
181
|
+
// intentPayload below is context-blind BY DESIGN (envelope invariance),
|
|
182
|
+
// so the context travels as a separate condensed string, never as extra
|
|
183
|
+
// messages that would pollute the anchor score.
|
|
184
|
+
const _contextBefore = (msg) => {
|
|
185
|
+
const idx = messages.indexOf(msg);
|
|
186
|
+
if (idx <= 0) return null;
|
|
187
|
+
let assistantText = '';
|
|
188
|
+
let priorUserText = '';
|
|
189
|
+
for (let j = idx - 1; j >= 0; j--) {
|
|
190
|
+
const m = messages[j];
|
|
191
|
+
if (!assistantText && m?.role === 'assistant') assistantText = _condenseText(m);
|
|
192
|
+
else if (assistantText && m?.role === 'user') {
|
|
193
|
+
priorUserText = _condenseText(m);
|
|
194
|
+
if (priorUserText) break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!assistantText && !priorUserText) return null;
|
|
198
|
+
const parts = [];
|
|
199
|
+
if (priorUserText) parts.push(`user asked: """${priorUserText}"""`);
|
|
200
|
+
if (assistantText) parts.push(`assistant replied about: """${assistantText}"""`);
|
|
201
|
+
return parts.join(' → ');
|
|
202
|
+
};
|
|
203
|
+
|
|
163
204
|
// Per-message scoring intentionally omits _sessionId so session affinity
|
|
164
205
|
// isn't polluted by multiple intent-only routing calls per request. The
|
|
165
206
|
// FINAL provider pick (downstream of this function) uses the full body
|
|
@@ -179,6 +220,9 @@ async function pickTierByIntent(body) {
|
|
|
179
220
|
// agentic detector inside determineProviderSmart can subtract the
|
|
180
221
|
// harness's baseline tools during intent scoring too.
|
|
181
222
|
_clientProfile: body?._clientProfile || null,
|
|
223
|
+
// Classifier context (see _contextBefore). Underscored — stripped at
|
|
224
|
+
// the outbound chokepoint like every internal field.
|
|
225
|
+
_conversationContext: _contextBefore(windowUserMsgs[i]),
|
|
182
226
|
};
|
|
183
227
|
try {
|
|
184
228
|
const decision = await determineProviderSmart(intentPayload, {
|
|
@@ -365,19 +409,14 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
365
409
|
if (contentType.includes("text/event-stream") && upstreamResp.body) {
|
|
366
410
|
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
367
411
|
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: badgeText } })}\n\n`,
|
|
377
|
-
`event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: 0 })}\n\n`,
|
|
378
|
-
].join('');
|
|
379
|
-
res.write(synthetic);
|
|
380
|
-
}
|
|
412
|
+
// Badge injection is frame-aware: the badge block must land AFTER the
|
|
413
|
+
// upstream's message_start, never before it — Anthropic's SSE contract
|
|
414
|
+
// opens with message_start and Claude Code's incremental parser rejects
|
|
415
|
+
// streams that lead with content_block events (it retries with
|
|
416
|
+
// "empty or malformed response"). Same processor the native-stream
|
|
417
|
+
// passthrough uses.
|
|
418
|
+
const { _createFrameProcessor } = require("../orchestrator/passthrough-stream");
|
|
419
|
+
const badgeInjector = badgeText ? _createFrameProcessor({ badgeText }) : null;
|
|
381
420
|
|
|
382
421
|
const reader = upstreamResp.body.getReader();
|
|
383
422
|
const decoder = new TextDecoder();
|
|
@@ -385,6 +424,15 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
385
424
|
while (true) {
|
|
386
425
|
const { value, done } = await readWithIdleTimeout(reader, "oauth-passthrough");
|
|
387
426
|
if (done) break;
|
|
427
|
+
if (badgeInjector) {
|
|
428
|
+
const outText = badgeInjector(decoder.decode(value, { stream: true }));
|
|
429
|
+
if (outText) res.write(outText);
|
|
430
|
+
if (typeof res.flush === "function") res.flush();
|
|
431
|
+
if (responseTextForObservability.length < 65536) {
|
|
432
|
+
responseTextForObservability += outText;
|
|
433
|
+
}
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
388
436
|
const buf = Buffer.from(value);
|
|
389
437
|
res.write(buf);
|
|
390
438
|
if (typeof res.flush === "function") res.flush();
|
|
@@ -393,6 +441,10 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
393
441
|
responseTextForObservability += decoder.decode(value, { stream: true });
|
|
394
442
|
}
|
|
395
443
|
}
|
|
444
|
+
if (badgeInjector) {
|
|
445
|
+
const flushed = badgeInjector(decoder.decode(), true);
|
|
446
|
+
if (flushed) res.write(flushed);
|
|
447
|
+
}
|
|
396
448
|
} catch (err) {
|
|
397
449
|
logger.warn({ err: err.message }, "OAuth passthrough stream stalled — ending response");
|
|
398
450
|
try { reader.cancel(); } catch { /* already dead */ }
|
|
@@ -466,7 +518,10 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
466
518
|
// Tier router telemetry (so it shows up in dashboards / routing stats)
|
|
467
519
|
const tlm = require("../routing/telemetry");
|
|
468
520
|
tlm.record({
|
|
469
|
-
request_id
|
|
521
|
+
// request_id is NOT NULL in the telemetry schema — a null id silently
|
|
522
|
+
// drops the whole row (subscription-passthrough serves were invisible
|
|
523
|
+
// in routing_telemetry whenever the client sent no request-id header).
|
|
524
|
+
request_id: req.headers["request-id"] || req.headers["x-request-id"] || require("crypto").randomUUID(),
|
|
470
525
|
session_id: req.body?._sessionId || req.sessionId || null,
|
|
471
526
|
timestamp: startedAt,
|
|
472
527
|
tier: tier.tier || "COMPLEX",
|
|
@@ -512,6 +567,137 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
512
567
|
});
|
|
513
568
|
}
|
|
514
569
|
|
|
570
|
+
/**
|
|
571
|
+
* One-line routing badge for LIVE streams (native passthrough + stream
|
|
572
|
+
* transform), built from the tier decision. The buffered paths build theirs
|
|
573
|
+
* from the interaction block; that block doesn't exist yet at the stream
|
|
574
|
+
* hooks, and the tier object carries the same fields.
|
|
575
|
+
*/
|
|
576
|
+
function _buildStreamBadge(tier) {
|
|
577
|
+
if (!config.routing?.visibleInteraction || !tier) return null;
|
|
578
|
+
const pin = typeof tier._pinScore === "number" ? ` · pin@${tier._pinScore}` : "";
|
|
579
|
+
return `*[Lynkr] ${tier.tier || "—"} → ${tier.model || "—"} (${tier.provider || "—"}) · score ${tier.score ?? "—"}${pin}*\n\n`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Terminal upstream errors carry no content array — without this, the synth
|
|
584
|
+
* SSE writers render an empty message and the turn goes silent in the TUI
|
|
585
|
+
* (only the routing badge shows). Returns visible-text content blocks for an
|
|
586
|
+
* error result, using the fallback chain's failure trail when the provider
|
|
587
|
+
* layer recorded one (so "moonshot: quota exhausted" isn't hidden behind the
|
|
588
|
+
* last rung's unrelated error), or null for normal responses.
|
|
589
|
+
*/
|
|
590
|
+
function _buildFailureBlocks(result, msg) {
|
|
591
|
+
if (result.terminationReason !== "api_error" && Array.isArray(msg?.content)) return null;
|
|
592
|
+
const trail = Array.isArray(msg?._lynkr_failure_trail) ? msg._lynkr_failure_trail : null;
|
|
593
|
+
const lastErr = msg?.error?.message || msg?.message
|
|
594
|
+
|| (typeof msg === "string" ? msg : JSON.stringify(msg ?? {}).slice(0, 200));
|
|
595
|
+
const trailText = (trail || [String(lastErr)]).map((t) => ` · ${t}`).join("\n");
|
|
596
|
+
const quotaHit = result.status === 429 || /rate.?limit|quota|TPD|TPM/i.test(trailText);
|
|
597
|
+
return [{
|
|
598
|
+
type: "text",
|
|
599
|
+
text:
|
|
600
|
+
`⚠ [Lynkr] ${quotaHit ? "Provider quota exhausted — no fallback could serve this request" : "No provider could serve this request"} (HTTP ${result.status ?? "?"}).\n\n` +
|
|
601
|
+
`${trailText}\n` +
|
|
602
|
+
(quotaHit ? `\nAll fallback candidates failed after the quota hit. Wait for the quota window to reset or repoint the affected tier in .env.\n` : ""),
|
|
603
|
+
}];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Scan the entire payload for tool_use or tool_result content blocks.
|
|
608
|
+
* Unlike session-affinity.payloadHasToolHistory (which checks only the last
|
|
609
|
+
* message, targeted at mid-exchange detection), this scans every message so
|
|
610
|
+
* a side-channel replay of a tool-active conversation is detected regardless
|
|
611
|
+
* of which turn it lands on. Best-effort — returns false on malformed input.
|
|
612
|
+
*/
|
|
613
|
+
function _payloadCarriesToolBlocks(body) {
|
|
614
|
+
const messages = body?.messages;
|
|
615
|
+
if (!Array.isArray(messages)) return false;
|
|
616
|
+
for (const m of messages) {
|
|
617
|
+
if (!Array.isArray(m?.content)) continue;
|
|
618
|
+
for (const b of m.content) {
|
|
619
|
+
if (b?.type === 'tool_use' || b?.type === 'tool_result') return true;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Forward a provider SSE stream (web ReadableStream) to the client response
|
|
627
|
+
* line by line. Assumes SSE headers were already set and flushed. Mid-stream
|
|
628
|
+
* failures surface as a parseable Anthropic error event, never a silent hang.
|
|
629
|
+
*/
|
|
630
|
+
async function forwardProviderStream(res, stream) {
|
|
631
|
+
const reader = stream.getReader();
|
|
632
|
+
const decoder = new TextDecoder();
|
|
633
|
+
const bufferChunks = []; // Use array to avoid string concatenation overhead
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
while (true) {
|
|
637
|
+
const { done, value } = await readWithIdleTimeout(reader, "provider-stream");
|
|
638
|
+
if (done) break;
|
|
639
|
+
|
|
640
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
641
|
+
bufferChunks.push(chunk);
|
|
642
|
+
|
|
643
|
+
const buffer = bufferChunks.join('');
|
|
644
|
+
const lines = buffer.split('\n');
|
|
645
|
+
|
|
646
|
+
// Keep last incomplete line in buffer chunks
|
|
647
|
+
const remaining = lines.pop() || '';
|
|
648
|
+
bufferChunks.length = 0;
|
|
649
|
+
if (remaining) bufferChunks.push(remaining);
|
|
650
|
+
|
|
651
|
+
for (const line of lines) {
|
|
652
|
+
if (line.trim()) {
|
|
653
|
+
res.write(line + '\n');
|
|
654
|
+
} else {
|
|
655
|
+
// Blank lines are SSE event delimiters — they must survive.
|
|
656
|
+
res.write('\n');
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (typeof res.flush === 'function') {
|
|
661
|
+
res.flush();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const remaining = bufferChunks.join('');
|
|
666
|
+
if (remaining.trim()) {
|
|
667
|
+
res.write(remaining + '\n');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
metrics.recordResponse(200);
|
|
671
|
+
res.end();
|
|
672
|
+
} catch (streamError) {
|
|
673
|
+
logger.error({ error: streamError }, "Error streaming response");
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
await reader.cancel();
|
|
677
|
+
} catch (cancelError) {
|
|
678
|
+
logger.debug({ error: cancelError }, "Failed to cancel stream");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (!res.headersSent) {
|
|
682
|
+
res.status(500).json({ error: "Streaming error" });
|
|
683
|
+
} else {
|
|
684
|
+
// Mid-stream failure: emit a parseable Anthropic error event so
|
|
685
|
+
// the client fails fast and retries, instead of seeing a
|
|
686
|
+
// truncated stream it may wait on.
|
|
687
|
+
try { res.write(SSE_STALL_EVENT); } catch { /* client gone */ }
|
|
688
|
+
res.end();
|
|
689
|
+
}
|
|
690
|
+
} finally {
|
|
691
|
+
// CRITICAL: Always release lock
|
|
692
|
+
try {
|
|
693
|
+
reader.releaseLock();
|
|
694
|
+
} catch (releaseError) {
|
|
695
|
+
// Lock may already be released, ignore
|
|
696
|
+
logger.debug({ error: releaseError }, "Stream lock already released");
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
515
701
|
/**
|
|
516
702
|
* Extract the final assembled Anthropic message from a captured SSE stream.
|
|
517
703
|
* Looks at message_start (for id/model), content_block_delta (for text),
|
|
@@ -969,7 +1155,48 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
969
1155
|
// (harness UA / tool fingerprint) before treating tool-less traffic as
|
|
970
1156
|
// side traffic; a suggestion-mode tag is harness evidence by itself.
|
|
971
1157
|
const isKnownHarness = !!req.body?._clientProfile;
|
|
1158
|
+
// Signal 1 — message-count regression. Real turns grow the transcript
|
|
1159
|
+
// monotonically; a harness replay (title-gen, recap, summary) truncates
|
|
1160
|
+
// the history down to the wrapper prompt. If this payload has fewer
|
|
1161
|
+
// messages than the session's pinned baseline, it cannot be a genuine
|
|
1162
|
+
// follow-up. Definitive — main conversations never shrink.
|
|
1163
|
+
//
|
|
1164
|
+
// Only trust the pin's counts when Lynkr is ACTUALLY serving from it
|
|
1165
|
+
// this turn. When pinCheck.serve === false the pin may be a stale
|
|
1166
|
+
// artifact from an earlier conversation with the same session
|
|
1167
|
+
// fingerprint (see the `new_conversation` and `opener_conversation`
|
|
1168
|
+
// guards in session-affinity.js) — in that case msg counts and
|
|
1169
|
+
// hasToolHistory are meaningless for the current session and must not
|
|
1170
|
+
// fire the side-channel signals.
|
|
1171
|
+
const _pinAuthoritative = pinCheck?.serve === true;
|
|
1172
|
+
const _currentMsgCount = Array.isArray(req.body?.messages) ? req.body.messages.length : 0;
|
|
1173
|
+
const _pinnedMsgCount = pinCheck?.pin?.messageCount ?? 0;
|
|
1174
|
+
const isMsgCountRegression = _pinAuthoritative
|
|
1175
|
+
&& _pinnedMsgCount > 2
|
|
1176
|
+
&& _currentMsgCount < _pinnedMsgCount;
|
|
1177
|
+
// Signal 2 — tool-history absence in a tool-active session. Once a
|
|
1178
|
+
// session has accumulated tool_use blocks, the main flow always carries
|
|
1179
|
+
// them forward. A payload with zero tool blocks in an active tool
|
|
1180
|
+
// session is a side channel by construction.
|
|
1181
|
+
const _pinHadTools = !!pinCheck?.pin?.hasToolHistory;
|
|
1182
|
+
const isBareRequest = _pinAuthoritative
|
|
1183
|
+
&& _pinHadTools
|
|
1184
|
+
&& !_payloadCarriesToolBlocks(req.body);
|
|
1185
|
+
// Signal 3 — first-user-message fingerprint drift. Handles the window
|
|
1186
|
+
// before signals 1 & 2 have data (no pin yet, no tool blocks yet). See
|
|
1187
|
+
// side-channel-detector.js — the detector caches the first observed
|
|
1188
|
+
// messages[0] for each session and flags payloads whose first message
|
|
1189
|
+
// no longer matches (title-gen wraps the original in <session>...</session>).
|
|
1190
|
+
let isFingerprintDrift = false;
|
|
1191
|
+
try {
|
|
1192
|
+
const sideChannelDetector = require('../routing/side-channel-detector');
|
|
1193
|
+
const sessionKey = pinCheck?.sessionId || req.body?.metadata?.user_id || null;
|
|
1194
|
+
isFingerprintDrift = sideChannelDetector.check(sessionKey, req.body?.messages);
|
|
1195
|
+
} catch { /* detector is best-effort */ }
|
|
972
1196
|
const isSideRequest = isSuggestionMode
|
|
1197
|
+
|| isMsgCountRegression
|
|
1198
|
+
|| isBareRequest
|
|
1199
|
+
|| isFingerprintDrift
|
|
973
1200
|
|| ((!Array.isArray(req.body?.tools) || req.body.tools.length === 0) && isKnownHarness);
|
|
974
1201
|
// Side requests short-circuit to the static SIMPLE tier: no pin read
|
|
975
1202
|
// (a COMPLEX-pinned conversation would burn expensive tokens on
|
|
@@ -980,12 +1207,23 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
980
1207
|
if (isSideRequest) {
|
|
981
1208
|
try {
|
|
982
1209
|
const sel = getModelTierSelector().selectModel('SIMPLE', null);
|
|
1210
|
+
const _sideMethod = isSuggestionMode ? 'side_request_suggestion'
|
|
1211
|
+
: isMsgCountRegression ? 'side_request_msg_regression'
|
|
1212
|
+
: isBareRequest ? 'side_request_bare'
|
|
1213
|
+
: isFingerprintDrift ? 'side_request_fingerprint_drift'
|
|
1214
|
+
: 'side_request';
|
|
1215
|
+
logger.debug({
|
|
1216
|
+
method: _sideMethod,
|
|
1217
|
+
currentMsgCount: _currentMsgCount,
|
|
1218
|
+
pinnedMsgCount: _pinnedMsgCount,
|
|
1219
|
+
pinHadTools: _pinHadTools,
|
|
1220
|
+
}, '[Routing] Side-channel request detected');
|
|
983
1221
|
sideTier = {
|
|
984
1222
|
tier: 'SIMPLE',
|
|
985
1223
|
provider: sel.provider,
|
|
986
1224
|
model: sel.model || null,
|
|
987
1225
|
score: null,
|
|
988
|
-
method:
|
|
1226
|
+
method: _sideMethod,
|
|
989
1227
|
reason: 'harness_side_request',
|
|
990
1228
|
base_tier: null,
|
|
991
1229
|
escalation_source: null,
|
|
@@ -1132,6 +1370,30 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1132
1370
|
return handleOauthPassthrough(req, res, { tier });
|
|
1133
1371
|
}
|
|
1134
1372
|
|
|
1373
|
+
// Phase 2a — native-format streaming passthrough. Client speaks
|
|
1374
|
+
// Anthropic, upstream speaks Anthropic, request wants a stream: skip the
|
|
1375
|
+
// buffered orchestrator and pipe upstream SSE bytes straight through.
|
|
1376
|
+
// Falls back to the buffered path below when the upstream fails before
|
|
1377
|
+
// the first byte. Kill switch: LYNKR_NATIVE_PASSTHROUGH=false.
|
|
1378
|
+
{
|
|
1379
|
+
const nativeStream = require("../orchestrator/passthrough-stream");
|
|
1380
|
+
if (nativeStream.canPassthrough(req.body, tier)) {
|
|
1381
|
+
metrics.recordStreamingStart();
|
|
1382
|
+
const handled = await nativeStream.handleNativeStream(req, res, {
|
|
1383
|
+
tier,
|
|
1384
|
+
badgeText: _buildStreamBadge(tier),
|
|
1385
|
+
});
|
|
1386
|
+
if (handled) {
|
|
1387
|
+
metrics.recordResponse(200);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
logger.info({
|
|
1391
|
+
tier: tier.tier,
|
|
1392
|
+
provider: tier.provider,
|
|
1393
|
+
}, "[NativeStream] Passthrough declined before first byte — using buffered path");
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1135
1397
|
// All other cases (subscription→non-Anthropic, payg, oauth): pin the
|
|
1136
1398
|
// window-scored tier so the orchestrator's internal tier router can't
|
|
1137
1399
|
// override it with a full-body re-score. Badge/headers downstream show
|
|
@@ -1334,78 +1596,14 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1334
1596
|
maxSteps: req.body?.max_steps,
|
|
1335
1597
|
maxDurationMs: req.body?.max_duration_ms,
|
|
1336
1598
|
tenantPolicy: res.locals?.tenantPolicy || null,
|
|
1599
|
+
clientWantsStream: wantsStream,
|
|
1600
|
+
streamBadgeText: _buildStreamBadge(req._intentTier || null),
|
|
1337
1601
|
},
|
|
1338
1602
|
});
|
|
1339
1603
|
|
|
1340
1604
|
if (result.stream) {
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
const decoder = new TextDecoder();
|
|
1344
|
-
const bufferChunks = []; // Use array to avoid string concatenation overhead
|
|
1345
|
-
|
|
1346
|
-
try {
|
|
1347
|
-
while (true) {
|
|
1348
|
-
const { done, value } = await readWithIdleTimeout(reader, "provider-stream");
|
|
1349
|
-
if (done) break;
|
|
1350
|
-
|
|
1351
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
1352
|
-
bufferChunks.push(chunk);
|
|
1353
|
-
|
|
1354
|
-
const buffer = bufferChunks.join('');
|
|
1355
|
-
const lines = buffer.split('\n');
|
|
1356
|
-
|
|
1357
|
-
// Keep last incomplete line in buffer chunks
|
|
1358
|
-
const remaining = lines.pop() || '';
|
|
1359
|
-
bufferChunks.length = 0;
|
|
1360
|
-
if (remaining) bufferChunks.push(remaining);
|
|
1361
|
-
|
|
1362
|
-
for (const line of lines) {
|
|
1363
|
-
if (line.trim()) {
|
|
1364
|
-
res.write(line + '\n');
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
if (typeof res.flush === 'function') {
|
|
1369
|
-
res.flush();
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
const remaining = bufferChunks.join('');
|
|
1374
|
-
if (remaining.trim()) {
|
|
1375
|
-
res.write(remaining + '\n');
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
metrics.recordResponse(200);
|
|
1379
|
-
res.end();
|
|
1380
|
-
return;
|
|
1381
|
-
} catch (streamError) {
|
|
1382
|
-
logger.error({ error: streamError }, "Error streaming response");
|
|
1383
|
-
|
|
1384
|
-
try {
|
|
1385
|
-
await reader.cancel();
|
|
1386
|
-
} catch (cancelError) {
|
|
1387
|
-
logger.debug({ error: cancelError }, "Failed to cancel stream");
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
if (!res.headersSent) {
|
|
1391
|
-
res.status(500).json({ error: "Streaming error" });
|
|
1392
|
-
} else {
|
|
1393
|
-
// Mid-stream failure: emit a parseable Anthropic error event so
|
|
1394
|
-
// the client fails fast and retries, instead of seeing a
|
|
1395
|
-
// truncated stream it may wait on.
|
|
1396
|
-
try { res.write(SSE_STALL_EVENT); } catch { /* client gone */ }
|
|
1397
|
-
res.end();
|
|
1398
|
-
}
|
|
1399
|
-
return;
|
|
1400
|
-
} finally {
|
|
1401
|
-
// CRITICAL: Always release lock
|
|
1402
|
-
try {
|
|
1403
|
-
reader.releaseLock();
|
|
1404
|
-
} catch (releaseError) {
|
|
1405
|
-
// Lock may already be released, ignore
|
|
1406
|
-
logger.debug({ error: releaseError }, "Stream lock already released");
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1605
|
+
await forwardProviderStream(res, result.stream);
|
|
1606
|
+
return;
|
|
1409
1607
|
}
|
|
1410
1608
|
|
|
1411
1609
|
if (!result || !result.body) {
|
|
@@ -1432,17 +1630,13 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1432
1630
|
}
|
|
1433
1631
|
})}\n\n`);
|
|
1434
1632
|
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
const
|
|
1441
|
-
|
|
1442
|
-
? (msg.content || []).slice()
|
|
1443
|
-
: (msg.content || []).filter(b =>
|
|
1444
|
-
!(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
|
|
1445
|
-
);
|
|
1633
|
+
// The client owns all its tools — every tool_use block passes through
|
|
1634
|
+
// untouched (stripping any would emit a malformed turn: stop_reason
|
|
1635
|
+
// tool_use with no tool_use block).
|
|
1636
|
+
let contentBlocks = (msg.content || []).slice();
|
|
1637
|
+
|
|
1638
|
+
const failureBlocks = _buildFailureBlocks(result, msg);
|
|
1639
|
+
if (failureBlocks) contentBlocks = failureBlocks;
|
|
1446
1640
|
|
|
1447
1641
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1448
1642
|
// users can see which tier/provider/model handled the request inside
|
|
@@ -1584,6 +1778,8 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1584
1778
|
maxSteps: req.body?.max_steps,
|
|
1585
1779
|
maxDurationMs: req.body?.max_duration_ms,
|
|
1586
1780
|
tenantPolicy: res.locals?.tenantPolicy || null,
|
|
1781
|
+
clientWantsStream: wantsStream,
|
|
1782
|
+
streamBadgeText: _buildStreamBadge(req._intentTier || null),
|
|
1587
1783
|
},
|
|
1588
1784
|
});
|
|
1589
1785
|
timer.mark("processMessage");
|
|
@@ -1596,11 +1792,19 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1596
1792
|
"Content-Type": "text/event-stream",
|
|
1597
1793
|
"Cache-Control": "no-cache",
|
|
1598
1794
|
Connection: "keep-alive",
|
|
1795
|
+
...routingHeaders,
|
|
1599
1796
|
});
|
|
1600
1797
|
if (typeof res.flushHeaders === "function") {
|
|
1601
1798
|
res.flushHeaders();
|
|
1602
1799
|
}
|
|
1603
1800
|
|
|
1801
|
+
// Phase 2b: the orchestrator returned a live (already Anthropic-shaped)
|
|
1802
|
+
// stream instead of a buffered body — forward it as-is.
|
|
1803
|
+
if (result && result.stream) {
|
|
1804
|
+
await forwardProviderStream(res, result.stream);
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1604
1808
|
if (!result || !result.body) {
|
|
1605
1809
|
res.write(`event: error\n`);
|
|
1606
1810
|
res.write(`data: ${JSON.stringify({ type: "error", error: { message: "Empty response from provider" } })}\n\n`);
|
|
@@ -1625,17 +1829,13 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1625
1829
|
}
|
|
1626
1830
|
})}\n\n`);
|
|
1627
1831
|
|
|
1628
|
-
//
|
|
1629
|
-
//
|
|
1630
|
-
//
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
const
|
|
1634
|
-
|
|
1635
|
-
? (msg.content || []).slice()
|
|
1636
|
-
: (msg.content || []).filter(b =>
|
|
1637
|
-
!(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
|
|
1638
|
-
);
|
|
1832
|
+
// The client owns all its tools — every tool_use block passes through
|
|
1833
|
+
// untouched (stripping any would emit a malformed turn: stop_reason
|
|
1834
|
+
// tool_use with no tool_use block).
|
|
1835
|
+
let contentBlocks = (msg.content || []).slice();
|
|
1836
|
+
|
|
1837
|
+
const failureBlocks = _buildFailureBlocks(result, msg);
|
|
1838
|
+
if (failureBlocks) contentBlocks = failureBlocks;
|
|
1639
1839
|
|
|
1640
1840
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1641
1841
|
// users can see which tier/provider/model handled the request inside
|
|
@@ -1799,67 +1999,16 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1799
1999
|
}
|
|
1800
2000
|
|
|
1801
2001
|
metrics.recordResponse(result.status);
|
|
2002
|
+
// finalBody is usually a JSON string; Express defaults string bodies to
|
|
2003
|
+
// text/html, which Claude Code rejects as a malformed 200. Only default —
|
|
2004
|
+
// an upstream content-type copied from result.headers above still wins.
|
|
2005
|
+
if (!res.getHeader("Content-Type")) res.type("application/json");
|
|
1802
2006
|
res.status(result.status).send(finalBody);
|
|
1803
2007
|
} catch (error) {
|
|
1804
2008
|
next(error);
|
|
1805
2009
|
}
|
|
1806
2010
|
});
|
|
1807
2011
|
|
|
1808
|
-
// List available agents (must come before parameterized routes)
|
|
1809
|
-
router.get("/v1/agents", (req, res) => {
|
|
1810
|
-
try {
|
|
1811
|
-
const { listAgents } = require("../agents");
|
|
1812
|
-
const agents = listAgents();
|
|
1813
|
-
res.json({ agents });
|
|
1814
|
-
} catch (error) {
|
|
1815
|
-
res.status(500).json({ error: error.message });
|
|
1816
|
-
}
|
|
1817
|
-
});
|
|
1818
|
-
|
|
1819
|
-
// Agent stats endpoint (specific path before parameterized)
|
|
1820
|
-
router.get("/v1/agents/stats", (req, res) => {
|
|
1821
|
-
try {
|
|
1822
|
-
const { getAgentStats } = require("../agents");
|
|
1823
|
-
const stats = getAgentStats();
|
|
1824
|
-
res.json({ stats });
|
|
1825
|
-
} catch (error) {
|
|
1826
|
-
res.status(500).json({ error: error.message });
|
|
1827
|
-
}
|
|
1828
|
-
});
|
|
1829
|
-
|
|
1830
|
-
// Read agent transcript (specific path with param before catch-all)
|
|
1831
|
-
router.get("/v1/agents/:agentId/transcript", (req, res) => {
|
|
1832
|
-
try {
|
|
1833
|
-
const ContextManager = require("../agents/context-manager");
|
|
1834
|
-
const cm = new ContextManager();
|
|
1835
|
-
const transcript = cm.readTranscript(req.params.agentId);
|
|
1836
|
-
|
|
1837
|
-
if (!transcript) {
|
|
1838
|
-
return res.status(404).json({ error: "Transcript not found" });
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
res.json({ transcript });
|
|
1842
|
-
} catch (error) {
|
|
1843
|
-
res.status(500).json({ error: error.message });
|
|
1844
|
-
}
|
|
1845
|
-
});
|
|
1846
|
-
|
|
1847
|
-
// Agent execution details (parameterized - must come last)
|
|
1848
|
-
router.get("/v1/agents/:executionId", (req, res) => {
|
|
1849
|
-
try {
|
|
1850
|
-
const { getAgentExecution } = require("../agents");
|
|
1851
|
-
const details = getAgentExecution(req.params.executionId);
|
|
1852
|
-
|
|
1853
|
-
if (!details) {
|
|
1854
|
-
return res.status(404).json({ error: "Execution not found" });
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
res.json(details);
|
|
1858
|
-
} catch (error) {
|
|
1859
|
-
res.status(500).json({ error: error.message });
|
|
1860
|
-
}
|
|
1861
|
-
});
|
|
1862
|
-
|
|
1863
2012
|
// Token usage statistics for a session
|
|
1864
2013
|
router.get("/api/sessions/:sessionId/tokens", (req, res) => {
|
|
1865
2014
|
try {
|