pi-smart-router 0.1.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 (310) hide show
  1. package/.pi/extensions/smart-router/command-formatters.ts +167 -0
  2. package/.pi/extensions/smart-router/commands.ts +242 -0
  3. package/.pi/extensions/smart-router/dataset-export.ts +142 -0
  4. package/.pi/extensions/smart-router/delegate-stream.ts +105 -0
  5. package/.pi/extensions/smart-router/delegation-runtime.ts +355 -0
  6. package/.pi/extensions/smart-router/extension-setup.ts +115 -0
  7. package/.pi/extensions/smart-router/fleet-bootstrap.ts +244 -0
  8. package/.pi/extensions/smart-router/index.ts +115 -0
  9. package/.pi/extensions/smart-router/package.json +3 -0
  10. package/.pi/extensions/smart-router/pi-model-scope.ts +55 -0
  11. package/.pi/extensions/smart-router/pricing-lifecycle.ts +43 -0
  12. package/.pi/extensions/smart-router/route-and-delegate.ts +562 -0
  13. package/.pi/extensions/smart-router/routing-context.ts +171 -0
  14. package/.pi/extensions/smart-router/routing-outcomes.ts +44 -0
  15. package/.pi/extensions/smart-router/session-lifecycle.ts +140 -0
  16. package/.pi/extensions/smart-router/stream-delegation.ts +75 -0
  17. package/.pi/extensions/smart-router/types.ts +90 -0
  18. package/.pi/extensions/smart-router/utils.ts +58 -0
  19. package/README.md +593 -0
  20. package/bin/pi-smart-router.mjs +79 -0
  21. package/config/.gitkeep +0 -0
  22. package/config/hydra-projection-weights.json.example +1169 -0
  23. package/config/models.yaml.example +58 -0
  24. package/config/p-success-weights.json.example +19 -0
  25. package/config/routing-calibration.json.example +1605 -0
  26. package/config/routing-centroids.json.example +1570 -0
  27. package/config/routing-clusters.yaml.example +41 -0
  28. package/data/contrib/example.json +25 -0
  29. package/dist/api/explain/router-explain.d.ts +47 -0
  30. package/dist/api/explain/router-explain.d.ts.map +1 -0
  31. package/dist/api/explain/router-explain.js +101 -0
  32. package/dist/api/explain/router-explain.js.map +1 -0
  33. package/dist/api/middleware/pi-router-middleware.d.ts +86 -0
  34. package/dist/api/middleware/pi-router-middleware.d.ts.map +1 -0
  35. package/dist/api/middleware/pi-router-middleware.js +83 -0
  36. package/dist/api/middleware/pi-router-middleware.js.map +1 -0
  37. package/dist/cli/smart-router-cli.d.ts +85 -0
  38. package/dist/cli/smart-router-cli.d.ts.map +1 -0
  39. package/dist/cli/smart-router-cli.js +293 -0
  40. package/dist/cli/smart-router-cli.js.map +1 -0
  41. package/dist/config/defaults.d.ts +7 -0
  42. package/dist/config/defaults.d.ts.map +1 -0
  43. package/dist/config/defaults.js +33 -0
  44. package/dist/config/defaults.js.map +1 -0
  45. package/dist/config/models-loader.d.ts +22 -0
  46. package/dist/config/models-loader.d.ts.map +1 -0
  47. package/dist/config/models-loader.js +51 -0
  48. package/dist/config/models-loader.js.map +1 -0
  49. package/dist/config/pi-model-mapper.d.ts +40 -0
  50. package/dist/config/pi-model-mapper.d.ts.map +1 -0
  51. package/dist/config/pi-model-mapper.js +216 -0
  52. package/dist/config/pi-model-mapper.js.map +1 -0
  53. package/dist/config/routing-clusters-loader.d.ts +35 -0
  54. package/dist/config/routing-clusters-loader.d.ts.map +1 -0
  55. package/dist/config/routing-clusters-loader.js +100 -0
  56. package/dist/config/routing-clusters-loader.js.map +1 -0
  57. package/dist/domain/delegation/delegation-context.d.ts +23 -0
  58. package/dist/domain/delegation/delegation-context.d.ts.map +1 -0
  59. package/dist/domain/delegation/delegation-context.js +73 -0
  60. package/dist/domain/delegation/delegation-context.js.map +1 -0
  61. package/dist/domain/delegation/execution-ledger.d.ts +19 -0
  62. package/dist/domain/delegation/execution-ledger.d.ts.map +1 -0
  63. package/dist/domain/delegation/execution-ledger.js +23 -0
  64. package/dist/domain/delegation/execution-ledger.js.map +1 -0
  65. package/dist/domain/delegation/output-headroom.d.ts +39 -0
  66. package/dist/domain/delegation/output-headroom.d.ts.map +1 -0
  67. package/dist/domain/delegation/output-headroom.js +78 -0
  68. package/dist/domain/delegation/output-headroom.js.map +1 -0
  69. package/dist/domain/matching/cluster-matcher.d.ts +69 -0
  70. package/dist/domain/matching/cluster-matcher.d.ts.map +1 -0
  71. package/dist/domain/matching/cluster-matcher.js +294 -0
  72. package/dist/domain/matching/cluster-matcher.js.map +1 -0
  73. package/dist/domain/matching/embedding-provider.d.ts +21 -0
  74. package/dist/domain/matching/embedding-provider.d.ts.map +1 -0
  75. package/dist/domain/matching/embedding-provider.js +42 -0
  76. package/dist/domain/matching/embedding-provider.js.map +1 -0
  77. package/dist/domain/matching/hydra-input.d.ts +18 -0
  78. package/dist/domain/matching/hydra-input.d.ts.map +1 -0
  79. package/dist/domain/matching/hydra-input.js +40 -0
  80. package/dist/domain/matching/hydra-input.js.map +1 -0
  81. package/dist/domain/matching/hydra-matcher.d.ts +103 -0
  82. package/dist/domain/matching/hydra-matcher.d.ts.map +1 -0
  83. package/dist/domain/matching/hydra-matcher.js +275 -0
  84. package/dist/domain/matching/hydra-matcher.js.map +1 -0
  85. package/dist/domain/pinning/cache-economics.d.ts +53 -0
  86. package/dist/domain/pinning/cache-economics.d.ts.map +1 -0
  87. package/dist/domain/pinning/cache-economics.js +72 -0
  88. package/dist/domain/pinning/cache-economics.js.map +1 -0
  89. package/dist/domain/pinning/loop-escalation.d.ts +41 -0
  90. package/dist/domain/pinning/loop-escalation.d.ts.map +1 -0
  91. package/dist/domain/pinning/loop-escalation.js +137 -0
  92. package/dist/domain/pinning/loop-escalation.js.map +1 -0
  93. package/dist/domain/pinning/session-pinner.d.ts +81 -0
  94. package/dist/domain/pinning/session-pinner.d.ts.map +1 -0
  95. package/dist/domain/pinning/session-pinner.js +237 -0
  96. package/dist/domain/pinning/session-pinner.js.map +1 -0
  97. package/dist/domain/pinning/sub-route-policy.d.ts +41 -0
  98. package/dist/domain/pinning/sub-route-policy.d.ts.map +1 -0
  99. package/dist/domain/pinning/sub-route-policy.js +72 -0
  100. package/dist/domain/pinning/sub-route-policy.js.map +1 -0
  101. package/dist/domain/pipeline/router-pipeline.d.ts +180 -0
  102. package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -0
  103. package/dist/domain/pipeline/router-pipeline.js +807 -0
  104. package/dist/domain/pipeline/router-pipeline.js.map +1 -0
  105. package/dist/domain/pipeline/safe-default.d.ts +25 -0
  106. package/dist/domain/pipeline/safe-default.d.ts.map +1 -0
  107. package/dist/domain/pipeline/safe-default.js +41 -0
  108. package/dist/domain/pipeline/safe-default.js.map +1 -0
  109. package/dist/domain/refactor-guardrails.d.ts +48 -0
  110. package/dist/domain/refactor-guardrails.d.ts.map +1 -0
  111. package/dist/domain/refactor-guardrails.js +129 -0
  112. package/dist/domain/refactor-guardrails.js.map +1 -0
  113. package/dist/domain/routing/context-fit.d.ts +53 -0
  114. package/dist/domain/routing/context-fit.d.ts.map +1 -0
  115. package/dist/domain/routing/context-fit.js +142 -0
  116. package/dist/domain/routing/context-fit.js.map +1 -0
  117. package/dist/domain/routing/expected-cost.d.ts +63 -0
  118. package/dist/domain/routing/expected-cost.d.ts.map +1 -0
  119. package/dist/domain/routing/expected-cost.js +177 -0
  120. package/dist/domain/routing/expected-cost.js.map +1 -0
  121. package/dist/domain/routing/p-success-classifier.d.ts +130 -0
  122. package/dist/domain/routing/p-success-classifier.d.ts.map +1 -0
  123. package/dist/domain/routing/p-success-classifier.js +350 -0
  124. package/dist/domain/routing/p-success-classifier.js.map +1 -0
  125. package/dist/domain/routing/tier-features.d.ts +70 -0
  126. package/dist/domain/routing/tier-features.d.ts.map +1 -0
  127. package/dist/domain/routing/tier-features.js +174 -0
  128. package/dist/domain/routing/tier-features.js.map +1 -0
  129. package/dist/domain/routing/tool-history-guard.d.ts +32 -0
  130. package/dist/domain/routing/tool-history-guard.d.ts.map +1 -0
  131. package/dist/domain/routing/tool-history-guard.js +110 -0
  132. package/dist/domain/routing/tool-history-guard.js.map +1 -0
  133. package/dist/domain/scoring/multi-objective.d.ts +48 -0
  134. package/dist/domain/scoring/multi-objective.d.ts.map +1 -0
  135. package/dist/domain/scoring/multi-objective.js +116 -0
  136. package/dist/domain/scoring/multi-objective.js.map +1 -0
  137. package/dist/domain/triage/triage-engine.d.ts +43 -0
  138. package/dist/domain/triage/triage-engine.d.ts.map +1 -0
  139. package/dist/domain/triage/triage-engine.js +317 -0
  140. package/dist/domain/triage/triage-engine.js.map +1 -0
  141. package/dist/domain/triage/turn-envelope.d.ts +17 -0
  142. package/dist/domain/triage/turn-envelope.d.ts.map +1 -0
  143. package/dist/domain/triage/turn-envelope.js +81 -0
  144. package/dist/domain/triage/turn-envelope.js.map +1 -0
  145. package/dist/domain/types/entities.d.ts +303 -0
  146. package/dist/domain/types/entities.d.ts.map +1 -0
  147. package/dist/domain/types/entities.js +6 -0
  148. package/dist/domain/types/entities.js.map +1 -0
  149. package/dist/domain/types/index.d.ts +4 -0
  150. package/dist/domain/types/index.d.ts.map +1 -0
  151. package/dist/domain/types/index.js +2 -0
  152. package/dist/domain/types/index.js.map +1 -0
  153. package/dist/domain/types/schemas.d.ts +346 -0
  154. package/dist/domain/types/schemas.d.ts.map +1 -0
  155. package/dist/domain/types/schemas.js +241 -0
  156. package/dist/domain/types/schemas.js.map +1 -0
  157. package/dist/domain/types/store-port.d.ts +44 -0
  158. package/dist/domain/types/store-port.d.ts.map +1 -0
  159. package/dist/domain/types/store-port.js +6 -0
  160. package/dist/domain/types/store-port.js.map +1 -0
  161. package/dist/index.d.ts +39 -0
  162. package/dist/index.d.ts.map +1 -0
  163. package/dist/index.js +36 -0
  164. package/dist/index.js.map +1 -0
  165. package/dist/infra/gemini-provider.d.ts +28 -0
  166. package/dist/infra/gemini-provider.d.ts.map +1 -0
  167. package/dist/infra/gemini-provider.js +112 -0
  168. package/dist/infra/gemini-provider.js.map +1 -0
  169. package/dist/infra/telemetry.d.ts +51 -0
  170. package/dist/infra/telemetry.d.ts.map +1 -0
  171. package/dist/infra/telemetry.js +100 -0
  172. package/dist/infra/telemetry.js.map +1 -0
  173. package/dist/infrastructure/delegation/provider-error.d.ts +44 -0
  174. package/dist/infrastructure/delegation/provider-error.d.ts.map +1 -0
  175. package/dist/infrastructure/delegation/provider-error.js +179 -0
  176. package/dist/infrastructure/delegation/provider-error.js.map +1 -0
  177. package/dist/infrastructure/gateway/circuit-breaker.d.ts +68 -0
  178. package/dist/infrastructure/gateway/circuit-breaker.d.ts.map +1 -0
  179. package/dist/infrastructure/gateway/circuit-breaker.js +150 -0
  180. package/dist/infrastructure/gateway/circuit-breaker.js.map +1 -0
  181. package/dist/infrastructure/gateway/gateway-dispatch.d.ts +138 -0
  182. package/dist/infrastructure/gateway/gateway-dispatch.d.ts.map +1 -0
  183. package/dist/infrastructure/gateway/gateway-dispatch.js +308 -0
  184. package/dist/infrastructure/gateway/gateway-dispatch.js.map +1 -0
  185. package/dist/infrastructure/hardware/hardware-probe.d.ts +32 -0
  186. package/dist/infrastructure/hardware/hardware-probe.d.ts.map +1 -0
  187. package/dist/infrastructure/hardware/hardware-probe.js +199 -0
  188. package/dist/infrastructure/hardware/hardware-probe.js.map +1 -0
  189. package/dist/infrastructure/local/local-zero-tier.d.ts +47 -0
  190. package/dist/infrastructure/local/local-zero-tier.d.ts.map +1 -0
  191. package/dist/infrastructure/local/local-zero-tier.js +92 -0
  192. package/dist/infrastructure/local/local-zero-tier.js.map +1 -0
  193. package/dist/infrastructure/persistence/memory-store.d.ts +31 -0
  194. package/dist/infrastructure/persistence/memory-store.d.ts.map +1 -0
  195. package/dist/infrastructure/persistence/memory-store.js +87 -0
  196. package/dist/infrastructure/persistence/memory-store.js.map +1 -0
  197. package/dist/infrastructure/persistence/sqlite-store.d.ts +81 -0
  198. package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -0
  199. package/dist/infrastructure/persistence/sqlite-store.js +658 -0
  200. package/dist/infrastructure/persistence/sqlite-store.js.map +1 -0
  201. package/dist/infrastructure/pricing/litellm-fetch.d.ts +46 -0
  202. package/dist/infrastructure/pricing/litellm-fetch.d.ts.map +1 -0
  203. package/dist/infrastructure/pricing/litellm-fetch.js +145 -0
  204. package/dist/infrastructure/pricing/litellm-fetch.js.map +1 -0
  205. package/dist/infrastructure/pricing/price-broker.d.ts +56 -0
  206. package/dist/infrastructure/pricing/price-broker.d.ts.map +1 -0
  207. package/dist/infrastructure/pricing/price-broker.js +133 -0
  208. package/dist/infrastructure/pricing/price-broker.js.map +1 -0
  209. package/dist/infrastructure/pricing/pricing-monitor.d.ts +27 -0
  210. package/dist/infrastructure/pricing/pricing-monitor.d.ts.map +1 -0
  211. package/dist/infrastructure/pricing/pricing-monitor.js +46 -0
  212. package/dist/infrastructure/pricing/pricing-monitor.js.map +1 -0
  213. package/dist/infrastructure/telemetry/dataset-limits.d.ts +13 -0
  214. package/dist/infrastructure/telemetry/dataset-limits.d.ts.map +1 -0
  215. package/dist/infrastructure/telemetry/dataset-limits.js +28 -0
  216. package/dist/infrastructure/telemetry/dataset-limits.js.map +1 -0
  217. package/dist/infrastructure/telemetry/dataset-recorder.d.ts +48 -0
  218. package/dist/infrastructure/telemetry/dataset-recorder.d.ts.map +1 -0
  219. package/dist/infrastructure/telemetry/dataset-recorder.js +154 -0
  220. package/dist/infrastructure/telemetry/dataset-recorder.js.map +1 -0
  221. package/dist/infrastructure/telemetry/outcome-limits.d.ts +12 -0
  222. package/dist/infrastructure/telemetry/outcome-limits.d.ts.map +1 -0
  223. package/dist/infrastructure/telemetry/outcome-limits.js +28 -0
  224. package/dist/infrastructure/telemetry/outcome-limits.js.map +1 -0
  225. package/dist/infrastructure/telemetry/outcome-recorder.d.ts +29 -0
  226. package/dist/infrastructure/telemetry/outcome-recorder.d.ts.map +1 -0
  227. package/dist/infrastructure/telemetry/outcome-recorder.js +49 -0
  228. package/dist/infrastructure/telemetry/outcome-recorder.js.map +1 -0
  229. package/dist/infrastructure/telemetry/routing-telemetry.d.ts +115 -0
  230. package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -0
  231. package/dist/infrastructure/telemetry/routing-telemetry.js +529 -0
  232. package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -0
  233. package/dist/infrastructure/telemetry/telemetry-limits.d.ts +16 -0
  234. package/dist/infrastructure/telemetry/telemetry-limits.d.ts.map +1 -0
  235. package/dist/infrastructure/telemetry/telemetry-limits.js +35 -0
  236. package/dist/infrastructure/telemetry/telemetry-limits.js.map +1 -0
  237. package/package.json +110 -0
  238. package/skills/router-backlog-orchestrator/SKILL.md +183 -0
  239. package/skills/router-backlog-orchestrator/references/github-router-issue-template.md +78 -0
  240. package/skills/router-backlog-orchestrator/references/packet-from-issue.md +99 -0
  241. package/skills/router-backlog-orchestrator/references/prioritization-rubric.md +65 -0
  242. package/skills/router-backlog-orchestrator/scripts/collect-backlog.sh +44 -0
  243. package/specs/001-build-smart-router/contracts/telemetry-contrib.schema.json +151 -0
  244. package/src/api/explain/.gitkeep +0 -0
  245. package/src/api/explain/router-explain.ts +146 -0
  246. package/src/api/middleware/.gitkeep +0 -0
  247. package/src/api/middleware/pi-router-middleware.ts +169 -0
  248. package/src/cli/smart-router-cli.ts +436 -0
  249. package/src/config/.gitkeep +0 -0
  250. package/src/config/defaults.ts +35 -0
  251. package/src/config/models-loader.ts +68 -0
  252. package/src/config/pi-model-mapper.ts +285 -0
  253. package/src/config/routing-clusters-loader.ts +148 -0
  254. package/src/domain/delegation/delegation-context.ts +111 -0
  255. package/src/domain/delegation/execution-ledger.ts +34 -0
  256. package/src/domain/delegation/output-headroom.ts +131 -0
  257. package/src/domain/matching/.gitkeep +0 -0
  258. package/src/domain/matching/cluster-matcher.ts +474 -0
  259. package/src/domain/matching/embedding-provider.ts +79 -0
  260. package/src/domain/matching/hydra-input.ts +53 -0
  261. package/src/domain/matching/hydra-matcher.ts +454 -0
  262. package/src/domain/pinning/.gitkeep +0 -0
  263. package/src/domain/pinning/cache-economics.ts +128 -0
  264. package/src/domain/pinning/loop-escalation.ts +181 -0
  265. package/src/domain/pinning/session-pinner.ts +388 -0
  266. package/src/domain/pinning/sub-route-policy.ts +119 -0
  267. package/src/domain/pipeline/.gitkeep +0 -0
  268. package/src/domain/pipeline/router-pipeline.ts +1139 -0
  269. package/src/domain/pipeline/safe-default.ts +70 -0
  270. package/src/domain/refactor-guardrails.ts +202 -0
  271. package/src/domain/routing/context-fit.ts +221 -0
  272. package/src/domain/routing/expected-cost.ts +320 -0
  273. package/src/domain/routing/p-success-classifier.ts +511 -0
  274. package/src/domain/routing/tier-features.ts +290 -0
  275. package/src/domain/routing/tool-history-guard.ts +159 -0
  276. package/src/domain/scoring/.gitkeep +0 -0
  277. package/src/domain/scoring/multi-objective.ts +175 -0
  278. package/src/domain/triage/.gitkeep +0 -0
  279. package/src/domain/triage/triage-engine.ts +373 -0
  280. package/src/domain/triage/turn-envelope.ts +104 -0
  281. package/src/domain/types/.gitkeep +0 -0
  282. package/src/domain/types/entities.ts +384 -0
  283. package/src/domain/types/index.ts +41 -0
  284. package/src/domain/types/schemas.ts +289 -0
  285. package/src/domain/types/store-port.ts +59 -0
  286. package/src/index.ts +103 -0
  287. package/src/infra/gemini-provider.ts +142 -0
  288. package/src/infra/telemetry.ts +169 -0
  289. package/src/infrastructure/delegation/provider-error.ts +251 -0
  290. package/src/infrastructure/gateway/.gitkeep +0 -0
  291. package/src/infrastructure/gateway/circuit-breaker.ts +196 -0
  292. package/src/infrastructure/gateway/gateway-dispatch.ts +444 -0
  293. package/src/infrastructure/hardware/.gitkeep +0 -0
  294. package/src/infrastructure/hardware/hardware-probe.ts +276 -0
  295. package/src/infrastructure/local/.gitkeep +0 -0
  296. package/src/infrastructure/local/local-zero-tier.ts +151 -0
  297. package/src/infrastructure/persistence/.gitkeep +0 -0
  298. package/src/infrastructure/persistence/memory-store.ts +122 -0
  299. package/src/infrastructure/persistence/sqlite-store.ts +900 -0
  300. package/src/infrastructure/pricing/.gitkeep +0 -0
  301. package/src/infrastructure/pricing/litellm-fetch.ts +215 -0
  302. package/src/infrastructure/pricing/price-broker.ts +183 -0
  303. package/src/infrastructure/pricing/pricing-monitor.ts +65 -0
  304. package/src/infrastructure/telemetry/.gitkeep +0 -0
  305. package/src/infrastructure/telemetry/dataset-limits.ts +39 -0
  306. package/src/infrastructure/telemetry/dataset-recorder.ts +215 -0
  307. package/src/infrastructure/telemetry/outcome-limits.ts +42 -0
  308. package/src/infrastructure/telemetry/outcome-recorder.ts +118 -0
  309. package/src/infrastructure/telemetry/routing-telemetry.ts +824 -0
  310. package/src/infrastructure/telemetry/telemetry-limits.ts +50 -0
@@ -0,0 +1,1139 @@
1
+ /**
2
+ * Pipeline stage orchestrator — FR-001, FR-006, FR-022.
3
+ *
4
+ * Runs stages sequentially with early-exit on decision.
5
+ * Documented order (SP-119, #69):
6
+ * hardware_probe → loop_escalation → turn_envelope → context_fit → low_intensity
7
+ * → session_pin → triage → local_zero → triage_cloud_fallback → hydra_match
8
+ * → safe_default → context_overflow_fallback
9
+ * Any stage failure falls back to safeCloudDefault(); never throws to host.
10
+ */
11
+
12
+ import type { ModelProfile, PriceCatalog, RoutingDecision, RoutingFeatureSidecar, RoutingRequest, Tier, CandidateScore } from '../types/index.js';
13
+ import type { LowIntensityConfig } from '../types/schemas.js';
14
+ import type { HardwareProbeConfig, HardwareProbeResult, SystemInfo } from '../../infrastructure/hardware/hardware-probe.js';
15
+ import type { HttpFetchPort, LocalZeroTierConfig } from '../../infrastructure/local/local-zero-tier.js';
16
+ import { probeHardware } from '../../infrastructure/hardware/hardware-probe.js';
17
+ import { pingLocalServices } from '../../infrastructure/local/local-zero-tier.js';
18
+ import { triage as triageClassify } from '../triage/triage-engine.js';
19
+ import type { TriageResult, TriageVerdict } from '../triage/triage-engine.js';
20
+ import { classifyTurnEnvelope } from '../triage/turn-envelope.js';
21
+ import { safeCloudDefault } from './safe-default.js';
22
+ import {
23
+ hasToolCallHistory,
24
+ isGoogleGeminiProfile,
25
+ } from '../routing/tool-history-guard.js';
26
+ import {
27
+ filterFleetByContextFit,
28
+ needsContextOverflowFallback,
29
+ resolveContextOverflowFallback,
30
+ CONTEXT_OVERFLOW_NO_FIT,
31
+ type ContextFitConfig,
32
+ } from '../routing/context-fit.js';
33
+ import type { SessionPinner } from '../pinning/session-pinner.js';
34
+ import { evaluateLoopEscalation } from '../pinning/loop-escalation.js';
35
+ import type { LoopEscalationConfig } from '../pinning/loop-escalation.js';
36
+ import { selectLowestCostModel } from '../pinning/sub-route-policy.js';
37
+ import {
38
+ RoutingTelemetryEmitter,
39
+ estimateRoutingCost,
40
+ enrichRoutingDecisionWithContextFit,
41
+ enrichRoutingDecisionWithTierSelection,
42
+ } from '../../infrastructure/telemetry/routing-telemetry.js';
43
+ import type { HydraMatcher as HydraMatcherType, MatchResult } from '../matching/hydra-matcher.js';
44
+ import type { ClusterMatcher, ClusterMatchResult } from '../matching/cluster-matcher.js';
45
+ import { clusterReasonCode } from '../../config/routing-clusters-loader.js';
46
+ import { DEFAULT_OPERATOR_CONFIG } from '../../config/defaults.js';
47
+ import {
48
+ buildTierFeatures,
49
+ scoreLowIntensity,
50
+ } from '../routing/tier-features.js';
51
+ import {
52
+ predictPSuccessCheapTimed,
53
+ resolvePSuccessWeights,
54
+ tierFeaturesToPSuccessFeatures,
55
+ type PSuccessWeights,
56
+ } from '../routing/p-success-classifier.js';
57
+ import {
58
+ selectTierByExpectedCost,
59
+ type ExpectedCostBreakdown,
60
+ } from '../routing/expected-cost.js';
61
+
62
+ // ─── Stage result ────────────────────────────────────────────────────────────
63
+
64
+ export interface StageResult {
65
+ readonly decided: boolean;
66
+ readonly decision?: RoutingDecision;
67
+ readonly stage: string;
68
+ }
69
+
70
+ export type PipelineStage = (request: RoutingRequest) => Promise<StageResult>;
71
+
72
+ /** Canonical pipeline stage order — keep README/specs in sync (SP-119). */
73
+ export const PIPELINE_STAGE_ORDER = [
74
+ 'hardware_probe',
75
+ 'loop_escalation',
76
+ 'turn_envelope',
77
+ 'context_fit',
78
+ 'low_intensity',
79
+ 'session_pin',
80
+ 'triage',
81
+ 'local_zero',
82
+ 'triage_cloud_fallback',
83
+ 'hydra_match',
84
+ 'safe_default',
85
+ 'context_overflow_fallback',
86
+ ] as const;
87
+
88
+ export type PipelineStageName = (typeof PIPELINE_STAGE_ORDER)[number];
89
+
90
+ interface NamedPipelineStage {
91
+ readonly name: string;
92
+ readonly run: PipelineStage;
93
+ }
94
+
95
+ /** Inputs for local_zero eligibility beyond trivial-only triage (SP-111, #59). */
96
+ export interface LocalEligibleInput {
97
+ readonly triageVerdict: TriageVerdict | null;
98
+ readonly tierHint: Tier | null;
99
+ readonly lowIntensityScore: number | null;
100
+ readonly highThreshold: number;
101
+ readonly clusterMatch: ClusterMatchResult | null;
102
+ }
103
+
104
+ export interface LocalEligibleResult {
105
+ readonly eligible: boolean;
106
+ readonly reason: string | null;
107
+ }
108
+
109
+ /**
110
+ * Disjunction: triage trivial OR low-intensity zero-tier hint (high confidence)
111
+ * OR high-confidence zero-tier cluster match.
112
+ */
113
+ export function resolveLocalEligible(input: LocalEligibleInput): LocalEligibleResult {
114
+ const clusterZeroTier =
115
+ input.clusterMatch?.confidence === 'high' &&
116
+ input.clusterMatch.tierBias === 'zero-tier';
117
+
118
+ const lowIntensityZeroTier =
119
+ input.tierHint === 'zero-tier' &&
120
+ input.lowIntensityScore !== null &&
121
+ input.lowIntensityScore >= input.highThreshold;
122
+
123
+ const triageTrivial = input.triageVerdict === 'trivial';
124
+
125
+ if (!triageTrivial && !lowIntensityZeroTier && !clusterZeroTier) {
126
+ return { eligible: false, reason: null };
127
+ }
128
+
129
+ if (triageTrivial) {
130
+ return { eligible: true, reason: 'triage_trivial' };
131
+ }
132
+
133
+ if (clusterZeroTier) {
134
+ return {
135
+ eligible: true,
136
+ reason: clusterReasonCode(input.clusterMatch!.clusterId),
137
+ };
138
+ }
139
+
140
+ return { eligible: true, reason: 'low_intensity_structural' };
141
+ }
142
+
143
+ // ─── Pipeline configuration ──────────────────────────────────────────────────
144
+
145
+ export interface PipelineOptions {
146
+ readonly hardwareConfig?: HardwareProbeConfig;
147
+ readonly localConfig?: LocalZeroTierConfig;
148
+ readonly systemInfoProvider?: () => Promise<SystemInfo>;
149
+ readonly httpFetchPort?: HttpFetchPort;
150
+ readonly sessionPinner?: SessionPinner;
151
+ readonly loopEscalationConfig?: LoopEscalationConfig;
152
+ readonly telemetryEmitter?: RoutingTelemetryEmitter;
153
+ readonly hydraMatcher?: HydraMatcherType;
154
+ readonly clusterMatcher?: ClusterMatcher;
155
+ readonly lowIntensityConfig?: LowIntensityConfig;
156
+ readonly priceCatalog?: PriceCatalog | null;
157
+ readonly contextFitConfig?: ContextFitConfig;
158
+ /** Preloaded P(success) weights for tests; lazy-loads artifact when omitted (SP-105). */
159
+ readonly pSuccessWeights?: PSuccessWeights;
160
+ readonly pSuccessWeightsPath?: string;
161
+ }
162
+
163
+ // ─── Orchestrator ────────────────────────────────────────────────────────────
164
+
165
+ export class RouterPipeline {
166
+ private readonly stages: readonly NamedPipelineStage[];
167
+ private readonly fleet: readonly ModelProfile[];
168
+ private readonly options: PipelineOptions;
169
+
170
+ /** Per-route transient fleet — defaults to constructor fleet. */
171
+ private activeFleet: readonly ModelProfile[] = [];
172
+
173
+ /** Unfiltered fleet for overflow escalation (SP-095). */
174
+ private fullFleet: readonly ModelProfile[] = [];
175
+
176
+ /** Per-route transient state — reset on each route() call. */
177
+ private currentHardwareResult: HardwareProbeResult = 'disabled';
178
+ private currentTriageResult: TriageResult | null = null;
179
+ private currentHydraResult: MatchResult | null = null;
180
+ private currentClusterMatch: ClusterMatchResult | null = null;
181
+ private currentTierHint: Tier | null = null;
182
+ private currentTierHintReasonCode: string | null = null;
183
+ private currentLowIntensityScore: number | null = null;
184
+ private currentPSuccessCheap: number | null = null;
185
+ private currentPSuccessAlpha: number | null = null;
186
+ private currentExpectedCostByTier: ExpectedCostBreakdown[] | null = null;
187
+ private currentLocalEligibleReason: string | null = null;
188
+ private pSuccessWeightsLoaded = false;
189
+ private cachedPSuccessWeights: PSuccessWeights | null = null;
190
+ private currentContextFitRejected: readonly CandidateScore[] = [];
191
+ private currentContextFitViableCount = 0;
192
+ private contextOverflowPreferredProvider: string | null = null;
193
+ private contextOverflowTriggered = false;
194
+
195
+ constructor(fleet: readonly ModelProfile[], options?: PipelineOptions) {
196
+ this.fleet = fleet;
197
+ this.options = options ?? {};
198
+ this.stages = [
199
+ { name: 'hardware_probe', run: this.hardwareProbeStage.bind(this) },
200
+ { name: 'loop_escalation', run: this.loopEscalation.bind(this) },
201
+ { name: 'turn_envelope', run: this.turnEnvelope.bind(this) },
202
+ { name: 'context_fit', run: this.contextFitStage.bind(this) },
203
+ { name: 'low_intensity', run: this.lowIntensityGate.bind(this) },
204
+ { name: 'session_pin', run: this.sessionPin.bind(this) },
205
+ { name: 'triage', run: this.triage.bind(this) },
206
+ { name: 'local_zero', run: this.localZeroTierStage.bind(this) },
207
+ { name: 'triage_cloud_fallback', run: this.triageCloudFallback.bind(this) },
208
+ { name: 'hydra_match', run: this.hydraMatcher.bind(this) },
209
+ { name: 'safe_default', run: this.safeDefaultStage.bind(this) },
210
+ { name: 'context_overflow_fallback', run: this.contextOverflowFallback.bind(this) },
211
+ ];
212
+ }
213
+
214
+ async route(
215
+ request: RoutingRequest,
216
+ fleetOverride?: readonly ModelProfile[],
217
+ ): Promise<RoutingDecision> {
218
+ const start = Date.now();
219
+ this.activeFleet = this.prioritizeFleetForToolHistory(
220
+ fleetOverride ?? this.fleet,
221
+ request,
222
+ );
223
+ this.fullFleet = this.activeFleet;
224
+ this.currentHardwareResult = 'disabled';
225
+ this.currentTriageResult = null;
226
+ this.currentHydraResult = null;
227
+ this.currentClusterMatch = null;
228
+ this.currentTierHint = null;
229
+ this.currentTierHintReasonCode = null;
230
+ this.currentLowIntensityScore = null;
231
+ this.currentPSuccessCheap = null;
232
+ this.currentPSuccessAlpha = null;
233
+ this.currentExpectedCostByTier = null;
234
+ this.currentLocalEligibleReason = null;
235
+ this.currentContextFitRejected = [];
236
+ this.currentContextFitViableCount = 0;
237
+ this.contextOverflowPreferredProvider = null;
238
+ this.contextOverflowTriggered = false;
239
+
240
+ let currentStage: NamedPipelineStage | undefined;
241
+
242
+ try {
243
+ for (const stage of this.stages) {
244
+ currentStage = stage;
245
+ const result = await stage.run(request);
246
+ if (result.decided && result.decision) {
247
+ this.persistPinIfNeeded(request, result.decision);
248
+ this.emitTelemetry(request, result.decision);
249
+ return this.attachFeatures(request, result.decision);
250
+ }
251
+ }
252
+ } catch (error: unknown) {
253
+ // Constitution VI: zero-crash resilience — degrade to safe default
254
+ const failedStage = this.resolveFailedStage(currentStage);
255
+ const elapsedMs = Date.now() - start;
256
+ const fallback = this.buildFallbackDecision(request, elapsedMs);
257
+ this.logPipelineError(request, failedStage, error);
258
+ this.emitPipelineErrorTelemetry(request, failedStage, fallback);
259
+ this.persistPinIfNeeded(request, fallback);
260
+ return this.attachFeatures(request, fallback);
261
+ }
262
+
263
+ const fallback = this.buildFallbackDecision(request, Date.now() - start);
264
+ this.persistPinIfNeeded(request, fallback);
265
+ this.emitTelemetry(request, fallback);
266
+ return this.attachFeatures(request, fallback);
267
+ }
268
+
269
+ /**
270
+ * SP-080: move Google/Gemini profiles to the end of the fleet when prior tool
271
+ * calls exist so tier `.find()` passes prefer non-Gemini models first.
272
+ * Honors `force_model_id` by leaving fleet order unchanged.
273
+ */
274
+ private prioritizeFleetForToolHistory(
275
+ fleet: readonly ModelProfile[],
276
+ request: RoutingRequest,
277
+ ): readonly ModelProfile[] {
278
+ if (request.force_model_id) {
279
+ return fleet;
280
+ }
281
+
282
+ const messages = request.messages;
283
+ if (!messages || messages.length === 0 || !hasToolCallHistory(messages)) {
284
+ return fleet;
285
+ }
286
+
287
+ const preferred: ModelProfile[] = [];
288
+ const deprioritized: ModelProfile[] = [];
289
+
290
+ for (const profile of fleet) {
291
+ if (isGoogleGeminiProfile(profile)) {
292
+ deprioritized.push(profile);
293
+ } else {
294
+ preferred.push(profile);
295
+ }
296
+ }
297
+
298
+ if (deprioritized.length === 0) {
299
+ return fleet;
300
+ }
301
+
302
+ return [...preferred, ...deprioritized];
303
+ }
304
+
305
+ /** Attach privacy-safe dataset features captured during pipeline stages (SP-057, SP-119). */
306
+ private attachFeatures(
307
+ request: RoutingRequest,
308
+ decision: RoutingDecision,
309
+ ): RoutingDecision {
310
+ const features: RoutingFeatureSidecar = {
311
+ triage: this.currentTriageResult
312
+ ? {
313
+ verdict: this.currentTriageResult.verdict,
314
+ reason_code: this.currentTriageResult.reason_code,
315
+ cyclomatic_score: this.currentTriageResult.cyclomatic_score,
316
+ }
317
+ : null,
318
+ requirements: this.currentHydraResult?.requirements ?? null,
319
+ candidates: this.mergeFeatureCandidates(),
320
+ tier_hint: this.currentTierHint,
321
+ tier_hint_reason_code: this.currentTierHintReasonCode,
322
+ low_intensity_score: this.currentLowIntensityScore,
323
+ p_success_cheap: this.currentPSuccessCheap,
324
+ p_success_alpha: this.currentPSuccessAlpha,
325
+ local_eligible_reason: this.currentLocalEligibleReason,
326
+ };
327
+
328
+ const withBaseFeatures = { ...decision, features };
329
+ const withContextFit = enrichRoutingDecisionWithContextFit(
330
+ request,
331
+ withBaseFeatures,
332
+ this.fullFleet,
333
+ this.options.contextFitConfig,
334
+ );
335
+ return enrichRoutingDecisionWithTierSelection(withContextFit);
336
+ }
337
+
338
+ private mergeFeatureCandidates(): readonly CandidateScore[] | null {
339
+ const hydraCandidates = this.currentHydraResult?.candidates ?? [];
340
+ const expectedCostCandidates =
341
+ this.currentExpectedCostByTier?.map((entry) => ({
342
+ model_id: `__expected_cost_${entry.tier}__`,
343
+ score: entry.expectedCostUsd,
344
+ shortfall: entry.adjustedExpectedCostUsd,
345
+ rejected_reason: `p_success=${entry.pSuccess.toFixed(4)}`,
346
+ })) ?? [];
347
+
348
+ if (
349
+ this.currentContextFitRejected.length === 0 &&
350
+ hydraCandidates.length === 0 &&
351
+ expectedCostCandidates.length === 0
352
+ ) {
353
+ return null;
354
+ }
355
+ return [
356
+ ...this.currentContextFitRejected,
357
+ ...expectedCostCandidates,
358
+ ...hydraCandidates,
359
+ ];
360
+ }
361
+
362
+ private resolveFailedStage(stage: NamedPipelineStage | undefined): string {
363
+ return stage?.name ?? 'unknown';
364
+ }
365
+
366
+ private logPipelineError(
367
+ request: RoutingRequest,
368
+ stage: string,
369
+ error: unknown,
370
+ ): void {
371
+ console.warn('Router pipeline stage failed; degrading to safe default', {
372
+ stage,
373
+ request_id: request.request_id,
374
+ session_id: request.session_id,
375
+ error: this.redactPromptFromError(error, request.prompt_text),
376
+ });
377
+ }
378
+
379
+ private redactPromptFromError(error: unknown, promptText: string): string {
380
+ const message = error instanceof Error ? error.message : String(error);
381
+ if (!promptText || !message.includes(promptText)) {
382
+ return message;
383
+ }
384
+ return message.replaceAll(promptText, '[REDACTED]');
385
+ }
386
+
387
+ private emitPipelineErrorTelemetry(
388
+ request: RoutingRequest,
389
+ failedStage: string,
390
+ fallback: RoutingDecision,
391
+ ): void {
392
+ this.options.telemetryEmitter?.emitPipelineError(request, failedStage, fallback);
393
+ }
394
+
395
+ /** Step 7: emit routing telemetry after decision (T040). */
396
+ private emitTelemetry(request: RoutingRequest, decision: RoutingDecision): void {
397
+ this.options.telemetryEmitter?.emit(request, decision);
398
+ }
399
+
400
+ private withEstimatedCost(
401
+ request: RoutingRequest,
402
+ model: ModelProfile,
403
+ decision: RoutingDecision,
404
+ ): RoutingDecision {
405
+ return {
406
+ ...decision,
407
+ estimated_cost_usd: estimateRoutingCost(
408
+ model,
409
+ request,
410
+ this.options.priceCatalog ?? null,
411
+ ),
412
+ };
413
+ }
414
+
415
+ private buildFallbackDecision(
416
+ request: RoutingRequest,
417
+ elapsedMs: number,
418
+ ): RoutingDecision {
419
+ if (this.shouldAttemptContextOverflowFallback(request)) {
420
+ return this.buildContextOverflowFallbackDecision(request, elapsedMs);
421
+ }
422
+
423
+ const fallbackModel = safeCloudDefault(this.activeFleet, {
424
+ request,
425
+ ...(this.options.contextFitConfig !== undefined
426
+ ? { contextFitConfig: this.options.contextFitConfig }
427
+ : {}),
428
+ });
429
+ const modelId = fallbackModel?.id ?? 'unknown';
430
+ const tier = fallbackModel?.tier ?? 'economical-cloud';
431
+
432
+ return {
433
+ request_id: request.request_id,
434
+ selected_model_id: modelId,
435
+ tier,
436
+ stage: 'fallback',
437
+ reason_code: 'safe_cloud_default',
438
+ routing_latency_ms: elapsedMs,
439
+ pin_reason: null,
440
+ };
441
+ }
442
+
443
+ private shouldAttemptContextOverflowFallback(request: RoutingRequest): boolean {
444
+ if (request.force_model_id) {
445
+ return false;
446
+ }
447
+
448
+ if (this.contextOverflowTriggered) {
449
+ return true;
450
+ }
451
+
452
+ return needsContextOverflowFallback(
453
+ this.activeFleet,
454
+ this.currentContextFitRejected,
455
+ this.fullFleet,
456
+ );
457
+ }
458
+
459
+ private buildContextOverflowFallbackDecision(
460
+ request: RoutingRequest,
461
+ elapsedMs: number,
462
+ ): RoutingDecision {
463
+ const overflow = resolveContextOverflowFallback(
464
+ this.fullFleet,
465
+ request,
466
+ this.contextOverflowPreferredProvider,
467
+ this.options.contextFitConfig,
468
+ );
469
+
470
+ if (overflow.kind === 'no_fit') {
471
+ return {
472
+ request_id: request.request_id,
473
+ selected_model_id: 'unknown',
474
+ tier: 'economical-cloud',
475
+ stage: 'fallback',
476
+ reason_code: CONTEXT_OVERFLOW_NO_FIT,
477
+ candidates: this.currentContextFitRejected,
478
+ routing_latency_ms: elapsedMs,
479
+ pin_reason: null,
480
+ };
481
+ }
482
+
483
+ const model = overflow.model!;
484
+ return this.withEstimatedCost(request, model, {
485
+ request_id: request.request_id,
486
+ selected_model_id: model.id,
487
+ tier: model.tier,
488
+ stage: 'fallback',
489
+ reason_code: overflow.reasonCode,
490
+ candidates: this.currentContextFitRejected,
491
+ routing_latency_ms: elapsedMs,
492
+ pin_reason: null,
493
+ });
494
+ }
495
+
496
+ /**
497
+ * SP-095: after safe_default, escalate to largest-fit model when economical
498
+ * models cannot fit the current context.
499
+ */
500
+ private async contextOverflowFallback(request: RoutingRequest): Promise<StageResult> {
501
+ if (!this.shouldAttemptContextOverflowFallback(request)) {
502
+ return { decided: false, stage: 'context_overflow_fallback' };
503
+ }
504
+
505
+ const elapsedMs = 0;
506
+ const decision = this.buildContextOverflowFallbackDecision(request, elapsedMs);
507
+ return {
508
+ decided: true,
509
+ stage: 'context_overflow_fallback',
510
+ decision,
511
+ };
512
+ }
513
+
514
+ /**
515
+ * SP-022: economical-cloud default when no earlier stage decides.
516
+ * Defers to context_overflow_fallback when economical models were context-rejected.
517
+ */
518
+ private async safeDefaultStage(request: RoutingRequest): Promise<StageResult> {
519
+ if (this.shouldAttemptContextOverflowFallback(request)) {
520
+ return { decided: false, stage: 'safe_default' };
521
+ }
522
+
523
+ const fallbackModel = safeCloudDefault(this.activeFleet, {
524
+ request,
525
+ ...(this.options.contextFitConfig !== undefined
526
+ ? { contextFitConfig: this.options.contextFitConfig }
527
+ : {}),
528
+ });
529
+
530
+ if (!fallbackModel) {
531
+ return { decided: false, stage: 'safe_default' };
532
+ }
533
+
534
+ return {
535
+ decided: true,
536
+ stage: 'fallback',
537
+ decision: {
538
+ request_id: request.request_id,
539
+ selected_model_id: fallbackModel.id,
540
+ tier: fallbackModel.tier,
541
+ stage: 'fallback',
542
+ reason_code: 'safe_cloud_default',
543
+ routing_latency_ms: 0,
544
+ pin_reason: null,
545
+ },
546
+ };
547
+ }
548
+
549
+ private markContextOverflowFromPin(
550
+ request: RoutingRequest,
551
+ pinnedModelId: string,
552
+ ): void {
553
+ const pinnedModel = this.fullFleet.find((model) => model.id === pinnedModelId);
554
+ this.contextOverflowTriggered = true;
555
+ this.contextOverflowPreferredProvider = pinnedModel?.provider ?? null;
556
+ }
557
+
558
+ // ─── Implemented stages ─────────────────────────────────────────────────────
559
+
560
+ private async hardwareProbeStage(request: RoutingRequest): Promise<StageResult> {
561
+ void request;
562
+ if (!this.options.hardwareConfig || !this.options.systemInfoProvider) {
563
+ return { decided: false, stage: 'hardware_probe' };
564
+ }
565
+
566
+ const systemInfo = await this.options.systemInfoProvider();
567
+ this.currentHardwareResult = probeHardware(this.options.hardwareConfig, systemInfo);
568
+ return { decided: false, stage: 'hardware_probe' };
569
+ }
570
+
571
+ /**
572
+ * SP-093: filter fleet to models whose context window fits estimated input
573
+ * tokens before session pin and HyDRA matching.
574
+ */
575
+ private async contextFitStage(request: RoutingRequest): Promise<StageResult> {
576
+ const result = filterFleetByContextFit(
577
+ this.activeFleet,
578
+ request,
579
+ this.options.contextFitConfig,
580
+ );
581
+ this.activeFleet = result.effectiveFleet;
582
+ this.currentContextFitRejected = result.rejected;
583
+ this.currentContextFitViableCount = result.effectiveFleet.length;
584
+ return { decided: false, stage: 'context_fit' };
585
+ }
586
+
587
+ /**
588
+ * SC-007: classification_only MUST NOT dispatch full local.
589
+ * Eligibility: triage trivial OR low-intensity zero-tier hint OR zero-tier cluster (SP-111).
590
+ */
591
+ private async localZeroTierStage(request: RoutingRequest): Promise<StageResult> {
592
+ const lowIntensityConfig =
593
+ this.options.lowIntensityConfig ?? DEFAULT_OPERATOR_CONFIG.low_intensity;
594
+ const eligibility = resolveLocalEligible({
595
+ triageVerdict: this.currentTriageResult?.verdict ?? null,
596
+ tierHint: this.currentTierHint,
597
+ lowIntensityScore: this.currentLowIntensityScore,
598
+ highThreshold: lowIntensityConfig.high_threshold,
599
+ clusterMatch: this.currentClusterMatch,
600
+ });
601
+
602
+ if (!eligibility.eligible) {
603
+ return { decided: false, stage: 'local_zero' };
604
+ }
605
+
606
+ if (this.currentHardwareResult !== 'full_local') {
607
+ return { decided: false, stage: 'local_zero' };
608
+ }
609
+
610
+ const readiness = await pingLocalServices(
611
+ this.options.localConfig,
612
+ this.options.httpFetchPort,
613
+ );
614
+
615
+ if (!readiness.anyModelReady) {
616
+ return { decided: false, stage: 'local_zero' };
617
+ }
618
+
619
+ const localModel = this.activeFleet.find(
620
+ (m) => m.tier === 'zero-tier' && m.healthy !== false,
621
+ );
622
+
623
+ if (!localModel) {
624
+ return { decided: false, stage: 'local_zero' };
625
+ }
626
+
627
+ this.currentLocalEligibleReason = eligibility.reason;
628
+
629
+ return {
630
+ decided: true,
631
+ stage: 'local_zero',
632
+ decision: {
633
+ request_id: request.request_id,
634
+ selected_model_id: localModel.id,
635
+ tier: 'zero-tier',
636
+ stage: 'local_zero',
637
+ reason_code: 'local_model_ready',
638
+ routing_latency_ms: readiness.combinedLatencyMs,
639
+ pin_reason: null,
640
+ },
641
+ };
642
+ }
643
+
644
+ // ─── Triage stage (FR-003, SC-004 <5ms budget) ──────────────────────────────
645
+
646
+ private async triage(request: RoutingRequest): Promise<StageResult> {
647
+ const result = triageClassify(request.prompt_text);
648
+ this.currentTriageResult = result;
649
+
650
+ if (result.verdict === 'ambiguous') {
651
+ return { decided: false, stage: 'triage' };
652
+ }
653
+
654
+ // Trivial prompts defer cloud routing until after local zero-tier (PRD Step 4).
655
+ if (result.verdict === 'trivial') {
656
+ return { decided: false, stage: 'triage' };
657
+ }
658
+
659
+ const targetTier = 'frontier-cloud';
660
+ const model = this.activeFleet.find((m) => m.tier === targetTier && m.healthy !== false);
661
+
662
+ if (!model) {
663
+ return { decided: false, stage: 'triage' };
664
+ }
665
+
666
+ return {
667
+ decided: true,
668
+ stage: 'triage',
669
+ decision: {
670
+ request_id: request.request_id,
671
+ selected_model_id: model.id,
672
+ tier: targetTier,
673
+ stage: 'triage',
674
+ reason_code: result.reason_code,
675
+ routing_latency_ms: 0,
676
+ pin_reason: null,
677
+ },
678
+ };
679
+ }
680
+
681
+ /**
682
+ * Economical-cloud fallback for trivial prompts after local zero-tier is skipped
683
+ * or unavailable (PRD Step 4 cloud fallback).
684
+ */
685
+ private async triageCloudFallback(request: RoutingRequest): Promise<StageResult> {
686
+ if (this.currentTriageResult?.verdict !== 'trivial') {
687
+ return { decided: false, stage: 'triage' };
688
+ }
689
+
690
+ const model = this.activeFleet.find(
691
+ (m) => m.tier === 'economical-cloud' && m.healthy !== false,
692
+ );
693
+
694
+ if (!model) {
695
+ return { decided: false, stage: 'triage' };
696
+ }
697
+
698
+ return {
699
+ decided: true,
700
+ stage: 'triage',
701
+ decision: {
702
+ request_id: request.request_id,
703
+ selected_model_id: model.id,
704
+ tier: 'economical-cloud',
705
+ stage: 'triage',
706
+ reason_code: this.currentTriageResult.reason_code,
707
+ routing_latency_ms: 0,
708
+ pin_reason: null,
709
+ },
710
+ };
711
+ }
712
+
713
+ // ─── Session pin stage (FR-006, FR-007, FR-008) ──────────────────────────
714
+
715
+ private async sessionPin(request: RoutingRequest): Promise<StageResult> {
716
+ const pinner = this.options.sessionPinner;
717
+ if (!pinner) {
718
+ return { decided: false, stage: 'session_pin' };
719
+ }
720
+
721
+ const existingPin = pinner.getPin(request.session_id);
722
+ const result = pinner.lookupPin(request, this.activeFleet);
723
+
724
+ switch (result.action) {
725
+ case 'use_pin': {
726
+ const model = result.pinnedModel!;
727
+ const pin = pinner.getPin(request.session_id);
728
+ return {
729
+ decided: true,
730
+ stage: 'session_pin',
731
+ decision: this.withEstimatedCost(request, model, {
732
+ request_id: request.request_id,
733
+ selected_model_id: model.id,
734
+ tier: model.tier,
735
+ stage: 'session_pin',
736
+ reason_code: 'session_pinned',
737
+ routing_latency_ms: 0,
738
+ pin_reason: pin?.pin_reason ?? null,
739
+ }),
740
+ };
741
+ }
742
+
743
+ case 'sub_route': {
744
+ const model = result.subRouteModel!;
745
+ const pin = pinner.getPin(request.session_id);
746
+ return {
747
+ decided: true,
748
+ stage: 'session_pin',
749
+ decision: this.withEstimatedCost(request, model, {
750
+ request_id: request.request_id,
751
+ selected_model_id: model.id,
752
+ tier: model.tier,
753
+ stage: 'session_pin',
754
+ reason_code: 'tool_result_sub_route',
755
+ routing_latency_ms: 0,
756
+ pin_reason: pin?.pin_reason ?? null,
757
+ }),
758
+ };
759
+ }
760
+
761
+ case 'break':
762
+ if (result.breakReason === 'context_overflow' && existingPin) {
763
+ this.markContextOverflowFromPin(request, existingPin.pinned_model_id);
764
+ }
765
+ return { decided: false, stage: 'session_pin' };
766
+
767
+ case 'no_pin':
768
+ if (existingPin) {
769
+ const wasContextRejected = this.currentContextFitRejected.some(
770
+ (candidate) => candidate.model_id === existingPin.pinned_model_id,
771
+ );
772
+ if (wasContextRejected) {
773
+ this.markContextOverflowFromPin(request, existingPin.pinned_model_id);
774
+ }
775
+ }
776
+ return { decided: false, stage: 'session_pin' };
777
+
778
+ default:
779
+ return { decided: false, stage: 'session_pin' };
780
+ }
781
+ }
782
+
783
+ /**
784
+ * After a routing decision, persist an initial pin when none exists.
785
+ * Sub-routes and already-pinned decisions skip persistence.
786
+ */
787
+ private persistPinIfNeeded(
788
+ request: RoutingRequest,
789
+ decision: RoutingDecision,
790
+ ): void {
791
+ const pinner = this.options.sessionPinner;
792
+ if (!pinner) return;
793
+
794
+ if (decision.reason_code === 'tool_result_sub_route') return;
795
+ if (decision.reason_code === 'session_pinned') return;
796
+
797
+ // Turn envelope is a per-turn tier bias — do not overwrite an existing pin (SP-064).
798
+ if (decision.stage === 'turn_envelope' && pinner.getPin(request.session_id)) return;
799
+
800
+ pinner.recordPin(request.session_id, decision.selected_model_id, 'initial');
801
+ }
802
+
803
+ // ─── Turn envelope stage (Step 2b, <2ms budget) ─────────────────────────
804
+
805
+ private static readonly TURN_TIER_MAP: Readonly<Record<string, Tier | null>> = {
806
+ planning: 'frontier-cloud',
807
+ tool_result: 'economical-cloud',
808
+ subagent: 'economical-cloud',
809
+ main_loop: null,
810
+ unknown: null,
811
+ };
812
+
813
+ private async turnEnvelope(request: RoutingRequest): Promise<StageResult> {
814
+ const turnType = request.turn_type ?? classifyTurnEnvelope(request.messages);
815
+ const targetTier = RouterPipeline.TURN_TIER_MAP[turnType] ?? null;
816
+
817
+ if (!targetTier) {
818
+ return { decided: false, stage: 'turn_envelope' };
819
+ }
820
+
821
+ const tierCandidates = this.activeFleet.filter(
822
+ (m) => m.tier === targetTier && m.healthy !== false,
823
+ );
824
+ const model = selectLowestCostModel(tierCandidates);
825
+ if (!model) {
826
+ return { decided: false, stage: 'turn_envelope' };
827
+ }
828
+
829
+ return {
830
+ decided: true,
831
+ stage: 'turn_envelope',
832
+ decision: this.withEstimatedCost(request, model, {
833
+ request_id: request.request_id,
834
+ selected_model_id: model.id,
835
+ tier: targetTier,
836
+ stage: 'turn_envelope',
837
+ reason_code: `turn_${turnType}`,
838
+ routing_latency_ms: 0,
839
+ pin_reason: null,
840
+ }),
841
+ };
842
+ }
843
+
844
+ // ─── Low-intensity tier gate (SP-103, #58) ───────────────────────────────
845
+
846
+ /**
847
+ * Runs after turn_envelope and context_fit, before session_pin. Computes low-intensity score
848
+ * from structural signals and optional cluster match; sets tier_hint and constrains
849
+ * the active fleet for subsequent HyDRA matching when confidence is high.
850
+ */
851
+ private async lowIntensityGate(request: RoutingRequest): Promise<StageResult> {
852
+ const config =
853
+ this.options.lowIntensityConfig ?? DEFAULT_OPERATOR_CONFIG.low_intensity;
854
+ const alpha = config.p_success_alpha;
855
+ this.currentPSuccessAlpha = alpha;
856
+
857
+ const triageResult = triageClassify(request.prompt_text);
858
+ let clusterMatch: ClusterMatchResult | undefined;
859
+
860
+ const matcher = this.options.clusterMatcher;
861
+ if (matcher) {
862
+ try {
863
+ const result = await matcher.match(request);
864
+ this.currentClusterMatch = result;
865
+ clusterMatch = result;
866
+ } catch {
867
+ this.currentClusterMatch = null;
868
+ }
869
+ }
870
+
871
+ const tierFeatures = buildTierFeatures(request, triageResult, undefined, clusterMatch);
872
+ const score = scoreLowIntensity(tierFeatures, config.weights);
873
+ this.currentLowIntensityScore = score;
874
+
875
+ const weights = this.resolvePSuccessWeights();
876
+ const pFeatures = tierFeaturesToPSuccessFeatures(tierFeatures);
877
+ const pResult = predictPSuccessCheapTimed(pFeatures, weights);
878
+ this.currentPSuccessCheap = pResult.probability;
879
+
880
+ const structuralHint = this.resolveTierHint(
881
+ score,
882
+ config.high_threshold,
883
+ config.low_threshold,
884
+ clusterMatch,
885
+ );
886
+ const weightsTrained =
887
+ weights.trained_sample_count >= weights.min_training_samples;
888
+ const adjustedHint = weightsTrained
889
+ ? (() => {
890
+ const selection = this.selectExpectedCostTierHint(
891
+ request,
892
+ pResult.probability,
893
+ alpha,
894
+ );
895
+ this.logExpectedCostExplain(pResult.probability, alpha, selection);
896
+ return {
897
+ tierHint: selection.tierHint,
898
+ reasonCode: selection.reasonCode,
899
+ };
900
+ })()
901
+ : structuralHint;
902
+ this.currentTierHint = adjustedHint.tierHint;
903
+ this.currentTierHintReasonCode = adjustedHint.reasonCode;
904
+
905
+ return { decided: false, stage: 'low_intensity' };
906
+ }
907
+
908
+ private selectExpectedCostTierHint(
909
+ request: RoutingRequest,
910
+ pSuccessCheap: number,
911
+ alpha: number,
912
+ ): {
913
+ tierHint: Tier | null;
914
+ reasonCode: string | null;
915
+ tierCosts: readonly ExpectedCostBreakdown[];
916
+ rationale: string;
917
+ } {
918
+ const estTokens =
919
+ request.estimated_input_tokens ?? request.prompt_text.length;
920
+ const pinner = this.options.sessionPinner;
921
+ const sessionPin = pinner?.getPin(request.session_id) ?? undefined;
922
+ const pinnedModel =
923
+ sessionPin !== undefined
924
+ ? this.activeFleet.find((model) => model.id === sessionPin.pinned_model_id)
925
+ : undefined;
926
+
927
+ const selection = selectTierByExpectedCost({
928
+ fleet: this.activeFleet,
929
+ priceCatalog: this.options.priceCatalog ?? null,
930
+ estTokens,
931
+ pSuccessCheap,
932
+ alpha,
933
+ localZeroReady: this.isLocalZeroTierReady(),
934
+ ...(pinnedModel !== undefined ? { pinnedModel } : {}),
935
+ ...(sessionPin !== undefined ? { sessionPin } : {}),
936
+ });
937
+
938
+ this.currentExpectedCostByTier = [...selection.tierCosts];
939
+
940
+ return {
941
+ tierHint: selection.tierHint,
942
+ reasonCode: selection.reasonCode,
943
+ tierCosts: selection.tierCosts,
944
+ rationale: selection.rationale,
945
+ };
946
+ }
947
+
948
+ private logExpectedCostExplain(
949
+ pSuccessCheap: number,
950
+ alpha: number,
951
+ selection: {
952
+ tierHint: Tier | null;
953
+ reasonCode: string | null;
954
+ tierCosts: readonly ExpectedCostBreakdown[];
955
+ rationale: string;
956
+ },
957
+ ): void {
958
+ console.info('Expected-cost tier gate', {
959
+ reason: selection.reasonCode,
960
+ p_success_cheap: pSuccessCheap,
961
+ alpha,
962
+ chosen_tier: selection.tierHint,
963
+ rationale: selection.rationale,
964
+ expected_cost_by_tier: selection.tierCosts.map((entry) => ({
965
+ tier: entry.tier,
966
+ p_success: entry.pSuccess,
967
+ cost_per_1m: entry.costPer1M,
968
+ expected_cost_usd: entry.expectedCostUsd,
969
+ adjusted_expected_cost_usd: entry.adjustedExpectedCostUsd,
970
+ })),
971
+ });
972
+ }
973
+
974
+ private resolvePSuccessWeights(): PSuccessWeights {
975
+ if (this.options.pSuccessWeights) {
976
+ return this.options.pSuccessWeights;
977
+ }
978
+
979
+ if (!this.pSuccessWeightsLoaded) {
980
+ this.cachedPSuccessWeights = resolvePSuccessWeights({
981
+ ...(this.options.pSuccessWeightsPath !== undefined
982
+ ? { filePath: this.options.pSuccessWeightsPath }
983
+ : {}),
984
+ });
985
+ this.pSuccessWeightsLoaded = true;
986
+ }
987
+
988
+ return this.cachedPSuccessWeights!;
989
+ }
990
+
991
+ private resolveTierHint(
992
+ score: number,
993
+ highThreshold: number,
994
+ lowThreshold: number,
995
+ clusterMatch?: ClusterMatchResult,
996
+ ): { tierHint: Tier | null; reasonCode: string | null } {
997
+ if (score >= highThreshold) {
998
+ return {
999
+ tierHint: this.resolveLowIntensityTierHint(),
1000
+ reasonCode: this.resolveLowIntensityReasonCode(clusterMatch),
1001
+ };
1002
+ }
1003
+
1004
+ if (score <= lowThreshold) {
1005
+ return {
1006
+ tierHint: 'frontier-cloud',
1007
+ reasonCode: this.resolveHighIntensityReasonCode(clusterMatch),
1008
+ };
1009
+ }
1010
+
1011
+ return { tierHint: null, reasonCode: null };
1012
+ }
1013
+
1014
+ private resolveLowIntensityTierHint(): Tier {
1015
+ if (this.isLocalZeroTierReady()) {
1016
+ return 'zero-tier';
1017
+ }
1018
+ return 'economical-cloud';
1019
+ }
1020
+
1021
+ private isLocalZeroTierReady(): boolean {
1022
+ const hasZeroTierModel = this.activeFleet.some(
1023
+ (model) => model.tier === 'zero-tier' && model.healthy !== false,
1024
+ );
1025
+ if (!hasZeroTierModel) {
1026
+ return false;
1027
+ }
1028
+ return this.currentHardwareResult === 'full_local';
1029
+ }
1030
+
1031
+ private resolveLowIntensityReasonCode(clusterMatch?: ClusterMatchResult): string {
1032
+ if (
1033
+ clusterMatch?.confidence === 'high' &&
1034
+ (clusterMatch.tierBias === 'zero-tier' ||
1035
+ clusterMatch.tierBias === 'economical-cloud')
1036
+ ) {
1037
+ return clusterReasonCode(clusterMatch.clusterId);
1038
+ }
1039
+ return 'low_intensity_structural';
1040
+ }
1041
+
1042
+ private resolveHighIntensityReasonCode(clusterMatch?: ClusterMatchResult): string {
1043
+ if (clusterMatch?.confidence === 'high' && clusterMatch.tierBias === 'frontier-cloud') {
1044
+ return clusterReasonCode(clusterMatch.clusterId);
1045
+ }
1046
+ return 'high_intensity_structural';
1047
+ }
1048
+
1049
+ private constrainFleetToTierHint(
1050
+ fleet: readonly ModelProfile[],
1051
+ tierHint: Tier,
1052
+ ): readonly ModelProfile[] {
1053
+ const filtered = fleet.filter(
1054
+ (model) => model.tier === tierHint && model.healthy !== false,
1055
+ );
1056
+ return filtered.length > 0 ? filtered : fleet;
1057
+ }
1058
+
1059
+ // ─── Loop escalation (Step 3b — FR-014) ─────────────────────────────────
1060
+
1061
+ /**
1062
+ * Observational loop escalation: detects repeated identical tool failures
1063
+ * and re-pins the session to a frontier-capable tier.
1064
+ *
1065
+ * Runs before turn_envelope and session_pin so it can modify pin state.
1066
+ * Never returns decided: true — turnEnvelope or sessionPin picks up the
1067
+ * (potentially escalated) pin on subsequent stages.
1068
+ */
1069
+ private async loopEscalation(request: RoutingRequest): Promise<StageResult> {
1070
+ const pinner = this.options.sessionPinner;
1071
+ const config = this.options.loopEscalationConfig;
1072
+ if (!pinner || !config) {
1073
+ return { decided: false, stage: 'loop_escalation' };
1074
+ }
1075
+
1076
+ const pin = pinner.getPin(request.session_id);
1077
+ const result = evaluateLoopEscalation(pin, request, this.activeFleet, config);
1078
+
1079
+ if (result.updatedPin) {
1080
+ pinner.loadPin(result.updatedPin);
1081
+ }
1082
+
1083
+ if (result.shouldEscalate && result.escalationTarget) {
1084
+ pinner.breakPin(request.session_id);
1085
+ pinner.recordPin(
1086
+ request.session_id,
1087
+ result.escalationTarget.id,
1088
+ 'loop_escalation',
1089
+ );
1090
+ }
1091
+
1092
+ return { decided: false, stage: 'loop_escalation' };
1093
+ }
1094
+
1095
+ /**
1096
+ * Step 5: HyDRA embedding matcher for ambiguous prompts (T050).
1097
+ * Scores fleet candidates via embedding cosine similarity with shortfall gate.
1098
+ * Pass-through when no matcher is configured.
1099
+ */
1100
+ private async hydraMatcher(request: RoutingRequest): Promise<StageResult> {
1101
+ const matcher = this.options.hydraMatcher;
1102
+ if (!matcher) {
1103
+ return { decided: false, stage: 'hydra_match' };
1104
+ }
1105
+
1106
+ const fleetForMatch = this.currentTierHint
1107
+ ? this.constrainFleetToTierHint(this.activeFleet, this.currentTierHint)
1108
+ : this.activeFleet;
1109
+
1110
+ const result = await matcher.match(request, fleetForMatch);
1111
+ this.currentHydraResult = result;
1112
+
1113
+ if (!result.selected) {
1114
+ return { decided: false, stage: 'hydra_match' };
1115
+ }
1116
+
1117
+ const selectedModel = this.activeFleet.find(
1118
+ (m) => m.id === result.selected!.model_id,
1119
+ );
1120
+ if (!selectedModel) {
1121
+ return { decided: false, stage: 'hydra_match' };
1122
+ }
1123
+
1124
+ return {
1125
+ decided: true,
1126
+ stage: 'hydra_match',
1127
+ decision: this.withEstimatedCost(request, selectedModel, {
1128
+ request_id: request.request_id,
1129
+ selected_model_id: selectedModel.id,
1130
+ tier: selectedModel.tier,
1131
+ stage: 'hydra_match',
1132
+ reason_code: 'hydra_embedding_match',
1133
+ candidates: result.candidates,
1134
+ routing_latency_ms: result.elapsedMs,
1135
+ pin_reason: null,
1136
+ }),
1137
+ };
1138
+ }
1139
+ }