lynkr 9.9.0 → 9.10.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 +92 -23
- package/bin/cli.js +13 -7
- package/bin/lynkr-init.js +34 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/scripts/build-eval-set.js +256 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/validate-difficulty-classifier.js +144 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +292 -160
- package/src/clients/databricks.js +95 -6
- package/src/config/index.js +3 -43
- package/src/context/tool-result-compressor.js +883 -40
- package/src/orchestrator/index.js +117 -1235
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/routing/affinity-store.js +17 -3
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/complexity-analyzer.js +40 -4
- package/src/routing/difficulty-classifier.js +261 -0
- package/src/routing/index.js +70 -7
- package/src/routing/intent-score.js +132 -12
- package/src/routing/session-affinity.js +8 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/server.js +20 -46
- 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
package/src/api/router.js
CHANGED
|
@@ -53,6 +53,8 @@ const rateLimiter = createRateLimiter();
|
|
|
53
53
|
* - force_local / force_cloud regex shortcuts
|
|
54
54
|
* - risk classifier (high-risk → forced COMPLEX)
|
|
55
55
|
* - complexity scoring (weighted heuristic)
|
|
56
|
+
* - thinking-budget trigger (≥10k tokens → REASONING, Claude Code ultrathink)
|
|
57
|
+
* - force patterns (ultrathink/prove → REASONING; architecture review → COMPLEX; hi → SIMPLE)
|
|
56
58
|
* - agentic-workflow detector (may bump min-tier)
|
|
57
59
|
* - kNN router (embedding-based nearest-neighbors of historical queries)
|
|
58
60
|
* - LinUCB contextual bandit (intra-tier model selection, learns from reward)
|
|
@@ -63,6 +65,21 @@ const rateLimiter = createRateLimiter();
|
|
|
63
65
|
* Plus telemetry — every decision is recorded so kNN/bandit improve over time.
|
|
64
66
|
*/
|
|
65
67
|
async function pickTierByIntent(body) {
|
|
68
|
+
// thinking.budget_tokens is NOT a reliable routing signal. Claude Code
|
|
69
|
+
// Enterprise on Haiku 4.5 attaches budget_tokens=31999 to EVERY request
|
|
70
|
+
// as its default extended-thinking behavior — the model's baseline, not
|
|
71
|
+
// an intent signal from the user. Trying to threshold this dragged every
|
|
72
|
+
// casual "hi" into REASONING via passthrough (live-verified 2026-07-19,
|
|
73
|
+
// fp-e33173b… session logs).
|
|
74
|
+
//
|
|
75
|
+
// Explicit user intent (ultrathink / prove / security audit / …) is
|
|
76
|
+
// caught by FORCE_REASONING_PATTERNS on the *text*, which is unambiguous.
|
|
77
|
+
// No thinking-budget trigger here on purpose. Kept a debug log of the
|
|
78
|
+
// observed value so future tuning has data.
|
|
79
|
+
const thinkingBudget = body?.thinking?.budget_tokens;
|
|
80
|
+
if (typeof thinkingBudget === 'number') {
|
|
81
|
+
logger.debug({ thinkingBudget }, '[OAuthIntent] thinking budget present (informational, not used for routing)');
|
|
82
|
+
}
|
|
66
83
|
// Build a user-intent payload. We INCLUDE the tools array (signals agentic
|
|
67
84
|
// intent — a request with 12 tools attached is meaningfully different from
|
|
68
85
|
// a chat-only one, even if both messages look short) but EXCLUDE the system
|
|
@@ -143,6 +160,47 @@ async function pickTierByIntent(body) {
|
|
|
143
160
|
};
|
|
144
161
|
}
|
|
145
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
|
+
|
|
146
204
|
// Per-message scoring intentionally omits _sessionId so session affinity
|
|
147
205
|
// isn't polluted by multiple intent-only routing calls per request. The
|
|
148
206
|
// FINAL provider pick (downstream of this function) uses the full body
|
|
@@ -162,6 +220,9 @@ async function pickTierByIntent(body) {
|
|
|
162
220
|
// agentic detector inside determineProviderSmart can subtract the
|
|
163
221
|
// harness's baseline tools during intent scoring too.
|
|
164
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]),
|
|
165
226
|
};
|
|
166
227
|
try {
|
|
167
228
|
const decision = await determineProviderSmart(intentPayload, {
|
|
@@ -348,19 +409,14 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
348
409
|
if (contentType.includes("text/event-stream") && upstreamResp.body) {
|
|
349
410
|
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
350
411
|
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: badgeText } })}\n\n`,
|
|
360
|
-
`event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: 0 })}\n\n`,
|
|
361
|
-
].join('');
|
|
362
|
-
res.write(synthetic);
|
|
363
|
-
}
|
|
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;
|
|
364
420
|
|
|
365
421
|
const reader = upstreamResp.body.getReader();
|
|
366
422
|
const decoder = new TextDecoder();
|
|
@@ -368,6 +424,15 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
368
424
|
while (true) {
|
|
369
425
|
const { value, done } = await readWithIdleTimeout(reader, "oauth-passthrough");
|
|
370
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
|
+
}
|
|
371
436
|
const buf = Buffer.from(value);
|
|
372
437
|
res.write(buf);
|
|
373
438
|
if (typeof res.flush === "function") res.flush();
|
|
@@ -376,6 +441,10 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
376
441
|
responseTextForObservability += decoder.decode(value, { stream: true });
|
|
377
442
|
}
|
|
378
443
|
}
|
|
444
|
+
if (badgeInjector) {
|
|
445
|
+
const flushed = badgeInjector(decoder.decode(), true);
|
|
446
|
+
if (flushed) res.write(flushed);
|
|
447
|
+
}
|
|
379
448
|
} catch (err) {
|
|
380
449
|
logger.warn({ err: err.message }, "OAuth passthrough stream stalled — ending response");
|
|
381
450
|
try { reader.cancel(); } catch { /* already dead */ }
|
|
@@ -449,7 +518,10 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
449
518
|
// Tier router telemetry (so it shows up in dashboards / routing stats)
|
|
450
519
|
const tlm = require("../routing/telemetry");
|
|
451
520
|
tlm.record({
|
|
452
|
-
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(),
|
|
453
525
|
session_id: req.body?._sessionId || req.sessionId || null,
|
|
454
526
|
timestamp: startedAt,
|
|
455
527
|
tier: tier.tier || "COMPLEX",
|
|
@@ -495,6 +567,113 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
495
567
|
});
|
|
496
568
|
}
|
|
497
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
|
+
* Scan the entire payload for tool_use or tool_result content blocks.
|
|
584
|
+
* Unlike session-affinity.payloadHasToolHistory (which checks only the last
|
|
585
|
+
* message, targeted at mid-exchange detection), this scans every message so
|
|
586
|
+
* a side-channel replay of a tool-active conversation is detected regardless
|
|
587
|
+
* of which turn it lands on. Best-effort — returns false on malformed input.
|
|
588
|
+
*/
|
|
589
|
+
function _payloadCarriesToolBlocks(body) {
|
|
590
|
+
const messages = body?.messages;
|
|
591
|
+
if (!Array.isArray(messages)) return false;
|
|
592
|
+
for (const m of messages) {
|
|
593
|
+
if (!Array.isArray(m?.content)) continue;
|
|
594
|
+
for (const b of m.content) {
|
|
595
|
+
if (b?.type === 'tool_use' || b?.type === 'tool_result') return true;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Forward a provider SSE stream (web ReadableStream) to the client response
|
|
603
|
+
* line by line. Assumes SSE headers were already set and flushed. Mid-stream
|
|
604
|
+
* failures surface as a parseable Anthropic error event, never a silent hang.
|
|
605
|
+
*/
|
|
606
|
+
async function forwardProviderStream(res, stream) {
|
|
607
|
+
const reader = stream.getReader();
|
|
608
|
+
const decoder = new TextDecoder();
|
|
609
|
+
const bufferChunks = []; // Use array to avoid string concatenation overhead
|
|
610
|
+
|
|
611
|
+
try {
|
|
612
|
+
while (true) {
|
|
613
|
+
const { done, value } = await readWithIdleTimeout(reader, "provider-stream");
|
|
614
|
+
if (done) break;
|
|
615
|
+
|
|
616
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
617
|
+
bufferChunks.push(chunk);
|
|
618
|
+
|
|
619
|
+
const buffer = bufferChunks.join('');
|
|
620
|
+
const lines = buffer.split('\n');
|
|
621
|
+
|
|
622
|
+
// Keep last incomplete line in buffer chunks
|
|
623
|
+
const remaining = lines.pop() || '';
|
|
624
|
+
bufferChunks.length = 0;
|
|
625
|
+
if (remaining) bufferChunks.push(remaining);
|
|
626
|
+
|
|
627
|
+
for (const line of lines) {
|
|
628
|
+
if (line.trim()) {
|
|
629
|
+
res.write(line + '\n');
|
|
630
|
+
} else {
|
|
631
|
+
// Blank lines are SSE event delimiters — they must survive.
|
|
632
|
+
res.write('\n');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (typeof res.flush === 'function') {
|
|
637
|
+
res.flush();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const remaining = bufferChunks.join('');
|
|
642
|
+
if (remaining.trim()) {
|
|
643
|
+
res.write(remaining + '\n');
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
metrics.recordResponse(200);
|
|
647
|
+
res.end();
|
|
648
|
+
} catch (streamError) {
|
|
649
|
+
logger.error({ error: streamError }, "Error streaming response");
|
|
650
|
+
|
|
651
|
+
try {
|
|
652
|
+
await reader.cancel();
|
|
653
|
+
} catch (cancelError) {
|
|
654
|
+
logger.debug({ error: cancelError }, "Failed to cancel stream");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (!res.headersSent) {
|
|
658
|
+
res.status(500).json({ error: "Streaming error" });
|
|
659
|
+
} else {
|
|
660
|
+
// Mid-stream failure: emit a parseable Anthropic error event so
|
|
661
|
+
// the client fails fast and retries, instead of seeing a
|
|
662
|
+
// truncated stream it may wait on.
|
|
663
|
+
try { res.write(SSE_STALL_EVENT); } catch { /* client gone */ }
|
|
664
|
+
res.end();
|
|
665
|
+
}
|
|
666
|
+
} finally {
|
|
667
|
+
// CRITICAL: Always release lock
|
|
668
|
+
try {
|
|
669
|
+
reader.releaseLock();
|
|
670
|
+
} catch (releaseError) {
|
|
671
|
+
// Lock may already be released, ignore
|
|
672
|
+
logger.debug({ error: releaseError }, "Stream lock already released");
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
498
677
|
/**
|
|
499
678
|
* Extract the final assembled Anthropic message from a captured SSE stream.
|
|
500
679
|
* Looks at message_start (for id/model), content_block_delta (for text),
|
|
@@ -952,7 +1131,48 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
952
1131
|
// (harness UA / tool fingerprint) before treating tool-less traffic as
|
|
953
1132
|
// side traffic; a suggestion-mode tag is harness evidence by itself.
|
|
954
1133
|
const isKnownHarness = !!req.body?._clientProfile;
|
|
1134
|
+
// Signal 1 — message-count regression. Real turns grow the transcript
|
|
1135
|
+
// monotonically; a harness replay (title-gen, recap, summary) truncates
|
|
1136
|
+
// the history down to the wrapper prompt. If this payload has fewer
|
|
1137
|
+
// messages than the session's pinned baseline, it cannot be a genuine
|
|
1138
|
+
// follow-up. Definitive — main conversations never shrink.
|
|
1139
|
+
//
|
|
1140
|
+
// Only trust the pin's counts when Lynkr is ACTUALLY serving from it
|
|
1141
|
+
// this turn. When pinCheck.serve === false the pin may be a stale
|
|
1142
|
+
// artifact from an earlier conversation with the same session
|
|
1143
|
+
// fingerprint (see the `new_conversation` and `opener_conversation`
|
|
1144
|
+
// guards in session-affinity.js) — in that case msg counts and
|
|
1145
|
+
// hasToolHistory are meaningless for the current session and must not
|
|
1146
|
+
// fire the side-channel signals.
|
|
1147
|
+
const _pinAuthoritative = pinCheck?.serve === true;
|
|
1148
|
+
const _currentMsgCount = Array.isArray(req.body?.messages) ? req.body.messages.length : 0;
|
|
1149
|
+
const _pinnedMsgCount = pinCheck?.pin?.messageCount ?? 0;
|
|
1150
|
+
const isMsgCountRegression = _pinAuthoritative
|
|
1151
|
+
&& _pinnedMsgCount > 2
|
|
1152
|
+
&& _currentMsgCount < _pinnedMsgCount;
|
|
1153
|
+
// Signal 2 — tool-history absence in a tool-active session. Once a
|
|
1154
|
+
// session has accumulated tool_use blocks, the main flow always carries
|
|
1155
|
+
// them forward. A payload with zero tool blocks in an active tool
|
|
1156
|
+
// session is a side channel by construction.
|
|
1157
|
+
const _pinHadTools = !!pinCheck?.pin?.hasToolHistory;
|
|
1158
|
+
const isBareRequest = _pinAuthoritative
|
|
1159
|
+
&& _pinHadTools
|
|
1160
|
+
&& !_payloadCarriesToolBlocks(req.body);
|
|
1161
|
+
// Signal 3 — first-user-message fingerprint drift. Handles the window
|
|
1162
|
+
// before signals 1 & 2 have data (no pin yet, no tool blocks yet). See
|
|
1163
|
+
// side-channel-detector.js — the detector caches the first observed
|
|
1164
|
+
// messages[0] for each session and flags payloads whose first message
|
|
1165
|
+
// no longer matches (title-gen wraps the original in <session>...</session>).
|
|
1166
|
+
let isFingerprintDrift = false;
|
|
1167
|
+
try {
|
|
1168
|
+
const sideChannelDetector = require('../routing/side-channel-detector');
|
|
1169
|
+
const sessionKey = pinCheck?.sessionId || req.body?.metadata?.user_id || null;
|
|
1170
|
+
isFingerprintDrift = sideChannelDetector.check(sessionKey, req.body?.messages);
|
|
1171
|
+
} catch { /* detector is best-effort */ }
|
|
955
1172
|
const isSideRequest = isSuggestionMode
|
|
1173
|
+
|| isMsgCountRegression
|
|
1174
|
+
|| isBareRequest
|
|
1175
|
+
|| isFingerprintDrift
|
|
956
1176
|
|| ((!Array.isArray(req.body?.tools) || req.body.tools.length === 0) && isKnownHarness);
|
|
957
1177
|
// Side requests short-circuit to the static SIMPLE tier: no pin read
|
|
958
1178
|
// (a COMPLEX-pinned conversation would burn expensive tokens on
|
|
@@ -963,12 +1183,23 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
963
1183
|
if (isSideRequest) {
|
|
964
1184
|
try {
|
|
965
1185
|
const sel = getModelTierSelector().selectModel('SIMPLE', null);
|
|
1186
|
+
const _sideMethod = isSuggestionMode ? 'side_request_suggestion'
|
|
1187
|
+
: isMsgCountRegression ? 'side_request_msg_regression'
|
|
1188
|
+
: isBareRequest ? 'side_request_bare'
|
|
1189
|
+
: isFingerprintDrift ? 'side_request_fingerprint_drift'
|
|
1190
|
+
: 'side_request';
|
|
1191
|
+
logger.debug({
|
|
1192
|
+
method: _sideMethod,
|
|
1193
|
+
currentMsgCount: _currentMsgCount,
|
|
1194
|
+
pinnedMsgCount: _pinnedMsgCount,
|
|
1195
|
+
pinHadTools: _pinHadTools,
|
|
1196
|
+
}, '[Routing] Side-channel request detected');
|
|
966
1197
|
sideTier = {
|
|
967
1198
|
tier: 'SIMPLE',
|
|
968
1199
|
provider: sel.provider,
|
|
969
1200
|
model: sel.model || null,
|
|
970
1201
|
score: null,
|
|
971
|
-
method:
|
|
1202
|
+
method: _sideMethod,
|
|
972
1203
|
reason: 'harness_side_request',
|
|
973
1204
|
base_tier: null,
|
|
974
1205
|
escalation_source: null,
|
|
@@ -1115,6 +1346,30 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1115
1346
|
return handleOauthPassthrough(req, res, { tier });
|
|
1116
1347
|
}
|
|
1117
1348
|
|
|
1349
|
+
// Phase 2a — native-format streaming passthrough. Client speaks
|
|
1350
|
+
// Anthropic, upstream speaks Anthropic, request wants a stream: skip the
|
|
1351
|
+
// buffered orchestrator and pipe upstream SSE bytes straight through.
|
|
1352
|
+
// Falls back to the buffered path below when the upstream fails before
|
|
1353
|
+
// the first byte. Kill switch: LYNKR_NATIVE_PASSTHROUGH=false.
|
|
1354
|
+
{
|
|
1355
|
+
const nativeStream = require("../orchestrator/passthrough-stream");
|
|
1356
|
+
if (nativeStream.canPassthrough(req.body, tier)) {
|
|
1357
|
+
metrics.recordStreamingStart();
|
|
1358
|
+
const handled = await nativeStream.handleNativeStream(req, res, {
|
|
1359
|
+
tier,
|
|
1360
|
+
badgeText: _buildStreamBadge(tier),
|
|
1361
|
+
});
|
|
1362
|
+
if (handled) {
|
|
1363
|
+
metrics.recordResponse(200);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
logger.info({
|
|
1367
|
+
tier: tier.tier,
|
|
1368
|
+
provider: tier.provider,
|
|
1369
|
+
}, "[NativeStream] Passthrough declined before first byte — using buffered path");
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1118
1373
|
// All other cases (subscription→non-Anthropic, payg, oauth): pin the
|
|
1119
1374
|
// window-scored tier so the orchestrator's internal tier router can't
|
|
1120
1375
|
// override it with a full-body re-score. Badge/headers downstream show
|
|
@@ -1317,78 +1572,14 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1317
1572
|
maxSteps: req.body?.max_steps,
|
|
1318
1573
|
maxDurationMs: req.body?.max_duration_ms,
|
|
1319
1574
|
tenantPolicy: res.locals?.tenantPolicy || null,
|
|
1575
|
+
clientWantsStream: wantsStream,
|
|
1576
|
+
streamBadgeText: _buildStreamBadge(req._intentTier || null),
|
|
1320
1577
|
},
|
|
1321
1578
|
});
|
|
1322
1579
|
|
|
1323
1580
|
if (result.stream) {
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
const decoder = new TextDecoder();
|
|
1327
|
-
const bufferChunks = []; // Use array to avoid string concatenation overhead
|
|
1328
|
-
|
|
1329
|
-
try {
|
|
1330
|
-
while (true) {
|
|
1331
|
-
const { done, value } = await readWithIdleTimeout(reader, "provider-stream");
|
|
1332
|
-
if (done) break;
|
|
1333
|
-
|
|
1334
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
1335
|
-
bufferChunks.push(chunk);
|
|
1336
|
-
|
|
1337
|
-
const buffer = bufferChunks.join('');
|
|
1338
|
-
const lines = buffer.split('\n');
|
|
1339
|
-
|
|
1340
|
-
// Keep last incomplete line in buffer chunks
|
|
1341
|
-
const remaining = lines.pop() || '';
|
|
1342
|
-
bufferChunks.length = 0;
|
|
1343
|
-
if (remaining) bufferChunks.push(remaining);
|
|
1344
|
-
|
|
1345
|
-
for (const line of lines) {
|
|
1346
|
-
if (line.trim()) {
|
|
1347
|
-
res.write(line + '\n');
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
if (typeof res.flush === 'function') {
|
|
1352
|
-
res.flush();
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
const remaining = bufferChunks.join('');
|
|
1357
|
-
if (remaining.trim()) {
|
|
1358
|
-
res.write(remaining + '\n');
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
metrics.recordResponse(200);
|
|
1362
|
-
res.end();
|
|
1363
|
-
return;
|
|
1364
|
-
} catch (streamError) {
|
|
1365
|
-
logger.error({ error: streamError }, "Error streaming response");
|
|
1366
|
-
|
|
1367
|
-
try {
|
|
1368
|
-
await reader.cancel();
|
|
1369
|
-
} catch (cancelError) {
|
|
1370
|
-
logger.debug({ error: cancelError }, "Failed to cancel stream");
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
if (!res.headersSent) {
|
|
1374
|
-
res.status(500).json({ error: "Streaming error" });
|
|
1375
|
-
} else {
|
|
1376
|
-
// Mid-stream failure: emit a parseable Anthropic error event so
|
|
1377
|
-
// the client fails fast and retries, instead of seeing a
|
|
1378
|
-
// truncated stream it may wait on.
|
|
1379
|
-
try { res.write(SSE_STALL_EVENT); } catch { /* client gone */ }
|
|
1380
|
-
res.end();
|
|
1381
|
-
}
|
|
1382
|
-
return;
|
|
1383
|
-
} finally {
|
|
1384
|
-
// CRITICAL: Always release lock
|
|
1385
|
-
try {
|
|
1386
|
-
reader.releaseLock();
|
|
1387
|
-
} catch (releaseError) {
|
|
1388
|
-
// Lock may already be released, ignore
|
|
1389
|
-
logger.debug({ error: releaseError }, "Stream lock already released");
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1581
|
+
await forwardProviderStream(res, result.stream);
|
|
1582
|
+
return;
|
|
1392
1583
|
}
|
|
1393
1584
|
|
|
1394
1585
|
if (!result || !result.body) {
|
|
@@ -1415,17 +1606,10 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1415
1606
|
}
|
|
1416
1607
|
})}\n\n`);
|
|
1417
1608
|
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
|
|
1422
|
-
const _serverTools = new Set(["task", "websearch", "webfetch", "web_search", "web_fetch", "web_agent"]);
|
|
1423
|
-
const _clientOwnsTools = config.toolExecutionMode === "client" || config.toolExecutionMode === "passthrough";
|
|
1424
|
-
let contentBlocks = _clientOwnsTools
|
|
1425
|
-
? (msg.content || []).slice()
|
|
1426
|
-
: (msg.content || []).filter(b =>
|
|
1427
|
-
!(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
|
|
1428
|
-
);
|
|
1609
|
+
// The client owns all its tools — every tool_use block passes through
|
|
1610
|
+
// untouched (stripping any would emit a malformed turn: stop_reason
|
|
1611
|
+
// tool_use with no tool_use block).
|
|
1612
|
+
let contentBlocks = (msg.content || []).slice();
|
|
1429
1613
|
|
|
1430
1614
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1431
1615
|
// users can see which tier/provider/model handled the request inside
|
|
@@ -1567,6 +1751,8 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1567
1751
|
maxSteps: req.body?.max_steps,
|
|
1568
1752
|
maxDurationMs: req.body?.max_duration_ms,
|
|
1569
1753
|
tenantPolicy: res.locals?.tenantPolicy || null,
|
|
1754
|
+
clientWantsStream: wantsStream,
|
|
1755
|
+
streamBadgeText: _buildStreamBadge(req._intentTier || null),
|
|
1570
1756
|
},
|
|
1571
1757
|
});
|
|
1572
1758
|
timer.mark("processMessage");
|
|
@@ -1579,11 +1765,19 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1579
1765
|
"Content-Type": "text/event-stream",
|
|
1580
1766
|
"Cache-Control": "no-cache",
|
|
1581
1767
|
Connection: "keep-alive",
|
|
1768
|
+
...routingHeaders,
|
|
1582
1769
|
});
|
|
1583
1770
|
if (typeof res.flushHeaders === "function") {
|
|
1584
1771
|
res.flushHeaders();
|
|
1585
1772
|
}
|
|
1586
1773
|
|
|
1774
|
+
// Phase 2b: the orchestrator returned a live (already Anthropic-shaped)
|
|
1775
|
+
// stream instead of a buffered body — forward it as-is.
|
|
1776
|
+
if (result && result.stream) {
|
|
1777
|
+
await forwardProviderStream(res, result.stream);
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1587
1781
|
if (!result || !result.body) {
|
|
1588
1782
|
res.write(`event: error\n`);
|
|
1589
1783
|
res.write(`data: ${JSON.stringify({ type: "error", error: { message: "Empty response from provider" } })}\n\n`);
|
|
@@ -1608,17 +1802,10 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1608
1802
|
}
|
|
1609
1803
|
})}\n\n`);
|
|
1610
1804
|
|
|
1611
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1614
|
-
|
|
1615
|
-
const _serverTools = new Set(["task", "websearch", "webfetch", "web_search", "web_fetch", "web_agent"]);
|
|
1616
|
-
const _clientOwnsTools = config.toolExecutionMode === "client" || config.toolExecutionMode === "passthrough";
|
|
1617
|
-
let contentBlocks = _clientOwnsTools
|
|
1618
|
-
? (msg.content || []).slice()
|
|
1619
|
-
: (msg.content || []).filter(b =>
|
|
1620
|
-
!(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
|
|
1621
|
-
);
|
|
1805
|
+
// The client owns all its tools — every tool_use block passes through
|
|
1806
|
+
// untouched (stripping any would emit a malformed turn: stop_reason
|
|
1807
|
+
// tool_use with no tool_use block).
|
|
1808
|
+
let contentBlocks = (msg.content || []).slice();
|
|
1622
1809
|
|
|
1623
1810
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1624
1811
|
// users can see which tier/provider/model handled the request inside
|
|
@@ -1788,61 +1975,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1788
1975
|
}
|
|
1789
1976
|
});
|
|
1790
1977
|
|
|
1791
|
-
// List available agents (must come before parameterized routes)
|
|
1792
|
-
router.get("/v1/agents", (req, res) => {
|
|
1793
|
-
try {
|
|
1794
|
-
const { listAgents } = require("../agents");
|
|
1795
|
-
const agents = listAgents();
|
|
1796
|
-
res.json({ agents });
|
|
1797
|
-
} catch (error) {
|
|
1798
|
-
res.status(500).json({ error: error.message });
|
|
1799
|
-
}
|
|
1800
|
-
});
|
|
1801
|
-
|
|
1802
|
-
// Agent stats endpoint (specific path before parameterized)
|
|
1803
|
-
router.get("/v1/agents/stats", (req, res) => {
|
|
1804
|
-
try {
|
|
1805
|
-
const { getAgentStats } = require("../agents");
|
|
1806
|
-
const stats = getAgentStats();
|
|
1807
|
-
res.json({ stats });
|
|
1808
|
-
} catch (error) {
|
|
1809
|
-
res.status(500).json({ error: error.message });
|
|
1810
|
-
}
|
|
1811
|
-
});
|
|
1812
|
-
|
|
1813
|
-
// Read agent transcript (specific path with param before catch-all)
|
|
1814
|
-
router.get("/v1/agents/:agentId/transcript", (req, res) => {
|
|
1815
|
-
try {
|
|
1816
|
-
const ContextManager = require("../agents/context-manager");
|
|
1817
|
-
const cm = new ContextManager();
|
|
1818
|
-
const transcript = cm.readTranscript(req.params.agentId);
|
|
1819
|
-
|
|
1820
|
-
if (!transcript) {
|
|
1821
|
-
return res.status(404).json({ error: "Transcript not found" });
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
res.json({ transcript });
|
|
1825
|
-
} catch (error) {
|
|
1826
|
-
res.status(500).json({ error: error.message });
|
|
1827
|
-
}
|
|
1828
|
-
});
|
|
1829
|
-
|
|
1830
|
-
// Agent execution details (parameterized - must come last)
|
|
1831
|
-
router.get("/v1/agents/:executionId", (req, res) => {
|
|
1832
|
-
try {
|
|
1833
|
-
const { getAgentExecution } = require("../agents");
|
|
1834
|
-
const details = getAgentExecution(req.params.executionId);
|
|
1835
|
-
|
|
1836
|
-
if (!details) {
|
|
1837
|
-
return res.status(404).json({ error: "Execution not found" });
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
res.json(details);
|
|
1841
|
-
} catch (error) {
|
|
1842
|
-
res.status(500).json({ error: error.message });
|
|
1843
|
-
}
|
|
1844
|
-
});
|
|
1845
|
-
|
|
1846
1978
|
// Token usage statistics for a session
|
|
1847
1979
|
router.get("/api/sessions/:sessionId/tokens", (req, res) => {
|
|
1848
1980
|
try {
|