lynkr 9.14.12 → 9.14.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lynkr-init.js CHANGED
@@ -595,6 +595,13 @@ async function runInteractive(opts) {
595
595
  const decay = parseFloat(decayRaw);
596
596
  if (!Number.isNaN(decay) && decay > 0 && decay <= 1) env.LYNKR_INTENT_DECAY = String(decay);
597
597
 
598
+ // Jev routing judge (TypeSafe System One): second opinion on intent
599
+ // difficulty + risk corroboration. Optional — anchor scoring carries
600
+ // routing alone when unset. Written under "# Provider credentials" by
601
+ // the _API_KEY grouping in buildEnvContent.
602
+ const tsKey = await askWithDefault(ask, 'TypeSafe API key for the Jev routing judge (optional — Enter to skip)', process.env.TYPESAFE_API_KEY || '');
603
+ if (tsKey) env.TYPESAFE_API_KEY = tsKey;
604
+
598
605
  close();
599
606
  console.log('');
600
607
  writeEnvFile(buildEnvContent(env, isWrap, tierConfig), opts);
@@ -12,11 +12,21 @@
12
12
  "substantive": [
13
13
  "Review this retry helper for bugs and race conditions",
14
14
  "give me a plan to refactor this code",
15
- "Read the current repo and explain it to me"
15
+ "Read the current repo and explain it to me",
16
+ "Implement a distributed rate limiter with Redis",
17
+ "Run the unit tests and summarize failures",
18
+ "What does this function do",
19
+ "List the exports from this file",
20
+ "Fix the linter warnings",
21
+ "Add error handling to this try block",
22
+ "Write a test for the login function",
23
+ "Debug this null reference error"
16
24
  ],
17
25
  "heavyweight": [
18
26
  "Do an architecture review of the orchestrator",
19
27
  "analyze every module in src/ for circular dependencies and give me a dependency-order refactor plan",
20
- "design a horizontally scalable architecture for this service with failure-mode analysis"
28
+ "design a horizontally scalable architecture for this service with failure-mode analysis",
29
+ "Compare how databricks.js and openai-format.js each handle non-2xx upstream responses, then propose a unified error-normalization helper both could share",
30
+ "Code review the PR #84 routing hardening changes"
21
31
  ]
22
32
  }
@@ -6,10 +6,14 @@
6
6
  "claude-opus-4*": { "caps": { "reasoning": 0.9, "codegen": 0.9, "debugging": 0.9, "tool_use": 0.9 }, "sources": ["swe-bench-verified", "artificial-analysis"], "note": "curated-estimate: flagship tier" },
7
7
  "claude-opus-3*": { "caps": { "reasoning": 0.8, "codegen": 0.82, "debugging": 0.8, "tool_use": 0.78 }, "sources": ["swe-bench-verified"], "note": "curated-estimate" },
8
8
  "claude-sonnet-4*": { "caps": { "reasoning": 0.78, "codegen": 0.8, "debugging": 0.78, "tool_use": 0.78 }, "sources": ["swe-bench-verified", "artificial-analysis"], "note": "curated-estimate" },
9
+ "claude-sonnet-5*": { "caps": { "reasoning": 0.8, "codegen": 0.82, "debugging": 0.8, "tool_use": 0.78 }, "sources": ["lynkr-operator-estimate"], "note": "provisional: 4.5 successor (Active per Anthropic deprecations table); sized just above sonnet-4.5 — promote on benchmark rows" },
10
+ "claude-sonnet": { "caps": { "reasoning": 0.78, "codegen": 0.8, "debugging": 0.78, "tool_use": 0.78 }, "sources": ["swe-bench-verified", "artificial-analysis"], "note": "alias: bare family id shares claude-sonnet-4 caps" },
11
+ "claude-opus": { "caps": { "reasoning": 0.9, "codegen": 0.9, "debugging": 0.9, "tool_use": 0.9 }, "sources": ["swe-bench-verified", "artificial-analysis"], "note": "alias: bare family id shares claude-opus-4 caps" },
9
12
  "claude-sonnet-3.5": { "caps": { "reasoning": 0.72, "codegen": 0.75, "debugging": 0.72, "tool_use": 0.7 }, "sources": ["swe-bench-verified"], "note": "curated-estimate" },
10
13
  "orcarouter-auto": { "caps": { "reasoning": 0.8, "codegen": 0.82, "debugging": 0.8, "tool_use": 0.82 }, "sources": ["provider-routing:adaptive auto"], "note": "curated-estimate: orcarouter/auto routes across frontier models per request" },
11
14
  "claude-3.5-sonnet*": { "caps": { "reasoning": 0.72, "codegen": 0.75, "debugging": 0.72, "tool_use": 0.7 }, "sources": ["swe-bench-verified"], "note": "alias: dated/provider form (claude-3-5-sonnet-20241022) shares claude-sonnet-3.5 caps" },
12
15
  "claude-haiku*": { "caps": { "reasoning": 0.42, "codegen": 0.48, "debugging": 0.42, "tool_use": 0.45 }, "sources": ["swe-bench-verified"], "note": "curated-estimate" },
16
+ "claude-haiku": { "caps": { "reasoning": 0.42, "codegen": 0.48, "debugging": 0.42, "tool_use": 0.45 }, "sources": ["swe-bench-verified"], "note": "alias: bare family id shares claude-haiku caps (matters: heuristic rates bare haiku 0.5, overstating it)" },
13
17
  "claude-3.5-haiku*": { "caps": { "reasoning": 0.42, "codegen": 0.48, "debugging": 0.42, "tool_use": 0.45 }, "sources": ["swe-bench-verified"], "note": "alias: dated/provider form (claude-3-5-haiku-20241022) shares claude-haiku caps" },
14
18
  "gpt-5.6*": { "caps": { "reasoning": 0.85, "codegen": 0.85, "debugging": 0.85, "tool_use": 0.82 }, "sources": ["swe-bench-verified"], "note": "curated-estimate: flagship tier" },
15
19
  "gpt-5.4*": { "caps": { "reasoning": 0.85, "codegen": 0.85, "debugging": 0.85, "tool_use": 0.82 }, "sources": ["swe-bench-verified"], "note": "curated-estimate: flagship tier" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "9.14.12",
3
+ "version": "9.14.14",
4
4
  "files": [
5
5
  "index.js",
6
6
  "install.sh",
@@ -38,7 +38,7 @@
38
38
  "start:supervised": "while true; do node index.js 2>&1 | npx pino-pretty --sync; code=$?; echo \"[supervisor] lynkr exited ($code) — restarting in 3s\"; sleep 3; done",
39
39
  "lint": "eslint src index.js",
40
40
  "test": "npm run test:unit && npm run test:performance",
41
- "test:unit": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js test/baidu-model-mapping.test.js test/tenant-policy-ingress-parity.test.js test/decide.test.js test/embeddings-degradation.test.js test/health-probe.test.js test/stuck-detector.test.js test/onnx-embedder.test.js test/ope.test.js test/hierarchical-budget.test.js test/token-rate-limit.test.js test/otel-export.test.js test/mcp-broker.test.js test/compression-budget.test.js test/gpt-utils.test.js test/dedup-observe-only.test.js test/context-window-header.test.js test/token-budget-auto.test.js test/opencode-setup.test.js test/auth-mode-first-party.test.js",
41
+ "test:unit": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js test/baidu-model-mapping.test.js test/tenant-policy-ingress-parity.test.js test/decide.test.js test/embeddings-degradation.test.js test/health-probe.test.js test/stuck-detector.test.js test/onnx-embedder.test.js test/ope.test.js test/hierarchical-budget.test.js test/token-rate-limit.test.js test/otel-export.test.js test/mcp-broker.test.js test/compression-budget.test.js test/gpt-utils.test.js test/dedup-observe-only.test.js test/context-window-header.test.js test/token-budget-auto.test.js test/opencode-setup.test.js test/auth-mode-first-party.test.js test/jev-router.test.js test/jev-routing.test.js test/force-patterns.test.js test/upstream-fidelity.test.js test/tool-schema-compression.test.js test/passthrough-route.test.js test/quota-ledger.test.js",
42
42
  "test:memory": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js",
43
43
  "test:new-features": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
44
44
  "test:performance": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
@@ -122,6 +122,6 @@
122
122
  "allowScripts": {},
123
123
  "overrides": {
124
124
  "sharp": "^0.35.4",
125
- "adm-zip": "^0.6.0"
125
+ "adm-zip": "^0.6.1"
126
126
  }
127
127
  }
@@ -56,14 +56,14 @@ const EVAL_SET = [
56
56
  { text: "Refactor this into smaller functions", target: "MEDIUM" },
57
57
  // COMPLEX (GLM)
58
58
  { text: "Do an architecture review of the orchestrator", target: "COMPLEX" },
59
- { text: "Review this retry helper for bugs", target: "COMPLEX" },
59
+ { text: "Review this retry helper for bugs", target: "MEDIUM" }, // near-paraphrase of a substantive anchor — single-file review, not systemic work (retargeted 2026-09-21)
60
60
  { text: "Refactor the entire ingestion pipeline and give me the plan", target: "COMPLEX" },
61
61
  { text: "Design a horizontally scalable architecture for the router", target: "COMPLEX" },
62
62
  { text: "Code review the PR #84 routing hardening changes", target: "COMPLEX" },
63
63
  { text: "Analyze every module in src/ for circular dependencies", target: "COMPLEX" },
64
64
  { text: "Debug this complex race condition in the connection pool", target: "COMPLEX" },
65
65
  { text: "Plan a zero-downtime migration to the new schema", target: "COMPLEX" },
66
- { text: "Implement a distributed rate limiter with Redis", target: "COMPLEX" },
66
+ { text: "Implement a distributed rate limiter with Redis", target: "MEDIUM" }, // single-component build with a named tech — implementation, not design (retargeted 2026-09-21)
67
67
  { text: "Design the caching strategy for this API gateway", target: "COMPLEX" },
68
68
  // REASONING (Claude)
69
69
  { text: "Prove the correctness of this lock-free queue implementation", target: "REASONING" },
package/src/api/router.js CHANGED
@@ -152,10 +152,18 @@ async function pickTierByIntent(body) {
152
152
  if (windowUserMsgs.length === 0) {
153
153
  // No user messages in payload (shouldn't happen) — fall through to the
154
154
  // error fallback below to preserve prior behavior.
155
+ // NOTE (2026-09-18): model must never be null here — a tier without a
156
+ // model reaches dispatch, where downstream deciders fail closed to the
157
+ // CLIENT's model and serve Haiku on a COMPLEX label with no trace.
158
+ // Resolve the tier's configured model so the label always means something.
159
+ let _fallbackModel = null;
160
+ try {
161
+ _fallbackModel = getModelTierSelector().selectModel('COMPLEX', null)?.model || null;
162
+ } catch { /* default below */ }
155
163
  return {
156
164
  tier: 'COMPLEX',
157
165
  provider: 'azure-anthropic',
158
- model: null,
166
+ model: _fallbackModel || 'claude-sonnet-4-5',
159
167
  score: null,
160
168
  method: 'fallback',
161
169
  reason: 'no_user_messages',
@@ -284,6 +292,10 @@ async function pickTierByIntent(body) {
284
292
  // path returned neither (e.g. legacy shadow decisions).
285
293
  propensity: d.propensity ?? 1.0,
286
294
  candidates: d.candidates ?? [{ provider: d.provider, model: d.model || null }],
295
+ // Jev verdict details for telemetry shadow/promotion analysis.
296
+ // Underscored: stripped at the outbound chokepoint with every other
297
+ // internal field, never leaks upstream or to headers.
298
+ _jev: (d.analysis && d.analysis.jev) || d.jev || null,
287
299
  // WS5: feedback path needs the bandit context vector (to call
288
300
  // bandit.update with the same features the arm was scored on) and the
289
301
  // query embedding (to add conclusive-quality outcomes to kNN). Both
@@ -296,9 +308,14 @@ async function pickTierByIntent(body) {
296
308
 
297
309
  /**
298
310
  * Transparent passthrough for Claude Code OAuth subscription requests.
299
- * Forwards the inbound body and headers verbatim to api.anthropic.com so the
300
- * outgoing request is byte-for-byte what Claude Code would have sent directly,
301
- * with no orchestrator mutations.
311
+ * Forwards the inbound body and headers to api.anthropic.com so the outgoing
312
+ * request is what Claude Code would have sent directly, with no orchestrator
313
+ * mutations — EXCEPT body.model, which tier-driven routing may escalate
314
+ * (default-on; see src/routing/passthrough-route.js and the dispatch fork
315
+ * below, kill-switch LYNKR_PASSTHROUGH_MODEL_ROUTING=false). Messages, tools,
316
+ * system prompt, thinking blocks and cache_control breakpoints are never
317
+ * touched: prefix-byte stability is what keeps Anthropic's prompt cache usable
318
+ * across a model switch (one cold-prefix turn, then warm).
302
319
  *
303
320
  * Observability is bolted on around the call (start telemetry, response
304
321
  * telemetry, memory extraction, audit) so we keep visibility even though we're
@@ -308,13 +325,13 @@ async function handleOauthPassthrough(req, res, opts = {}) {
308
325
  const upstream = process.env.LYNKR_OAUTH_PASSTHROUGH_URL
309
326
  || "https://api.anthropic.com/v1/messages";
310
327
 
311
- // NO body mutation on the passthrough — deliberate policy (2026-09). The
328
+ // NO body mutation on the passthrough apart from tier-driven model
329
+ // routing (see docstring above) — deliberate policy (2026-09). The
312
330
  // memory-injection option that lived here (LYNKR_OAUTH_MEMORY_INJECTION)
313
- // was removed: this function's whole contract is byte-for-byte fidelity
314
- // to what the first-party client sent, and any mutation of subscription
315
- // traffic both breaks that claim and carries anti-abuse risk. Memory
331
+ // was removed: mutating subscription traffic beyond the configured model
332
+ // both breaks the fidelity claim and carries anti-abuse risk. Memory
316
333
  // injection remains available on the orchestrator paths, where Lynkr is
317
- // openly transforming requests. The only change made below is stripping
334
+ // openly transforming requests. The only other change made below is stripping
318
335
  // Lynkr-internal underscore-prefixed fields, which Anthropic would reject
319
336
  // as unknown keys — protocol necessity, not content mutation.
320
337
  let bodyToSend = req.body;
@@ -394,18 +411,44 @@ async function handleOauthPassthrough(req, res, opts = {}) {
394
411
  if (contextWindow) res.set("X-Lynkr-Context-Window", String(contextWindow));
395
412
  }
396
413
  res.set("X-Lynkr-Routing-Method", "oauth-subscription-passthrough");
414
+ if (opts.routeDecision?.action) res.set("X-Lynkr-Route-Action", opts.routeDecision.action);
415
+ if (opts.routeDecision?.reason) res.set("X-Lynkr-Route-Reason", opts.routeDecision.reason);
397
416
 
398
417
  // Capture the response (buffered or streamed) so we can do observability hooks
399
418
  // on the way back without changing what the client sees.
400
419
  let responseTextForObservability = "";
401
420
 
421
+ // Guarded scheduler (declared up here: the buffered badge path below
422
+ // returns early, so it schedules from down there — a const declared at the
423
+ // bottom would be TDZ-dead at that point). The worker is a hoisted
424
+ // function declaration for the same reason. Without this scheduling, badged
425
+ // buffered responses skip ALL observability (telemetry, metrics, audit,
426
+ // memory extraction AND the cache-state feed).
427
+ let _observabilityScheduled = false;
428
+ const _scheduleObservability = () => {
429
+ if (_observabilityScheduled) return;
430
+ _observabilityScheduled = true;
431
+ setImmediate(runPassthroughObservability);
432
+ };
433
+
402
434
  // LYNKR_VISIBLE_ROUTING=true: inject a routing badge into the response on
403
435
  // its way back to the client. Mutating the RESPONSE is safe — Anthropic's
404
436
  // anti-abuse fingerprints the inbound request, not what the proxy does
405
437
  // with the response stream before handing it to the client.
406
438
  const wantsBadge = config.routing?.visibleInteraction && upstreamResp.ok;
407
- const badgeText = wantsBadge
408
- ? `*[Lynkr] subscription-passthrough → ${req.body?.model || '—'} (azure-anthropic)*\n\n`
439
+ const _routeDecision = opts.routeDecision;
440
+ // Badge variants per decider action (pure builder in passthrough-route.js,
441
+ // unit-tested). The reason suffix names the rule that fired.
442
+ const { buildPassthroughBadge } = require("../routing/passthrough-route");
443
+ let badgeText = wantsBadge
444
+ ? buildPassthroughBadge({
445
+ action: _routeDecision?.action ?? null,
446
+ reason: _routeDecision?.reason ?? null,
447
+ routeModel: _routeDecision?.model ?? null,
448
+ clientModel: opts.clientModel || null,
449
+ tierName: opts.tier?.tier || null,
450
+ servedModel: req.body?.model || null,
451
+ }) + '\n\n'
409
452
  : null;
410
453
 
411
454
  if (contentType.includes("text/event-stream") && upstreamResp.body) {
@@ -456,7 +499,7 @@ async function handleOauthPassthrough(req, res, opts = {}) {
456
499
  }
457
500
  res.end();
458
501
  } else {
459
- const text = await upstreamResp.text();
502
+ let text = await upstreamResp.text();
460
503
  if (!upstreamResp.ok) {
461
504
  // Auth-shape diagnostics (prefix only, never the token): sk-ant-oat*
462
505
  // = subscription OAuth (fix: /login refresh), sk-ant-api* = an API KEY
@@ -472,8 +515,58 @@ async function handleOauthPassthrough(req, res, opts = {}) {
472
515
  xApiKeyShape: apiKeyHdr ? apiKeyHdr.slice(0, 12) + "…" : "(none)",
473
516
  }, "OAuth passthrough upstream returned non-2xx");
474
517
  }
518
+ // Model-rejection fallback: a rewritten model id can go stale (snapshot
519
+ // rotation) while the client's own id keeps working — observed live as
520
+ // a 400 naming the model on an otherwise healthy subscription. Retry
521
+ // ONCE with the client's original model so a routing decision degrades
522
+ // to a Haiku answer instead of a failed turn. Strictly gated: rewritten
523
+ // request only, 400/404 only, error body must mention model. Telemetry
524
+ // and headers below report the SERVED model, not the attempted one.
525
+ let _routeFellBack = false;
526
+ if (!upstreamResp.ok && (upstreamResp.status === 400 || upstreamResp.status === 404)
527
+ && opts.routeDecision && opts.routeDecision.action !== 'verbatim' && opts.clientModel) {
528
+ let _errText = '';
529
+ try { _errText = JSON.stringify(JSON.parse(text)); } catch { _errText = text; }
530
+ if (/model/i.test(_errText)) {
531
+ logger.warn({
532
+ status: upstreamResp.status,
533
+ from: opts.routeDecision.model,
534
+ to: opts.clientModel,
535
+ }, 'OAuth passthrough model rejected — retrying once with client model');
536
+ try {
537
+ const _retryResp = await fetch(upstream, {
538
+ method: 'POST',
539
+ headers: outHeaders,
540
+ body: JSON.stringify({ ...JSON.parse(bodyText), model: opts.clientModel }),
541
+ });
542
+ const _retryText = await _retryResp.text();
543
+ if (_retryResp.ok) {
544
+ upstreamResp = _retryResp;
545
+ text = _retryText;
546
+ _routeFellBack = true;
547
+ req.body = { ...req.body, model: opts.clientModel };
548
+ res.set('X-Lynkr-Model', opts.clientModel);
549
+ } else {
550
+ logger.warn({ status: _retryResp.status }, 'OAuth passthrough client-model retry also failed');
551
+ }
552
+ } catch (err) {
553
+ logger.debug({ err: err.message }, '[Routing] Passthrough fallback retry failed (non-fatal)');
554
+ }
555
+ }
556
+ }
557
+ if (_routeFellBack) {
558
+ badgeText = wantsBadge
559
+ ? `*[Lynkr] subscription-passthrough (route to ${opts.routeDecision.model} rejected upstream — serving requested model)*\n\n`
560
+ : null;
561
+ }
475
562
  responseTextForObservability = text;
476
563
 
564
+ // Schedule observability BEFORE the sends below: the badge path returns
565
+ // early and would otherwise skip telemetry/metrics/cache-feed entirely.
566
+ // Guarded against double-scheduling (the fall-through at the bottom also
567
+ // schedules for the non-badge buffered path).
568
+ _scheduleObservability();
569
+
477
570
  // For buffered JSON: prepend a text content block.
478
571
  if (badgeText && contentType.includes('application/json')) {
479
572
  try {
@@ -490,8 +583,10 @@ async function handleOauthPassthrough(req, res, opts = {}) {
490
583
 
491
584
  // === Observability: end ===
492
585
  // Fire-and-forget: never block returning to the client. Record telemetry,
493
- // metrics, audit, memory — all read-only on the response.
494
- setImmediate(() => {
586
+ // metrics, audit, memory — all read-only on the response. (Scheduler lives
587
+ // near the top of this function; this worker is a hoisted function so both
588
+ // branches can schedule it.)
589
+ function runPassthroughObservability() {
495
590
  try {
496
591
  const latencyMs = Date.now() - startedAt;
497
592
  const tier = opts.tier || {};
@@ -511,12 +606,44 @@ async function handleOauthPassthrough(req, res, opts = {}) {
511
606
  ?? parsedResponse?.usage?.prompt_tokens
512
607
  ?? inputTokenEstimate;
513
608
 
609
+ // Prompt-cache state feed: Anthropic usage carries cache_creation /
610
+ // cache_read counters. Recording them keeps getCacheState accurate for
611
+ // subsequent turns (passthrough serves were previously invisible to
612
+ // cache tracking, so upgrade-once/hold logic had no data to consult).
613
+ // Served model is req.body.model (rewritten above when routed).
614
+ try {
615
+ const _usage = parsedResponse?.usage || {};
616
+ const _cacheRead = Number(_usage.cache_read_input_tokens) || 0;
617
+ const _cacheCreated = Number(_usage.cache_creation_input_tokens) || 0;
618
+ if (_cacheRead + _cacheCreated > 0) {
619
+ require("../routing/session-affinity").recordCacheUsage(
620
+ req.body?._sessionId || req.sessionId || null,
621
+ {
622
+ provider: 'azure-anthropic',
623
+ model: req.body?.model || null,
624
+ cacheReadTokens: _cacheRead,
625
+ cacheCreationTokens: _cacheCreated,
626
+ }
627
+ );
628
+ }
629
+ } catch { /* observability must never break the response path */ }
630
+
514
631
  // Lynkr-wide metrics
515
632
  const { getMetricsCollector } = require("../observability/metrics");
516
633
  const mc = getMetricsCollector();
517
634
  mc.recordProviderSuccess("azure-anthropic-passthrough", latencyMs);
518
635
  if (outputTokens || inputTokensActual) mc.recordTokens(inputTokensActual, outputTokens || 0);
519
636
 
637
+ // Quota ledger: record the served turn so sustained expensive serving
638
+ // shortens future hold horizons (horizon pressure). Ledger never throws
639
+ // and never blocks observability.
640
+ try {
641
+ require("../routing/quota-ledger").record(
642
+ req.body?._sessionId || req.sessionId || null,
643
+ { model: req.body?.model || null, inputTokens: inputTokensActual || 0, outputTokens: outputTokens || 0 }
644
+ );
645
+ } catch { /* fail-soft */ }
646
+
520
647
  // Tier router telemetry (so it shows up in dashboards / routing stats)
521
648
  const tlm = require("../routing/telemetry");
522
649
  tlm.record({
@@ -536,7 +663,24 @@ async function handleOauthPassthrough(req, res, opts = {}) {
536
663
  output_tokens: outputTokens || null,
537
664
  message_count: req.body?.messages?.length || null,
538
665
  tool_count: Array.isArray(req.body?.tools) ? req.body.tools.length : 0,
666
+ ...tlm.jevFields(tier),
667
+ // Inner-decision attribution: the tier label alone can't explain a
668
+ // serve (a COMPLEX label once served Haiku with no trace because the
669
+ // tier object carried a null model). Record the decision's own
670
+ // method/reason/score so the next investigation starts from data.
671
+ complexity_score: tier.score ?? null,
672
+ switch_reason: tier.switch_reason ?? tier.reason ?? null,
673
+ escalation_source: tier.escalation_source ?? null,
539
674
  was_fallback: false,
675
+ // Decider attribution: what the passthrough model router did with
676
+ // the tier (upgrade/hold/descent) and how much warmth was involved.
677
+ // switch_reason above already carries the decider reason (stamped at
678
+ // the fork site); this preserves the structured form for dashboards.
679
+ cache_decision: opts.routeDecision ? {
680
+ action: opts.routeDecision.action ?? null,
681
+ reason: opts.routeDecision.reason ?? null,
682
+ warmPrefixTokens: opts.routeDecision.warmPrefixTokens ?? null,
683
+ } : null,
540
684
  });
541
685
 
542
686
  // Audit log. NOTE: the interface returned by createAuditLogger exposes
@@ -566,7 +710,8 @@ async function handleOauthPassthrough(req, res, opts = {}) {
566
710
  } catch (err) {
567
711
  logger.debug({ err: err.message }, "OAuth passthrough observability hook failed (non-fatal)");
568
712
  }
569
- });
713
+ }
714
+ _scheduleObservability();
570
715
  }
571
716
 
572
717
  /**
@@ -1049,8 +1194,65 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1049
1194
  // session pins, side-request detection, and content scoring alike.
1050
1195
  // "Lynkr Auto" (claude-fable-5) and any unrecognized id resolve to null
1051
1196
  // and fall through to the existing cascade unchanged.
1197
+ //
1198
+ // EXCEPTION — force triggers outrank the pin. The pin reflects the
1199
+ // client's ambient session model (Claude Code attaches its session
1200
+ // model, e.g. haiku-4-5-20251001, to EVERY request), not per-request
1201
+ // intent — letting it win unconditionally makes tier routing a no-op
1202
+ // for all Claude Code traffic (notably it neuters the subscription
1203
+ // passthrough model routing in passthrough-route.js, whose tier input
1204
+ // would otherwise always equal the client's own model). Explicit
1205
+ // CONTENT signals (ultrathink/prove/security-audit, architecture
1206
+ // review, production incident, risk-high) fall through to the cascade,
1207
+ // where the force branches decide the tier.
1052
1208
  const modelPinTier = resolveTierForModelId(req.body?.model);
1209
+ let _pinBypassForce = null;
1053
1210
  if (modelPinTier) {
1211
+ try {
1212
+ const ca = require("../routing/complexity-analyzer");
1213
+ if (ca.shouldForceReasoning(req.body)) _pinBypassForce = 'force_reasoning';
1214
+ else if (ca.shouldForceCloud(req.body)) _pinBypassForce = 'force_cloud';
1215
+ else {
1216
+ const _pinRisk = analyzeRisk(req.body);
1217
+ if (_pinRisk?.level === 'high') _pinBypassForce = 'risk_high';
1218
+ }
1219
+ } catch (err) {
1220
+ logger.debug({ err: err.message }, '[Routing] Pin-bypass force check failed — pin stands');
1221
+ }
1222
+ if (_pinBypassForce) {
1223
+ logger.debug({
1224
+ model: req.body?.model,
1225
+ bypass: _pinBypassForce,
1226
+ }, '[Routing] Force trigger outranks model-id pin — full cascade');
1227
+ }
1228
+ }
1229
+ if (modelPinTier && !_pinBypassForce) {
1230
+ // Upward-drift escape (WS1.5 for the ambient model pin). The pin is
1231
+ // the client's session model, not a scored decision — without this,
1232
+ // every non-force prompt rides the pin and tier routing is a no-op
1233
+ // for Claude Code traffic (live: a two-file compare+design ask served
1234
+ // Haiku with reason client_selected_model). A cheap per-message drift
1235
+ // check against the pin tier lets genuinely harder asks fall through
1236
+ // to the cascade; trivial turns keep the pin (and the WS1 cost win).
1237
+ // One-directional by construction (checkPinScoreDrift never drifts
1238
+ // down), and tool-result-only turns score nothing, so tool-loop
1239
+ // economics are untouched.
1240
+ try {
1241
+ const _drift = await checkPinScoreDrift({ tier: modelPinTier }, req.body);
1242
+ if (_drift?.drift) {
1243
+ _pinBypassForce = 'pin_drift';
1244
+ logger.debug({
1245
+ model: req.body?.model,
1246
+ pinnedTier: modelPinTier,
1247
+ freshScore: _drift.freshScore,
1248
+ forced: _drift.forced || null,
1249
+ }, '[Routing] Upward drift outranks model-id pin — full cascade');
1250
+ }
1251
+ } catch (err) {
1252
+ logger.debug({ err: err.message }, '[Routing] Pin drift check failed — pin stands');
1253
+ }
1254
+ }
1255
+ if (modelPinTier && !_pinBypassForce) {
1054
1256
  const _sel = getModelTierSelector().selectModel(modelPinTier, null);
1055
1257
  tier = {
1056
1258
  tier: modelPinTier,
@@ -1213,6 +1415,43 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1213
1415
  provider: tier.provider,
1214
1416
  }, "OAuth intent — side request routed to static SIMPLE");
1215
1417
  } else if (pinCheck.serve && !pinDrift?.drift) {
1418
+ // Force triggers outrank session pins too — not just the model-id pin
1419
+ // above. A stale SIMPLE pin (e.g. "Hi"-opener fingerprint colliding
1420
+ // with an earlier session) must not serve haiku past an explicit
1421
+ // ultrathink / architecture-review / risky ask. Scoped to the LATEST
1422
+ // user message only, so old trigger words in history can't permanently
1423
+ // unpin a session — and guards_passed only, never tool_history turns
1424
+ // (switching models mid tool-loop breaks tool_use↔tool_result linkage;
1425
+ // those keep serving the pin, matching the drift rule above).
1426
+ // When it fires we fall through to pickTierByIntent, whose force
1427
+ // branches decide the tier; the fresh decision then re-pins, healing
1428
+ // the poisoned pin for subsequent turns.
1429
+ let _pinForceBypass = null;
1430
+ if (!sideTier && pinCheck.serve && pinCheck.reason === 'guards_passed' && !isSideRequest) {
1431
+ try {
1432
+ const _probe = { messages: [{ role: 'user', content: _lastUserText || '' }] };
1433
+ const ca = require("../routing/complexity-analyzer");
1434
+ if (_lastUserText && ca.shouldForceReasoning(_probe)) _pinForceBypass = 'force_reasoning';
1435
+ else if (_lastUserText && ca.shouldForceCloud(_probe)) _pinForceBypass = 'force_cloud';
1436
+ else if (_lastUserText) {
1437
+ const _pinRisk = analyzeRisk(_probe);
1438
+ if (_pinRisk?.level === 'high') _pinForceBypass = 'risk_high';
1439
+ }
1440
+ } catch (err) {
1441
+ logger.debug({ err: err.message }, '[Routing] Pin-serve force check failed — pin stands');
1442
+ }
1443
+ if (_pinForceBypass) {
1444
+ logger.debug({
1445
+ sessionId: pinCheck.sessionId,
1446
+ pinnedTier: pinCheck.pin?.tier,
1447
+ bypass: _pinForceBypass,
1448
+ }, '[Routing] Force trigger outranks session pin — full cascade');
1449
+ }
1450
+ }
1451
+ if (_pinForceBypass) {
1452
+ tier = await pickTierByIntent(req.body);
1453
+ tier.switch_reason = 'pin_force_bypass';
1454
+ } else {
1216
1455
  tier = {
1217
1456
  tier: pinCheck.pin.tier || null,
1218
1457
  provider: pinCheck.pin.provider,
@@ -1244,6 +1483,7 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1244
1483
  sessionId: pinCheck.sessionId,
1245
1484
  tier,
1246
1485
  }, "OAuth intent — served from session pin");
1486
+ } // end pin-serve (else branch of _pinForceBypass)
1247
1487
  } else {
1248
1488
  if (pinDrift?.drift) {
1249
1489
  logger.info({
@@ -1299,6 +1539,12 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1299
1539
  }
1300
1540
  }
1301
1541
  } // end else — no explicit model-id pin, scored normally above
1542
+ // Attribute model-pin drift escapes in telemetry: the cascade above may
1543
+ // serve a session pin or side tier without stamping a reason, leaving
1544
+ // the escape invisible. Never overwrite a reason the cascade did set.
1545
+ if (_pinBypassForce === 'pin_drift' && tier && !tier.switch_reason) {
1546
+ tier.switch_reason = 'model_pin_drift';
1547
+ }
1302
1548
 
1303
1549
  // Subscription-only fork: byte-for-byte passthrough to api.anthropic.com
1304
1550
  // when the picked tier resolves to azure-anthropic — FIRST-PARTY
@@ -1328,7 +1574,115 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1328
1574
  model: req.body?.model,
1329
1575
  tier: tier.tier,
1330
1576
  }, "Subscription passthrough → api.anthropic.com");
1331
- return handleOauthPassthrough(req, res, { tier });
1577
+ // Tier-driven model routing on passthrough (default-on; kill-switch:
1578
+ // LYNKR_PASSTHROUGH_MODEL_ROUTING=false). The tier was already picked
1579
+ // above (score / force keywords / risk / pin); without this step the
1580
+ // client's own model always serves and escalation can never fire.
1581
+ // See src/routing/passthrough-route.js. Only body.model is rewritten.
1582
+ // Downgrades consult the session's cache state (warmth worth protecting
1583
+ // holds the pin; cold/small/absent lets it fall) — the normal flow's
1584
+ // hysteresis, ported to flat-subscription latency economics.
1585
+ const _passthroughClientModel = req.body?.model ?? null;
1586
+ let _passthroughRoute = { model: _passthroughClientModel, action: 'verbatim', reason: 'not_evaluated', warmPrefixTokens: null };
1587
+ try {
1588
+ const { decidePassthroughModel, resolveTierModel } = require("../routing/passthrough-route");
1589
+ let _pinCacheState = null;
1590
+ try {
1591
+ if (pinCheck?.sessionId) {
1592
+ _pinCacheState = require("../routing/session-affinity").getCacheState(pinCheck.sessionId);
1593
+ }
1594
+ } catch { /* no cache state — downgrade gate treats as absent */ }
1595
+ // Resolve the effective tier model first: a tier whose label
1596
+ // outranks its carried model (null from fallback paths, stale
1597
+ // kNN/bandit swaps, poisoned pins) must never reach the decider
1598
+ // as-is — fail-closed would serve the client's model on a
1599
+ // COMPLEX/REASONING label with no trace. On flat-fee subscription
1600
+ // a demotion saves $0, so the label wins ties; promotions survive.
1601
+ // See resolveTierModel.
1602
+ let _tierModelForDecider = tier?.model || null;
1603
+ try {
1604
+ const _resolved = resolveTierModel({
1605
+ tierName: tier?.tier || null,
1606
+ tierModel: _tierModelForDecider,
1607
+ selectModelFn: (t) => getModelTierSelector().selectModel(t, null),
1608
+ });
1609
+ _tierModelForDecider = _resolved.model;
1610
+ if (_resolved.resolved) {
1611
+ logger.info({
1612
+ tier: tier?.tier,
1613
+ fromModel: tier?.model || null,
1614
+ toModel: _resolved.model,
1615
+ }, '[Routing] Tier model resolved to configured (label wins tie)');
1616
+ }
1617
+ } catch { /* keep carried model → decider fail-closes, as before */ }
1618
+ _passthroughRoute = decidePassthroughModel({
1619
+ tierModel: _tierModelForDecider,
1620
+ clientModel: _passthroughClientModel,
1621
+ pinModel: pinCheck?.pin?.model || null,
1622
+ tierMethod: tier?.method || null,
1623
+ tierPinned: tier?.pinned ?? null,
1624
+ cacheState: _pinCacheState,
1625
+ // Quota pressure shortens the hold horizon inside the gate (high
1626
+ // sustained burn → descents clear sooner). Zero when the ledger has
1627
+ // no history — the gate behaves exactly as before.
1628
+ sessionBurnPressure: (() => {
1629
+ try {
1630
+ return require("../routing/quota-ledger").pressure(pinCheck?.sessionId || req.body?._sessionId || req.sessionId || null);
1631
+ } catch { return 0; }
1632
+ })(),
1633
+ remainingTurns: (() => {
1634
+ try {
1635
+ const msgCount = Array.isArray(req.body?.messages) ? req.body.messages.length : 0;
1636
+ return require("../routing/telemetry").getExpectedRemainingTurns(msgCount);
1637
+ } catch { return null; }
1638
+ })(),
1639
+ });
1640
+ } catch (err) {
1641
+ logger.debug({ err: err.message }, '[Routing] Passthrough route decide failed — verbatim');
1642
+ }
1643
+ if (_passthroughRoute.action === 'pin_hold' && pinCheck?.sessionId && pinCheck?.pin) {
1644
+ // The pin write above stored the fresh LOWER tier; restore the higher
1645
+ // pin so the session keeps serving the upgraded model while its
1646
+ // prefix is warm. Re-evaluated every turn as the cache cools.
1647
+ try {
1648
+ const { writeSessionPin } = require("../routing/index");
1649
+ writeSessionPin(pinCheck.sessionId, {
1650
+ provider: pinCheck.pin.provider,
1651
+ model: _passthroughRoute.model,
1652
+ tier: pinCheck.pin.tier,
1653
+ score: pinCheck.pin.score ?? null,
1654
+ method: 'session_pin',
1655
+ reason: 'passthrough_pin_hold',
1656
+ }, req.body);
1657
+ } catch (err) {
1658
+ logger.debug({ err: err.message }, '[Routing] Pin-hold restore failed (non-fatal)');
1659
+ }
1660
+ }
1661
+ if (_passthroughRoute.action !== 'verbatim' && _passthroughRoute.model) {
1662
+ logger.info({
1663
+ from: _passthroughClientModel,
1664
+ to: _passthroughRoute.model,
1665
+ action: _passthroughRoute.action,
1666
+ reason: _passthroughRoute.reason,
1667
+ warmPrefixTokens: _passthroughRoute.warmPrefixTokens ?? undefined,
1668
+ tier: tier?.tier,
1669
+ }, '[Routing] Passthrough model route');
1670
+ req.body = { ...req.body, model: _passthroughRoute.model };
1671
+ }
1672
+ // Attribution: surface the decider's verdict on the tier object so
1673
+ // telemetry (switch_reason), headers and the badge report what the
1674
+ // decider did, not just what the cascade scored. Upgrades keep the
1675
+ // cascade's reason (force/drift — the *why*); holds and descents take
1676
+ // the decider's reason (the *what*), which previously reached nowhere
1677
+ // past server logs.
1678
+ if (tier && _passthroughRoute) {
1679
+ if (_passthroughRoute.action === 'upgrade') {
1680
+ if (!tier.switch_reason) tier.switch_reason = 'tier_outranks_client';
1681
+ } else if (_passthroughRoute.reason) {
1682
+ tier.switch_reason = _passthroughRoute.reason;
1683
+ }
1684
+ }
1685
+ return handleOauthPassthrough(req, res, { tier, routeDecision: _passthroughRoute, clientModel: _passthroughClientModel });
1332
1686
  }
1333
1687
 
1334
1688
  // Phase 2a — native-format streaming passthrough. Client speaks