lynkr 9.7.3 → 9.9.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 +29 -19
- package/bin/cli.js +11 -0
- package/bin/lynkr-init.js +14 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +24 -3
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +450 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +455 -142
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +51 -9
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +120 -85
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +48 -11
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +897 -87
- package/src/routing/intent-score.js +339 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +286 -13
- package/src/routing/verifier.js +267 -0
- package/src/server.js +66 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- package/src/workers/helpers.js +0 -185
package/src/api/router.js
CHANGED
|
@@ -7,7 +7,32 @@ const logger = require("../logger");
|
|
|
7
7
|
const { createRateLimiter } = require("./middleware/rate-limiter");
|
|
8
8
|
const openaiRouter = require("./openai-router");
|
|
9
9
|
const providersRouter = require("./providers-handler");
|
|
10
|
-
const { getRoutingHeaders, getRoutingStats, analyzeComplexity, getModelTierSelector, analyzeRisk } = require("../routing");
|
|
10
|
+
const { getRoutingHeaders, getRoutingStats, analyzeComplexity, getModelTierSelector, analyzeRisk, checkSessionPin, writeSessionPin, checkPinScoreDrift } = require("../routing");
|
|
11
|
+
|
|
12
|
+
// Upstream streams can die without a clean end (reader.read() never
|
|
13
|
+
// resolves on a dropped socket), hanging the client forever. Every
|
|
14
|
+
// forwarding loop must read through this idle watchdog. The window is
|
|
15
|
+
// IDLE time, reset per chunk — long-thinking models stream for minutes.
|
|
16
|
+
const STREAM_IDLE_TIMEOUT_MS = Number(process.env.LYNKR_STREAM_IDLE_TIMEOUT_MS) || 90000;
|
|
17
|
+
async function readWithIdleTimeout(reader, label) {
|
|
18
|
+
let timer;
|
|
19
|
+
try {
|
|
20
|
+
return await Promise.race([
|
|
21
|
+
reader.read(),
|
|
22
|
+
new Promise((_, reject) => {
|
|
23
|
+
timer = setTimeout(
|
|
24
|
+
() => reject(new Error(`upstream stream idle >${STREAM_IDLE_TIMEOUT_MS}ms (${label})`)),
|
|
25
|
+
STREAM_IDLE_TIMEOUT_MS,
|
|
26
|
+
);
|
|
27
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
28
|
+
}),
|
|
29
|
+
]);
|
|
30
|
+
} finally {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const SSE_STALL_EVENT = `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "overloaded_error", message: "Lynkr: upstream stream stalled — retry" } })}\n\n`;
|
|
35
|
+
const { detectClient } = require("../routing/client-profiles");
|
|
11
36
|
const { buildInteractionBlock } = require("../routing/interaction");
|
|
12
37
|
const { validateCwd } = require("../workspace");
|
|
13
38
|
const { renderText } = require("../utils/markdown-ansi");
|
|
@@ -15,7 +40,6 @@ const { classifyAuthMode } = require("../auth-mode");
|
|
|
15
40
|
|
|
16
41
|
const router = express.Router();
|
|
17
42
|
|
|
18
|
-
// Create rate limiter middleware
|
|
19
43
|
const rateLimiter = createRateLimiter();
|
|
20
44
|
|
|
21
45
|
/**
|
|
@@ -61,14 +85,24 @@ async function pickTierByIntent(body) {
|
|
|
61
85
|
const N = Math.max(1, Number(process.env.LYNKR_INTENT_WINDOW_N) || 5);
|
|
62
86
|
const decay = Number(process.env.LYNKR_INTENT_DECAY);
|
|
63
87
|
const decayFactor = Number.isFinite(decay) && decay > 0 && decay <= 1 ? decay : 0.7;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
88
|
+
// Window over messages the USER actually authored: tool_result-only and
|
|
89
|
+
// harness frames would otherwise age the typed ask out of the window and
|
|
90
|
+
// score envelope noise in its place. Pure tool exchanges keep the raw
|
|
91
|
+
// window as a fallback.
|
|
92
|
+
const { extractCleanUserText } = require("../routing/intent-score");
|
|
93
|
+
const textBearingMsgs = allUserMsgs.filter(
|
|
94
|
+
(m) => extractCleanUserText({ messages: [m] })
|
|
95
|
+
);
|
|
96
|
+
const windowUserMsgs = (textBearingMsgs.length > 0 ? textBearingMsgs : allUserMsgs)
|
|
97
|
+
.slice(-N); // chronological, oldest-first
|
|
98
|
+
|
|
99
|
+
// WS3 — we USED to slice tools to 3 here so Claude Code's 11 baseline
|
|
100
|
+
// tools didn't inflate the agentic detector's tool-count signal. That
|
|
101
|
+
// hack was client-specific and also discarded real MCP tools the user
|
|
102
|
+
// had configured. Now we pass the full tools array and let the detector
|
|
103
|
+
// subtract the client's baseline via the profile threaded onto the
|
|
104
|
+
// payload — same short-message-intent guarantee, but MCP tools count.
|
|
105
|
+
const intentTools = Array.isArray(body?.tools) ? body.tools : undefined;
|
|
72
106
|
|
|
73
107
|
// CLEAN each user message: Claude Code wraps user input in
|
|
74
108
|
// <system-reminder>...</system-reminder> blocks (CLAUDE.md context,
|
|
@@ -124,6 +158,10 @@ async function pickTierByIntent(body) {
|
|
|
124
158
|
const intentPayload = {
|
|
125
159
|
messages: cleaned ? [cleaned] : [],
|
|
126
160
|
tools: intentTools,
|
|
161
|
+
// WS3 — inherit the client profile from the parent request so the
|
|
162
|
+
// agentic detector inside determineProviderSmart can subtract the
|
|
163
|
+
// harness's baseline tools during intent scoring too.
|
|
164
|
+
_clientProfile: body?._clientProfile || null,
|
|
127
165
|
};
|
|
128
166
|
try {
|
|
129
167
|
const decision = await determineProviderSmart(intentPayload, {
|
|
@@ -173,6 +211,23 @@ async function pickTierByIntent(body) {
|
|
|
173
211
|
reason: d.reason || null,
|
|
174
212
|
agenticResult: d.agenticResult || null,
|
|
175
213
|
risk: d.risk || null,
|
|
214
|
+
// WS0: forward the intent-scoring decision's escalation ledger so the
|
|
215
|
+
// downstream forced-provider path can record it in telemetry.
|
|
216
|
+
base_tier: d.base_tier ?? null,
|
|
217
|
+
escalation_source: d.escalation_source ?? null,
|
|
218
|
+
// WS4: off-policy evaluation needs propensity + candidates on every row.
|
|
219
|
+
// The inner determineProviderSmart already sets these — we just forward.
|
|
220
|
+
// Falls back to a deterministic single-candidate view when the inner
|
|
221
|
+
// path returned neither (e.g. legacy shadow decisions).
|
|
222
|
+
propensity: d.propensity ?? 1.0,
|
|
223
|
+
candidates: d.candidates ?? [{ provider: d.provider, model: d.model || null }],
|
|
224
|
+
// WS5: feedback path needs the bandit context vector (to call
|
|
225
|
+
// bandit.update with the same features the arm was scored on) and the
|
|
226
|
+
// query embedding (to add conclusive-quality outcomes to kNN). Both
|
|
227
|
+
// are underscored — they don't leak through response headers.
|
|
228
|
+
_banditContext: d._banditContext ?? null,
|
|
229
|
+
_queryEmbedding: d._queryEmbedding ?? null,
|
|
230
|
+
_queryText: d._queryText ?? null,
|
|
176
231
|
};
|
|
177
232
|
}
|
|
178
233
|
|
|
@@ -226,6 +281,21 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
226
281
|
if (HOP_BY_HOP.has(name.toLowerCase())) continue;
|
|
227
282
|
outHeaders[name] = Array.isArray(value) ? value.join(", ") : value;
|
|
228
283
|
}
|
|
284
|
+
// Strip Lynkr's internal underscore-prefixed fields (_sessionId,
|
|
285
|
+
// _forceProvider, _tierModel, _tierName, _forcedMethod, _baseTier,
|
|
286
|
+
// _escalationSource, _pinnedRoute, _switchReason, _clientProfile,
|
|
287
|
+
// _workspace, _tenantPolicy, _deadlineMs, _suggestionModeModel). Anthropic
|
|
288
|
+
// rejects unknown top-level keys with "Extra inputs are not permitted".
|
|
289
|
+
// The orchestrator's downstream paths whitelist their outbound bodies,
|
|
290
|
+
// but the passthrough sends this body VERBATIM — so we must strip here.
|
|
291
|
+
if (bodyToSend && typeof bodyToSend === 'object') {
|
|
292
|
+
const stripped = { ...bodyToSend };
|
|
293
|
+
for (const key of Object.keys(stripped)) {
|
|
294
|
+
if (key.startsWith('_')) delete stripped[key];
|
|
295
|
+
}
|
|
296
|
+
bodyToSend = stripped;
|
|
297
|
+
}
|
|
298
|
+
|
|
229
299
|
// Re-stringify the body — express already parsed it. Identical re-encoding
|
|
230
300
|
// is fine; Anthropic doesn't fingerprint key ordering.
|
|
231
301
|
const bodyText = JSON.stringify(bodyToSend);
|
|
@@ -296,7 +366,7 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
296
366
|
const decoder = new TextDecoder();
|
|
297
367
|
try {
|
|
298
368
|
while (true) {
|
|
299
|
-
const { value, done } = await reader
|
|
369
|
+
const { value, done } = await readWithIdleTimeout(reader, "oauth-passthrough");
|
|
300
370
|
if (done) break;
|
|
301
371
|
const buf = Buffer.from(value);
|
|
302
372
|
res.write(buf);
|
|
@@ -306,6 +376,10 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
306
376
|
responseTextForObservability += decoder.decode(value, { stream: true });
|
|
307
377
|
}
|
|
308
378
|
}
|
|
379
|
+
} catch (err) {
|
|
380
|
+
logger.warn({ err: err.message }, "OAuth passthrough stream stalled — ending response");
|
|
381
|
+
try { reader.cancel(); } catch { /* already dead */ }
|
|
382
|
+
try { res.write(SSE_STALL_EVENT); } catch { /* client gone */ }
|
|
309
383
|
} finally {
|
|
310
384
|
try { reader.releaseLock(); } catch {}
|
|
311
385
|
}
|
|
@@ -313,10 +387,18 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
313
387
|
} else {
|
|
314
388
|
const text = await upstreamResp.text();
|
|
315
389
|
if (!upstreamResp.ok) {
|
|
390
|
+
// Auth-shape diagnostics (prefix only, never the token): sk-ant-oat*
|
|
391
|
+
// = subscription OAuth (fix: /login refresh), sk-ant-api* = an API KEY
|
|
392
|
+
// is overriding the subscription (fix: unset ANTHROPIC_API_KEY or the
|
|
393
|
+
// client's injected key), JWT/none = client sent something unusable.
|
|
394
|
+
const authHdr = String(req.headers?.authorization || "");
|
|
395
|
+
const apiKeyHdr = String(req.headers?.["x-api-key"] || "");
|
|
316
396
|
logger.warn({
|
|
317
397
|
status: upstreamResp.status,
|
|
318
398
|
bodyPreview: text.slice(0, 500),
|
|
319
399
|
upstream,
|
|
400
|
+
authShape: authHdr ? authHdr.replace("Bearer ", "").slice(0, 12) + "…" : "(none)",
|
|
401
|
+
xApiKeyShape: apiKeyHdr ? apiKeyHdr.slice(0, 12) + "…" : "(none)",
|
|
320
402
|
}, "OAuth passthrough upstream returned non-2xx");
|
|
321
403
|
}
|
|
322
404
|
responseTextForObservability = text;
|
|
@@ -359,59 +441,53 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
359
441
|
?? inputTokenEstimate;
|
|
360
442
|
|
|
361
443
|
// Lynkr-wide metrics
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if (outputTokens || inputTokensActual) mc.recordTokens?.(inputTokensActual, outputTokens || 0);
|
|
367
|
-
} catch (_) {}
|
|
444
|
+
const { getMetricsCollector } = require("../observability/metrics");
|
|
445
|
+
const mc = getMetricsCollector();
|
|
446
|
+
mc.recordProviderSuccess("azure-anthropic-passthrough", latencyMs);
|
|
447
|
+
if (outputTokens || inputTokensActual) mc.recordTokens(inputTokensActual, outputTokens || 0);
|
|
368
448
|
|
|
369
449
|
// Tier router telemetry (so it shows up in dashboards / routing stats)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
});
|
|
403
|
-
} catch (_) {}
|
|
450
|
+
const tlm = require("../routing/telemetry");
|
|
451
|
+
tlm.record({
|
|
452
|
+
request_id: req.headers["request-id"] || req.headers["x-request-id"] || null,
|
|
453
|
+
session_id: req.body?._sessionId || req.sessionId || null,
|
|
454
|
+
timestamp: startedAt,
|
|
455
|
+
tier: tier.tier || "COMPLEX",
|
|
456
|
+
provider: "azure-anthropic-passthrough",
|
|
457
|
+
model: req.body?.model || tier.model || null,
|
|
458
|
+
routing_method: "oauth-passthrough",
|
|
459
|
+
status_code: upstreamResp.status,
|
|
460
|
+
latency_ms: latencyMs,
|
|
461
|
+
input_tokens: inputTokensActual || null,
|
|
462
|
+
output_tokens: outputTokens || null,
|
|
463
|
+
message_count: req.body?.messages?.length || null,
|
|
464
|
+
tool_count: Array.isArray(req.body?.tools) ? req.body.tools.length : 0,
|
|
465
|
+
was_fallback: false,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// Audit log. NOTE: the interface returned by createAuditLogger exposes
|
|
469
|
+
// logLlmRequest/logLlmResponse — no generic `.log` — so the optional
|
|
470
|
+
// call stays until this is migrated to the real audit API.
|
|
471
|
+
const { createAuditLogger } = require("../logger/audit-logger");
|
|
472
|
+
const audit = createAuditLogger(config.audit);
|
|
473
|
+
audit.log?.({
|
|
474
|
+
provider: "azure-anthropic-passthrough",
|
|
475
|
+
destination: upstream,
|
|
476
|
+
status: upstreamResp.status,
|
|
477
|
+
latencyMs,
|
|
478
|
+
inputTokens: inputTokensActual,
|
|
479
|
+
outputTokens,
|
|
480
|
+
model: req.body?.model,
|
|
481
|
+
});
|
|
404
482
|
|
|
405
483
|
// Memory extraction (read-only on response, no LLM call — pure regex)
|
|
406
484
|
if (parsedResponse && config.memory?.extraction?.enabled) {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
).catch(() => {});
|
|
414
|
-
} catch (_) {}
|
|
485
|
+
const memoryExtractor = require("../memory/extractor");
|
|
486
|
+
memoryExtractor.extractMemories(
|
|
487
|
+
parsedResponse,
|
|
488
|
+
req.body?.messages || [],
|
|
489
|
+
{ sessionId: req.body?._sessionId || req.sessionId || null }
|
|
490
|
+
).catch(() => {});
|
|
415
491
|
}
|
|
416
492
|
} catch (err) {
|
|
417
493
|
logger.debug({ err: err.message }, "OAuth passthrough observability hook failed (non-fatal)");
|
|
@@ -464,7 +540,6 @@ function extractAnthropicMessageFromSSE(sseText) {
|
|
|
464
540
|
* - Memory is disabled
|
|
465
541
|
* - No memories retrieved
|
|
466
542
|
* - Latest message is not a user turn (could be tool_result, assistant)
|
|
467
|
-
* - Latest user message sits inside a cache_control-marked prefix
|
|
468
543
|
*
|
|
469
544
|
* Returns a new body with appended context otherwise. Original body never
|
|
470
545
|
* mutated (returns a shallow-cloned messages array).
|
|
@@ -476,29 +551,6 @@ function maybeInjectMemoryIntoUserTail(body) {
|
|
|
476
551
|
const lastMsg = body.messages[lastIdx];
|
|
477
552
|
if (!lastMsg || lastMsg.role !== "user") return body;
|
|
478
553
|
|
|
479
|
-
// Frozen-prefix check: if the previous message has cache_control set, the
|
|
480
|
-
// model client (Claude Code) considers messages up to that point cached.
|
|
481
|
-
// We refuse to mutate inside the cached prefix to preserve cache hits.
|
|
482
|
-
// (For Anthropic, cache_control is on a content block, not the message
|
|
483
|
-
// itself, so scan content blocks.)
|
|
484
|
-
const hasCacheControlAtOrBefore = (idx) => {
|
|
485
|
-
for (let i = 0; i <= idx; i++) {
|
|
486
|
-
const m = body.messages[i];
|
|
487
|
-
if (!m || !Array.isArray(m.content)) continue;
|
|
488
|
-
for (const blk of m.content) {
|
|
489
|
-
if (blk && typeof blk === "object" && blk.cache_control) return true;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
return false;
|
|
493
|
-
};
|
|
494
|
-
// Only mutate if the previous message (lastIdx-1) is NOT cache-marked.
|
|
495
|
-
// That keeps Claude Code's prompt-cache breakpoint stable.
|
|
496
|
-
if (lastIdx >= 1 && hasCacheControlAtOrBefore(lastIdx - 1)) {
|
|
497
|
-
// Common case: it's fine — the user message itself isn't in the prefix.
|
|
498
|
-
// Continue.
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// Retrieve relevant memories for this user query.
|
|
502
554
|
const { retrieveRelevantMemories, formatMemoriesForContext, extractQueryFromMessage } =
|
|
503
555
|
require("../memory/retriever");
|
|
504
556
|
const query = extractQueryFromMessage(lastMsg);
|
|
@@ -675,16 +727,27 @@ router.post("/routing/analyze", async (req, res) => {
|
|
|
675
727
|
const { getModelTierSelector } = require("../routing/model-tiers");
|
|
676
728
|
const { getModelRegistry } = require("../routing/model-registry");
|
|
677
729
|
|
|
678
|
-
|
|
730
|
+
// ?mode=intent scores through the WS7 anchor intent path — the same
|
|
731
|
+
// scorer live /v1/messages routing uses — instead of the legacy lexical
|
|
732
|
+
// complexity analyzer. Falls back to lexical when embeddings are down
|
|
733
|
+
// (mirroring live behavior).
|
|
734
|
+
let analysis;
|
|
735
|
+
if (req.query.mode === "intent") {
|
|
736
|
+
const { scoreIntent } = require("../routing/intent-score");
|
|
737
|
+
const intent = await scoreIntent(req.body);
|
|
738
|
+
analysis = intent
|
|
739
|
+
? { score: intent.score, intentMode: intent.mode, intentClass: intent.class ?? null }
|
|
740
|
+
: await analyzeComplexity(req.body, { weighted: req.query.weighted === "true" });
|
|
741
|
+
} else {
|
|
742
|
+
analysis = await analyzeComplexity(req.body, { weighted: req.query.weighted === "true" });
|
|
743
|
+
}
|
|
679
744
|
const agentic = getAgenticDetector().detect(req.body);
|
|
680
745
|
const selector = getModelTierSelector();
|
|
681
746
|
const tier = selector.getTier(analysis.score);
|
|
682
747
|
|
|
683
|
-
// Get recommended model for tier
|
|
684
748
|
const provider = req.query.provider || "openai";
|
|
685
749
|
const modelSelection = selector.selectModel(tier, provider);
|
|
686
750
|
|
|
687
|
-
// Get model cost info
|
|
688
751
|
let modelInfo = null;
|
|
689
752
|
if (modelSelection.model) {
|
|
690
753
|
const registry = await getModelRegistry();
|
|
@@ -719,7 +782,6 @@ router.post("/v1/messages/count_tokens", rateLimiter, async (req, res, next) =>
|
|
|
719
782
|
try {
|
|
720
783
|
const { messages, system } = req.body;
|
|
721
784
|
|
|
722
|
-
// Validate required fields
|
|
723
785
|
if (!messages || !Array.isArray(messages)) {
|
|
724
786
|
return res.status(400).json({
|
|
725
787
|
error: {
|
|
@@ -729,7 +791,6 @@ router.post("/v1/messages/count_tokens", rateLimiter, async (req, res, next) =>
|
|
|
729
791
|
});
|
|
730
792
|
}
|
|
731
793
|
|
|
732
|
-
// Estimate token count
|
|
733
794
|
const inputTokens = estimateTokenCount(messages, system);
|
|
734
795
|
|
|
735
796
|
// Return token count in Anthropic API format
|
|
@@ -743,7 +804,6 @@ router.post("/v1/messages/count_tokens", rateLimiter, async (req, res, next) =>
|
|
|
743
804
|
|
|
744
805
|
// Stub endpoint for event logging (used by Claude CLI)
|
|
745
806
|
router.post("/api/event_logging/batch", (req, res) => {
|
|
746
|
-
// Silently accept and discard event logging requests
|
|
747
807
|
res.status(200).json({ success: true });
|
|
748
808
|
});
|
|
749
809
|
|
|
@@ -796,6 +856,20 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
796
856
|
model: req.body?.model,
|
|
797
857
|
}, "Inbound /v1/messages");
|
|
798
858
|
|
|
859
|
+
// WS3 — detect the client harness (Claude Code / Cursor / Codex / …)
|
|
860
|
+
// ONCE per request and stash on the body so every downstream routing
|
|
861
|
+
// stage (pickTierByIntent's per-message scoring, orchestrator's full
|
|
862
|
+
// determineProviderSmart) sees the same profile and can subtract the
|
|
863
|
+
// client's baseline tool loadout when scoring agentic signals.
|
|
864
|
+
if (!req.body._clientProfile) {
|
|
865
|
+
try {
|
|
866
|
+
const profile = detectClient({ headers: req.headers, payload: req.body });
|
|
867
|
+
if (profile) req.body._clientProfile = profile;
|
|
868
|
+
} catch (err) {
|
|
869
|
+
logger.debug({ err: err.message }, '[Router] client detection failed');
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
799
873
|
// Auth-mode classification (Headroom-style, UA-first):
|
|
800
874
|
//
|
|
801
875
|
// - 'subscription': UX-bound CLI/IDE (Claude Code, Cursor, Copilot, …).
|
|
@@ -821,13 +895,217 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
821
895
|
// COMPLEX/REASONING regardless of what the user actually typed. Window-
|
|
822
896
|
// scoring fixes that for PAYG too.
|
|
823
897
|
const authMode = classifyAuthMode(req.headers);
|
|
824
|
-
|
|
898
|
+
|
|
899
|
+
// The session middleware sets req.sessionId from the x-session-id header,
|
|
900
|
+
// but req.body._sessionId isn't populated until deep in the orchestrator
|
|
901
|
+
// (src/orchestrator/index.js). WS1's checkSessionPin reads _sessionId off
|
|
902
|
+
// the payload, so mirror it onto the body here — the orchestrator's later
|
|
903
|
+
// assignment is a no-op when the value already matches.
|
|
904
|
+
if (req.sessionId && !req.body._sessionId) {
|
|
905
|
+
req.body._sessionId = req.sessionId;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// WS1 — sticky-session reuse. If this session already has a valid pin
|
|
909
|
+
// (guards pass, no compaction), skip pickTierByIntent entirely and reuse
|
|
910
|
+
// the pinned decision. This is the biggest cost win of WS1: repeat turns
|
|
911
|
+
// in a session skip the whole window-scored intent pipeline.
|
|
912
|
+
let tier;
|
|
913
|
+
const pinCheck = checkSessionPin(req.body);
|
|
914
|
+
// Side-request detection. Claude Code fires internal background calls
|
|
915
|
+
// (title generation, summarization, memory extraction, suggestion-mode
|
|
916
|
+
// autocomplete) that REPLAY the conversation — so they share the
|
|
917
|
+
// conversation's content fingerprint — but wrap it in harness prompts
|
|
918
|
+
// and repo transcript text. Two live incidents (2026-07-07):
|
|
919
|
+
// 1. A summarization side request replayed tool outputs full of
|
|
920
|
+
// "security"/"credential" strings from the repo itself, tripped
|
|
921
|
+
// the risk guard, re-routed COMPLEX (score 100), and OVERWROTE
|
|
922
|
+
// the conversation's pin.
|
|
923
|
+
// 2. A suggestion-mode request — which DOES carry the full 13-tool
|
|
924
|
+
// loadout, defeating the tool-less check — tripped risk on its
|
|
925
|
+
// own "[SUGGESTION MODE: ...]" wrapper instructions and poisoned
|
|
926
|
+
// the pin the same way (telemetry: risk+window COMPLEX 100).
|
|
927
|
+
// Discriminators: interactive turns attach tools AND have a plain last
|
|
928
|
+
// user message; side traffic is tool-less OR suggestion-tagged.
|
|
929
|
+
// Side requests are routed to a static cheap tier below — scoring
|
|
930
|
+
// harness wrapper text is meaningless and, worse, can land them on the
|
|
931
|
+
// subscription passthrough, burning quota on autocomplete calls.
|
|
932
|
+
const _lastUserText = (() => {
|
|
933
|
+
const msgs = req.body?.messages;
|
|
934
|
+
if (!Array.isArray(msgs)) return '';
|
|
935
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
936
|
+
const m = msgs[i];
|
|
937
|
+
if (m?.role !== 'user') continue;
|
|
938
|
+
if (typeof m.content === 'string') return m.content;
|
|
939
|
+
if (Array.isArray(m.content)) {
|
|
940
|
+
return m.content.filter(b => b?.type === 'text').map(b => b.text || '').join(' ');
|
|
941
|
+
}
|
|
942
|
+
return '';
|
|
943
|
+
}
|
|
944
|
+
return '';
|
|
945
|
+
})();
|
|
946
|
+
const isSuggestionMode = _lastUserText.includes('[SUGGESTION MODE:');
|
|
947
|
+
// Tool-lessness alone is NOT harness evidence: generic API clients
|
|
948
|
+
// (curl, benchmarks, SDKs) legitimately send bare messages and must get
|
|
949
|
+
// full routing — live regression 2026-07-08: a benchmark's security-
|
|
950
|
+
// analysis scenario was force-SIMPLE'd as a "side request" and never
|
|
951
|
+
// reached the risk classifier. Require a detected client profile
|
|
952
|
+
// (harness UA / tool fingerprint) before treating tool-less traffic as
|
|
953
|
+
// side traffic; a suggestion-mode tag is harness evidence by itself.
|
|
954
|
+
const isKnownHarness = !!req.body?._clientProfile;
|
|
955
|
+
const isSideRequest = isSuggestionMode
|
|
956
|
+
|| ((!Array.isArray(req.body?.tools) || req.body.tools.length === 0) && isKnownHarness);
|
|
957
|
+
// Side requests short-circuit to the static SIMPLE tier: no pin read
|
|
958
|
+
// (a COMPLEX-pinned conversation would burn expensive tokens on
|
|
959
|
+
// autocomplete), no pin write, no intent scoring of wrapper text.
|
|
960
|
+
// Upstream failures (e.g. a huge summarization overflowing the SIMPLE
|
|
961
|
+
// model's context) are rescued by the tier-fallback chain.
|
|
962
|
+
let sideTier = null;
|
|
963
|
+
if (isSideRequest) {
|
|
964
|
+
try {
|
|
965
|
+
const sel = getModelTierSelector().selectModel('SIMPLE', null);
|
|
966
|
+
sideTier = {
|
|
967
|
+
tier: 'SIMPLE',
|
|
968
|
+
provider: sel.provider,
|
|
969
|
+
model: sel.model || null,
|
|
970
|
+
score: null,
|
|
971
|
+
method: isSuggestionMode ? 'side_request_suggestion' : 'side_request',
|
|
972
|
+
reason: 'harness_side_request',
|
|
973
|
+
base_tier: null,
|
|
974
|
+
escalation_source: null,
|
|
975
|
+
pinned: false,
|
|
976
|
+
switch_reason: null,
|
|
977
|
+
propensity: 1.0,
|
|
978
|
+
candidates: [{ provider: sel.provider, model: sel.model || null }],
|
|
979
|
+
};
|
|
980
|
+
} catch (err) {
|
|
981
|
+
// Tier selector unavailable — fall through to the normal flow;
|
|
982
|
+
// the isSideRequest guards below still block pin writes.
|
|
983
|
+
logger.debug({ err: err.message }, 'Side-request static tier failed, falling through');
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
// WS1.5 — upward-drift check. A session pinned SIMPLE by a trivial
|
|
987
|
+
// opener ("Hi") must escape the pin the moment the real task arrives
|
|
988
|
+
// ("plan a refactor of the whole repo"). Only meaningful for
|
|
989
|
+
// guards_passed serves — tool_history turns must never switch models.
|
|
990
|
+
let pinDrift = null;
|
|
991
|
+
if (!sideTier && pinCheck.serve && pinCheck.reason === 'guards_passed' && !isSideRequest) {
|
|
992
|
+
pinDrift = await checkPinScoreDrift(pinCheck.pin, req.body);
|
|
993
|
+
}
|
|
994
|
+
if (sideTier) {
|
|
995
|
+
tier = sideTier;
|
|
996
|
+
logger.debug({
|
|
997
|
+
reqNumber: messagesRequestCount,
|
|
998
|
+
authMode,
|
|
999
|
+
suggestion: isSuggestionMode,
|
|
1000
|
+
provider: tier.provider,
|
|
1001
|
+
}, "OAuth intent — side request routed to static SIMPLE");
|
|
1002
|
+
} else if (pinCheck.serve && !pinDrift?.drift) {
|
|
1003
|
+
tier = {
|
|
1004
|
+
tier: pinCheck.pin.tier || null,
|
|
1005
|
+
provider: pinCheck.pin.provider,
|
|
1006
|
+
model: pinCheck.pin.model || null,
|
|
1007
|
+
// Prefer the drift check's fresh per-message score — it's already
|
|
1008
|
+
// computed on every guards_passed pinned turn, and showing it makes
|
|
1009
|
+
// the badge reflect THIS message instead of repeating the score
|
|
1010
|
+
// that created the pin turns ago (users read a wall of "score 0"
|
|
1011
|
+
// as the router being asleep). Falls back to the pin's original
|
|
1012
|
+
// score on tool_history serves, where drift is deliberately
|
|
1013
|
+
// skipped. Never complexity.score — the full-body value is
|
|
1014
|
+
// inflated by tools + system + history.
|
|
1015
|
+
score: typeof pinDrift?.freshScore === 'number'
|
|
1016
|
+
? pinDrift.freshScore
|
|
1017
|
+
: (typeof pinCheck.pin.score === 'number' ? pinCheck.pin.score : null),
|
|
1018
|
+
// Original pin score, surfaced in the badge as "pin@N" so both
|
|
1019
|
+
// numbers are visible.
|
|
1020
|
+
_pinScore: typeof pinCheck.pin.score === 'number' ? pinCheck.pin.score : null,
|
|
1021
|
+
method: 'session_pin',
|
|
1022
|
+
reason: 'sticky_' + pinCheck.reason,
|
|
1023
|
+
base_tier: null,
|
|
1024
|
+
escalation_source: null,
|
|
1025
|
+
pinned: true,
|
|
1026
|
+
switch_reason: null,
|
|
1027
|
+
};
|
|
1028
|
+
logger.debug({
|
|
1029
|
+
reqNumber: messagesRequestCount,
|
|
1030
|
+
authMode,
|
|
1031
|
+
sessionId: pinCheck.sessionId,
|
|
1032
|
+
tier,
|
|
1033
|
+
}, "OAuth intent — served from session pin");
|
|
1034
|
+
} else {
|
|
1035
|
+
if (pinDrift?.drift) {
|
|
1036
|
+
logger.info({
|
|
1037
|
+
sessionId: pinCheck.sessionId,
|
|
1038
|
+
pinnedTier: pinCheck.pin?.tier,
|
|
1039
|
+
freshScore: pinDrift.freshScore,
|
|
1040
|
+
ceiling: pinDrift.ceiling,
|
|
1041
|
+
}, "OAuth intent — pin score drift, re-deciding");
|
|
1042
|
+
}
|
|
1043
|
+
tier = await pickTierByIntent(req.body);
|
|
1044
|
+
if (pinDrift?.drift && tier) tier.switch_reason = 'score_drift';
|
|
1045
|
+
|
|
1046
|
+
// Compaction floor: compaction resets cache economics, not task
|
|
1047
|
+
// difficulty — post-compaction windows score harness summaries, not
|
|
1048
|
+
// the ask that earned the pin. Re-routes may move up, never below
|
|
1049
|
+
// the pinned tier.
|
|
1050
|
+
if (pinCheck.reason === 'compaction' && pinCheck.pin?.tier && tier?.tier) {
|
|
1051
|
+
const { TIER_DEFINITIONS } = require("../routing/model-tiers");
|
|
1052
|
+
const pri = (t) => TIER_DEFINITIONS[t]?.priority ?? -1;
|
|
1053
|
+
if (pri(tier.tier) < pri(pinCheck.pin.tier)) {
|
|
1054
|
+
const { getModelTierSelector } = require("../routing/model-tiers");
|
|
1055
|
+
const floored = getModelTierSelector().selectModel(pinCheck.pin.tier, null);
|
|
1056
|
+
logger.info({
|
|
1057
|
+
sessionId: pinCheck.sessionId,
|
|
1058
|
+
windowTier: tier.tier,
|
|
1059
|
+
flooredTo: pinCheck.pin.tier,
|
|
1060
|
+
}, "OAuth intent — compaction re-route floored at pinned tier");
|
|
1061
|
+
tier = {
|
|
1062
|
+
...tier,
|
|
1063
|
+
tier: pinCheck.pin.tier,
|
|
1064
|
+
provider: floored.provider,
|
|
1065
|
+
model: floored.model,
|
|
1066
|
+
method: (tier.method || 'tier_config') + '+compaction_floor',
|
|
1067
|
+
switch_reason: 'compaction_floor',
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
// Persist the fresh decision so the next turn on this session can
|
|
1072
|
+
// reuse it. checkSessionPin returned serve=false (or WS1.5 drift fired),
|
|
1073
|
+
// so either there was no pin, the pin lost a guard (context/vision/
|
|
1074
|
+
// risk), the session was compacted, or the conversation outgrew its
|
|
1075
|
+
// pinned tier — in every case we want the new pin. EXCEPT side
|
|
1076
|
+
// requests: their decisions reflect harness wrapper text, not the
|
|
1077
|
+
// user's conversation, and must never poison the conversation's pin.
|
|
1078
|
+
if (!isSideRequest && pinCheck.sessionId && tier?.provider) {
|
|
1079
|
+
writeSessionPin(pinCheck.sessionId, tier, req.body);
|
|
1080
|
+
} else if (isSideRequest && pinCheck.sessionId && tier?.provider) {
|
|
1081
|
+
logger.debug({
|
|
1082
|
+
sessionId: pinCheck.sessionId,
|
|
1083
|
+
tier: tier.tier,
|
|
1084
|
+
provider: tier.provider,
|
|
1085
|
+
}, "OAuth intent — side request (no tools), pin write skipped");
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
825
1088
|
|
|
826
1089
|
// Subscription-only fork: anti-abuse stealth passthrough when the picked
|
|
827
1090
|
// tier resolves to azure-anthropic. Bypasses the orchestrator entirely
|
|
828
1091
|
// so the inbound bytes hit api.anthropic.com unchanged (Anthropic
|
|
829
1092
|
// fingerprints subscription clients; any mutation gets flagged).
|
|
830
|
-
|
|
1093
|
+
// Passthrough forwards the CLIENT's credentials; GUI harnesses that
|
|
1094
|
+
// spawn claude headless inject placeholder keys ("dummy") that Anthropic
|
|
1095
|
+
// 401s. Placeholder auth ⇒ serve REASONING via the provider's own
|
|
1096
|
+
// credentials instead.
|
|
1097
|
+
const _clientApiKey = String(req.headers?.['x-api-key'] || '').toLowerCase();
|
|
1098
|
+
const _clientAuthHdr = String(req.headers?.authorization || '');
|
|
1099
|
+
const _placeholderAuth =
|
|
1100
|
+
!_clientAuthHdr &&
|
|
1101
|
+
(['dummy', 'test', 'placeholder', 'none', 'x', 'sk-dummy'].includes(_clientApiKey) || _clientApiKey.length < 8);
|
|
1102
|
+
if (_placeholderAuth && authMode === 'subscription' && tier.provider === 'azure-anthropic') {
|
|
1103
|
+
logger.info({
|
|
1104
|
+
reqNumber: messagesRequestCount,
|
|
1105
|
+
xApiKeyShape: _clientApiKey.slice(0, 12),
|
|
1106
|
+
}, 'Placeholder client auth — skipping passthrough, serving REASONING via provider credentials');
|
|
1107
|
+
}
|
|
1108
|
+
if (authMode === 'subscription' && tier.provider === 'azure-anthropic' && !_placeholderAuth) {
|
|
831
1109
|
logger.debug({
|
|
832
1110
|
reqNumber: messagesRequestCount,
|
|
833
1111
|
authMode,
|
|
@@ -852,6 +1130,26 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
852
1130
|
}, "Intent-scored tier routing → orchestrator (forced provider)");
|
|
853
1131
|
req.body._forceProvider = tier.provider;
|
|
854
1132
|
if (tier.model) req.body._tierModel = tier.model;
|
|
1133
|
+
// Carry the full intent-scored decision into the downstream client so
|
|
1134
|
+
// WS0's telemetry columns (tier, base_tier, escalation_source, pinned)
|
|
1135
|
+
// are populated for the forced path too — otherwise every OAuth-intent
|
|
1136
|
+
// request lands in routing_telemetry with empty tier + method='forced'.
|
|
1137
|
+
if (tier.tier) req.body._tierName = tier.tier;
|
|
1138
|
+
if (tier.method) req.body._forcedMethod = tier.method;
|
|
1139
|
+
if (tier.base_tier) req.body._baseTier = tier.base_tier;
|
|
1140
|
+
if (tier.escalation_source) req.body._escalationSource = tier.escalation_source;
|
|
1141
|
+
if (tier.pinned) req.body._pinnedRoute = true;
|
|
1142
|
+
if (tier.switch_reason) req.body._switchReason = tier.switch_reason;
|
|
1143
|
+
// WS4 — propensity + candidates land on every telemetry row so downstream
|
|
1144
|
+
// off-policy evaluation can score any counterfactual policy from logs.
|
|
1145
|
+
if (tier.propensity != null) req.body._propensity = tier.propensity;
|
|
1146
|
+
if (tier.candidates) req.body._candidates = tier.candidates;
|
|
1147
|
+
// WS5 — bandit context vector + query embedding for the feedback loop.
|
|
1148
|
+
// All three are underscored; `_stripInternalFields` scrubs them before
|
|
1149
|
+
// the outbound provider request so no risk of leaking to Anthropic/Ollama.
|
|
1150
|
+
if (tier._banditContext) req.body._banditContext = tier._banditContext;
|
|
1151
|
+
if (tier._queryEmbedding) req.body._queryEmbedding = tier._queryEmbedding;
|
|
1152
|
+
if (tier._queryText) req.body._queryText = tier._queryText;
|
|
855
1153
|
req._intentTier = tier;
|
|
856
1154
|
|
|
857
1155
|
// Convert Anthropic server tools (web_search_20260209, etc.) to regular
|
|
@@ -959,16 +1257,32 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
959
1257
|
preRouteReason = 'user_intent';
|
|
960
1258
|
}
|
|
961
1259
|
|
|
1260
|
+
// Prefer the intent scorer's per-message score over the full-payload
|
|
1261
|
+
// complexity score. The full-payload score is always inflated on
|
|
1262
|
+
// subscription clients (5 KB system + 11 tools + prior turns) and would
|
|
1263
|
+
// display "score 46" on a trivial "what did I just say?" follow-up.
|
|
1264
|
+
// Only fall back to complexity.score when we don't have an intent
|
|
1265
|
+
// decision at all (e.g. shouldForceLocal shortcut, or the intent scorer
|
|
1266
|
+
// didn't run for this request type).
|
|
1267
|
+
let displayScore = null;
|
|
1268
|
+
if (req._intentTier && typeof req._intentTier.score === 'number') {
|
|
1269
|
+
displayScore = req._intentTier.score;
|
|
1270
|
+
} else if (!req._intentTier) {
|
|
1271
|
+
displayScore = complexity.score;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
962
1274
|
const preRouteDecision = {
|
|
963
1275
|
provider: preRouteProvider,
|
|
964
1276
|
tier: preRouteTier,
|
|
965
1277
|
model: preRouteModel,
|
|
966
1278
|
method: preRouteMethod,
|
|
967
1279
|
reason: preRouteReason,
|
|
968
|
-
|
|
969
|
-
score: req._intentTier?.score ?? complexity.score,
|
|
1280
|
+
score: displayScore,
|
|
970
1281
|
threshold: complexity.threshold,
|
|
971
1282
|
risk: preRouteRisk,
|
|
1283
|
+
// Pin-serve turns carry the pin's original score so the badge can
|
|
1284
|
+
// show "score <fresh> · pin@<original>".
|
|
1285
|
+
_pinScore: typeof req._intentTier?._pinScore === 'number' ? req._intentTier._pinScore : null,
|
|
972
1286
|
};
|
|
973
1287
|
|
|
974
1288
|
const routingHeaders = getRoutingHeaders(preRouteDecision);
|
|
@@ -978,19 +1292,17 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
978
1292
|
// response body when LYNKR_VISIBLE_ROUTING=true.
|
|
979
1293
|
const interaction = buildInteractionBlock(preRouteDecision);
|
|
980
1294
|
|
|
981
|
-
// Extract client CWD from request body or header
|
|
982
1295
|
const clientCwd = validateCwd(req.body?.cwd || req.headers['x-workspace-cwd']);
|
|
983
1296
|
|
|
984
1297
|
// For true streaming: only support non-tool requests for MVP
|
|
985
1298
|
// Tool requests require buffering for agent loop
|
|
986
1299
|
if (wantsStream && !hasTools) {
|
|
987
|
-
// True streaming path for text-only requests
|
|
988
1300
|
metrics.recordStreamingStart();
|
|
989
1301
|
res.set({
|
|
990
1302
|
"Content-Type": "text/event-stream",
|
|
991
1303
|
"Cache-Control": "no-cache",
|
|
992
1304
|
Connection: "keep-alive",
|
|
993
|
-
...routingHeaders,
|
|
1305
|
+
...routingHeaders,
|
|
994
1306
|
});
|
|
995
1307
|
if (typeof res.flushHeaders === "function") {
|
|
996
1308
|
res.flushHeaders();
|
|
@@ -1008,7 +1320,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1008
1320
|
},
|
|
1009
1321
|
});
|
|
1010
1322
|
|
|
1011
|
-
// Check if we got a stream back
|
|
1012
1323
|
if (result.stream) {
|
|
1013
1324
|
// Parse SSE stream from provider and forward to client
|
|
1014
1325
|
const reader = result.stream.getReader();
|
|
@@ -1017,13 +1328,12 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1017
1328
|
|
|
1018
1329
|
try {
|
|
1019
1330
|
while (true) {
|
|
1020
|
-
const { done, value } = await reader
|
|
1331
|
+
const { done, value } = await readWithIdleTimeout(reader, "provider-stream");
|
|
1021
1332
|
if (done) break;
|
|
1022
1333
|
|
|
1023
1334
|
const chunk = decoder.decode(value, { stream: true });
|
|
1024
1335
|
bufferChunks.push(chunk);
|
|
1025
1336
|
|
|
1026
|
-
// Join buffer and split by lines
|
|
1027
1337
|
const buffer = bufferChunks.join('');
|
|
1028
1338
|
const lines = buffer.split('\n');
|
|
1029
1339
|
|
|
@@ -1038,13 +1348,11 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1038
1348
|
}
|
|
1039
1349
|
}
|
|
1040
1350
|
|
|
1041
|
-
// Flush after each chunk
|
|
1042
1351
|
if (typeof res.flush === 'function') {
|
|
1043
1352
|
res.flush();
|
|
1044
1353
|
}
|
|
1045
1354
|
}
|
|
1046
1355
|
|
|
1047
|
-
// Send any remaining buffer
|
|
1048
1356
|
const remaining = bufferChunks.join('');
|
|
1049
1357
|
if (remaining.trim()) {
|
|
1050
1358
|
res.write(remaining + '\n');
|
|
@@ -1056,7 +1364,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1056
1364
|
} catch (streamError) {
|
|
1057
1365
|
logger.error({ error: streamError }, "Error streaming response");
|
|
1058
1366
|
|
|
1059
|
-
// Cancel stream on error
|
|
1060
1367
|
try {
|
|
1061
1368
|
await reader.cancel();
|
|
1062
1369
|
} catch (cancelError) {
|
|
@@ -1066,6 +1373,10 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1066
1373
|
if (!res.headersSent) {
|
|
1067
1374
|
res.status(500).json({ error: "Streaming error" });
|
|
1068
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 */ }
|
|
1069
1380
|
res.end();
|
|
1070
1381
|
}
|
|
1071
1382
|
return;
|
|
@@ -1080,8 +1391,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1080
1391
|
}
|
|
1081
1392
|
}
|
|
1082
1393
|
|
|
1083
|
-
// Fallback: if no stream, wrap buffered response in proper Anthropic SSE format
|
|
1084
|
-
// Check if result.body exists
|
|
1085
1394
|
if (!result || !result.body) {
|
|
1086
1395
|
res.write(`event: error\n`);
|
|
1087
1396
|
res.write(`data: ${JSON.stringify({ type: "error", error: { message: "Empty response from provider" } })}\n\n`);
|
|
@@ -1091,7 +1400,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1091
1400
|
|
|
1092
1401
|
const msg = result.body;
|
|
1093
1402
|
|
|
1094
|
-
// 1. message_start
|
|
1095
1403
|
res.write(`event: message_start\n`);
|
|
1096
1404
|
res.write(`data: ${JSON.stringify({
|
|
1097
1405
|
type: "message_start",
|
|
@@ -1107,19 +1415,24 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1107
1415
|
}
|
|
1108
1416
|
})}\n\n`);
|
|
1109
1417
|
|
|
1110
|
-
// 2. content_block_start and content_block_delta for each content block
|
|
1111
1418
|
// Filter out server-side tools that shouldn't reach the client
|
|
1419
|
+
// Server-tool filtering is server-mode only: in client mode Task and
|
|
1420
|
+
// WebSearch are the CLIENT'S tools, and stripping them emits a
|
|
1421
|
+
// malformed turn (stop_reason tool_use with no tool_use block).
|
|
1112
1422
|
const _serverTools = new Set(["task", "websearch", "webfetch", "web_search", "web_fetch", "web_agent"]);
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
+
);
|
|
1116
1429
|
|
|
1117
1430
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1118
1431
|
// users can see which tier/provider/model handled the request inside
|
|
1119
1432
|
// Claude Code's TUI (TUI only renders content blocks; unknown top-level
|
|
1120
1433
|
// fields are silently dropped).
|
|
1121
1434
|
if (config.routing?.visibleInteraction && interaction) {
|
|
1122
|
-
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'}*\n\n`;
|
|
1435
|
+
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'}${interaction.pin_score != null ? ` · pin@${interaction.pin_score}` : ''}*\n\n`;
|
|
1123
1436
|
contentBlocks = [{ type: 'text', text: badge }, ...contentBlocks];
|
|
1124
1437
|
}
|
|
1125
1438
|
|
|
@@ -1221,15 +1534,20 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1221
1534
|
}
|
|
1222
1535
|
}
|
|
1223
1536
|
|
|
1224
|
-
// 3. message_delta with stop_reason
|
|
1225
1537
|
res.write(`event: message_delta\n`);
|
|
1226
1538
|
res.write(`data: ${JSON.stringify({
|
|
1227
1539
|
type: "message_delta",
|
|
1228
|
-
|
|
1540
|
+
// Consistency: never claim tool_use if no tool_use block was emitted
|
|
1541
|
+
// (a stop_reason pointing at a missing block hangs the client).
|
|
1542
|
+
delta: {
|
|
1543
|
+
stop_reason: (msg.stop_reason === "tool_use" && !contentBlocks.some(b => b.type === "tool_use"))
|
|
1544
|
+
? "end_turn"
|
|
1545
|
+
: (msg.stop_reason || "end_turn"),
|
|
1546
|
+
stop_sequence: null,
|
|
1547
|
+
},
|
|
1229
1548
|
usage: { output_tokens: msg.usage?.output_tokens || 0 }
|
|
1230
1549
|
})}\n\n`);
|
|
1231
1550
|
|
|
1232
|
-
// 4. message_stop
|
|
1233
1551
|
res.write(`event: message_stop\n`);
|
|
1234
1552
|
res.write(`data: ${JSON.stringify({ type: "message_stop" })}\n\n`);
|
|
1235
1553
|
|
|
@@ -1266,7 +1584,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1266
1584
|
res.flushHeaders();
|
|
1267
1585
|
}
|
|
1268
1586
|
|
|
1269
|
-
// Check if result.body exists
|
|
1270
1587
|
if (!result || !result.body) {
|
|
1271
1588
|
res.write(`event: error\n`);
|
|
1272
1589
|
res.write(`data: ${JSON.stringify({ type: "error", error: { message: "Empty response from provider" } })}\n\n`);
|
|
@@ -1274,10 +1591,8 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1274
1591
|
return;
|
|
1275
1592
|
}
|
|
1276
1593
|
|
|
1277
|
-
// Use proper Anthropic SSE format
|
|
1278
1594
|
const msg = result.body;
|
|
1279
1595
|
|
|
1280
|
-
// 1. message_start
|
|
1281
1596
|
res.write(`event: message_start\n`);
|
|
1282
1597
|
res.write(`data: ${JSON.stringify({
|
|
1283
1598
|
type: "message_start",
|
|
@@ -1293,19 +1608,24 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1293
1608
|
}
|
|
1294
1609
|
})}\n\n`);
|
|
1295
1610
|
|
|
1296
|
-
// 2. content_block_start and content_block_delta for each content block
|
|
1297
1611
|
// Filter out server-side tools that shouldn't reach the client
|
|
1612
|
+
// Server-tool filtering is server-mode only: in client mode Task and
|
|
1613
|
+
// WebSearch are the CLIENT'S tools, and stripping them emits a
|
|
1614
|
+
// malformed turn (stop_reason tool_use with no tool_use block).
|
|
1298
1615
|
const _serverTools = new Set(["task", "websearch", "webfetch", "web_search", "web_fetch", "web_agent"]);
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
+
);
|
|
1302
1622
|
|
|
1303
1623
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1304
1624
|
// users can see which tier/provider/model handled the request inside
|
|
1305
1625
|
// Claude Code's TUI (TUI only renders content blocks; unknown top-level
|
|
1306
1626
|
// fields are silently dropped).
|
|
1307
1627
|
if (config.routing?.visibleInteraction && interaction) {
|
|
1308
|
-
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'}*\n\n`;
|
|
1628
|
+
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'}${interaction.pin_score != null ? ` · pin@${interaction.pin_score}` : ''}*\n\n`;
|
|
1309
1629
|
contentBlocks = [{ type: 'text', text: badge }, ...contentBlocks];
|
|
1310
1630
|
}
|
|
1311
1631
|
|
|
@@ -1387,15 +1707,20 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1387
1707
|
}
|
|
1388
1708
|
}
|
|
1389
1709
|
|
|
1390
|
-
// 3. message_delta with stop_reason
|
|
1391
1710
|
res.write(`event: message_delta\n`);
|
|
1392
1711
|
res.write(`data: ${JSON.stringify({
|
|
1393
1712
|
type: "message_delta",
|
|
1394
|
-
|
|
1713
|
+
// Consistency: never claim tool_use if no tool_use block was emitted
|
|
1714
|
+
// (a stop_reason pointing at a missing block hangs the client).
|
|
1715
|
+
delta: {
|
|
1716
|
+
stop_reason: (msg.stop_reason === "tool_use" && !contentBlocks.some(b => b.type === "tool_use"))
|
|
1717
|
+
? "end_turn"
|
|
1718
|
+
: (msg.stop_reason || "end_turn"),
|
|
1719
|
+
stop_sequence: null,
|
|
1720
|
+
},
|
|
1395
1721
|
usage: { output_tokens: msg.usage?.output_tokens || 0 }
|
|
1396
1722
|
})}\n\n`);
|
|
1397
1723
|
|
|
1398
|
-
// 4. message_stop
|
|
1399
1724
|
res.write(`event: message_stop\n`);
|
|
1400
1725
|
res.write(`data: ${JSON.stringify({ type: "message_stop" })}\n\n`);
|
|
1401
1726
|
|
|
@@ -1446,7 +1771,7 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1446
1771
|
parsed.lynkr_interaction = interaction;
|
|
1447
1772
|
// Inject a one-line routing badge into content so the TUI renders it.
|
|
1448
1773
|
if (Array.isArray(parsed.content)) {
|
|
1449
|
-
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'} · savings ~${interaction.estimated_savings_percent ?? 0}%*\n\n`;
|
|
1774
|
+
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'}${interaction.pin_score != null ? ` · pin@${interaction.pin_score}` : ''} · savings ~${interaction.estimated_savings_percent ?? 0}%*\n\n`;
|
|
1450
1775
|
parsed.content.unshift({ type: 'text', text: badge });
|
|
1451
1776
|
}
|
|
1452
1777
|
finalBody = JSON.stringify(parsed);
|