lynkr 9.7.2 → 9.9.0

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