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,807 @@
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
+ import { probeHardware } from '../../infrastructure/hardware/hardware-probe.js';
12
+ import { pingLocalServices } from '../../infrastructure/local/local-zero-tier.js';
13
+ import { triage as triageClassify } from '../triage/triage-engine.js';
14
+ import { classifyTurnEnvelope } from '../triage/turn-envelope.js';
15
+ import { safeCloudDefault } from './safe-default.js';
16
+ import { hasToolCallHistory, isGoogleGeminiProfile, } from '../routing/tool-history-guard.js';
17
+ import { filterFleetByContextFit, needsContextOverflowFallback, resolveContextOverflowFallback, CONTEXT_OVERFLOW_NO_FIT, } from '../routing/context-fit.js';
18
+ import { evaluateLoopEscalation } from '../pinning/loop-escalation.js';
19
+ import { selectLowestCostModel } from '../pinning/sub-route-policy.js';
20
+ import { estimateRoutingCost, enrichRoutingDecisionWithContextFit, enrichRoutingDecisionWithTierSelection, } from '../../infrastructure/telemetry/routing-telemetry.js';
21
+ import { clusterReasonCode } from '../../config/routing-clusters-loader.js';
22
+ import { DEFAULT_OPERATOR_CONFIG } from '../../config/defaults.js';
23
+ import { buildTierFeatures, scoreLowIntensity, } from '../routing/tier-features.js';
24
+ import { predictPSuccessCheapTimed, resolvePSuccessWeights, tierFeaturesToPSuccessFeatures, } from '../routing/p-success-classifier.js';
25
+ import { selectTierByExpectedCost, } from '../routing/expected-cost.js';
26
+ /** Canonical pipeline stage order — keep README/specs in sync (SP-119). */
27
+ export const PIPELINE_STAGE_ORDER = [
28
+ 'hardware_probe',
29
+ 'loop_escalation',
30
+ 'turn_envelope',
31
+ 'context_fit',
32
+ 'low_intensity',
33
+ 'session_pin',
34
+ 'triage',
35
+ 'local_zero',
36
+ 'triage_cloud_fallback',
37
+ 'hydra_match',
38
+ 'safe_default',
39
+ 'context_overflow_fallback',
40
+ ];
41
+ /**
42
+ * Disjunction: triage trivial OR low-intensity zero-tier hint (high confidence)
43
+ * OR high-confidence zero-tier cluster match.
44
+ */
45
+ export function resolveLocalEligible(input) {
46
+ const clusterZeroTier = input.clusterMatch?.confidence === 'high' &&
47
+ input.clusterMatch.tierBias === 'zero-tier';
48
+ const lowIntensityZeroTier = input.tierHint === 'zero-tier' &&
49
+ input.lowIntensityScore !== null &&
50
+ input.lowIntensityScore >= input.highThreshold;
51
+ const triageTrivial = input.triageVerdict === 'trivial';
52
+ if (!triageTrivial && !lowIntensityZeroTier && !clusterZeroTier) {
53
+ return { eligible: false, reason: null };
54
+ }
55
+ if (triageTrivial) {
56
+ return { eligible: true, reason: 'triage_trivial' };
57
+ }
58
+ if (clusterZeroTier) {
59
+ return {
60
+ eligible: true,
61
+ reason: clusterReasonCode(input.clusterMatch.clusterId),
62
+ };
63
+ }
64
+ return { eligible: true, reason: 'low_intensity_structural' };
65
+ }
66
+ // ─── Orchestrator ────────────────────────────────────────────────────────────
67
+ export class RouterPipeline {
68
+ stages;
69
+ fleet;
70
+ options;
71
+ /** Per-route transient fleet — defaults to constructor fleet. */
72
+ activeFleet = [];
73
+ /** Unfiltered fleet for overflow escalation (SP-095). */
74
+ fullFleet = [];
75
+ /** Per-route transient state — reset on each route() call. */
76
+ currentHardwareResult = 'disabled';
77
+ currentTriageResult = null;
78
+ currentHydraResult = null;
79
+ currentClusterMatch = null;
80
+ currentTierHint = null;
81
+ currentTierHintReasonCode = null;
82
+ currentLowIntensityScore = null;
83
+ currentPSuccessCheap = null;
84
+ currentPSuccessAlpha = null;
85
+ currentExpectedCostByTier = null;
86
+ currentLocalEligibleReason = null;
87
+ pSuccessWeightsLoaded = false;
88
+ cachedPSuccessWeights = null;
89
+ currentContextFitRejected = [];
90
+ currentContextFitViableCount = 0;
91
+ contextOverflowPreferredProvider = null;
92
+ contextOverflowTriggered = false;
93
+ constructor(fleet, options) {
94
+ this.fleet = fleet;
95
+ this.options = options ?? {};
96
+ this.stages = [
97
+ { name: 'hardware_probe', run: this.hardwareProbeStage.bind(this) },
98
+ { name: 'loop_escalation', run: this.loopEscalation.bind(this) },
99
+ { name: 'turn_envelope', run: this.turnEnvelope.bind(this) },
100
+ { name: 'context_fit', run: this.contextFitStage.bind(this) },
101
+ { name: 'low_intensity', run: this.lowIntensityGate.bind(this) },
102
+ { name: 'session_pin', run: this.sessionPin.bind(this) },
103
+ { name: 'triage', run: this.triage.bind(this) },
104
+ { name: 'local_zero', run: this.localZeroTierStage.bind(this) },
105
+ { name: 'triage_cloud_fallback', run: this.triageCloudFallback.bind(this) },
106
+ { name: 'hydra_match', run: this.hydraMatcher.bind(this) },
107
+ { name: 'safe_default', run: this.safeDefaultStage.bind(this) },
108
+ { name: 'context_overflow_fallback', run: this.contextOverflowFallback.bind(this) },
109
+ ];
110
+ }
111
+ async route(request, fleetOverride) {
112
+ const start = Date.now();
113
+ this.activeFleet = this.prioritizeFleetForToolHistory(fleetOverride ?? this.fleet, request);
114
+ this.fullFleet = this.activeFleet;
115
+ this.currentHardwareResult = 'disabled';
116
+ this.currentTriageResult = null;
117
+ this.currentHydraResult = null;
118
+ this.currentClusterMatch = null;
119
+ this.currentTierHint = null;
120
+ this.currentTierHintReasonCode = null;
121
+ this.currentLowIntensityScore = null;
122
+ this.currentPSuccessCheap = null;
123
+ this.currentPSuccessAlpha = null;
124
+ this.currentExpectedCostByTier = null;
125
+ this.currentLocalEligibleReason = null;
126
+ this.currentContextFitRejected = [];
127
+ this.currentContextFitViableCount = 0;
128
+ this.contextOverflowPreferredProvider = null;
129
+ this.contextOverflowTriggered = false;
130
+ let currentStage;
131
+ try {
132
+ for (const stage of this.stages) {
133
+ currentStage = stage;
134
+ const result = await stage.run(request);
135
+ if (result.decided && result.decision) {
136
+ this.persistPinIfNeeded(request, result.decision);
137
+ this.emitTelemetry(request, result.decision);
138
+ return this.attachFeatures(request, result.decision);
139
+ }
140
+ }
141
+ }
142
+ catch (error) {
143
+ // Constitution VI: zero-crash resilience — degrade to safe default
144
+ const failedStage = this.resolveFailedStage(currentStage);
145
+ const elapsedMs = Date.now() - start;
146
+ const fallback = this.buildFallbackDecision(request, elapsedMs);
147
+ this.logPipelineError(request, failedStage, error);
148
+ this.emitPipelineErrorTelemetry(request, failedStage, fallback);
149
+ this.persistPinIfNeeded(request, fallback);
150
+ return this.attachFeatures(request, fallback);
151
+ }
152
+ const fallback = this.buildFallbackDecision(request, Date.now() - start);
153
+ this.persistPinIfNeeded(request, fallback);
154
+ this.emitTelemetry(request, fallback);
155
+ return this.attachFeatures(request, fallback);
156
+ }
157
+ /**
158
+ * SP-080: move Google/Gemini profiles to the end of the fleet when prior tool
159
+ * calls exist so tier `.find()` passes prefer non-Gemini models first.
160
+ * Honors `force_model_id` by leaving fleet order unchanged.
161
+ */
162
+ prioritizeFleetForToolHistory(fleet, request) {
163
+ if (request.force_model_id) {
164
+ return fleet;
165
+ }
166
+ const messages = request.messages;
167
+ if (!messages || messages.length === 0 || !hasToolCallHistory(messages)) {
168
+ return fleet;
169
+ }
170
+ const preferred = [];
171
+ const deprioritized = [];
172
+ for (const profile of fleet) {
173
+ if (isGoogleGeminiProfile(profile)) {
174
+ deprioritized.push(profile);
175
+ }
176
+ else {
177
+ preferred.push(profile);
178
+ }
179
+ }
180
+ if (deprioritized.length === 0) {
181
+ return fleet;
182
+ }
183
+ return [...preferred, ...deprioritized];
184
+ }
185
+ /** Attach privacy-safe dataset features captured during pipeline stages (SP-057, SP-119). */
186
+ attachFeatures(request, decision) {
187
+ const features = {
188
+ triage: this.currentTriageResult
189
+ ? {
190
+ verdict: this.currentTriageResult.verdict,
191
+ reason_code: this.currentTriageResult.reason_code,
192
+ cyclomatic_score: this.currentTriageResult.cyclomatic_score,
193
+ }
194
+ : null,
195
+ requirements: this.currentHydraResult?.requirements ?? null,
196
+ candidates: this.mergeFeatureCandidates(),
197
+ tier_hint: this.currentTierHint,
198
+ tier_hint_reason_code: this.currentTierHintReasonCode,
199
+ low_intensity_score: this.currentLowIntensityScore,
200
+ p_success_cheap: this.currentPSuccessCheap,
201
+ p_success_alpha: this.currentPSuccessAlpha,
202
+ local_eligible_reason: this.currentLocalEligibleReason,
203
+ };
204
+ const withBaseFeatures = { ...decision, features };
205
+ const withContextFit = enrichRoutingDecisionWithContextFit(request, withBaseFeatures, this.fullFleet, this.options.contextFitConfig);
206
+ return enrichRoutingDecisionWithTierSelection(withContextFit);
207
+ }
208
+ mergeFeatureCandidates() {
209
+ const hydraCandidates = this.currentHydraResult?.candidates ?? [];
210
+ const expectedCostCandidates = this.currentExpectedCostByTier?.map((entry) => ({
211
+ model_id: `__expected_cost_${entry.tier}__`,
212
+ score: entry.expectedCostUsd,
213
+ shortfall: entry.adjustedExpectedCostUsd,
214
+ rejected_reason: `p_success=${entry.pSuccess.toFixed(4)}`,
215
+ })) ?? [];
216
+ if (this.currentContextFitRejected.length === 0 &&
217
+ hydraCandidates.length === 0 &&
218
+ expectedCostCandidates.length === 0) {
219
+ return null;
220
+ }
221
+ return [
222
+ ...this.currentContextFitRejected,
223
+ ...expectedCostCandidates,
224
+ ...hydraCandidates,
225
+ ];
226
+ }
227
+ resolveFailedStage(stage) {
228
+ return stage?.name ?? 'unknown';
229
+ }
230
+ logPipelineError(request, stage, error) {
231
+ console.warn('Router pipeline stage failed; degrading to safe default', {
232
+ stage,
233
+ request_id: request.request_id,
234
+ session_id: request.session_id,
235
+ error: this.redactPromptFromError(error, request.prompt_text),
236
+ });
237
+ }
238
+ redactPromptFromError(error, promptText) {
239
+ const message = error instanceof Error ? error.message : String(error);
240
+ if (!promptText || !message.includes(promptText)) {
241
+ return message;
242
+ }
243
+ return message.replaceAll(promptText, '[REDACTED]');
244
+ }
245
+ emitPipelineErrorTelemetry(request, failedStage, fallback) {
246
+ this.options.telemetryEmitter?.emitPipelineError(request, failedStage, fallback);
247
+ }
248
+ /** Step 7: emit routing telemetry after decision (T040). */
249
+ emitTelemetry(request, decision) {
250
+ this.options.telemetryEmitter?.emit(request, decision);
251
+ }
252
+ withEstimatedCost(request, model, decision) {
253
+ return {
254
+ ...decision,
255
+ estimated_cost_usd: estimateRoutingCost(model, request, this.options.priceCatalog ?? null),
256
+ };
257
+ }
258
+ buildFallbackDecision(request, elapsedMs) {
259
+ if (this.shouldAttemptContextOverflowFallback(request)) {
260
+ return this.buildContextOverflowFallbackDecision(request, elapsedMs);
261
+ }
262
+ const fallbackModel = safeCloudDefault(this.activeFleet, {
263
+ request,
264
+ ...(this.options.contextFitConfig !== undefined
265
+ ? { contextFitConfig: this.options.contextFitConfig }
266
+ : {}),
267
+ });
268
+ const modelId = fallbackModel?.id ?? 'unknown';
269
+ const tier = fallbackModel?.tier ?? 'economical-cloud';
270
+ return {
271
+ request_id: request.request_id,
272
+ selected_model_id: modelId,
273
+ tier,
274
+ stage: 'fallback',
275
+ reason_code: 'safe_cloud_default',
276
+ routing_latency_ms: elapsedMs,
277
+ pin_reason: null,
278
+ };
279
+ }
280
+ shouldAttemptContextOverflowFallback(request) {
281
+ if (request.force_model_id) {
282
+ return false;
283
+ }
284
+ if (this.contextOverflowTriggered) {
285
+ return true;
286
+ }
287
+ return needsContextOverflowFallback(this.activeFleet, this.currentContextFitRejected, this.fullFleet);
288
+ }
289
+ buildContextOverflowFallbackDecision(request, elapsedMs) {
290
+ const overflow = resolveContextOverflowFallback(this.fullFleet, request, this.contextOverflowPreferredProvider, this.options.contextFitConfig);
291
+ if (overflow.kind === 'no_fit') {
292
+ return {
293
+ request_id: request.request_id,
294
+ selected_model_id: 'unknown',
295
+ tier: 'economical-cloud',
296
+ stage: 'fallback',
297
+ reason_code: CONTEXT_OVERFLOW_NO_FIT,
298
+ candidates: this.currentContextFitRejected,
299
+ routing_latency_ms: elapsedMs,
300
+ pin_reason: null,
301
+ };
302
+ }
303
+ const model = overflow.model;
304
+ return this.withEstimatedCost(request, model, {
305
+ request_id: request.request_id,
306
+ selected_model_id: model.id,
307
+ tier: model.tier,
308
+ stage: 'fallback',
309
+ reason_code: overflow.reasonCode,
310
+ candidates: this.currentContextFitRejected,
311
+ routing_latency_ms: elapsedMs,
312
+ pin_reason: null,
313
+ });
314
+ }
315
+ /**
316
+ * SP-095: after safe_default, escalate to largest-fit model when economical
317
+ * models cannot fit the current context.
318
+ */
319
+ async contextOverflowFallback(request) {
320
+ if (!this.shouldAttemptContextOverflowFallback(request)) {
321
+ return { decided: false, stage: 'context_overflow_fallback' };
322
+ }
323
+ const elapsedMs = 0;
324
+ const decision = this.buildContextOverflowFallbackDecision(request, elapsedMs);
325
+ return {
326
+ decided: true,
327
+ stage: 'context_overflow_fallback',
328
+ decision,
329
+ };
330
+ }
331
+ /**
332
+ * SP-022: economical-cloud default when no earlier stage decides.
333
+ * Defers to context_overflow_fallback when economical models were context-rejected.
334
+ */
335
+ async safeDefaultStage(request) {
336
+ if (this.shouldAttemptContextOverflowFallback(request)) {
337
+ return { decided: false, stage: 'safe_default' };
338
+ }
339
+ const fallbackModel = safeCloudDefault(this.activeFleet, {
340
+ request,
341
+ ...(this.options.contextFitConfig !== undefined
342
+ ? { contextFitConfig: this.options.contextFitConfig }
343
+ : {}),
344
+ });
345
+ if (!fallbackModel) {
346
+ return { decided: false, stage: 'safe_default' };
347
+ }
348
+ return {
349
+ decided: true,
350
+ stage: 'fallback',
351
+ decision: {
352
+ request_id: request.request_id,
353
+ selected_model_id: fallbackModel.id,
354
+ tier: fallbackModel.tier,
355
+ stage: 'fallback',
356
+ reason_code: 'safe_cloud_default',
357
+ routing_latency_ms: 0,
358
+ pin_reason: null,
359
+ },
360
+ };
361
+ }
362
+ markContextOverflowFromPin(request, pinnedModelId) {
363
+ const pinnedModel = this.fullFleet.find((model) => model.id === pinnedModelId);
364
+ this.contextOverflowTriggered = true;
365
+ this.contextOverflowPreferredProvider = pinnedModel?.provider ?? null;
366
+ }
367
+ // ─── Implemented stages ─────────────────────────────────────────────────────
368
+ async hardwareProbeStage(request) {
369
+ void request;
370
+ if (!this.options.hardwareConfig || !this.options.systemInfoProvider) {
371
+ return { decided: false, stage: 'hardware_probe' };
372
+ }
373
+ const systemInfo = await this.options.systemInfoProvider();
374
+ this.currentHardwareResult = probeHardware(this.options.hardwareConfig, systemInfo);
375
+ return { decided: false, stage: 'hardware_probe' };
376
+ }
377
+ /**
378
+ * SP-093: filter fleet to models whose context window fits estimated input
379
+ * tokens before session pin and HyDRA matching.
380
+ */
381
+ async contextFitStage(request) {
382
+ const result = filterFleetByContextFit(this.activeFleet, request, this.options.contextFitConfig);
383
+ this.activeFleet = result.effectiveFleet;
384
+ this.currentContextFitRejected = result.rejected;
385
+ this.currentContextFitViableCount = result.effectiveFleet.length;
386
+ return { decided: false, stage: 'context_fit' };
387
+ }
388
+ /**
389
+ * SC-007: classification_only MUST NOT dispatch full local.
390
+ * Eligibility: triage trivial OR low-intensity zero-tier hint OR zero-tier cluster (SP-111).
391
+ */
392
+ async localZeroTierStage(request) {
393
+ const lowIntensityConfig = this.options.lowIntensityConfig ?? DEFAULT_OPERATOR_CONFIG.low_intensity;
394
+ const eligibility = resolveLocalEligible({
395
+ triageVerdict: this.currentTriageResult?.verdict ?? null,
396
+ tierHint: this.currentTierHint,
397
+ lowIntensityScore: this.currentLowIntensityScore,
398
+ highThreshold: lowIntensityConfig.high_threshold,
399
+ clusterMatch: this.currentClusterMatch,
400
+ });
401
+ if (!eligibility.eligible) {
402
+ return { decided: false, stage: 'local_zero' };
403
+ }
404
+ if (this.currentHardwareResult !== 'full_local') {
405
+ return { decided: false, stage: 'local_zero' };
406
+ }
407
+ const readiness = await pingLocalServices(this.options.localConfig, this.options.httpFetchPort);
408
+ if (!readiness.anyModelReady) {
409
+ return { decided: false, stage: 'local_zero' };
410
+ }
411
+ const localModel = this.activeFleet.find((m) => m.tier === 'zero-tier' && m.healthy !== false);
412
+ if (!localModel) {
413
+ return { decided: false, stage: 'local_zero' };
414
+ }
415
+ this.currentLocalEligibleReason = eligibility.reason;
416
+ return {
417
+ decided: true,
418
+ stage: 'local_zero',
419
+ decision: {
420
+ request_id: request.request_id,
421
+ selected_model_id: localModel.id,
422
+ tier: 'zero-tier',
423
+ stage: 'local_zero',
424
+ reason_code: 'local_model_ready',
425
+ routing_latency_ms: readiness.combinedLatencyMs,
426
+ pin_reason: null,
427
+ },
428
+ };
429
+ }
430
+ // ─── Triage stage (FR-003, SC-004 <5ms budget) ──────────────────────────────
431
+ async triage(request) {
432
+ const result = triageClassify(request.prompt_text);
433
+ this.currentTriageResult = result;
434
+ if (result.verdict === 'ambiguous') {
435
+ return { decided: false, stage: 'triage' };
436
+ }
437
+ // Trivial prompts defer cloud routing until after local zero-tier (PRD Step 4).
438
+ if (result.verdict === 'trivial') {
439
+ return { decided: false, stage: 'triage' };
440
+ }
441
+ const targetTier = 'frontier-cloud';
442
+ const model = this.activeFleet.find((m) => m.tier === targetTier && m.healthy !== false);
443
+ if (!model) {
444
+ return { decided: false, stage: 'triage' };
445
+ }
446
+ return {
447
+ decided: true,
448
+ stage: 'triage',
449
+ decision: {
450
+ request_id: request.request_id,
451
+ selected_model_id: model.id,
452
+ tier: targetTier,
453
+ stage: 'triage',
454
+ reason_code: result.reason_code,
455
+ routing_latency_ms: 0,
456
+ pin_reason: null,
457
+ },
458
+ };
459
+ }
460
+ /**
461
+ * Economical-cloud fallback for trivial prompts after local zero-tier is skipped
462
+ * or unavailable (PRD Step 4 cloud fallback).
463
+ */
464
+ async triageCloudFallback(request) {
465
+ if (this.currentTriageResult?.verdict !== 'trivial') {
466
+ return { decided: false, stage: 'triage' };
467
+ }
468
+ const model = this.activeFleet.find((m) => m.tier === 'economical-cloud' && m.healthy !== false);
469
+ if (!model) {
470
+ return { decided: false, stage: 'triage' };
471
+ }
472
+ return {
473
+ decided: true,
474
+ stage: 'triage',
475
+ decision: {
476
+ request_id: request.request_id,
477
+ selected_model_id: model.id,
478
+ tier: 'economical-cloud',
479
+ stage: 'triage',
480
+ reason_code: this.currentTriageResult.reason_code,
481
+ routing_latency_ms: 0,
482
+ pin_reason: null,
483
+ },
484
+ };
485
+ }
486
+ // ─── Session pin stage (FR-006, FR-007, FR-008) ──────────────────────────
487
+ async sessionPin(request) {
488
+ const pinner = this.options.sessionPinner;
489
+ if (!pinner) {
490
+ return { decided: false, stage: 'session_pin' };
491
+ }
492
+ const existingPin = pinner.getPin(request.session_id);
493
+ const result = pinner.lookupPin(request, this.activeFleet);
494
+ switch (result.action) {
495
+ case 'use_pin': {
496
+ const model = result.pinnedModel;
497
+ const pin = pinner.getPin(request.session_id);
498
+ return {
499
+ decided: true,
500
+ stage: 'session_pin',
501
+ decision: this.withEstimatedCost(request, model, {
502
+ request_id: request.request_id,
503
+ selected_model_id: model.id,
504
+ tier: model.tier,
505
+ stage: 'session_pin',
506
+ reason_code: 'session_pinned',
507
+ routing_latency_ms: 0,
508
+ pin_reason: pin?.pin_reason ?? null,
509
+ }),
510
+ };
511
+ }
512
+ case 'sub_route': {
513
+ const model = result.subRouteModel;
514
+ const pin = pinner.getPin(request.session_id);
515
+ return {
516
+ decided: true,
517
+ stage: 'session_pin',
518
+ decision: this.withEstimatedCost(request, model, {
519
+ request_id: request.request_id,
520
+ selected_model_id: model.id,
521
+ tier: model.tier,
522
+ stage: 'session_pin',
523
+ reason_code: 'tool_result_sub_route',
524
+ routing_latency_ms: 0,
525
+ pin_reason: pin?.pin_reason ?? null,
526
+ }),
527
+ };
528
+ }
529
+ case 'break':
530
+ if (result.breakReason === 'context_overflow' && existingPin) {
531
+ this.markContextOverflowFromPin(request, existingPin.pinned_model_id);
532
+ }
533
+ return { decided: false, stage: 'session_pin' };
534
+ case 'no_pin':
535
+ if (existingPin) {
536
+ const wasContextRejected = this.currentContextFitRejected.some((candidate) => candidate.model_id === existingPin.pinned_model_id);
537
+ if (wasContextRejected) {
538
+ this.markContextOverflowFromPin(request, existingPin.pinned_model_id);
539
+ }
540
+ }
541
+ return { decided: false, stage: 'session_pin' };
542
+ default:
543
+ return { decided: false, stage: 'session_pin' };
544
+ }
545
+ }
546
+ /**
547
+ * After a routing decision, persist an initial pin when none exists.
548
+ * Sub-routes and already-pinned decisions skip persistence.
549
+ */
550
+ persistPinIfNeeded(request, decision) {
551
+ const pinner = this.options.sessionPinner;
552
+ if (!pinner)
553
+ return;
554
+ if (decision.reason_code === 'tool_result_sub_route')
555
+ return;
556
+ if (decision.reason_code === 'session_pinned')
557
+ return;
558
+ // Turn envelope is a per-turn tier bias — do not overwrite an existing pin (SP-064).
559
+ if (decision.stage === 'turn_envelope' && pinner.getPin(request.session_id))
560
+ return;
561
+ pinner.recordPin(request.session_id, decision.selected_model_id, 'initial');
562
+ }
563
+ // ─── Turn envelope stage (Step 2b, <2ms budget) ─────────────────────────
564
+ static TURN_TIER_MAP = {
565
+ planning: 'frontier-cloud',
566
+ tool_result: 'economical-cloud',
567
+ subagent: 'economical-cloud',
568
+ main_loop: null,
569
+ unknown: null,
570
+ };
571
+ async turnEnvelope(request) {
572
+ const turnType = request.turn_type ?? classifyTurnEnvelope(request.messages);
573
+ const targetTier = RouterPipeline.TURN_TIER_MAP[turnType] ?? null;
574
+ if (!targetTier) {
575
+ return { decided: false, stage: 'turn_envelope' };
576
+ }
577
+ const tierCandidates = this.activeFleet.filter((m) => m.tier === targetTier && m.healthy !== false);
578
+ const model = selectLowestCostModel(tierCandidates);
579
+ if (!model) {
580
+ return { decided: false, stage: 'turn_envelope' };
581
+ }
582
+ return {
583
+ decided: true,
584
+ stage: 'turn_envelope',
585
+ decision: this.withEstimatedCost(request, model, {
586
+ request_id: request.request_id,
587
+ selected_model_id: model.id,
588
+ tier: targetTier,
589
+ stage: 'turn_envelope',
590
+ reason_code: `turn_${turnType}`,
591
+ routing_latency_ms: 0,
592
+ pin_reason: null,
593
+ }),
594
+ };
595
+ }
596
+ // ─── Low-intensity tier gate (SP-103, #58) ───────────────────────────────
597
+ /**
598
+ * Runs after turn_envelope and context_fit, before session_pin. Computes low-intensity score
599
+ * from structural signals and optional cluster match; sets tier_hint and constrains
600
+ * the active fleet for subsequent HyDRA matching when confidence is high.
601
+ */
602
+ async lowIntensityGate(request) {
603
+ const config = this.options.lowIntensityConfig ?? DEFAULT_OPERATOR_CONFIG.low_intensity;
604
+ const alpha = config.p_success_alpha;
605
+ this.currentPSuccessAlpha = alpha;
606
+ const triageResult = triageClassify(request.prompt_text);
607
+ let clusterMatch;
608
+ const matcher = this.options.clusterMatcher;
609
+ if (matcher) {
610
+ try {
611
+ const result = await matcher.match(request);
612
+ this.currentClusterMatch = result;
613
+ clusterMatch = result;
614
+ }
615
+ catch {
616
+ this.currentClusterMatch = null;
617
+ }
618
+ }
619
+ const tierFeatures = buildTierFeatures(request, triageResult, undefined, clusterMatch);
620
+ const score = scoreLowIntensity(tierFeatures, config.weights);
621
+ this.currentLowIntensityScore = score;
622
+ const weights = this.resolvePSuccessWeights();
623
+ const pFeatures = tierFeaturesToPSuccessFeatures(tierFeatures);
624
+ const pResult = predictPSuccessCheapTimed(pFeatures, weights);
625
+ this.currentPSuccessCheap = pResult.probability;
626
+ const structuralHint = this.resolveTierHint(score, config.high_threshold, config.low_threshold, clusterMatch);
627
+ const weightsTrained = weights.trained_sample_count >= weights.min_training_samples;
628
+ const adjustedHint = weightsTrained
629
+ ? (() => {
630
+ const selection = this.selectExpectedCostTierHint(request, pResult.probability, alpha);
631
+ this.logExpectedCostExplain(pResult.probability, alpha, selection);
632
+ return {
633
+ tierHint: selection.tierHint,
634
+ reasonCode: selection.reasonCode,
635
+ };
636
+ })()
637
+ : structuralHint;
638
+ this.currentTierHint = adjustedHint.tierHint;
639
+ this.currentTierHintReasonCode = adjustedHint.reasonCode;
640
+ return { decided: false, stage: 'low_intensity' };
641
+ }
642
+ selectExpectedCostTierHint(request, pSuccessCheap, alpha) {
643
+ const estTokens = request.estimated_input_tokens ?? request.prompt_text.length;
644
+ const pinner = this.options.sessionPinner;
645
+ const sessionPin = pinner?.getPin(request.session_id) ?? undefined;
646
+ const pinnedModel = sessionPin !== undefined
647
+ ? this.activeFleet.find((model) => model.id === sessionPin.pinned_model_id)
648
+ : undefined;
649
+ const selection = selectTierByExpectedCost({
650
+ fleet: this.activeFleet,
651
+ priceCatalog: this.options.priceCatalog ?? null,
652
+ estTokens,
653
+ pSuccessCheap,
654
+ alpha,
655
+ localZeroReady: this.isLocalZeroTierReady(),
656
+ ...(pinnedModel !== undefined ? { pinnedModel } : {}),
657
+ ...(sessionPin !== undefined ? { sessionPin } : {}),
658
+ });
659
+ this.currentExpectedCostByTier = [...selection.tierCosts];
660
+ return {
661
+ tierHint: selection.tierHint,
662
+ reasonCode: selection.reasonCode,
663
+ tierCosts: selection.tierCosts,
664
+ rationale: selection.rationale,
665
+ };
666
+ }
667
+ logExpectedCostExplain(pSuccessCheap, alpha, selection) {
668
+ console.info('Expected-cost tier gate', {
669
+ reason: selection.reasonCode,
670
+ p_success_cheap: pSuccessCheap,
671
+ alpha,
672
+ chosen_tier: selection.tierHint,
673
+ rationale: selection.rationale,
674
+ expected_cost_by_tier: selection.tierCosts.map((entry) => ({
675
+ tier: entry.tier,
676
+ p_success: entry.pSuccess,
677
+ cost_per_1m: entry.costPer1M,
678
+ expected_cost_usd: entry.expectedCostUsd,
679
+ adjusted_expected_cost_usd: entry.adjustedExpectedCostUsd,
680
+ })),
681
+ });
682
+ }
683
+ resolvePSuccessWeights() {
684
+ if (this.options.pSuccessWeights) {
685
+ return this.options.pSuccessWeights;
686
+ }
687
+ if (!this.pSuccessWeightsLoaded) {
688
+ this.cachedPSuccessWeights = resolvePSuccessWeights({
689
+ ...(this.options.pSuccessWeightsPath !== undefined
690
+ ? { filePath: this.options.pSuccessWeightsPath }
691
+ : {}),
692
+ });
693
+ this.pSuccessWeightsLoaded = true;
694
+ }
695
+ return this.cachedPSuccessWeights;
696
+ }
697
+ resolveTierHint(score, highThreshold, lowThreshold, clusterMatch) {
698
+ if (score >= highThreshold) {
699
+ return {
700
+ tierHint: this.resolveLowIntensityTierHint(),
701
+ reasonCode: this.resolveLowIntensityReasonCode(clusterMatch),
702
+ };
703
+ }
704
+ if (score <= lowThreshold) {
705
+ return {
706
+ tierHint: 'frontier-cloud',
707
+ reasonCode: this.resolveHighIntensityReasonCode(clusterMatch),
708
+ };
709
+ }
710
+ return { tierHint: null, reasonCode: null };
711
+ }
712
+ resolveLowIntensityTierHint() {
713
+ if (this.isLocalZeroTierReady()) {
714
+ return 'zero-tier';
715
+ }
716
+ return 'economical-cloud';
717
+ }
718
+ isLocalZeroTierReady() {
719
+ const hasZeroTierModel = this.activeFleet.some((model) => model.tier === 'zero-tier' && model.healthy !== false);
720
+ if (!hasZeroTierModel) {
721
+ return false;
722
+ }
723
+ return this.currentHardwareResult === 'full_local';
724
+ }
725
+ resolveLowIntensityReasonCode(clusterMatch) {
726
+ if (clusterMatch?.confidence === 'high' &&
727
+ (clusterMatch.tierBias === 'zero-tier' ||
728
+ clusterMatch.tierBias === 'economical-cloud')) {
729
+ return clusterReasonCode(clusterMatch.clusterId);
730
+ }
731
+ return 'low_intensity_structural';
732
+ }
733
+ resolveHighIntensityReasonCode(clusterMatch) {
734
+ if (clusterMatch?.confidence === 'high' && clusterMatch.tierBias === 'frontier-cloud') {
735
+ return clusterReasonCode(clusterMatch.clusterId);
736
+ }
737
+ return 'high_intensity_structural';
738
+ }
739
+ constrainFleetToTierHint(fleet, tierHint) {
740
+ const filtered = fleet.filter((model) => model.tier === tierHint && model.healthy !== false);
741
+ return filtered.length > 0 ? filtered : fleet;
742
+ }
743
+ // ─── Loop escalation (Step 3b — FR-014) ─────────────────────────────────
744
+ /**
745
+ * Observational loop escalation: detects repeated identical tool failures
746
+ * and re-pins the session to a frontier-capable tier.
747
+ *
748
+ * Runs before turn_envelope and session_pin so it can modify pin state.
749
+ * Never returns decided: true — turnEnvelope or sessionPin picks up the
750
+ * (potentially escalated) pin on subsequent stages.
751
+ */
752
+ async loopEscalation(request) {
753
+ const pinner = this.options.sessionPinner;
754
+ const config = this.options.loopEscalationConfig;
755
+ if (!pinner || !config) {
756
+ return { decided: false, stage: 'loop_escalation' };
757
+ }
758
+ const pin = pinner.getPin(request.session_id);
759
+ const result = evaluateLoopEscalation(pin, request, this.activeFleet, config);
760
+ if (result.updatedPin) {
761
+ pinner.loadPin(result.updatedPin);
762
+ }
763
+ if (result.shouldEscalate && result.escalationTarget) {
764
+ pinner.breakPin(request.session_id);
765
+ pinner.recordPin(request.session_id, result.escalationTarget.id, 'loop_escalation');
766
+ }
767
+ return { decided: false, stage: 'loop_escalation' };
768
+ }
769
+ /**
770
+ * Step 5: HyDRA embedding matcher for ambiguous prompts (T050).
771
+ * Scores fleet candidates via embedding cosine similarity with shortfall gate.
772
+ * Pass-through when no matcher is configured.
773
+ */
774
+ async hydraMatcher(request) {
775
+ const matcher = this.options.hydraMatcher;
776
+ if (!matcher) {
777
+ return { decided: false, stage: 'hydra_match' };
778
+ }
779
+ const fleetForMatch = this.currentTierHint
780
+ ? this.constrainFleetToTierHint(this.activeFleet, this.currentTierHint)
781
+ : this.activeFleet;
782
+ const result = await matcher.match(request, fleetForMatch);
783
+ this.currentHydraResult = result;
784
+ if (!result.selected) {
785
+ return { decided: false, stage: 'hydra_match' };
786
+ }
787
+ const selectedModel = this.activeFleet.find((m) => m.id === result.selected.model_id);
788
+ if (!selectedModel) {
789
+ return { decided: false, stage: 'hydra_match' };
790
+ }
791
+ return {
792
+ decided: true,
793
+ stage: 'hydra_match',
794
+ decision: this.withEstimatedCost(request, selectedModel, {
795
+ request_id: request.request_id,
796
+ selected_model_id: selectedModel.id,
797
+ tier: selectedModel.tier,
798
+ stage: 'hydra_match',
799
+ reason_code: 'hydra_embedding_match',
800
+ candidates: result.candidates,
801
+ routing_latency_ms: result.elapsedMs,
802
+ pin_reason: null,
803
+ }),
804
+ };
805
+ }
806
+ }
807
+ //# sourceMappingURL=router-pipeline.js.map