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 +7 -0
- package/config/difficulty-anchors.json +12 -2
- package/config/model-capability-seeds.json +4 -0
- package/package.json +3 -3
- package/scripts/validate-intent-anchors.js +2 -2
- package/src/api/router.js +370 -16
- package/src/clients/databricks.js +64 -23
- package/src/clients/openai-format.js +37 -2
- package/src/clients/openrouter-utils.js +20 -0
- package/src/orchestrator/index.js +47 -10
- package/src/prompts/system.js +41 -49
- package/src/routing/affinity-store.js +47 -21
- package/src/routing/cache-switch-cost.js +46 -2
- package/src/routing/complexity-analyzer.js +21 -4
- package/src/routing/index.js +121 -1
- package/src/routing/intent-score.js +48 -13
- package/src/routing/jev-router.js +272 -0
- package/src/routing/model-tiers.js +13 -0
- package/src/routing/passthrough-route.js +286 -0
- package/src/routing/quota-ledger.js +110 -0
- package/src/routing/risk-analyzer.js +17 -1
- package/src/routing/telemetry.js +47 -2
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.
|
|
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.
|
|
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: "
|
|
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: "
|
|
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:
|
|
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
|
|
300
|
-
*
|
|
301
|
-
*
|
|
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
|
|
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:
|
|
314
|
-
//
|
|
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
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|