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,444 @@
1
+ /**
2
+ * Gateway dispatch — T020, T055, T056, FR-001, FR-017, FR-018, FR-022, FR-023.
3
+ *
4
+ * Infrastructure entry point that delegates routing to the domain pipeline.
5
+ * Integrates circuit breaker for resilient failover (FR-018: infra errors only),
6
+ * weighted distribution across same-tier model endpoints (T056), and
7
+ * per-key rate limiting with 429 + Retry-After responses (T057, FR-017).
8
+ *
9
+ * FR-023: preserves provider context-caching semantics on same-provider
10
+ * request paths by tracking the last-used provider per session.
11
+ *
12
+ * SP-097: Cursor subscription quota exhaustion detection and failover to
13
+ * `cursor/auto` or economical API models with `cursor_quota_exhausted` telemetry.
14
+ */
15
+
16
+ import type {
17
+ ModelProfile,
18
+ RoutingDecision,
19
+ RoutingRequest,
20
+ } from '../../domain/types/index.js';
21
+ import { RouterPipeline } from '../../domain/pipeline/router-pipeline.js';
22
+ import type { PipelineOptions } from '../../domain/pipeline/router-pipeline.js';
23
+ import { CircuitBreaker, isInfraError } from './circuit-breaker.js';
24
+ import type { CircuitBreakerConfig } from './circuit-breaker.js';
25
+
26
+ // ─── Cache marker tracking (FR-023) ──────────────────────────────────────────
27
+
28
+ export interface CacheMarker {
29
+ readonly sessionId: string;
30
+ readonly provider: string;
31
+ readonly modelId: string;
32
+ readonly cacheFriendly: boolean;
33
+ }
34
+
35
+ // ─── Rate limit rejection (T057, FR-017) ─────────────────────────────────────
36
+
37
+ export interface RateLimitResult {
38
+ readonly limited: true;
39
+ readonly error: 'rate_limit_exceeded';
40
+ /** Seconds until the bucket refills enough for the next request. */
41
+ readonly retry_after_seconds: number;
42
+ }
43
+
44
+ export interface RateLimitPort {
45
+ /** Attempt to consume a token for the given key. */
46
+ consumeToken(key: string, cost?: number): { allowed: boolean; remaining: number; retryAfterSeconds: number | null };
47
+ }
48
+
49
+ // ─── Cursor quota exhaustion (SP-097 / #70) ──────────────────────────────────
50
+
51
+ export interface ProviderErrorShape {
52
+ readonly statusCode?: number;
53
+ readonly code?: string;
54
+ readonly message?: string;
55
+ }
56
+
57
+ const CURSOR_QUOTA_MESSAGE_PATTERNS: readonly RegExp[] = [
58
+ /usage\s+limit/i,
59
+ /hit your usage/i,
60
+ /quota\s+exhaust/i,
61
+ /subscription.*limit/i,
62
+ /switch to auto/i,
63
+ ];
64
+
65
+ const CURSOR_QUOTA_ERROR_CODES = new Set([
66
+ 'usage_limit_exceeded',
67
+ 'resource_exhausted',
68
+ 'quota_exceeded',
69
+ 'insufficient_quota',
70
+ ]);
71
+
72
+ /**
73
+ * Classify Cursor subscription quota / usage-limit errors from dogfood evidence
74
+ * (#70: "You've hit your usage limit… Switch to Auto for more usage…").
75
+ */
76
+ export function isCursorQuotaExhaustedError(error: ProviderErrorShape): boolean {
77
+ const code = error.code?.trim().toLowerCase();
78
+ if (code && CURSOR_QUOTA_ERROR_CODES.has(code)) {
79
+ return true;
80
+ }
81
+
82
+ const message = error.message ?? '';
83
+ if (message.length > 0 && CURSOR_QUOTA_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) {
84
+ return true;
85
+ }
86
+
87
+ if (error.statusCode === 429 && /usage|quota|limit/i.test(message)) {
88
+ return true;
89
+ }
90
+
91
+ return false;
92
+ }
93
+
94
+ /** Cursor frontier models billed against subscription quota (composer-*, cursor/*). */
95
+ export function isCursorSubscriptionModel(model: ModelProfile): boolean {
96
+ const provider = model.provider.trim().toLowerCase();
97
+ if (provider === 'cursor') {
98
+ return true;
99
+ }
100
+
101
+ const id = model.id.trim().toLowerCase();
102
+ return id.startsWith('composer-') || id.startsWith('cursor/');
103
+ }
104
+
105
+ /** Cursor opaque-auto models (`cursor/auto`, `cursor/*`). */
106
+ export function isCursorAutoModel(model: ModelProfile): boolean {
107
+ const id = model.id.trim().toLowerCase();
108
+ return id === 'cursor/auto' || id === 'auto' || id.startsWith('cursor/');
109
+ }
110
+
111
+ /**
112
+ * Whether a provider error should trigger model failover (infra or Cursor quota).
113
+ * Quota exhaustion on Cursor subscription models must not trip the circuit breaker.
114
+ */
115
+ export function shouldFailoverOnProviderError(
116
+ error: ProviderErrorShape,
117
+ failedModel?: ModelProfile,
118
+ ): boolean {
119
+ if (isCursorQuotaExhaustedError(error)) {
120
+ return true;
121
+ }
122
+
123
+ if (failedModel && isCursorSubscriptionModel(failedModel) && error.statusCode === 429) {
124
+ return true;
125
+ }
126
+
127
+ return isInfraError(error);
128
+ }
129
+
130
+ // ─── Dispatch options ────────────────────────────────────────────────────────
131
+
132
+ export interface GatewayDispatchOptions extends PipelineOptions {
133
+ readonly circuitBreakerConfig?: Partial<CircuitBreakerConfig>;
134
+ readonly rateLimiter?: RateLimitPort;
135
+ }
136
+
137
+ export interface GatewayDispatchRequestOptions {
138
+ readonly effectiveFleet?: readonly ModelProfile[];
139
+ }
140
+
141
+ // ─── Weighted model selection (T056) ─────────────────────────────────────────
142
+
143
+ /**
144
+ * Select a model from same-tier candidates using inverse-cost weighting.
145
+ * Models with lower cost get proportionally more traffic. Falls back to
146
+ * uniform random when all costs are equal.
147
+ */
148
+ export function weightedSelect(
149
+ candidates: readonly ModelProfile[],
150
+ ): ModelProfile | undefined {
151
+ if (candidates.length === 0) return undefined;
152
+ if (candidates.length === 1) return candidates[0];
153
+
154
+ const costs = candidates.map((c) => c.pricing.fallback_cost_per_1m);
155
+ const maxCost = Math.max(...costs);
156
+
157
+ if (maxCost === 0) {
158
+ return candidates[Math.floor(Math.random() * candidates.length)];
159
+ }
160
+
161
+ const weights = costs.map((c) => (maxCost + 1) / (c + 1));
162
+ const totalWeight = weights.reduce((sum, w) => sum + w, 0);
163
+
164
+ let random = Math.random() * totalWeight;
165
+ for (let i = 0; i < candidates.length; i++) {
166
+ random -= weights[i]!;
167
+ if (random <= 0) return candidates[i];
168
+ }
169
+
170
+ return candidates[candidates.length - 1];
171
+ }
172
+
173
+ export class GatewayDispatch {
174
+ private readonly pipeline: RouterPipeline;
175
+ private readonly fleet: readonly ModelProfile[];
176
+ private readonly cacheMarkers = new Map<string, CacheMarker>();
177
+ private readonly circuitBreaker: CircuitBreaker;
178
+ private readonly rateLimiter: RateLimitPort | undefined;
179
+ private readonly quotaExhaustedModels = new Set<string>();
180
+
181
+ constructor(fleet: readonly ModelProfile[], options?: GatewayDispatchOptions) {
182
+ this.fleet = fleet;
183
+ this.pipeline = new RouterPipeline(fleet, options);
184
+ this.circuitBreaker = new CircuitBreaker(options?.circuitBreakerConfig);
185
+ this.rateLimiter = options?.rateLimiter;
186
+ }
187
+
188
+ /**
189
+ * Route a single request through the pipeline.
190
+ *
191
+ * 1. Run pipeline to get routing decision.
192
+ * 2. Verify circuit breaker allows dispatch to the selected model.
193
+ * 3. If the selected model's circuit is open, attempt failover to a
194
+ * same-tier alternative (T056).
195
+ * 4. Update FR-023 cache marker.
196
+ *
197
+ * Never throws.
198
+ */
199
+ async dispatch(
200
+ request: RoutingRequest,
201
+ options?: GatewayDispatchRequestOptions,
202
+ ): Promise<RoutingDecision> {
203
+ const effectiveFleet = options?.effectiveFleet ?? this.fleet;
204
+ const decision = await this.pipeline.route(request, effectiveFleet);
205
+ const finalDecision = this.applyCircuitBreaker(decision, effectiveFleet);
206
+ this.updateCacheMarker(request.session_id, finalDecision);
207
+ return finalDecision;
208
+ }
209
+
210
+ /**
211
+ * Route with per-key rate limiting (T057, FR-017).
212
+ *
213
+ * Checks the token bucket before routing. Returns a RateLimitResult with
214
+ * 429-compatible body `{ error, retry_after_seconds }` when the bucket is
215
+ * exhausted. Otherwise delegates to dispatch().
216
+ */
217
+ async dispatchWithRateLimit(
218
+ request: RoutingRequest,
219
+ rateLimitKey: string,
220
+ ): Promise<RoutingDecision | RateLimitResult> {
221
+ if (this.rateLimiter) {
222
+ const bucket = this.rateLimiter.consumeToken(rateLimitKey);
223
+ if (!bucket.allowed) {
224
+ return {
225
+ limited: true,
226
+ error: 'rate_limit_exceeded',
227
+ retry_after_seconds: bucket.retryAfterSeconds ?? 0,
228
+ };
229
+ }
230
+ }
231
+
232
+ return this.dispatch(request);
233
+ }
234
+
235
+ /**
236
+ * Record an upstream response outcome for circuit breaker tracking.
237
+ * Only infra errors trip the breaker; policy/safety rejections are ignored (FR-018).
238
+ * Cursor quota exhaustion is tracked for SP-097 failover without opening circuits.
239
+ */
240
+ recordOutcome(
241
+ modelId: string,
242
+ error?: ProviderErrorShape,
243
+ ): void {
244
+ if (!error) {
245
+ this.circuitBreaker.recordSuccess(modelId);
246
+ return;
247
+ }
248
+
249
+ if (isCursorQuotaExhaustedError(error)) {
250
+ this.quotaExhaustedModels.add(modelId);
251
+ return;
252
+ }
253
+
254
+ const failedModel = this.fleet.find((m) => m.id === modelId);
255
+ if (failedModel && isCursorSubscriptionModel(failedModel) && error.statusCode === 429) {
256
+ this.quotaExhaustedModels.add(modelId);
257
+ return;
258
+ }
259
+
260
+ if (isInfraError(error)) {
261
+ this.circuitBreaker.recordFailure(modelId);
262
+ }
263
+ }
264
+
265
+ /** Expose circuit breaker for observability and testing. */
266
+ getCircuitBreaker(): CircuitBreaker {
267
+ return this.circuitBreaker;
268
+ }
269
+
270
+ /** Whether a model recently returned a Cursor quota exhaustion error (SP-097). */
271
+ hasQuotaExhaustion(modelId: string): boolean {
272
+ return this.quotaExhaustedModels.has(modelId);
273
+ }
274
+
275
+ /**
276
+ * Retrieve the current cache marker for a session (FR-023 inspection).
277
+ */
278
+ getCacheMarker(sessionId: string): CacheMarker | null {
279
+ return this.cacheMarkers.get(sessionId) ?? null;
280
+ }
281
+
282
+ /**
283
+ * Select an alternate model when the current target is unavailable or failed.
284
+ * Prefers same-tier healthy models with closed circuits, then any tier.
285
+ * When the failed model hit Cursor quota limits, prefers `cursor/auto` then
286
+ * economical API models with `cursor_quota_exhausted`.
287
+ */
288
+ selectFailover(
289
+ decision: RoutingDecision,
290
+ excludeModelIds: readonly string[] = [],
291
+ fleetOverride?: readonly ModelProfile[],
292
+ ): RoutingDecision | undefined {
293
+ const excluded = new Set(excludeModelIds);
294
+ const fleet = fleetOverride ?? this.fleet;
295
+
296
+ const quotaFailover = this.selectCursorQuotaFailover(decision, excluded, fleet);
297
+ if (quotaFailover) {
298
+ return quotaFailover;
299
+ }
300
+
301
+ const sameTier = fleet.filter(
302
+ (m) =>
303
+ m.tier === decision.tier &&
304
+ m.id !== decision.selected_model_id &&
305
+ !excluded.has(m.id) &&
306
+ m.healthy !== false &&
307
+ this.circuitBreaker.canDispatch(m.id),
308
+ );
309
+
310
+ const sameTierAlternative = weightedSelect(sameTier);
311
+ if (sameTierAlternative) {
312
+ return {
313
+ ...decision,
314
+ selected_model_id: sameTierAlternative.id,
315
+ reason_code: 'circuit_breaker_failover',
316
+ };
317
+ }
318
+
319
+ const anyHealthy = fleet.filter(
320
+ (m) =>
321
+ m.id !== decision.selected_model_id &&
322
+ !excluded.has(m.id) &&
323
+ m.healthy !== false &&
324
+ this.circuitBreaker.canDispatch(m.id),
325
+ );
326
+
327
+ const fallback = weightedSelect(anyHealthy);
328
+ if (fallback) {
329
+ return {
330
+ ...decision,
331
+ selected_model_id: fallback.id,
332
+ tier: fallback.tier,
333
+ reason_code: 'circuit_breaker_failover',
334
+ };
335
+ }
336
+
337
+ return undefined;
338
+ }
339
+
340
+ /**
341
+ * If the selected model's circuit is open, attempt to fail over to
342
+ * an alternative model on the same tier (T056 weighted distribution).
343
+ * Falls back to any healthy model on any tier if no same-tier alternative exists.
344
+ */
345
+ private applyCircuitBreaker(
346
+ decision: RoutingDecision,
347
+ fleet: readonly ModelProfile[] = this.fleet,
348
+ ): RoutingDecision {
349
+ if (this.circuitBreaker.canDispatch(decision.selected_model_id)) {
350
+ return decision;
351
+ }
352
+
353
+ return this.selectFailover(decision, [], fleet) ?? decision;
354
+ }
355
+
356
+ /**
357
+ * SP-097: fail over from quota-exhausted Cursor models to `cursor/auto` or
358
+ * the cheapest viable economical API model in the scoped fleet.
359
+ */
360
+ private selectCursorQuotaFailover(
361
+ decision: RoutingDecision,
362
+ excluded: ReadonlySet<string>,
363
+ fleet: readonly ModelProfile[],
364
+ ): RoutingDecision | undefined {
365
+ const failedIds = [decision.selected_model_id, ...excluded];
366
+ const quotaFailed = failedIds.some((id) => this.quotaExhaustedModels.has(id));
367
+ if (!quotaFailed) {
368
+ return undefined;
369
+ }
370
+
371
+ const failedModel = fleet.find((m) => m.id === decision.selected_model_id);
372
+ if (failedModel && !isCursorSubscriptionModel(failedModel)) {
373
+ return undefined;
374
+ }
375
+
376
+ const cursorAuto = fleet.find(
377
+ (m) =>
378
+ isCursorAutoModel(m) &&
379
+ !excluded.has(m.id) &&
380
+ m.id !== decision.selected_model_id &&
381
+ m.healthy !== false &&
382
+ this.circuitBreaker.canDispatch(m.id),
383
+ );
384
+ if (cursorAuto) {
385
+ return {
386
+ ...decision,
387
+ selected_model_id: cursorAuto.id,
388
+ tier: cursorAuto.tier,
389
+ reason_code: 'cursor_quota_exhausted',
390
+ };
391
+ }
392
+
393
+ const economical = fleet.filter(
394
+ (m) =>
395
+ m.tier === 'economical-cloud' &&
396
+ !excluded.has(m.id) &&
397
+ m.id !== decision.selected_model_id &&
398
+ m.healthy !== false &&
399
+ !isCursorSubscriptionModel(m) &&
400
+ this.circuitBreaker.canDispatch(m.id),
401
+ );
402
+ const economicalFallback = weightedSelect(economical);
403
+ if (economicalFallback) {
404
+ return {
405
+ ...decision,
406
+ selected_model_id: economicalFallback.id,
407
+ tier: economicalFallback.tier,
408
+ reason_code: 'cursor_quota_exhausted',
409
+ };
410
+ }
411
+
412
+ return undefined;
413
+ }
414
+
415
+ /**
416
+ * FR-023: track the provider and cache-friendly status for the session.
417
+ * Sub-routed requests on the same provider preserve the existing marker
418
+ * rather than replacing it — the pin model's cache state is authoritative.
419
+ */
420
+ private updateCacheMarker(
421
+ sessionId: string,
422
+ decision: RoutingDecision,
423
+ ): void {
424
+ const model = this.fleet.find((m) => m.id === decision.selected_model_id);
425
+ if (!model) return;
426
+
427
+ const existing = this.cacheMarkers.get(sessionId);
428
+
429
+ if (
430
+ existing &&
431
+ existing.provider === model.provider &&
432
+ decision.reason_code === 'tool_result_sub_route'
433
+ ) {
434
+ return;
435
+ }
436
+
437
+ this.cacheMarkers.set(sessionId, {
438
+ sessionId,
439
+ provider: model.provider,
440
+ modelId: model.id,
441
+ cacheFriendly: model.performance?.cache_friendly ?? false,
442
+ });
443
+ }
444
+ }
File without changes