lynkr 9.7.3 → 9.9.1
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 +63 -25
- package/bin/cli.js +16 -1
- package/bin/lynkr-init.js +44 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +23 -2
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/build-eval-set.js +256 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/validate-difficulty-classifier.js +123 -0
- package/scripts/validate-intent-anchors.js +186 -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 +467 -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 +932 -47
- 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/classifier-setup.js +207 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +88 -15
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/difficulty-classifier.js +219 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +931 -90
- package/src/routing/intent-score.js +441 -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 +86 -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
|
/**
|
|
@@ -29,6 +53,8 @@ const rateLimiter = createRateLimiter();
|
|
|
29
53
|
* - force_local / force_cloud regex shortcuts
|
|
30
54
|
* - risk classifier (high-risk → forced COMPLEX)
|
|
31
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)
|
|
32
58
|
* - agentic-workflow detector (may bump min-tier)
|
|
33
59
|
* - kNN router (embedding-based nearest-neighbors of historical queries)
|
|
34
60
|
* - LinUCB contextual bandit (intra-tier model selection, learns from reward)
|
|
@@ -39,6 +65,21 @@ const rateLimiter = createRateLimiter();
|
|
|
39
65
|
* Plus telemetry — every decision is recorded so kNN/bandit improve over time.
|
|
40
66
|
*/
|
|
41
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
|
+
}
|
|
42
83
|
// Build a user-intent payload. We INCLUDE the tools array (signals agentic
|
|
43
84
|
// intent — a request with 12 tools attached is meaningfully different from
|
|
44
85
|
// a chat-only one, even if both messages look short) but EXCLUDE the system
|
|
@@ -61,14 +102,24 @@ async function pickTierByIntent(body) {
|
|
|
61
102
|
const N = Math.max(1, Number(process.env.LYNKR_INTENT_WINDOW_N) || 5);
|
|
62
103
|
const decay = Number(process.env.LYNKR_INTENT_DECAY);
|
|
63
104
|
const decayFactor = Number.isFinite(decay) && decay > 0 && decay <= 1 ? decay : 0.7;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
105
|
+
// Window over messages the USER actually authored: tool_result-only and
|
|
106
|
+
// harness frames would otherwise age the typed ask out of the window and
|
|
107
|
+
// score envelope noise in its place. Pure tool exchanges keep the raw
|
|
108
|
+
// window as a fallback.
|
|
109
|
+
const { extractCleanUserText } = require("../routing/intent-score");
|
|
110
|
+
const textBearingMsgs = allUserMsgs.filter(
|
|
111
|
+
(m) => extractCleanUserText({ messages: [m] })
|
|
112
|
+
);
|
|
113
|
+
const windowUserMsgs = (textBearingMsgs.length > 0 ? textBearingMsgs : allUserMsgs)
|
|
114
|
+
.slice(-N); // chronological, oldest-first
|
|
115
|
+
|
|
116
|
+
// WS3 — we USED to slice tools to 3 here so Claude Code's 11 baseline
|
|
117
|
+
// tools didn't inflate the agentic detector's tool-count signal. That
|
|
118
|
+
// hack was client-specific and also discarded real MCP tools the user
|
|
119
|
+
// had configured. Now we pass the full tools array and let the detector
|
|
120
|
+
// subtract the client's baseline via the profile threaded onto the
|
|
121
|
+
// payload — same short-message-intent guarantee, but MCP tools count.
|
|
122
|
+
const intentTools = Array.isArray(body?.tools) ? body.tools : undefined;
|
|
72
123
|
|
|
73
124
|
// CLEAN each user message: Claude Code wraps user input in
|
|
74
125
|
// <system-reminder>...</system-reminder> blocks (CLAUDE.md context,
|
|
@@ -124,6 +175,10 @@ async function pickTierByIntent(body) {
|
|
|
124
175
|
const intentPayload = {
|
|
125
176
|
messages: cleaned ? [cleaned] : [],
|
|
126
177
|
tools: intentTools,
|
|
178
|
+
// WS3 — inherit the client profile from the parent request so the
|
|
179
|
+
// agentic detector inside determineProviderSmart can subtract the
|
|
180
|
+
// harness's baseline tools during intent scoring too.
|
|
181
|
+
_clientProfile: body?._clientProfile || null,
|
|
127
182
|
};
|
|
128
183
|
try {
|
|
129
184
|
const decision = await determineProviderSmart(intentPayload, {
|
|
@@ -173,6 +228,23 @@ async function pickTierByIntent(body) {
|
|
|
173
228
|
reason: d.reason || null,
|
|
174
229
|
agenticResult: d.agenticResult || null,
|
|
175
230
|
risk: d.risk || null,
|
|
231
|
+
// WS0: forward the intent-scoring decision's escalation ledger so the
|
|
232
|
+
// downstream forced-provider path can record it in telemetry.
|
|
233
|
+
base_tier: d.base_tier ?? null,
|
|
234
|
+
escalation_source: d.escalation_source ?? null,
|
|
235
|
+
// WS4: off-policy evaluation needs propensity + candidates on every row.
|
|
236
|
+
// The inner determineProviderSmart already sets these — we just forward.
|
|
237
|
+
// Falls back to a deterministic single-candidate view when the inner
|
|
238
|
+
// path returned neither (e.g. legacy shadow decisions).
|
|
239
|
+
propensity: d.propensity ?? 1.0,
|
|
240
|
+
candidates: d.candidates ?? [{ provider: d.provider, model: d.model || null }],
|
|
241
|
+
// WS5: feedback path needs the bandit context vector (to call
|
|
242
|
+
// bandit.update with the same features the arm was scored on) and the
|
|
243
|
+
// query embedding (to add conclusive-quality outcomes to kNN). Both
|
|
244
|
+
// are underscored — they don't leak through response headers.
|
|
245
|
+
_banditContext: d._banditContext ?? null,
|
|
246
|
+
_queryEmbedding: d._queryEmbedding ?? null,
|
|
247
|
+
_queryText: d._queryText ?? null,
|
|
176
248
|
};
|
|
177
249
|
}
|
|
178
250
|
|
|
@@ -226,6 +298,21 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
226
298
|
if (HOP_BY_HOP.has(name.toLowerCase())) continue;
|
|
227
299
|
outHeaders[name] = Array.isArray(value) ? value.join(", ") : value;
|
|
228
300
|
}
|
|
301
|
+
// Strip Lynkr's internal underscore-prefixed fields (_sessionId,
|
|
302
|
+
// _forceProvider, _tierModel, _tierName, _forcedMethod, _baseTier,
|
|
303
|
+
// _escalationSource, _pinnedRoute, _switchReason, _clientProfile,
|
|
304
|
+
// _workspace, _tenantPolicy, _deadlineMs, _suggestionModeModel). Anthropic
|
|
305
|
+
// rejects unknown top-level keys with "Extra inputs are not permitted".
|
|
306
|
+
// The orchestrator's downstream paths whitelist their outbound bodies,
|
|
307
|
+
// but the passthrough sends this body VERBATIM — so we must strip here.
|
|
308
|
+
if (bodyToSend && typeof bodyToSend === 'object') {
|
|
309
|
+
const stripped = { ...bodyToSend };
|
|
310
|
+
for (const key of Object.keys(stripped)) {
|
|
311
|
+
if (key.startsWith('_')) delete stripped[key];
|
|
312
|
+
}
|
|
313
|
+
bodyToSend = stripped;
|
|
314
|
+
}
|
|
315
|
+
|
|
229
316
|
// Re-stringify the body — express already parsed it. Identical re-encoding
|
|
230
317
|
// is fine; Anthropic doesn't fingerprint key ordering.
|
|
231
318
|
const bodyText = JSON.stringify(bodyToSend);
|
|
@@ -296,7 +383,7 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
296
383
|
const decoder = new TextDecoder();
|
|
297
384
|
try {
|
|
298
385
|
while (true) {
|
|
299
|
-
const { value, done } = await reader
|
|
386
|
+
const { value, done } = await readWithIdleTimeout(reader, "oauth-passthrough");
|
|
300
387
|
if (done) break;
|
|
301
388
|
const buf = Buffer.from(value);
|
|
302
389
|
res.write(buf);
|
|
@@ -306,6 +393,10 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
306
393
|
responseTextForObservability += decoder.decode(value, { stream: true });
|
|
307
394
|
}
|
|
308
395
|
}
|
|
396
|
+
} catch (err) {
|
|
397
|
+
logger.warn({ err: err.message }, "OAuth passthrough stream stalled — ending response");
|
|
398
|
+
try { reader.cancel(); } catch { /* already dead */ }
|
|
399
|
+
try { res.write(SSE_STALL_EVENT); } catch { /* client gone */ }
|
|
309
400
|
} finally {
|
|
310
401
|
try { reader.releaseLock(); } catch {}
|
|
311
402
|
}
|
|
@@ -313,10 +404,18 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
313
404
|
} else {
|
|
314
405
|
const text = await upstreamResp.text();
|
|
315
406
|
if (!upstreamResp.ok) {
|
|
407
|
+
// Auth-shape diagnostics (prefix only, never the token): sk-ant-oat*
|
|
408
|
+
// = subscription OAuth (fix: /login refresh), sk-ant-api* = an API KEY
|
|
409
|
+
// is overriding the subscription (fix: unset ANTHROPIC_API_KEY or the
|
|
410
|
+
// client's injected key), JWT/none = client sent something unusable.
|
|
411
|
+
const authHdr = String(req.headers?.authorization || "");
|
|
412
|
+
const apiKeyHdr = String(req.headers?.["x-api-key"] || "");
|
|
316
413
|
logger.warn({
|
|
317
414
|
status: upstreamResp.status,
|
|
318
415
|
bodyPreview: text.slice(0, 500),
|
|
319
416
|
upstream,
|
|
417
|
+
authShape: authHdr ? authHdr.replace("Bearer ", "").slice(0, 12) + "…" : "(none)",
|
|
418
|
+
xApiKeyShape: apiKeyHdr ? apiKeyHdr.slice(0, 12) + "…" : "(none)",
|
|
320
419
|
}, "OAuth passthrough upstream returned non-2xx");
|
|
321
420
|
}
|
|
322
421
|
responseTextForObservability = text;
|
|
@@ -359,59 +458,53 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
359
458
|
?? inputTokenEstimate;
|
|
360
459
|
|
|
361
460
|
// Lynkr-wide metrics
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if (outputTokens || inputTokensActual) mc.recordTokens?.(inputTokensActual, outputTokens || 0);
|
|
367
|
-
} catch (_) {}
|
|
461
|
+
const { getMetricsCollector } = require("../observability/metrics");
|
|
462
|
+
const mc = getMetricsCollector();
|
|
463
|
+
mc.recordProviderSuccess("azure-anthropic-passthrough", latencyMs);
|
|
464
|
+
if (outputTokens || inputTokensActual) mc.recordTokens(inputTokensActual, outputTokens || 0);
|
|
368
465
|
|
|
369
466
|
// 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 (_) {}
|
|
467
|
+
const tlm = require("../routing/telemetry");
|
|
468
|
+
tlm.record({
|
|
469
|
+
request_id: req.headers["request-id"] || req.headers["x-request-id"] || null,
|
|
470
|
+
session_id: req.body?._sessionId || req.sessionId || null,
|
|
471
|
+
timestamp: startedAt,
|
|
472
|
+
tier: tier.tier || "COMPLEX",
|
|
473
|
+
provider: "azure-anthropic-passthrough",
|
|
474
|
+
model: req.body?.model || tier.model || null,
|
|
475
|
+
routing_method: "oauth-passthrough",
|
|
476
|
+
status_code: upstreamResp.status,
|
|
477
|
+
latency_ms: latencyMs,
|
|
478
|
+
input_tokens: inputTokensActual || null,
|
|
479
|
+
output_tokens: outputTokens || null,
|
|
480
|
+
message_count: req.body?.messages?.length || null,
|
|
481
|
+
tool_count: Array.isArray(req.body?.tools) ? req.body.tools.length : 0,
|
|
482
|
+
was_fallback: false,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Audit log. NOTE: the interface returned by createAuditLogger exposes
|
|
486
|
+
// logLlmRequest/logLlmResponse — no generic `.log` — so the optional
|
|
487
|
+
// call stays until this is migrated to the real audit API.
|
|
488
|
+
const { createAuditLogger } = require("../logger/audit-logger");
|
|
489
|
+
const audit = createAuditLogger(config.audit);
|
|
490
|
+
audit.log?.({
|
|
491
|
+
provider: "azure-anthropic-passthrough",
|
|
492
|
+
destination: upstream,
|
|
493
|
+
status: upstreamResp.status,
|
|
494
|
+
latencyMs,
|
|
495
|
+
inputTokens: inputTokensActual,
|
|
496
|
+
outputTokens,
|
|
497
|
+
model: req.body?.model,
|
|
498
|
+
});
|
|
404
499
|
|
|
405
500
|
// Memory extraction (read-only on response, no LLM call — pure regex)
|
|
406
501
|
if (parsedResponse && config.memory?.extraction?.enabled) {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
).catch(() => {});
|
|
414
|
-
} catch (_) {}
|
|
502
|
+
const memoryExtractor = require("../memory/extractor");
|
|
503
|
+
memoryExtractor.extractMemories(
|
|
504
|
+
parsedResponse,
|
|
505
|
+
req.body?.messages || [],
|
|
506
|
+
{ sessionId: req.body?._sessionId || req.sessionId || null }
|
|
507
|
+
).catch(() => {});
|
|
415
508
|
}
|
|
416
509
|
} catch (err) {
|
|
417
510
|
logger.debug({ err: err.message }, "OAuth passthrough observability hook failed (non-fatal)");
|
|
@@ -464,7 +557,6 @@ function extractAnthropicMessageFromSSE(sseText) {
|
|
|
464
557
|
* - Memory is disabled
|
|
465
558
|
* - No memories retrieved
|
|
466
559
|
* - Latest message is not a user turn (could be tool_result, assistant)
|
|
467
|
-
* - Latest user message sits inside a cache_control-marked prefix
|
|
468
560
|
*
|
|
469
561
|
* Returns a new body with appended context otherwise. Original body never
|
|
470
562
|
* mutated (returns a shallow-cloned messages array).
|
|
@@ -476,29 +568,6 @@ function maybeInjectMemoryIntoUserTail(body) {
|
|
|
476
568
|
const lastMsg = body.messages[lastIdx];
|
|
477
569
|
if (!lastMsg || lastMsg.role !== "user") return body;
|
|
478
570
|
|
|
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
571
|
const { retrieveRelevantMemories, formatMemoriesForContext, extractQueryFromMessage } =
|
|
503
572
|
require("../memory/retriever");
|
|
504
573
|
const query = extractQueryFromMessage(lastMsg);
|
|
@@ -675,16 +744,27 @@ router.post("/routing/analyze", async (req, res) => {
|
|
|
675
744
|
const { getModelTierSelector } = require("../routing/model-tiers");
|
|
676
745
|
const { getModelRegistry } = require("../routing/model-registry");
|
|
677
746
|
|
|
678
|
-
|
|
747
|
+
// ?mode=intent scores through the WS7 anchor intent path — the same
|
|
748
|
+
// scorer live /v1/messages routing uses — instead of the legacy lexical
|
|
749
|
+
// complexity analyzer. Falls back to lexical when embeddings are down
|
|
750
|
+
// (mirroring live behavior).
|
|
751
|
+
let analysis;
|
|
752
|
+
if (req.query.mode === "intent") {
|
|
753
|
+
const { scoreIntent } = require("../routing/intent-score");
|
|
754
|
+
const intent = await scoreIntent(req.body);
|
|
755
|
+
analysis = intent
|
|
756
|
+
? { score: intent.score, intentMode: intent.mode, intentClass: intent.class ?? null }
|
|
757
|
+
: await analyzeComplexity(req.body, { weighted: req.query.weighted === "true" });
|
|
758
|
+
} else {
|
|
759
|
+
analysis = await analyzeComplexity(req.body, { weighted: req.query.weighted === "true" });
|
|
760
|
+
}
|
|
679
761
|
const agentic = getAgenticDetector().detect(req.body);
|
|
680
762
|
const selector = getModelTierSelector();
|
|
681
763
|
const tier = selector.getTier(analysis.score);
|
|
682
764
|
|
|
683
|
-
// Get recommended model for tier
|
|
684
765
|
const provider = req.query.provider || "openai";
|
|
685
766
|
const modelSelection = selector.selectModel(tier, provider);
|
|
686
767
|
|
|
687
|
-
// Get model cost info
|
|
688
768
|
let modelInfo = null;
|
|
689
769
|
if (modelSelection.model) {
|
|
690
770
|
const registry = await getModelRegistry();
|
|
@@ -719,7 +799,6 @@ router.post("/v1/messages/count_tokens", rateLimiter, async (req, res, next) =>
|
|
|
719
799
|
try {
|
|
720
800
|
const { messages, system } = req.body;
|
|
721
801
|
|
|
722
|
-
// Validate required fields
|
|
723
802
|
if (!messages || !Array.isArray(messages)) {
|
|
724
803
|
return res.status(400).json({
|
|
725
804
|
error: {
|
|
@@ -729,7 +808,6 @@ router.post("/v1/messages/count_tokens", rateLimiter, async (req, res, next) =>
|
|
|
729
808
|
});
|
|
730
809
|
}
|
|
731
810
|
|
|
732
|
-
// Estimate token count
|
|
733
811
|
const inputTokens = estimateTokenCount(messages, system);
|
|
734
812
|
|
|
735
813
|
// Return token count in Anthropic API format
|
|
@@ -743,7 +821,6 @@ router.post("/v1/messages/count_tokens", rateLimiter, async (req, res, next) =>
|
|
|
743
821
|
|
|
744
822
|
// Stub endpoint for event logging (used by Claude CLI)
|
|
745
823
|
router.post("/api/event_logging/batch", (req, res) => {
|
|
746
|
-
// Silently accept and discard event logging requests
|
|
747
824
|
res.status(200).json({ success: true });
|
|
748
825
|
});
|
|
749
826
|
|
|
@@ -796,6 +873,20 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
796
873
|
model: req.body?.model,
|
|
797
874
|
}, "Inbound /v1/messages");
|
|
798
875
|
|
|
876
|
+
// WS3 — detect the client harness (Claude Code / Cursor / Codex / …)
|
|
877
|
+
// ONCE per request and stash on the body so every downstream routing
|
|
878
|
+
// stage (pickTierByIntent's per-message scoring, orchestrator's full
|
|
879
|
+
// determineProviderSmart) sees the same profile and can subtract the
|
|
880
|
+
// client's baseline tool loadout when scoring agentic signals.
|
|
881
|
+
if (!req.body._clientProfile) {
|
|
882
|
+
try {
|
|
883
|
+
const profile = detectClient({ headers: req.headers, payload: req.body });
|
|
884
|
+
if (profile) req.body._clientProfile = profile;
|
|
885
|
+
} catch (err) {
|
|
886
|
+
logger.debug({ err: err.message }, '[Router] client detection failed');
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
799
890
|
// Auth-mode classification (Headroom-style, UA-first):
|
|
800
891
|
//
|
|
801
892
|
// - 'subscription': UX-bound CLI/IDE (Claude Code, Cursor, Copilot, …).
|
|
@@ -821,13 +912,217 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
821
912
|
// COMPLEX/REASONING regardless of what the user actually typed. Window-
|
|
822
913
|
// scoring fixes that for PAYG too.
|
|
823
914
|
const authMode = classifyAuthMode(req.headers);
|
|
824
|
-
|
|
915
|
+
|
|
916
|
+
// The session middleware sets req.sessionId from the x-session-id header,
|
|
917
|
+
// but req.body._sessionId isn't populated until deep in the orchestrator
|
|
918
|
+
// (src/orchestrator/index.js). WS1's checkSessionPin reads _sessionId off
|
|
919
|
+
// the payload, so mirror it onto the body here — the orchestrator's later
|
|
920
|
+
// assignment is a no-op when the value already matches.
|
|
921
|
+
if (req.sessionId && !req.body._sessionId) {
|
|
922
|
+
req.body._sessionId = req.sessionId;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// WS1 — sticky-session reuse. If this session already has a valid pin
|
|
926
|
+
// (guards pass, no compaction), skip pickTierByIntent entirely and reuse
|
|
927
|
+
// the pinned decision. This is the biggest cost win of WS1: repeat turns
|
|
928
|
+
// in a session skip the whole window-scored intent pipeline.
|
|
929
|
+
let tier;
|
|
930
|
+
const pinCheck = checkSessionPin(req.body);
|
|
931
|
+
// Side-request detection. Claude Code fires internal background calls
|
|
932
|
+
// (title generation, summarization, memory extraction, suggestion-mode
|
|
933
|
+
// autocomplete) that REPLAY the conversation — so they share the
|
|
934
|
+
// conversation's content fingerprint — but wrap it in harness prompts
|
|
935
|
+
// and repo transcript text. Two live incidents (2026-07-07):
|
|
936
|
+
// 1. A summarization side request replayed tool outputs full of
|
|
937
|
+
// "security"/"credential" strings from the repo itself, tripped
|
|
938
|
+
// the risk guard, re-routed COMPLEX (score 100), and OVERWROTE
|
|
939
|
+
// the conversation's pin.
|
|
940
|
+
// 2. A suggestion-mode request — which DOES carry the full 13-tool
|
|
941
|
+
// loadout, defeating the tool-less check — tripped risk on its
|
|
942
|
+
// own "[SUGGESTION MODE: ...]" wrapper instructions and poisoned
|
|
943
|
+
// the pin the same way (telemetry: risk+window COMPLEX 100).
|
|
944
|
+
// Discriminators: interactive turns attach tools AND have a plain last
|
|
945
|
+
// user message; side traffic is tool-less OR suggestion-tagged.
|
|
946
|
+
// Side requests are routed to a static cheap tier below — scoring
|
|
947
|
+
// harness wrapper text is meaningless and, worse, can land them on the
|
|
948
|
+
// subscription passthrough, burning quota on autocomplete calls.
|
|
949
|
+
const _lastUserText = (() => {
|
|
950
|
+
const msgs = req.body?.messages;
|
|
951
|
+
if (!Array.isArray(msgs)) return '';
|
|
952
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
953
|
+
const m = msgs[i];
|
|
954
|
+
if (m?.role !== 'user') continue;
|
|
955
|
+
if (typeof m.content === 'string') return m.content;
|
|
956
|
+
if (Array.isArray(m.content)) {
|
|
957
|
+
return m.content.filter(b => b?.type === 'text').map(b => b.text || '').join(' ');
|
|
958
|
+
}
|
|
959
|
+
return '';
|
|
960
|
+
}
|
|
961
|
+
return '';
|
|
962
|
+
})();
|
|
963
|
+
const isSuggestionMode = _lastUserText.includes('[SUGGESTION MODE:');
|
|
964
|
+
// Tool-lessness alone is NOT harness evidence: generic API clients
|
|
965
|
+
// (curl, benchmarks, SDKs) legitimately send bare messages and must get
|
|
966
|
+
// full routing — live regression 2026-07-08: a benchmark's security-
|
|
967
|
+
// analysis scenario was force-SIMPLE'd as a "side request" and never
|
|
968
|
+
// reached the risk classifier. Require a detected client profile
|
|
969
|
+
// (harness UA / tool fingerprint) before treating tool-less traffic as
|
|
970
|
+
// side traffic; a suggestion-mode tag is harness evidence by itself.
|
|
971
|
+
const isKnownHarness = !!req.body?._clientProfile;
|
|
972
|
+
const isSideRequest = isSuggestionMode
|
|
973
|
+
|| ((!Array.isArray(req.body?.tools) || req.body.tools.length === 0) && isKnownHarness);
|
|
974
|
+
// Side requests short-circuit to the static SIMPLE tier: no pin read
|
|
975
|
+
// (a COMPLEX-pinned conversation would burn expensive tokens on
|
|
976
|
+
// autocomplete), no pin write, no intent scoring of wrapper text.
|
|
977
|
+
// Upstream failures (e.g. a huge summarization overflowing the SIMPLE
|
|
978
|
+
// model's context) are rescued by the tier-fallback chain.
|
|
979
|
+
let sideTier = null;
|
|
980
|
+
if (isSideRequest) {
|
|
981
|
+
try {
|
|
982
|
+
const sel = getModelTierSelector().selectModel('SIMPLE', null);
|
|
983
|
+
sideTier = {
|
|
984
|
+
tier: 'SIMPLE',
|
|
985
|
+
provider: sel.provider,
|
|
986
|
+
model: sel.model || null,
|
|
987
|
+
score: null,
|
|
988
|
+
method: isSuggestionMode ? 'side_request_suggestion' : 'side_request',
|
|
989
|
+
reason: 'harness_side_request',
|
|
990
|
+
base_tier: null,
|
|
991
|
+
escalation_source: null,
|
|
992
|
+
pinned: false,
|
|
993
|
+
switch_reason: null,
|
|
994
|
+
propensity: 1.0,
|
|
995
|
+
candidates: [{ provider: sel.provider, model: sel.model || null }],
|
|
996
|
+
};
|
|
997
|
+
} catch (err) {
|
|
998
|
+
// Tier selector unavailable — fall through to the normal flow;
|
|
999
|
+
// the isSideRequest guards below still block pin writes.
|
|
1000
|
+
logger.debug({ err: err.message }, 'Side-request static tier failed, falling through');
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
// WS1.5 — upward-drift check. A session pinned SIMPLE by a trivial
|
|
1004
|
+
// opener ("Hi") must escape the pin the moment the real task arrives
|
|
1005
|
+
// ("plan a refactor of the whole repo"). Only meaningful for
|
|
1006
|
+
// guards_passed serves — tool_history turns must never switch models.
|
|
1007
|
+
let pinDrift = null;
|
|
1008
|
+
if (!sideTier && pinCheck.serve && pinCheck.reason === 'guards_passed' && !isSideRequest) {
|
|
1009
|
+
pinDrift = await checkPinScoreDrift(pinCheck.pin, req.body);
|
|
1010
|
+
}
|
|
1011
|
+
if (sideTier) {
|
|
1012
|
+
tier = sideTier;
|
|
1013
|
+
logger.debug({
|
|
1014
|
+
reqNumber: messagesRequestCount,
|
|
1015
|
+
authMode,
|
|
1016
|
+
suggestion: isSuggestionMode,
|
|
1017
|
+
provider: tier.provider,
|
|
1018
|
+
}, "OAuth intent — side request routed to static SIMPLE");
|
|
1019
|
+
} else if (pinCheck.serve && !pinDrift?.drift) {
|
|
1020
|
+
tier = {
|
|
1021
|
+
tier: pinCheck.pin.tier || null,
|
|
1022
|
+
provider: pinCheck.pin.provider,
|
|
1023
|
+
model: pinCheck.pin.model || null,
|
|
1024
|
+
// Prefer the drift check's fresh per-message score — it's already
|
|
1025
|
+
// computed on every guards_passed pinned turn, and showing it makes
|
|
1026
|
+
// the badge reflect THIS message instead of repeating the score
|
|
1027
|
+
// that created the pin turns ago (users read a wall of "score 0"
|
|
1028
|
+
// as the router being asleep). Falls back to the pin's original
|
|
1029
|
+
// score on tool_history serves, where drift is deliberately
|
|
1030
|
+
// skipped. Never complexity.score — the full-body value is
|
|
1031
|
+
// inflated by tools + system + history.
|
|
1032
|
+
score: typeof pinDrift?.freshScore === 'number'
|
|
1033
|
+
? pinDrift.freshScore
|
|
1034
|
+
: (typeof pinCheck.pin.score === 'number' ? pinCheck.pin.score : null),
|
|
1035
|
+
// Original pin score, surfaced in the badge as "pin@N" so both
|
|
1036
|
+
// numbers are visible.
|
|
1037
|
+
_pinScore: typeof pinCheck.pin.score === 'number' ? pinCheck.pin.score : null,
|
|
1038
|
+
method: 'session_pin',
|
|
1039
|
+
reason: 'sticky_' + pinCheck.reason,
|
|
1040
|
+
base_tier: null,
|
|
1041
|
+
escalation_source: null,
|
|
1042
|
+
pinned: true,
|
|
1043
|
+
switch_reason: null,
|
|
1044
|
+
};
|
|
1045
|
+
logger.debug({
|
|
1046
|
+
reqNumber: messagesRequestCount,
|
|
1047
|
+
authMode,
|
|
1048
|
+
sessionId: pinCheck.sessionId,
|
|
1049
|
+
tier,
|
|
1050
|
+
}, "OAuth intent — served from session pin");
|
|
1051
|
+
} else {
|
|
1052
|
+
if (pinDrift?.drift) {
|
|
1053
|
+
logger.info({
|
|
1054
|
+
sessionId: pinCheck.sessionId,
|
|
1055
|
+
pinnedTier: pinCheck.pin?.tier,
|
|
1056
|
+
freshScore: pinDrift.freshScore,
|
|
1057
|
+
ceiling: pinDrift.ceiling,
|
|
1058
|
+
}, "OAuth intent — pin score drift, re-deciding");
|
|
1059
|
+
}
|
|
1060
|
+
tier = await pickTierByIntent(req.body);
|
|
1061
|
+
if (pinDrift?.drift && tier) tier.switch_reason = 'score_drift';
|
|
1062
|
+
|
|
1063
|
+
// Compaction floor: compaction resets cache economics, not task
|
|
1064
|
+
// difficulty — post-compaction windows score harness summaries, not
|
|
1065
|
+
// the ask that earned the pin. Re-routes may move up, never below
|
|
1066
|
+
// the pinned tier.
|
|
1067
|
+
if (pinCheck.reason === 'compaction' && pinCheck.pin?.tier && tier?.tier) {
|
|
1068
|
+
const { TIER_DEFINITIONS } = require("../routing/model-tiers");
|
|
1069
|
+
const pri = (t) => TIER_DEFINITIONS[t]?.priority ?? -1;
|
|
1070
|
+
if (pri(tier.tier) < pri(pinCheck.pin.tier)) {
|
|
1071
|
+
const { getModelTierSelector } = require("../routing/model-tiers");
|
|
1072
|
+
const floored = getModelTierSelector().selectModel(pinCheck.pin.tier, null);
|
|
1073
|
+
logger.info({
|
|
1074
|
+
sessionId: pinCheck.sessionId,
|
|
1075
|
+
windowTier: tier.tier,
|
|
1076
|
+
flooredTo: pinCheck.pin.tier,
|
|
1077
|
+
}, "OAuth intent — compaction re-route floored at pinned tier");
|
|
1078
|
+
tier = {
|
|
1079
|
+
...tier,
|
|
1080
|
+
tier: pinCheck.pin.tier,
|
|
1081
|
+
provider: floored.provider,
|
|
1082
|
+
model: floored.model,
|
|
1083
|
+
method: (tier.method || 'tier_config') + '+compaction_floor',
|
|
1084
|
+
switch_reason: 'compaction_floor',
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// Persist the fresh decision so the next turn on this session can
|
|
1089
|
+
// reuse it. checkSessionPin returned serve=false (or WS1.5 drift fired),
|
|
1090
|
+
// so either there was no pin, the pin lost a guard (context/vision/
|
|
1091
|
+
// risk), the session was compacted, or the conversation outgrew its
|
|
1092
|
+
// pinned tier — in every case we want the new pin. EXCEPT side
|
|
1093
|
+
// requests: their decisions reflect harness wrapper text, not the
|
|
1094
|
+
// user's conversation, and must never poison the conversation's pin.
|
|
1095
|
+
if (!isSideRequest && pinCheck.sessionId && tier?.provider) {
|
|
1096
|
+
writeSessionPin(pinCheck.sessionId, tier, req.body);
|
|
1097
|
+
} else if (isSideRequest && pinCheck.sessionId && tier?.provider) {
|
|
1098
|
+
logger.debug({
|
|
1099
|
+
sessionId: pinCheck.sessionId,
|
|
1100
|
+
tier: tier.tier,
|
|
1101
|
+
provider: tier.provider,
|
|
1102
|
+
}, "OAuth intent — side request (no tools), pin write skipped");
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
825
1105
|
|
|
826
1106
|
// Subscription-only fork: anti-abuse stealth passthrough when the picked
|
|
827
1107
|
// tier resolves to azure-anthropic. Bypasses the orchestrator entirely
|
|
828
1108
|
// so the inbound bytes hit api.anthropic.com unchanged (Anthropic
|
|
829
1109
|
// fingerprints subscription clients; any mutation gets flagged).
|
|
830
|
-
|
|
1110
|
+
// Passthrough forwards the CLIENT's credentials; GUI harnesses that
|
|
1111
|
+
// spawn claude headless inject placeholder keys ("dummy") that Anthropic
|
|
1112
|
+
// 401s. Placeholder auth ⇒ serve REASONING via the provider's own
|
|
1113
|
+
// credentials instead.
|
|
1114
|
+
const _clientApiKey = String(req.headers?.['x-api-key'] || '').toLowerCase();
|
|
1115
|
+
const _clientAuthHdr = String(req.headers?.authorization || '');
|
|
1116
|
+
const _placeholderAuth =
|
|
1117
|
+
!_clientAuthHdr &&
|
|
1118
|
+
(['dummy', 'test', 'placeholder', 'none', 'x', 'sk-dummy'].includes(_clientApiKey) || _clientApiKey.length < 8);
|
|
1119
|
+
if (_placeholderAuth && authMode === 'subscription' && tier.provider === 'azure-anthropic') {
|
|
1120
|
+
logger.info({
|
|
1121
|
+
reqNumber: messagesRequestCount,
|
|
1122
|
+
xApiKeyShape: _clientApiKey.slice(0, 12),
|
|
1123
|
+
}, 'Placeholder client auth — skipping passthrough, serving REASONING via provider credentials');
|
|
1124
|
+
}
|
|
1125
|
+
if (authMode === 'subscription' && tier.provider === 'azure-anthropic' && !_placeholderAuth) {
|
|
831
1126
|
logger.debug({
|
|
832
1127
|
reqNumber: messagesRequestCount,
|
|
833
1128
|
authMode,
|
|
@@ -852,6 +1147,26 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
852
1147
|
}, "Intent-scored tier routing → orchestrator (forced provider)");
|
|
853
1148
|
req.body._forceProvider = tier.provider;
|
|
854
1149
|
if (tier.model) req.body._tierModel = tier.model;
|
|
1150
|
+
// Carry the full intent-scored decision into the downstream client so
|
|
1151
|
+
// WS0's telemetry columns (tier, base_tier, escalation_source, pinned)
|
|
1152
|
+
// are populated for the forced path too — otherwise every OAuth-intent
|
|
1153
|
+
// request lands in routing_telemetry with empty tier + method='forced'.
|
|
1154
|
+
if (tier.tier) req.body._tierName = tier.tier;
|
|
1155
|
+
if (tier.method) req.body._forcedMethod = tier.method;
|
|
1156
|
+
if (tier.base_tier) req.body._baseTier = tier.base_tier;
|
|
1157
|
+
if (tier.escalation_source) req.body._escalationSource = tier.escalation_source;
|
|
1158
|
+
if (tier.pinned) req.body._pinnedRoute = true;
|
|
1159
|
+
if (tier.switch_reason) req.body._switchReason = tier.switch_reason;
|
|
1160
|
+
// WS4 — propensity + candidates land on every telemetry row so downstream
|
|
1161
|
+
// off-policy evaluation can score any counterfactual policy from logs.
|
|
1162
|
+
if (tier.propensity != null) req.body._propensity = tier.propensity;
|
|
1163
|
+
if (tier.candidates) req.body._candidates = tier.candidates;
|
|
1164
|
+
// WS5 — bandit context vector + query embedding for the feedback loop.
|
|
1165
|
+
// All three are underscored; `_stripInternalFields` scrubs them before
|
|
1166
|
+
// the outbound provider request so no risk of leaking to Anthropic/Ollama.
|
|
1167
|
+
if (tier._banditContext) req.body._banditContext = tier._banditContext;
|
|
1168
|
+
if (tier._queryEmbedding) req.body._queryEmbedding = tier._queryEmbedding;
|
|
1169
|
+
if (tier._queryText) req.body._queryText = tier._queryText;
|
|
855
1170
|
req._intentTier = tier;
|
|
856
1171
|
|
|
857
1172
|
// Convert Anthropic server tools (web_search_20260209, etc.) to regular
|
|
@@ -959,16 +1274,32 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
959
1274
|
preRouteReason = 'user_intent';
|
|
960
1275
|
}
|
|
961
1276
|
|
|
1277
|
+
// Prefer the intent scorer's per-message score over the full-payload
|
|
1278
|
+
// complexity score. The full-payload score is always inflated on
|
|
1279
|
+
// subscription clients (5 KB system + 11 tools + prior turns) and would
|
|
1280
|
+
// display "score 46" on a trivial "what did I just say?" follow-up.
|
|
1281
|
+
// Only fall back to complexity.score when we don't have an intent
|
|
1282
|
+
// decision at all (e.g. shouldForceLocal shortcut, or the intent scorer
|
|
1283
|
+
// didn't run for this request type).
|
|
1284
|
+
let displayScore = null;
|
|
1285
|
+
if (req._intentTier && typeof req._intentTier.score === 'number') {
|
|
1286
|
+
displayScore = req._intentTier.score;
|
|
1287
|
+
} else if (!req._intentTier) {
|
|
1288
|
+
displayScore = complexity.score;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
962
1291
|
const preRouteDecision = {
|
|
963
1292
|
provider: preRouteProvider,
|
|
964
1293
|
tier: preRouteTier,
|
|
965
1294
|
model: preRouteModel,
|
|
966
1295
|
method: preRouteMethod,
|
|
967
1296
|
reason: preRouteReason,
|
|
968
|
-
|
|
969
|
-
score: req._intentTier?.score ?? complexity.score,
|
|
1297
|
+
score: displayScore,
|
|
970
1298
|
threshold: complexity.threshold,
|
|
971
1299
|
risk: preRouteRisk,
|
|
1300
|
+
// Pin-serve turns carry the pin's original score so the badge can
|
|
1301
|
+
// show "score <fresh> · pin@<original>".
|
|
1302
|
+
_pinScore: typeof req._intentTier?._pinScore === 'number' ? req._intentTier._pinScore : null,
|
|
972
1303
|
};
|
|
973
1304
|
|
|
974
1305
|
const routingHeaders = getRoutingHeaders(preRouteDecision);
|
|
@@ -978,19 +1309,17 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
978
1309
|
// response body when LYNKR_VISIBLE_ROUTING=true.
|
|
979
1310
|
const interaction = buildInteractionBlock(preRouteDecision);
|
|
980
1311
|
|
|
981
|
-
// Extract client CWD from request body or header
|
|
982
1312
|
const clientCwd = validateCwd(req.body?.cwd || req.headers['x-workspace-cwd']);
|
|
983
1313
|
|
|
984
1314
|
// For true streaming: only support non-tool requests for MVP
|
|
985
1315
|
// Tool requests require buffering for agent loop
|
|
986
1316
|
if (wantsStream && !hasTools) {
|
|
987
|
-
// True streaming path for text-only requests
|
|
988
1317
|
metrics.recordStreamingStart();
|
|
989
1318
|
res.set({
|
|
990
1319
|
"Content-Type": "text/event-stream",
|
|
991
1320
|
"Cache-Control": "no-cache",
|
|
992
1321
|
Connection: "keep-alive",
|
|
993
|
-
...routingHeaders,
|
|
1322
|
+
...routingHeaders,
|
|
994
1323
|
});
|
|
995
1324
|
if (typeof res.flushHeaders === "function") {
|
|
996
1325
|
res.flushHeaders();
|
|
@@ -1008,7 +1337,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1008
1337
|
},
|
|
1009
1338
|
});
|
|
1010
1339
|
|
|
1011
|
-
// Check if we got a stream back
|
|
1012
1340
|
if (result.stream) {
|
|
1013
1341
|
// Parse SSE stream from provider and forward to client
|
|
1014
1342
|
const reader = result.stream.getReader();
|
|
@@ -1017,13 +1345,12 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1017
1345
|
|
|
1018
1346
|
try {
|
|
1019
1347
|
while (true) {
|
|
1020
|
-
const { done, value } = await reader
|
|
1348
|
+
const { done, value } = await readWithIdleTimeout(reader, "provider-stream");
|
|
1021
1349
|
if (done) break;
|
|
1022
1350
|
|
|
1023
1351
|
const chunk = decoder.decode(value, { stream: true });
|
|
1024
1352
|
bufferChunks.push(chunk);
|
|
1025
1353
|
|
|
1026
|
-
// Join buffer and split by lines
|
|
1027
1354
|
const buffer = bufferChunks.join('');
|
|
1028
1355
|
const lines = buffer.split('\n');
|
|
1029
1356
|
|
|
@@ -1038,13 +1365,11 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1038
1365
|
}
|
|
1039
1366
|
}
|
|
1040
1367
|
|
|
1041
|
-
// Flush after each chunk
|
|
1042
1368
|
if (typeof res.flush === 'function') {
|
|
1043
1369
|
res.flush();
|
|
1044
1370
|
}
|
|
1045
1371
|
}
|
|
1046
1372
|
|
|
1047
|
-
// Send any remaining buffer
|
|
1048
1373
|
const remaining = bufferChunks.join('');
|
|
1049
1374
|
if (remaining.trim()) {
|
|
1050
1375
|
res.write(remaining + '\n');
|
|
@@ -1056,7 +1381,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1056
1381
|
} catch (streamError) {
|
|
1057
1382
|
logger.error({ error: streamError }, "Error streaming response");
|
|
1058
1383
|
|
|
1059
|
-
// Cancel stream on error
|
|
1060
1384
|
try {
|
|
1061
1385
|
await reader.cancel();
|
|
1062
1386
|
} catch (cancelError) {
|
|
@@ -1066,6 +1390,10 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1066
1390
|
if (!res.headersSent) {
|
|
1067
1391
|
res.status(500).json({ error: "Streaming error" });
|
|
1068
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 */ }
|
|
1069
1397
|
res.end();
|
|
1070
1398
|
}
|
|
1071
1399
|
return;
|
|
@@ -1080,8 +1408,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1080
1408
|
}
|
|
1081
1409
|
}
|
|
1082
1410
|
|
|
1083
|
-
// Fallback: if no stream, wrap buffered response in proper Anthropic SSE format
|
|
1084
|
-
// Check if result.body exists
|
|
1085
1411
|
if (!result || !result.body) {
|
|
1086
1412
|
res.write(`event: error\n`);
|
|
1087
1413
|
res.write(`data: ${JSON.stringify({ type: "error", error: { message: "Empty response from provider" } })}\n\n`);
|
|
@@ -1091,7 +1417,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1091
1417
|
|
|
1092
1418
|
const msg = result.body;
|
|
1093
1419
|
|
|
1094
|
-
// 1. message_start
|
|
1095
1420
|
res.write(`event: message_start\n`);
|
|
1096
1421
|
res.write(`data: ${JSON.stringify({
|
|
1097
1422
|
type: "message_start",
|
|
@@ -1107,19 +1432,24 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1107
1432
|
}
|
|
1108
1433
|
})}\n\n`);
|
|
1109
1434
|
|
|
1110
|
-
// 2. content_block_start and content_block_delta for each content block
|
|
1111
1435
|
// Filter out server-side tools that shouldn't reach the client
|
|
1436
|
+
// Server-tool filtering is server-mode only: in client mode Task and
|
|
1437
|
+
// WebSearch are the CLIENT'S tools, and stripping them emits a
|
|
1438
|
+
// malformed turn (stop_reason tool_use with no tool_use block).
|
|
1112
1439
|
const _serverTools = new Set(["task", "websearch", "webfetch", "web_search", "web_fetch", "web_agent"]);
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1440
|
+
const _clientOwnsTools = config.toolExecutionMode === "client" || config.toolExecutionMode === "passthrough";
|
|
1441
|
+
let contentBlocks = _clientOwnsTools
|
|
1442
|
+
? (msg.content || []).slice()
|
|
1443
|
+
: (msg.content || []).filter(b =>
|
|
1444
|
+
!(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
|
|
1445
|
+
);
|
|
1116
1446
|
|
|
1117
1447
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1118
1448
|
// users can see which tier/provider/model handled the request inside
|
|
1119
1449
|
// Claude Code's TUI (TUI only renders content blocks; unknown top-level
|
|
1120
1450
|
// fields are silently dropped).
|
|
1121
1451
|
if (config.routing?.visibleInteraction && interaction) {
|
|
1122
|
-
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'}*\n\n`;
|
|
1452
|
+
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'}${interaction.pin_score != null ? ` · pin@${interaction.pin_score}` : ''}*\n\n`;
|
|
1123
1453
|
contentBlocks = [{ type: 'text', text: badge }, ...contentBlocks];
|
|
1124
1454
|
}
|
|
1125
1455
|
|
|
@@ -1221,15 +1551,20 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1221
1551
|
}
|
|
1222
1552
|
}
|
|
1223
1553
|
|
|
1224
|
-
// 3. message_delta with stop_reason
|
|
1225
1554
|
res.write(`event: message_delta\n`);
|
|
1226
1555
|
res.write(`data: ${JSON.stringify({
|
|
1227
1556
|
type: "message_delta",
|
|
1228
|
-
|
|
1557
|
+
// Consistency: never claim tool_use if no tool_use block was emitted
|
|
1558
|
+
// (a stop_reason pointing at a missing block hangs the client).
|
|
1559
|
+
delta: {
|
|
1560
|
+
stop_reason: (msg.stop_reason === "tool_use" && !contentBlocks.some(b => b.type === "tool_use"))
|
|
1561
|
+
? "end_turn"
|
|
1562
|
+
: (msg.stop_reason || "end_turn"),
|
|
1563
|
+
stop_sequence: null,
|
|
1564
|
+
},
|
|
1229
1565
|
usage: { output_tokens: msg.usage?.output_tokens || 0 }
|
|
1230
1566
|
})}\n\n`);
|
|
1231
1567
|
|
|
1232
|
-
// 4. message_stop
|
|
1233
1568
|
res.write(`event: message_stop\n`);
|
|
1234
1569
|
res.write(`data: ${JSON.stringify({ type: "message_stop" })}\n\n`);
|
|
1235
1570
|
|
|
@@ -1266,7 +1601,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1266
1601
|
res.flushHeaders();
|
|
1267
1602
|
}
|
|
1268
1603
|
|
|
1269
|
-
// Check if result.body exists
|
|
1270
1604
|
if (!result || !result.body) {
|
|
1271
1605
|
res.write(`event: error\n`);
|
|
1272
1606
|
res.write(`data: ${JSON.stringify({ type: "error", error: { message: "Empty response from provider" } })}\n\n`);
|
|
@@ -1274,10 +1608,8 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1274
1608
|
return;
|
|
1275
1609
|
}
|
|
1276
1610
|
|
|
1277
|
-
// Use proper Anthropic SSE format
|
|
1278
1611
|
const msg = result.body;
|
|
1279
1612
|
|
|
1280
|
-
// 1. message_start
|
|
1281
1613
|
res.write(`event: message_start\n`);
|
|
1282
1614
|
res.write(`data: ${JSON.stringify({
|
|
1283
1615
|
type: "message_start",
|
|
@@ -1293,19 +1625,24 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1293
1625
|
}
|
|
1294
1626
|
})}\n\n`);
|
|
1295
1627
|
|
|
1296
|
-
// 2. content_block_start and content_block_delta for each content block
|
|
1297
1628
|
// Filter out server-side tools that shouldn't reach the client
|
|
1629
|
+
// Server-tool filtering is server-mode only: in client mode Task and
|
|
1630
|
+
// WebSearch are the CLIENT'S tools, and stripping them emits a
|
|
1631
|
+
// malformed turn (stop_reason tool_use with no tool_use block).
|
|
1298
1632
|
const _serverTools = new Set(["task", "websearch", "webfetch", "web_search", "web_fetch", "web_agent"]);
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1633
|
+
const _clientOwnsTools = config.toolExecutionMode === "client" || config.toolExecutionMode === "passthrough";
|
|
1634
|
+
let contentBlocks = _clientOwnsTools
|
|
1635
|
+
? (msg.content || []).slice()
|
|
1636
|
+
: (msg.content || []).filter(b =>
|
|
1637
|
+
!(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
|
|
1638
|
+
);
|
|
1302
1639
|
|
|
1303
1640
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1304
1641
|
// users can see which tier/provider/model handled the request inside
|
|
1305
1642
|
// Claude Code's TUI (TUI only renders content blocks; unknown top-level
|
|
1306
1643
|
// fields are silently dropped).
|
|
1307
1644
|
if (config.routing?.visibleInteraction && interaction) {
|
|
1308
|
-
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'}*\n\n`;
|
|
1645
|
+
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'}${interaction.pin_score != null ? ` · pin@${interaction.pin_score}` : ''}*\n\n`;
|
|
1309
1646
|
contentBlocks = [{ type: 'text', text: badge }, ...contentBlocks];
|
|
1310
1647
|
}
|
|
1311
1648
|
|
|
@@ -1387,15 +1724,20 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1387
1724
|
}
|
|
1388
1725
|
}
|
|
1389
1726
|
|
|
1390
|
-
// 3. message_delta with stop_reason
|
|
1391
1727
|
res.write(`event: message_delta\n`);
|
|
1392
1728
|
res.write(`data: ${JSON.stringify({
|
|
1393
1729
|
type: "message_delta",
|
|
1394
|
-
|
|
1730
|
+
// Consistency: never claim tool_use if no tool_use block was emitted
|
|
1731
|
+
// (a stop_reason pointing at a missing block hangs the client).
|
|
1732
|
+
delta: {
|
|
1733
|
+
stop_reason: (msg.stop_reason === "tool_use" && !contentBlocks.some(b => b.type === "tool_use"))
|
|
1734
|
+
? "end_turn"
|
|
1735
|
+
: (msg.stop_reason || "end_turn"),
|
|
1736
|
+
stop_sequence: null,
|
|
1737
|
+
},
|
|
1395
1738
|
usage: { output_tokens: msg.usage?.output_tokens || 0 }
|
|
1396
1739
|
})}\n\n`);
|
|
1397
1740
|
|
|
1398
|
-
// 4. message_stop
|
|
1399
1741
|
res.write(`event: message_stop\n`);
|
|
1400
1742
|
res.write(`data: ${JSON.stringify({ type: "message_stop" })}\n\n`);
|
|
1401
1743
|
|
|
@@ -1446,7 +1788,7 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1446
1788
|
parsed.lynkr_interaction = interaction;
|
|
1447
1789
|
// Inject a one-line routing badge into content so the TUI renders it.
|
|
1448
1790
|
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`;
|
|
1791
|
+
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
1792
|
parsed.content.unshift({ type: 'text', text: badge });
|
|
1451
1793
|
}
|
|
1452
1794
|
finalBody = JSON.stringify(parsed);
|