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,70 @@
1
+ /**
2
+ * Safe cloud default — FR-022 fallback selector.
3
+ *
4
+ * Selects the first healthy economical-cloud model from the fleet,
5
+ * falling back to frontier-cloud only when no economical model is available.
6
+ * Never throws; returns undefined only when the fleet is completely empty or unhealthy.
7
+ *
8
+ * SP-095: when a request is provided, only models that fit context are eligible.
9
+ */
10
+
11
+ import type { ModelProfile, RoutingRequest } from '../types/index.js';
12
+ import {
13
+ modelFitsContext,
14
+ resolveSafetyMargin,
15
+ type ContextFitConfig,
16
+ } from '../routing/context-fit.js';
17
+
18
+ export interface SafeCloudDefaultOptions {
19
+ readonly request?: RoutingRequest;
20
+ readonly contextFitConfig?: ContextFitConfig;
21
+ }
22
+
23
+ function isHealthy(model: ModelProfile): boolean {
24
+ return model.healthy !== false;
25
+ }
26
+
27
+ function fitsWhenRequired(
28
+ model: ModelProfile,
29
+ request: RoutingRequest | undefined,
30
+ config: ContextFitConfig | undefined,
31
+ ): boolean {
32
+ if (!request) {
33
+ return true;
34
+ }
35
+
36
+ const estimatedInputTokens =
37
+ request.estimated_input_tokens ?? request.prompt_text.length;
38
+ return modelFitsContext(model, estimatedInputTokens, resolveSafetyMargin(config));
39
+ }
40
+
41
+ /**
42
+ * Select a safe cloud default model from the fleet catalog.
43
+ *
44
+ * Priority order:
45
+ * 1. First healthy economical-cloud model (context-fit aware when request provided)
46
+ * 2. First healthy frontier-cloud model (fallback)
47
+ * 3. undefined (no viable model)
48
+ */
49
+ export function safeCloudDefault(
50
+ models: readonly ModelProfile[],
51
+ options?: SafeCloudDefaultOptions,
52
+ ): ModelProfile | undefined {
53
+ const request = options?.request;
54
+ const config = options?.contextFitConfig;
55
+
56
+ const economical = models.find(
57
+ (model) =>
58
+ model.tier === 'economical-cloud' &&
59
+ isHealthy(model) &&
60
+ fitsWhenRequired(model, request, config),
61
+ );
62
+ if (economical) return economical;
63
+
64
+ return models.find(
65
+ (model) =>
66
+ model.tier === 'frontier-cloud' &&
67
+ isHealthy(model) &&
68
+ fitsWhenRequired(model, request, config),
69
+ );
70
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Epic refactor guardrails — SP-083.
3
+ *
4
+ * Detects large refactoring workloads and emits spine-decomposition,
5
+ * test-gate, and frontier-routing policies. Inert for normal requests —
6
+ * callers treat `is_epic: false` as a no-op.
7
+ */
8
+
9
+ import { cyclomaticScan } from './triage/triage-engine.js';
10
+ import type { RoutingRequest, Tier } from './types/index.js';
11
+
12
+ export const EPIC_REFACTOR_DETECTED = 'epic_refactor_detected' as const;
13
+ export const EPIC_REFACTOR_NOT_DETECTED = 'epic_refactor_not_detected' as const;
14
+
15
+ export const EPIC_REFACTOR_RECOMMENDED_TIER: Tier = 'frontier-cloud';
16
+
17
+ export type EpicRefactorGuardrailKind =
18
+ | 'spine_decomposition'
19
+ | 'test_gate'
20
+ | 'frontier_routing';
21
+
22
+ export const EPIC_REFACTOR_GUARDRAILS: readonly EpicRefactorGuardrailKind[] = [
23
+ 'spine_decomposition',
24
+ 'test_gate',
25
+ 'frontier_routing',
26
+ ];
27
+
28
+ export interface EpicRefactorScopeSignals {
29
+ readonly epic_keyword_hits: number;
30
+ readonly file_path_mentions: number;
31
+ readonly cyclomatic_score: number;
32
+ readonly planning_turn: boolean;
33
+ }
34
+
35
+ export interface EpicRefactorGuardrailEvaluation {
36
+ readonly is_epic: boolean;
37
+ readonly reason_code:
38
+ | typeof EPIC_REFACTOR_DETECTED
39
+ | typeof EPIC_REFACTOR_NOT_DETECTED;
40
+ readonly guardrails: readonly EpicRefactorGuardrailKind[];
41
+ readonly recommended_tier?: Tier;
42
+ readonly signals: EpicRefactorScopeSignals;
43
+ }
44
+
45
+ export interface EpicRefactorGuardrailConfig {
46
+ /** Minimum epic-keyword hits when paired with other scope signals. Default 1. */
47
+ readonly minKeywordHits?: number;
48
+ /** File-path mentions required with a keyword hit. Default 3. */
49
+ readonly minFilePathMentions?: number;
50
+ /** Cyclomatic score required with planning turn + keyword. Default 15. */
51
+ readonly minCyclomaticScore?: number;
52
+ /** Keyword hits that alone qualify as epic. Default 2. */
53
+ readonly standaloneKeywordHits?: number;
54
+ }
55
+
56
+ const DEFAULT_CONFIG: Required<EpicRefactorGuardrailConfig> = {
57
+ minKeywordHits: 1,
58
+ minFilePathMentions: 3,
59
+ minCyclomaticScore: 15,
60
+ standaloneKeywordHits: 2,
61
+ };
62
+
63
+ const EPIC_KEYWORDS: readonly string[] = [
64
+ 'epic refactor',
65
+ 'god file',
66
+ 'god object',
67
+ 'god class',
68
+ 'decompose',
69
+ 'decomposition',
70
+ 'large refactor',
71
+ 'large-scale refactor',
72
+ 'mass refactor',
73
+ 'restructure',
74
+ 'split module',
75
+ 'extract module',
76
+ 'break up',
77
+ 'monolith',
78
+ 'cross-cutting',
79
+ 'wide-reaching',
80
+ 'rename across',
81
+ 'move across',
82
+ 'refactor entire',
83
+ 'refactor whole',
84
+ ];
85
+
86
+ const STRONG_EPIC_PHRASES: readonly RegExp[] = [
87
+ /\bepic\s+refactor\b/i,
88
+ /\bgod\s+(?:file|object|class)\b/i,
89
+ /\bdecompos(?:e|ition)\b/i,
90
+ ];
91
+
92
+ const RE_FILE_PATH =
93
+ /\b(?:[\w@.-]+\/)+[\w@.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|swift|rb|php|cs|vue|svelte)\b/gi;
94
+
95
+ const RE_BARE_FILE =
96
+ /\b[\w@.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|swift|rb|php|cs|vue|svelte)\b/gi;
97
+
98
+ function countKeywordHits(text: string): number {
99
+ const lower = text.toLowerCase();
100
+ let hits = 0;
101
+
102
+ for (const keyword of EPIC_KEYWORDS) {
103
+ if (lower.includes(keyword)) {
104
+ hits += 1;
105
+ }
106
+ }
107
+
108
+ for (const pattern of STRONG_EPIC_PHRASES) {
109
+ if (pattern.test(text)) {
110
+ hits += 1;
111
+ }
112
+ }
113
+
114
+ return hits;
115
+ }
116
+
117
+ function countFilePathMentions(text: string): number {
118
+ const paths = new Set<string>();
119
+
120
+ for (const match of text.matchAll(RE_FILE_PATH)) {
121
+ paths.add(match[0]!.toLowerCase());
122
+ }
123
+
124
+ for (const match of text.matchAll(RE_BARE_FILE)) {
125
+ paths.add(match[0]!.toLowerCase());
126
+ }
127
+
128
+ return paths.size;
129
+ }
130
+
131
+ export function collectEpicRefactorScopeSignals(
132
+ request: RoutingRequest,
133
+ ): EpicRefactorScopeSignals {
134
+ const text = request.prompt_text;
135
+ return {
136
+ epic_keyword_hits: countKeywordHits(text),
137
+ file_path_mentions: countFilePathMentions(text),
138
+ cyclomatic_score: cyclomaticScan(text),
139
+ planning_turn: request.turn_type === 'planning',
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Returns true when scope signals indicate an epic refactor workload.
145
+ * Conservative thresholds keep normal routing and small edits unaffected.
146
+ */
147
+ export function isEpicRefactorScope(
148
+ signals: EpicRefactorScopeSignals,
149
+ config?: EpicRefactorGuardrailConfig,
150
+ ): boolean {
151
+ const resolved = { ...DEFAULT_CONFIG, ...config };
152
+
153
+ if (signals.epic_keyword_hits >= resolved.standaloneKeywordHits) {
154
+ return true;
155
+ }
156
+
157
+ if (
158
+ signals.epic_keyword_hits >= resolved.minKeywordHits &&
159
+ signals.file_path_mentions >= resolved.minFilePathMentions
160
+ ) {
161
+ return true;
162
+ }
163
+
164
+ if (
165
+ signals.planning_turn &&
166
+ signals.epic_keyword_hits >= resolved.minKeywordHits &&
167
+ signals.cyclomatic_score >= resolved.minCyclomaticScore
168
+ ) {
169
+ return true;
170
+ }
171
+
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * Evaluate epic-refactor guardrails for a routing request.
177
+ * Non-epic requests return empty guardrails and no tier override.
178
+ */
179
+ export function evaluateEpicRefactorGuardrails(
180
+ request: RoutingRequest,
181
+ config?: EpicRefactorGuardrailConfig,
182
+ ): EpicRefactorGuardrailEvaluation {
183
+ const signals = collectEpicRefactorScopeSignals(request);
184
+ const is_epic = isEpicRefactorScope(signals, config);
185
+
186
+ if (!is_epic) {
187
+ return {
188
+ is_epic: false,
189
+ reason_code: EPIC_REFACTOR_NOT_DETECTED,
190
+ guardrails: [],
191
+ signals,
192
+ };
193
+ }
194
+
195
+ return {
196
+ is_epic: true,
197
+ reason_code: EPIC_REFACTOR_DETECTED,
198
+ guardrails: EPIC_REFACTOR_GUARDRAILS,
199
+ recommended_tier: EPIC_REFACTOR_RECOMMENDED_TIER,
200
+ signals,
201
+ };
202
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Context-fit gate — SP-093, overflow fallback — SP-095.
3
+ *
4
+ * Filters fleet models whose context window cannot accommodate the estimated
5
+ * input token count (with a configurable safety margin).
6
+ */
7
+
8
+ import type {
9
+ CandidateScore,
10
+ ModelProfile,
11
+ RoutingRequest,
12
+ } from '../types/index.js';
13
+ import { selectLowestCostModel } from '../pinning/sub-route-policy.js';
14
+
15
+ export const CONTEXT_FIT_EXCEEDED = 'context_fit_exceeded' as const;
16
+
17
+ export const CONTEXT_OVERFLOW_SAME_PROVIDER_FALLBACK =
18
+ 'context_overflow_same_provider_fallback' as const;
19
+ export const CONTEXT_OVERFLOW_FRONTIER_FALLBACK =
20
+ 'context_overflow_frontier_fallback' as const;
21
+ export const CONTEXT_OVERFLOW_NO_FIT = 'context_overflow_no_fit' as const;
22
+
23
+ export const DEFAULT_CONTEXT_FIT_SAFETY_MARGIN = 0.9;
24
+
25
+ export const CONTEXT_FIT_SAFETY_MARGIN_ENV = 'CONTEXT_FIT_SAFETY_MARGIN';
26
+
27
+ export interface ContextFitConfig {
28
+ /** Fraction of max_input_tokens treated as usable (default 0.90). */
29
+ readonly safetyMargin?: number;
30
+ }
31
+
32
+ export interface ContextFitFilterResult {
33
+ readonly effectiveFleet: readonly ModelProfile[];
34
+ readonly rejected: readonly CandidateScore[];
35
+ }
36
+
37
+ export interface ContextOverflowFallbackResult {
38
+ readonly kind: 'selected' | 'no_fit';
39
+ readonly model?: ModelProfile;
40
+ readonly reasonCode:
41
+ | typeof CONTEXT_OVERFLOW_SAME_PROVIDER_FALLBACK
42
+ | typeof CONTEXT_OVERFLOW_FRONTIER_FALLBACK
43
+ | typeof CONTEXT_OVERFLOW_NO_FIT;
44
+ }
45
+
46
+ function resolveEstimatedInputTokens(request: RoutingRequest): number {
47
+ return request.estimated_input_tokens ?? request.prompt_text.length;
48
+ }
49
+
50
+ export function resolveSafetyMargin(config?: ContextFitConfig): number {
51
+ if (config?.safetyMargin !== undefined) {
52
+ return config.safetyMargin;
53
+ }
54
+
55
+ const raw = process.env[CONTEXT_FIT_SAFETY_MARGIN_ENV];
56
+ if (raw === undefined || raw.trim() === '') {
57
+ return DEFAULT_CONTEXT_FIT_SAFETY_MARGIN;
58
+ }
59
+
60
+ const parsed = Number(raw);
61
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) {
62
+ return DEFAULT_CONTEXT_FIT_SAFETY_MARGIN;
63
+ }
64
+
65
+ return parsed;
66
+ }
67
+
68
+ export function modelFitsContext(
69
+ profile: ModelProfile,
70
+ estimatedInputTokens: number,
71
+ safetyMargin: number,
72
+ ): boolean {
73
+ const maxInput = profile.limits?.max_input_tokens;
74
+ if (maxInput === undefined) {
75
+ return true;
76
+ }
77
+
78
+ const effectiveLimit = Math.floor(maxInput * safetyMargin);
79
+ return estimatedInputTokens <= effectiveLimit;
80
+ }
81
+
82
+ function isHealthy(model: ModelProfile): boolean {
83
+ return model.healthy !== false;
84
+ }
85
+
86
+ /**
87
+ * Select the model with the largest declared context window.
88
+ * Models without declared limits sort last (treated as unknown / unbounded).
89
+ */
90
+ export function selectLargestWindowModel(
91
+ candidates: readonly ModelProfile[],
92
+ ): ModelProfile | undefined {
93
+ let best: ModelProfile | undefined;
94
+ let bestWindow = -1;
95
+
96
+ for (const model of candidates) {
97
+ if (!isHealthy(model)) continue;
98
+ const window = model.limits?.max_input_tokens ?? Number.MAX_SAFE_INTEGER;
99
+ if (window > bestWindow) {
100
+ bestWindow = window;
101
+ best = model;
102
+ }
103
+ }
104
+
105
+ return best;
106
+ }
107
+
108
+ /**
109
+ * SP-095: escalate when economical/pinned models cannot fit context.
110
+ *
111
+ * 1. Same-provider largest-fit model
112
+ * 2. Cheapest frontier model that fits
113
+ * 3. Structured no-fit (never dispatch undersized)
114
+ */
115
+ export function resolveContextOverflowFallback(
116
+ fleet: readonly ModelProfile[],
117
+ request: RoutingRequest,
118
+ preferredProvider: string | null,
119
+ config?: ContextFitConfig,
120
+ ): ContextOverflowFallbackResult {
121
+ const estimatedInputTokens = resolveEstimatedInputTokens(request);
122
+ const safetyMargin = resolveSafetyMargin(config);
123
+ const fits = (model: ModelProfile): boolean =>
124
+ modelFitsContext(model, estimatedInputTokens, safetyMargin);
125
+
126
+ if (preferredProvider) {
127
+ const sameProviderCandidates = fleet.filter(
128
+ (model) => model.provider === preferredProvider && fits(model),
129
+ );
130
+ const sameProviderModel = selectLargestWindowModel(sameProviderCandidates);
131
+ if (sameProviderModel) {
132
+ return {
133
+ kind: 'selected',
134
+ model: sameProviderModel,
135
+ reasonCode: CONTEXT_OVERFLOW_SAME_PROVIDER_FALLBACK,
136
+ };
137
+ }
138
+ }
139
+
140
+ const frontierCandidates = fleet.filter(
141
+ (model) => model.tier === 'frontier-cloud' && fits(model),
142
+ );
143
+ const frontierModel = selectLowestCostModel(frontierCandidates);
144
+ if (frontierModel) {
145
+ return {
146
+ kind: 'selected',
147
+ model: frontierModel,
148
+ reasonCode: CONTEXT_OVERFLOW_FRONTIER_FALLBACK,
149
+ };
150
+ }
151
+
152
+ return {
153
+ kind: 'no_fit',
154
+ reasonCode: CONTEXT_OVERFLOW_NO_FIT,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * True when economical models were rejected for context fit and none remain
160
+ * in the post-filter fleet.
161
+ */
162
+ export function needsContextOverflowFallback(
163
+ activeFleet: readonly ModelProfile[],
164
+ rejected: readonly CandidateScore[],
165
+ fullFleet: readonly ModelProfile[],
166
+ ): boolean {
167
+ if (rejected.length === 0) {
168
+ return false;
169
+ }
170
+
171
+ const hasActiveEconomical = activeFleet.some(
172
+ (model) => model.tier === 'economical-cloud' && isHealthy(model),
173
+ );
174
+ if (hasActiveEconomical) {
175
+ return false;
176
+ }
177
+
178
+ return rejected.some((candidate) => {
179
+ const profile = fullFleet.find((model) => model.id === candidate.model_id);
180
+ return profile?.tier === 'economical-cloud';
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Remove fleet entries whose max_input_tokens cannot fit estimated_input_tokens
186
+ * at the given safety margin. Honors `force_model_id` by leaving fleet unchanged.
187
+ * Models without declared limits are retained (unknown window).
188
+ */
189
+ export function filterFleetByContextFit(
190
+ fleet: readonly ModelProfile[],
191
+ request: RoutingRequest,
192
+ config?: ContextFitConfig,
193
+ ): ContextFitFilterResult {
194
+ if (request.force_model_id) {
195
+ return { effectiveFleet: fleet, rejected: [] };
196
+ }
197
+
198
+ const estimatedInputTokens = resolveEstimatedInputTokens(request);
199
+ const safetyMargin = resolveSafetyMargin(config);
200
+
201
+ const effectiveFleet: ModelProfile[] = [];
202
+ const rejected: CandidateScore[] = [];
203
+
204
+ for (const profile of fleet) {
205
+ if (modelFitsContext(profile, estimatedInputTokens, safetyMargin)) {
206
+ effectiveFleet.push(profile);
207
+ continue;
208
+ }
209
+
210
+ const maxInput = profile.limits!.max_input_tokens!;
211
+ const effectiveLimit = Math.floor(maxInput * safetyMargin);
212
+ rejected.push({
213
+ model_id: profile.id,
214
+ score: 0,
215
+ shortfall: estimatedInputTokens - effectiveLimit,
216
+ rejected_reason: CONTEXT_FIT_EXCEEDED,
217
+ });
218
+ }
219
+
220
+ return { effectiveFleet, rejected };
221
+ }