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.
Files changed (80) hide show
  1. package/README.md +63 -25
  2. package/bin/cli.js +16 -1
  3. package/bin/lynkr-init.js +44 -1
  4. package/bin/lynkr-usage.js +78 -0
  5. package/bin/wrap.js +60 -35
  6. package/config/difficulty-anchors.json +22 -0
  7. package/package.json +23 -2
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/build-eval-set.js +256 -0
  10. package/scripts/calibrate-thresholds.js +38 -157
  11. package/scripts/compact-dictionary.js +204 -0
  12. package/scripts/mine-difficulty-anchors.js +288 -0
  13. package/scripts/test-deduplication.js +448 -0
  14. package/scripts/validate-difficulty-classifier.js +123 -0
  15. package/scripts/validate-intent-anchors.js +186 -0
  16. package/scripts/ws7-anchor-replay.js +108 -0
  17. package/skills/lynkr/SKILL.md +195 -0
  18. package/src/api/middleware/loop-guard.js +87 -0
  19. package/src/api/middleware/request-logging.js +5 -64
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +120 -101
  22. package/src/api/providers-handler.js +27 -2
  23. package/src/api/router.js +467 -125
  24. package/src/budget/index.js +2 -19
  25. package/src/cache/semantic.js +9 -0
  26. package/src/clients/databricks.js +455 -142
  27. package/src/clients/gpt-utils.js +11 -105
  28. package/src/clients/openai-format.js +10 -3
  29. package/src/clients/openrouter-utils.js +49 -24
  30. package/src/clients/prompt-cache-injection.js +1 -0
  31. package/src/clients/provider-capabilities.js +1 -1
  32. package/src/clients/responses-format.js +34 -3
  33. package/src/clients/routing.js +15 -0
  34. package/src/config/index.js +36 -2
  35. package/src/context/gcf.js +275 -0
  36. package/src/context/tool-result-compressor.js +932 -47
  37. package/src/dashboard/api.js +1 -0
  38. package/src/logger/index.js +14 -1
  39. package/src/memory/search.js +12 -40
  40. package/src/memory/tools.js +3 -24
  41. package/src/orchestrator/bypass.js +4 -2
  42. package/src/orchestrator/index.js +120 -85
  43. package/src/routing/affinity-store.js +194 -0
  44. package/src/routing/agentic-detector.js +36 -6
  45. package/src/routing/bandit.js +25 -6
  46. package/src/routing/calibration.js +212 -0
  47. package/src/routing/classifier-setup.js +207 -0
  48. package/src/routing/client-profiles.js +292 -0
  49. package/src/routing/complexity-analyzer.js +88 -15
  50. package/src/routing/deescalator.js +148 -0
  51. package/src/routing/degradation.js +109 -0
  52. package/src/routing/difficulty-classifier.js +219 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +931 -90
  55. package/src/routing/intent-score.js +441 -0
  56. package/src/routing/interaction.js +3 -0
  57. package/src/routing/knn-router.js +70 -21
  58. package/src/routing/model-registry.js +28 -7
  59. package/src/routing/model-tiers.js +25 -2
  60. package/src/routing/reward-pipeline.js +68 -2
  61. package/src/routing/risk-analyzer.js +30 -1
  62. package/src/routing/risk-classifier.js +6 -2
  63. package/src/routing/session-affinity.js +162 -34
  64. package/src/routing/telemetry.js +286 -13
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +86 -21
  67. package/src/sessions/cleanup.js +17 -0
  68. package/src/tools/index.js +1 -15
  69. package/src/tools/smart-selection.js +10 -0
  70. package/src/tools/web-client.js +3 -3
  71. package/.eslintrc.cjs +0 -12
  72. package/benchmark-configs/litellm_config.yaml +0 -86
  73. package/benchmark-configs/lynkr.env +0 -48
  74. package/benchmark-configs/portkey-config.json +0 -60
  75. package/benchmark-configs/portkey-docker.sh +0 -23
  76. package/benchmark-tier-routing.js +0 -449
  77. package/funding.json +0 -110
  78. package/src/api/middleware/validation.js +0 -261
  79. package/src/routing/drift-monitor.js +0 -113
  80. 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
- const windowUserMsgs = allUserMsgs.slice(-N); // chronological, oldest-first
65
-
66
- // Cap tools at 3 so we stay below the agentic detector's tool-count
67
- // signal thresholds: high_tool_count fires at >5, moderate_tool_count at
68
- // >3, no tool-count signal at <=3. Claude Code interactive mode attaches
69
- // 11+ tools every request, but our pickTier needs to reflect USER intent,
70
- // not session context.
71
- const intentTools = Array.isArray(body?.tools) ? body.tools.slice(0, 3) : undefined;
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.read();
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
- try {
363
- const { getMetricsCollector } = require("../observability/metrics");
364
- const mc = getMetricsCollector();
365
- mc.recordProviderSuccess?.("azure-anthropic-passthrough", latencyMs);
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
- try {
371
- const tlm = require("../routing/telemetry");
372
- tlm.record?.({
373
- request_id: req.headers["request-id"] || req.headers["x-request-id"] || null,
374
- session_id: req.body?._sessionId || req.sessionId || null,
375
- timestamp: startedAt,
376
- tier: tier.tier || "COMPLEX",
377
- provider: "azure-anthropic-passthrough",
378
- model: req.body?.model || tier.model || null,
379
- routing_method: "oauth-passthrough",
380
- status_code: upstreamResp.status,
381
- latency_ms: latencyMs,
382
- input_tokens: inputTokensActual || null,
383
- output_tokens: outputTokens || null,
384
- message_count: req.body?.messages?.length || null,
385
- tool_count: Array.isArray(req.body?.tools) ? req.body.tools.length : 0,
386
- was_fallback: false,
387
- });
388
- } catch (_) {}
389
-
390
- // Audit log
391
- try {
392
- const { createAuditLogger } = require("../logger/audit-logger");
393
- const audit = createAuditLogger(config.audit);
394
- audit?.log?.({
395
- provider: "azure-anthropic-passthrough",
396
- destination: upstream,
397
- status: upstreamResp.status,
398
- latencyMs,
399
- inputTokens: inputTokensActual,
400
- outputTokens,
401
- model: req.body?.model,
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
- try {
408
- const memoryExtractor = require("../memory/extractor");
409
- memoryExtractor.extractMemories?.(
410
- parsedResponse,
411
- req.body?.messages || [],
412
- { sessionId: req.body?._sessionId || req.sessionId || null }
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
- const analysis = await analyzeComplexity(req.body, { weighted: req.query.weighted === "true" });
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
- const tier = await pickTierByIntent(req.body);
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
- if (authMode === 'subscription' && tier.provider === 'azure-anthropic') {
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
- // For OAuth requests, surface the user-intent score, not the full-payload one.
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, // Include routing headers
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.read();
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
- let contentBlocks = (msg.content || []).filter(b =>
1114
- !(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
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
- delta: { stop_reason: msg.stop_reason || "end_turn", stop_sequence: null },
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
- let contentBlocks = (msg.content || []).filter(b =>
1300
- !(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
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
- delta: { stop_reason: msg.stop_reason || "end_turn", stop_sequence: null },
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);