cartethyia 2.5.9 → 2.6.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 (386) hide show
  1. package/.env.example +15 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
  38. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  39. package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  41. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  42. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  43. package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
  44. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  46. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  48. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
  50. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
  52. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
  54. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
  60. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
  62. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  66. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  68. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
  72. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
  74. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
  76. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  77. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
  78. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
  80. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  82. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  86. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  92. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
  94. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  96. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
  98. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
  100. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
  102. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  105. package/dist/dashboard/index.html +1 -1
  106. package/dist/dashboard/index.html.gz +0 -0
  107. package/dist/dashboard/precompressed-assets.json +51 -51
  108. package/migrations/README.md +52 -52
  109. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  110. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  111. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  112. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  113. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  114. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  115. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  116. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  117. package/package.json +96 -97
  118. package/scripts/cli/admin.ts +126 -125
  119. package/scripts/cli/autostart.ts +237 -237
  120. package/scripts/cli/background.ts +120 -110
  121. package/scripts/cli/bin/carte.cjs +8 -8
  122. package/scripts/cli/bin/cartethyia.cjs +167 -167
  123. package/scripts/cli/console-menu.ts +308 -275
  124. package/scripts/cli/controller.ts +145 -0
  125. package/scripts/cli/doctor.ts +1 -1
  126. package/scripts/cli/env.ts +99 -99
  127. package/scripts/cli/gateway.ts +264 -261
  128. package/scripts/{health.ts → cli/health.ts} +4 -4
  129. package/scripts/cli/instance.ts +234 -224
  130. package/scripts/cli/main.ts +188 -164
  131. package/scripts/cli/platform.ts +64 -64
  132. package/scripts/cli/prompt.ts +275 -107
  133. package/scripts/cli/services.ts +88 -88
  134. package/scripts/cli/setup.ts +445 -445
  135. package/scripts/cli/start.ts +85 -82
  136. package/scripts/cli/startup-signal.ts +23 -23
  137. package/scripts/cli/telemetry.ts +70 -0
  138. package/scripts/cli/tray/index.ts +263 -263
  139. package/scripts/cli/tray/windows.ps1 +107 -107
  140. package/scripts/cli/tray.ts +192 -208
  141. package/scripts/cli/update.ts +39 -39
  142. package/scripts/cli/version.ts +67 -67
  143. package/scripts/cli/wordmark.ts +58 -58
  144. package/src/boot/app.ts +16 -1
  145. package/src/boot/dependencies.ts +128 -25
  146. package/src/config/package-paths.ts +24 -0
  147. package/src/config/resources.ts +137 -0
  148. package/src/config/runtime.ts +133 -76
  149. package/src/config/spec.ts +30 -29
  150. package/src/config/telemetry.ts +0 -25
  151. package/src/console/api-keys/bucket-spend.ts +14 -17
  152. package/src/console/api-keys/contracts.ts +410 -410
  153. package/src/console/api-keys/routes.ts +12 -0
  154. package/src/console/audit/store.ts +2 -3
  155. package/src/console/auth/setup-service.ts +8 -1
  156. package/src/console/backup/routes.ts +7 -0
  157. package/src/console/backup/service.ts +463 -463
  158. package/src/console/backup/store.ts +16 -11
  159. package/src/console/backup/validate.ts +1 -4
  160. package/src/console/cli-tools/fs-ops.ts +18 -2
  161. package/src/console/data-management/contracts.ts +1 -8
  162. package/src/console/data-management/operations.ts +61 -48
  163. package/src/console/data-management/routes.ts +4 -2
  164. package/src/console/domain-registration.ts +25 -3
  165. package/src/console/inferhub/client.ts +139 -0
  166. package/src/console/inferhub/key-store.ts +129 -0
  167. package/src/console/inferhub/normalize.ts +347 -0
  168. package/src/console/inferhub/routes.ts +411 -0
  169. package/src/console/inferhub/store.ts +109 -0
  170. package/src/console/inferhub/types.ts +183 -0
  171. package/src/console/observability/contracts.ts +2 -41
  172. package/src/console/observability/store.ts +176 -371
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/store.ts +23 -36
  175. package/src/console/quota/account-quota-shared.ts +52 -52
  176. package/src/console/quota/account-quota-tenant.ts +16 -24
  177. package/src/console/quota/refresh.ts +441 -441
  178. package/src/console/remote-support/service.ts +270 -114
  179. package/src/console/routing/pools/import-parse.ts +206 -206
  180. package/src/console/routing/pools/operations.ts +543 -543
  181. package/src/console/routing/pools/probe-verdict.ts +132 -132
  182. package/src/console/routing/pools/routes.ts +181 -181
  183. package/src/console/routing/pools/store.ts +1 -1
  184. package/src/console/settings/contracts.ts +42 -40
  185. package/src/console/settings/store.ts +25 -25
  186. package/src/console/share/share-stats.ts +22 -4
  187. package/src/console/share/share-usage.ts +30 -4
  188. package/src/console/shared/cursor.ts +15 -0
  189. package/src/console/updates/repository-updates.ts +40 -39
  190. package/src/console/updates/routes.ts +12 -0
  191. package/src/main.ts +13 -5
  192. package/src/network/pool/resolver.ts +1104 -1104
  193. package/src/observability/client-name.ts +22 -0
  194. package/src/observability/event-log.ts +5 -29
  195. package/src/observability/event-query.ts +690 -0
  196. package/src/observability/log-ring.ts +26 -2
  197. package/src/observability/log-sanitize.ts +118 -0
  198. package/src/observability/logger.ts +17 -13
  199. package/src/observability/metrics.ts +35 -0
  200. package/src/observability/payload-capture.ts +114 -41
  201. package/src/observability/payload-store.ts +249 -24
  202. package/src/observability/raw-event-writer.ts +118 -0
  203. package/src/observability/runtime-metrics.ts +71 -1
  204. package/src/observability/telemetry-buffer.ts +100 -17
  205. package/src/observability/telemetry-reconciliation.ts +417 -0
  206. package/src/persistence/boot.ts +20 -9
  207. package/src/persistence/db-pg.ts +13 -0
  208. package/src/persistence/db-sqlite.ts +569 -497
  209. package/src/persistence/dialect.ts +3 -2
  210. package/src/persistence/migrate.ts +181 -181
  211. package/src/persistence/schema.ts +1496 -1406
  212. package/src/persistence/shared-cache.ts +5 -1
  213. package/src/persistence/stores/api-key-store.ts +581 -581
  214. package/src/persistence/stores/telemetry-store.ts +124 -63
  215. package/src/protocol/reasoning-text.ts +30 -0
  216. package/src/protocol/registry.ts +2 -0
  217. package/src/protocol/response/chat.ts +33 -7
  218. package/src/protocol/response/codex.ts +14 -2
  219. package/src/protocol/response/messages.ts +5 -2
  220. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  221. package/src/providers/bundled-discovery-providers.ts +122 -122
  222. package/src/providers/default-registry.ts +14 -0
  223. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  224. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  225. package/src/providers/discovery/probe-phases.ts +43 -7
  226. package/src/providers/discovery/probing-service.ts +70 -32
  227. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  228. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  231. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  232. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  233. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  234. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  235. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  236. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  237. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  238. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  239. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  240. package/src/providers/integrations/claude/claude.ts +3 -4
  241. package/src/providers/integrations/cline/cline.ts +94 -8
  242. package/src/providers/integrations/codex/codex.ts +61 -37
  243. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  244. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  245. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  246. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  247. package/src/providers/integrations/cursor/cursor.ts +29 -17
  248. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  249. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  250. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  251. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  252. package/src/providers/integrations/github/github.ts +3 -1
  253. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  254. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  255. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  256. package/src/providers/integrations/hermes.ts +7 -1
  257. package/src/providers/integrations/inferhub.ts +201 -73
  258. package/src/providers/integrations/kilo/kilo.ts +152 -152
  259. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  260. package/src/providers/integrations/opencode/provider.ts +2 -0
  261. package/src/providers/integrations/openrouter/provider.ts +129 -129
  262. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  263. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  264. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  265. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  266. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  267. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  268. package/src/providers/integrations/trae/trae.ts +22 -24
  269. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  270. package/src/providers/integrations/zcode/oauth.ts +1 -1
  271. package/src/providers/operations/account-health-service.ts +605 -279
  272. package/src/providers/provider-metadata.ts +12 -1
  273. package/src/providers/provider-model-definition.ts +373 -373
  274. package/src/providers/provider-probe.ts +3 -0
  275. package/src/providers/quota/quota-contracts.ts +3 -7
  276. package/src/providers/search/search-provider.ts +2 -6
  277. package/src/security/admission/contracts.ts +10 -0
  278. package/src/security/admission/in-memory-store.ts +89 -14
  279. package/src/security/admission/lua.ts +191 -182
  280. package/src/security/admission/redis-store.ts +30 -0
  281. package/src/security/admission/service.ts +18 -0
  282. package/src/security/api-key-auth-cache.ts +96 -0
  283. package/src/security/api-key-auth.ts +11 -100
  284. package/src/security/client-router-fingerprint.ts +3 -7
  285. package/src/security/ip-boundary.ts +7 -3
  286. package/src/shared/coerce.ts +67 -0
  287. package/src/shared/hash.ts +6 -0
  288. package/src/shared/time.ts +7 -0
  289. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  290. package/src/transport/dispatch/attempt-loop.ts +103 -81
  291. package/src/transport/dispatch/capture-policy.ts +78 -0
  292. package/src/transport/dispatch/proxy-request.ts +4 -2
  293. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  294. package/src/transport/dispatch/streaming/stream.ts +19 -2
  295. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  296. package/src/transport/failure-policy.ts +27 -0
  297. package/src/transport/guards/abuse-guard.ts +2 -2
  298. package/src/transport/guards/auth-guard.ts +17 -0
  299. package/src/transport/guards/body-policy.ts +3 -2
  300. package/src/transport/guards/concurrency-guard.ts +89 -13
  301. package/src/transport/guards/early-rejection-capture.ts +43 -3
  302. package/src/transport/guards/error-lifecycle.ts +10 -2
  303. package/src/transport/guards/guard-errors.ts +23 -0
  304. package/src/transport/guards/headroom-guard.ts +41 -7
  305. package/src/transport/guards/pipeline.ts +24 -0
  306. package/src/transport/guards/request-context.ts +2 -1
  307. package/src/transport/routing/admission.ts +51 -9
  308. package/src/transport/routing/bounded-lru.ts +49 -0
  309. package/src/transport/routing/candidate-index.ts +88 -0
  310. package/src/transport/routing/reservations.ts +121 -7
  311. package/src/transport/routing/round-robin.ts +10 -22
  312. package/src/transport/routing/route-catalog.ts +26 -13
  313. package/src/transport/routing/route-model.ts +22 -2
  314. package/src/transport/routing/router.ts +53 -31
  315. package/src/transport/surface/chat/encode.ts +2 -3
  316. package/src/transport/surface/chat/parse.ts +2 -1
  317. package/src/transport/surface/dialects.ts +11 -3
  318. package/src/transport/surface/messages/encode.ts +2 -1
  319. package/src/transport/surface/messages/parse.ts +3 -1
  320. package/src/transport/surface/messages/stream.ts +2 -6
  321. package/src/workers/maintenance-lease.ts +200 -0
  322. package/src/workers/scheduler.ts +101 -1
  323. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  324. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  325. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  326. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  327. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  328. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  329. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
  330. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  331. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  332. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  333. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
  334. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  335. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
  336. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  337. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  338. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  339. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
  340. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  341. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
  342. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  343. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  344. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  345. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  346. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  347. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  348. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  349. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  350. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  351. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  352. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  353. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  354. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  355. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  356. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  357. package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
  358. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
  360. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  361. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  363. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  365. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  367. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  368. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  369. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  370. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  371. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  372. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  373. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  374. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  375. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  376. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  377. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  378. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  379. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  380. package/scripts/build/aot.ts +0 -143
  381. package/scripts/build/build.ts +0 -24
  382. package/scripts/build/compile.ts +0 -88
  383. package/scripts/build/restart.ts +0 -120
  384. package/scripts/build/start.ts +0 -25
  385. package/src/observability/event-compaction.ts +0 -317
  386. package/src/observability/event-day-index.ts +0 -440
@@ -6,6 +6,7 @@ import { GATEWAY_SECURITY_HEADERS } from "../../security/outbound-headers";
6
6
  import type { TelemetryBatchBuffer, TelemetryEventInput } from "../../observability/telemetry-buffer";
7
7
  import type { TelemetryPayloadCapture } from "../../observability/payload-capture";
8
8
  import { captureEarlyRejectionPayload } from "./early-rejection-capture";
9
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
9
10
  import { reasoningEffortFromIntent } from "../../providers/provider-reasoning";
10
11
  import { metrics } from "../../observability/metrics";
11
12
  import { computeTokensPerSec } from "../../observability/token-speed";
@@ -168,6 +169,7 @@ export interface AfterResponseApp {
168
169
  export function finalizeRequestTelemetry(
169
170
  state: ProxyRequestState,
170
171
  telemetryBuffer: TelemetryBatchBuffer,
172
+ db?: CartethyiaDatabase,
171
173
  ): void {
172
174
  // Discovery/surface routes under `/v1` (e.g. `/v1/models`) are authenticated
173
175
  // gateway routes that never dispatch upstream. They hold request state but
@@ -225,7 +227,11 @@ export function finalizeRequestTelemetry(
225
227
  // would have nothing to read. Capture the ingress body and the error the
226
228
  // client saw, keyed to the same request id as the row.
227
229
  if (requestData === undefined && state.ingressBody !== undefined) {
228
- void captureEarlyRejectionPayload(state, authorization);
230
+ // `deps.db` so the helper resolves the tenant's capture mode through the
231
+ // same preference path every dispatched request uses — without it, an
232
+ // early rejection captured bodies with the deployment default regardless
233
+ // of the tenant's `none`/`metadata` choice.
234
+ void captureEarlyRejectionPayload(state, authorization, db);
229
235
  }
230
236
 
231
237
  telemetryBuffer.enqueue(
@@ -385,6 +391,8 @@ export function registerTelemetryLifecycle(
385
391
  * telemetry row without a payload frame rather than failing.
386
392
  */
387
393
  readonly payloadCapture?: TelemetryPayloadCapture;
394
+ /** Database handle for early-rejection capture-mode resolution. */
395
+ readonly db?: CartethyiaDatabase;
388
396
  },
389
397
  ): void {
390
398
  app.afterResponse(async ({ request }) => {
@@ -404,7 +412,7 @@ export function registerTelemetryLifecycle(
404
412
  // finalizer for requests that never reached completion (early
405
413
  // rejections). Cleanup below must still run for completed requests:
406
414
  // skipping it leaks one in-flight count per request, forever.
407
- if (!state.completed) finalizeRequestTelemetry(state, deps.telemetryBuffer);
415
+ if (!state.completed) finalizeRequestTelemetry(state, deps.telemetryBuffer, deps.db);
408
416
  } finally {
409
417
  state.cleanup();
410
418
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared construction points for the guard errors that more than one guard
3
+ * raises. Normalisation downstream is single-seam (`error-lifecycle.ts`); the
4
+ * duplication this module removes was on the construction side, where four
5
+ * guards each hand-rolled their own code + message for the same conditions.
6
+ */
7
+ import { GatewayError } from "../../protocol/gateway-error";
8
+
9
+ /** Thrown when the request's client peer address cannot be established. */
10
+ export function peerAddressUnavailableError(): GatewayError {
11
+ return new GatewayError("admission_unavailable", 503, "client peer address unavailable");
12
+ }
13
+
14
+ /** Thrown when the request body exceeds the endpoint's byte budget. */
15
+ export function bodyTooLargeError(maxBytes: number): GatewayError {
16
+ return new GatewayError(
17
+ "invalid_request",
18
+ 413,
19
+ `The request body is larger than this endpoint accepts (${maxBytes} bytes). Shorten the prompt or split the request.`,
20
+ );
21
+ }
22
+
23
+
@@ -17,15 +17,14 @@
17
17
  * metrics are exempt so an operator can always reach the console that explains
18
18
  * the shedding.
19
19
  */
20
- import { totalmem } from "node:os";
21
20
  import { Elysia } from "elysia";
22
21
  import { GatewayError } from "../../protocol/gateway-error";
23
22
  import type { RedisClient } from "../../persistence/redis";
23
+ import { deriveMemoryHeadroomBytes } from "../../config/resources";
24
+ import type { RuntimeMemorySnapshot } from "../../observability/runtime-metrics";
24
25
  import type { ProxyRequestStateStore } from "../request/state";
25
26
  import { isProxyRequest } from "./body-policy";
26
27
 
27
- /** Free bytes the process must keep below its RSS before it stops accepting. */
28
- const DEFAULT_MIN_FREE_BYTES = 64 * 1024 * 1024;
29
28
  /** How long the shared shed marker outlives the last shed, so peers can see it. */
30
29
  const SHED_MARKER_TTL_SECONDS = 5;
31
30
  /** Fleet-visible marker: set on the first shed so every instance reports the same state. */
@@ -34,7 +33,22 @@ const SHED_RETRY_MS = 1_000;
34
33
 
35
34
  export interface HeadroomGuardDeps {
36
35
  readonly store: ProxyRequestStateStore;
37
- /** Free bytes to keep; defaults to 64 MiB. A smaller box wants a larger value. */
36
+ /**
37
+ * The last sampled memory state, published once per sample by
38
+ * `RuntimeMetricsSampler`.
39
+ *
40
+ * Taken from the sampler rather than measured here: this runs on EVERY
41
+ * request, and `process.memoryUsage()` on the request path is a cost paid
42
+ * per request to answer a question that changes over seconds. When absent
43
+ * (before the first sample) the guard falls back to measuring once — an
44
+ * unsampled process is still a process that can be OOM-killed.
45
+ */
46
+ readonly memorySnapshot?: () => RuntimeMemorySnapshot | undefined;
47
+ /**
48
+ * Free bytes to keep below the resolved limit. Defaults to
49
+ * `MEMORY_HEADROOM_FRACTION` of it, so a 512 MiB container keeps ~102 MiB
50
+ * free rather than the flat 64 MiB a 2 GiB one would keep.
51
+ */
38
52
  readonly minFreeBytes?: number;
39
53
  /**
40
54
  * Optional shared client. Used only to publish a shed marker for visibility;
@@ -45,11 +59,27 @@ export interface HeadroomGuardDeps {
45
59
  }
46
60
 
47
61
  export function createHeadroomGuard(deps: HeadroomGuardDeps): Elysia {
48
- const minFreeBytes = deps.minFreeBytes ?? DEFAULT_MIN_FREE_BYTES;
62
+ const fixedHeadroom = deps.minFreeBytes;
49
63
  return new Elysia()
50
64
  .beforeHandle(({ request }) => {
51
65
  if (!isProxyRequest(request)) return;
52
- if (process.memoryUsage().rss + minFreeBytes <= totalmem()) return;
66
+ // Shed when RSS plus the headroom would exceed this process's LIMIT —
67
+ // not the host's total memory. A container capped at 512 MiB on a 16 GiB
68
+ // host never approached the old `os.totalmem()` threshold, so the guard
69
+ // never fired and the process was OOM-killed instead of shedding.
70
+ //
71
+ // The snapshot is up to one sample interval old. That is deliberate:
72
+ // this is the hottest path in the process, and the condition it guards
73
+ // develops over seconds.
74
+ const snapshot = deps.memorySnapshot?.();
75
+ const limit = snapshot?.limitBytes;
76
+ if (limit === undefined) {
77
+ // No limit discoverable and no sample yet: nothing to shed against.
78
+ return;
79
+ }
80
+ const rss = snapshot?.rssBytes ?? process.memoryUsage().rss;
81
+ const headroom = fixedHeadroom ?? deriveMemoryHeadroomBytes(limit);
82
+ if (rss + headroom <= limit) return;
53
83
  if (deps.redis) {
54
84
  // Fire-and-forget: a failed marker write must not change this request's
55
85
  // outcome, and awaiting it would put Redis on the shed path.
@@ -61,7 +91,11 @@ export function createHeadroomGuard(deps: HeadroomGuardDeps): Elysia {
61
91
  "overloaded",
62
92
  503,
63
93
  "The gateway is at capacity; retry shortly.",
64
- { retryAfterMs: SHED_RETRY_MS, minFreeBytes, activeRequests: deps.store.activeCount() },
94
+ {
95
+ retryAfterMs: SHED_RETRY_MS,
96
+ minFreeBytes: headroom,
97
+ activeRequests: deps.store.activeCount(),
98
+ },
65
99
  );
66
100
  })
67
101
  .as("plugin");
@@ -28,6 +28,7 @@ import type { CartethyiaDatabase } from "../../persistence/db-handle";
28
28
  import type { RedisClient } from "../../persistence/redis";
29
29
  import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
30
30
  import type { ModelStrikeService } from "../../security/model-abuse";
31
+ import type { RuntimeMemorySnapshot } from "../../observability/runtime-metrics";
31
32
  import {
32
33
  TelemetryPayloadCapture,
33
34
  payloadCaptureKeepsBodies,
@@ -58,6 +59,20 @@ export interface TransportPipelineContext {
58
59
  * decision is local, and Redis is only written for fleet visibility.
59
60
  */
60
61
  readonly redis?: RedisClient | undefined;
62
+ /**
63
+ * The sampled memory state the headroom guard sheds against.
64
+ *
65
+ * Supplied by the runtime sampler. When absent the guard cannot compare RSS
66
+ * against a limit and does not shed — so this must be wired in production,
67
+ * and the sampler must be initialized at boot even before its first tick.
68
+ */
69
+ readonly memorySnapshot?: () => RuntimeMemorySnapshot | undefined;
70
+ /**
71
+ * Whether invoke is switched off for a tenant. Optional: without it the
72
+ * gateway never refuses on the kill switch, which is the pre-change
73
+ * behaviour rather than a failure.
74
+ */
75
+ readonly isInvokeDisabled?: (tenantId: string) => Promise<boolean | undefined>;
61
76
  }
62
77
 
63
78
  /**
@@ -84,6 +99,7 @@ export function createTransportPipeline(context: TransportPipelineContext): Tran
84
99
  createProcessConcurrencyMiddleware(),
85
100
  createHeadroomGuard({
86
101
  store: context.stateStore,
102
+ ...(context.memorySnapshot === undefined ? {} : { memorySnapshot: context.memorySnapshot }),
87
103
  ...(context.redis === undefined ? {} : { redis: context.redis }),
88
104
  }),
89
105
  createDependencyReadinessMiddleware({
@@ -103,6 +119,9 @@ export function createTransportPipeline(context: TransportPipelineContext): Tran
103
119
  db: context.db,
104
120
  stateStore: context.stateStore,
105
121
  ...(context.modelStrikes ? { modelStrikes: context.modelStrikes } : {}),
122
+ ...(context.isInvokeDisabled
123
+ ? { isInvokeDisabled: context.isInvokeDisabled }
124
+ : {}),
106
125
  }),
107
126
  createCanonicalRequestMiddleware({
108
127
  stateStore: context.stateStore,
@@ -181,6 +200,11 @@ export function createTransportPipeline(context: TransportPipelineContext): Tran
181
200
  (resolved) => (resolved.ok ? resolved.retentionMs : payloadRetentionMs()),
182
201
  ),
183
202
  ),
203
+ // The early-rejection capture resolves the tenant's capture mode
204
+ // through the same preference path dispatched requests use, instead of
205
+ // capturing bodies under the deployment default regardless of the
206
+ // tenant's none/metadata choice.
207
+ db: context.db,
184
208
  });
185
209
  } else {
186
210
  registerRequestCleanup(rootApp, { stateStore: context.stateStore });
@@ -20,6 +20,7 @@ import {
20
20
  type ModelAbuseOutcome,
21
21
  type ModelStrikeService,
22
22
  } from "../../security/model-abuse";
23
+ import { peerAddressUnavailableError } from "./guard-errors";
23
24
 
24
25
  /**
25
26
  * Whether a thrown error is a *model* rejection the strike layer counts: the
@@ -123,7 +124,7 @@ export function createClientIdentityMiddleware(deps: {
123
124
  ? deps.resolvePeerAddress(request)
124
125
  : (server?.requestIP(request)?.address ?? null);
125
126
  if (!peer)
126
- throw new GatewayError("admission_unavailable", 503, "client peer address unavailable");
127
+ throw peerAddressUnavailableError();
127
128
  const address = resolveClientIdentity(request, deps.trustedProxyBoundary, peer);
128
129
  const state = deps.stateStore.require(request);
129
130
  state.clientIdentity = {
@@ -69,8 +69,20 @@ function capacityRejected(): AdmissionDecision {
69
69
 
70
70
  export class InMemoryAdmissionController implements AdmissionController {
71
71
  private inflight = new Map<string, number>();
72
- snapshotAccountInflight(): ReadonlyMap<string, number> {
73
- return new Map(this.inflight);
72
+ readAccountInflight(
73
+ candidates: readonly RouteCandidate[],
74
+ ): ReadonlyMap<string, number> {
75
+ // Dedupe buckets: one candidate list often repeats a bucket (combo members
76
+ // fan out to several candidates on one account), and the caller only needs
77
+ // one count per bucket to make its ordering decision.
78
+ const snapshot = new Map<string, number>();
79
+ for (const candidate of candidates) {
80
+ const key = admissionKey(candidate);
81
+ if (snapshot.has(key)) continue;
82
+ const value = this.inflight.get(key) ?? 0;
83
+ if (value > 0) snapshot.set(key, value);
84
+ }
85
+ return snapshot;
74
86
  }
75
87
  async admit(candidate: RouteCandidate): Promise<AdmissionDecision> {
76
88
  // Bucket per ACCOUNT, not per provider:model. A provider with N accounts
@@ -166,17 +178,47 @@ export class RedisAdmissionController implements AdmissionController {
166
178
  await redisEvalNumber(this.redis, RELEASE_SCRIPT, 1, this.key(reservation.candidate));
167
179
  }
168
180
 
169
- async snapshotAccountInflight(): Promise<ReadonlyMap<string, number>> {
170
- const keys = await this.redis.keys("admission:inflight:*");
171
- if (keys.length === 0) return new Map();
172
- const values = await this.redis.mget(...keys);
181
+ async readAccountInflight(
182
+ candidates: readonly RouteCandidate[],
183
+ ): Promise<ReadonlyMap<string, number>> {
184
+ // Candidate-scoped exact reads replace the fleet-wide `KEYS` + unbounded
185
+ // `MGET` this method used to run on every `plan()` that had any bounded
186
+ // candidate. `KEYS` is O(keyspace) and blocks the single-threaded Redis
187
+ // server; on a shared box the scan was measured as the dominant planning
188
+ // cost. The keys here are exactly the buckets the caller is about to order
189
+ // candidates by, read in bounded sequential chunks.
190
+ //
191
+ // `KEYS` and `SCAN` stay banned on this path by construction: neither can
192
+ // answer "what are the values of these N known keys" in fewer than
193
+ // O(keyspace) server work, and a fleet-wide inflight snapshot has no
194
+ // request-path consumer left. (If one ever appears, it belongs in a
195
+ // maintenance task using `SCAN` with `COUNT`, never here.)
196
+ const buckets = [...new Set(candidates.map(admissionKey))];
197
+ if (buckets.length === 0) return new Map();
198
+ const keys = buckets.map((bucket) => `admission:inflight:${bucket}`);
199
+ const values: number[] = [];
200
+ // Bounded sequential chunks: one huge MGET would move the unbounded
201
+ // payload problem from the KEYS call to the MGET call. 256 keys keeps a
202
+ // single command payload small while the round trips stay proportional to
203
+ // the (deduped, candidate-bounded) request size.
204
+ for (let offset = 0; offset < keys.length; offset += MGET_CHUNK_SIZE) {
205
+ const chunk = keys.slice(offset, offset + MGET_CHUNK_SIZE);
206
+ const raw = (await this.redis.mget(...chunk)) as (string | null)[];
207
+ for (const value of raw) values.push(Number(value ?? 0));
208
+ }
173
209
  const snapshot = new Map<string, number>();
174
- for (const [index, key] of keys.entries()) {
175
- const value = Number(values[index] ?? 0);
210
+ for (const [index, bucket] of buckets.entries()) {
211
+ const value = values[index] ?? 0;
176
212
  if (Number.isFinite(value) && value > 0) {
177
- snapshot.set(key.slice("admission:inflight:".length), value);
213
+ snapshot.set(bucket, value);
178
214
  }
215
+ // The Redis controller's counter keys are TTL self-healing, so a bucket
216
+ // absent from the reply reads as zero and is omitted — the projection
217
+ // both controllers share: positive-finite counts only, unprefixed names.
179
218
  }
180
219
  return snapshot;
181
220
  }
182
221
  }
222
+
223
+ /** Sequential MGET chunk bound for {@link RedisAdmissionController.readAccountInflight}. */
224
+ const MGET_CHUNK_SIZE = 256;
@@ -0,0 +1,49 @@
1
+ // Bounded LRU map: the one shared implementation for routing-layer caches.
2
+
3
+ /**
4
+ * A capacity-bounded LRU `Map`.
5
+ *
6
+ * `get()` refreshes recency; `set()` evicts exactly the least-recently-used
7
+ * entry on overflow. Insertion order is the recency order (head = LRU,
8
+ * tail = MRU), which `Map` maintains for free — so eviction is one
9
+ * `keys().next()` and no bookkeeping arrays.
10
+ *
11
+ * The routing layer previously hand-rolled this shape twice (round-robin
12
+ * state, capability profiles) with subtly different code. One implementation
13
+ * means the bound is provable once: the map's size can never exceed
14
+ * `capacity`, and every entry is reachable through the normal `Map` API the
15
+ * callers already use.
16
+ */
17
+ export class BoundedLruMap<K, V> extends Map<K, V> {
18
+ private readonly capacity: number;
19
+
20
+ constructor(capacity: number) {
21
+ if (!Number.isFinite(capacity) || capacity < 1 || Math.trunc(capacity) !== capacity) {
22
+ throw new Error(`BoundedLruMap capacity must be a positive integer, got ${capacity}`);
23
+ }
24
+ super();
25
+ this.capacity = Math.trunc(capacity);
26
+ }
27
+
28
+ override get(key: K): V | undefined {
29
+ if (!super.has(key)) return undefined;
30
+ // Refresh recency: delete + re-insert moves the entry to the tail.
31
+ const value = super.get(key)!;
32
+ super.delete(key);
33
+ super.set(key, value);
34
+ return value;
35
+ }
36
+
37
+ override set(key: K, value: V): this {
38
+ // An existing key is re-inserted at the tail even when the value is the
39
+ // same object, so a read-modify-write cycle cannot leave a hot entry
40
+ // parked at the head one eviction away from disappearing.
41
+ if (super.has(key)) super.delete(key);
42
+ else if (super.size >= this.capacity) {
43
+ const oldest = super.keys().next().value;
44
+ if (oldest !== undefined) super.delete(oldest);
45
+ }
46
+ super.set(key, value);
47
+ return this;
48
+ }
49
+ }
@@ -0,0 +1,88 @@
1
+ // Immutable per-snapshot candidate index for model resolution.
2
+ import type { RouteCandidate, RouteSnapshot } from "./route-model";
3
+
4
+ /** One parsed model reference: `provider/model` or a bare `model`. */
5
+ export interface ParsedModel {
6
+ readonly providerId: string | null;
7
+ readonly modelId: string;
8
+ }
9
+
10
+ /** Parse one model reference into its provider and bare model id. */
11
+ export function parseModelReference(model: string): ParsedModel {
12
+ const slash = model.indexOf("/");
13
+ if (slash === -1) return { providerId: null, modelId: model };
14
+ return { providerId: model.slice(0, slash), modelId: model.slice(slash + 1) };
15
+ }
16
+
17
+ /**
18
+ * An immutable, per-snapshot index of route candidates.
19
+ *
20
+ * Snapshots are immutable by contract (rebuilt wholesale on catalog change),
21
+ * so the index is built once per snapshot — O(C) — and reused by every plan,
22
+ * instead of the previous behavior of rescanning the full candidate list per
23
+ * resolved model inside one plan: a combo with K unique members over C
24
+ * candidates cost K×C `candidateMatches` calls (each re-parsing the model
25
+ * string), repeated on every request.
26
+ */
27
+ export interface RouteCandidateIndex {
28
+ /** Candidates keyed by `provider_id/model_id`, snapshot order preserved. */
29
+ readonly qualified: ReadonlyMap<string, readonly RouteCandidate[]>;
30
+ /** Candidates keyed by bare `model_id`, snapshot order preserved. */
31
+ readonly bare: ReadonlyMap<string, readonly RouteCandidate[]>;
32
+ }
33
+
34
+ /** WeakMap cache: an evicted/rebuilt snapshot releases its index automatically. */
35
+ const indexBySnapshot = new WeakMap<RouteSnapshot, RouteCandidateIndex>();
36
+
37
+ /**
38
+ * Builds (once per snapshot) the lookup index for `resolveMatchingCandidates`.
39
+ *
40
+ * Each candidate is visited exactly once and filed under both its qualified and
41
+ * bare key, so a lookup later is one `Map.get` regardless of catalog size.
42
+ * Snapshot order is preserved within each bucket because `Map` iterates in
43
+ * insertion order and candidates are appended in one pass.
44
+ */
45
+ export function routeCandidateIndex(snapshot: RouteSnapshot): RouteCandidateIndex {
46
+ const cached = indexBySnapshot.get(snapshot);
47
+ if (cached) return cached;
48
+ const qualified = new Map<string, RouteCandidate[]>();
49
+ const bare = new Map<string, RouteCandidate[]>();
50
+ for (const candidate of snapshot.candidates) {
51
+ const qualifiedKey = `${candidate.provider_id}/${candidate.model_id}`;
52
+ const qualifiedList = qualified.get(qualifiedKey);
53
+ if (qualifiedList) qualifiedList.push(candidate);
54
+ else qualified.set(qualifiedKey, [candidate]);
55
+
56
+ const bareList = bare.get(candidate.model_id);
57
+ if (bareList) bareList.push(candidate);
58
+ else bare.set(candidate.model_id, [candidate]);
59
+ }
60
+ const index: RouteCandidateIndex = { qualified, bare };
61
+ indexBySnapshot.set(snapshot, index);
62
+ return index;
63
+ }
64
+
65
+ /**
66
+ * Looks up the candidates a parsed model reference resolves to, filtered by
67
+ * tenant.
68
+ *
69
+ * A qualified reference (`provider/model`) reads the exact bucket and requires
70
+ * the provider to match; a bare reference reads every provider's bucket for
71
+ * that model. The tenant rule is the resolver's existing one, unchanged:
72
+ * a candidate is visible only when it is global (`tenant_id == null`) or
73
+ * belongs to the requesting tenant.
74
+ */
75
+ export function lookupRouteCandidates(
76
+ index: RouteCandidateIndex,
77
+ parsedModel: ParsedModel,
78
+ tenantId: string | null,
79
+ ): readonly RouteCandidate[] {
80
+ const group =
81
+ parsedModel.providerId === null
82
+ ? index.bare.get(parsedModel.modelId)
83
+ : index.qualified.get(`${parsedModel.providerId}/${parsedModel.modelId}`);
84
+ if (group === undefined || group.length === 0) return [];
85
+ return group.filter(
86
+ (candidate) => candidate.tenant_id == null || candidate.tenant_id === tenantId,
87
+ );
88
+ }
@@ -1,25 +1,136 @@
1
- // In-memory reservation lease store with lazy TTL cleanup.
1
+ // In-memory reservation lease store with expiry-ordered cleanup.
2
2
  import type { Reservation, RouteCandidate, RoutingRevision } from "./route-model";
3
3
 
4
+ /** One heap node: when a lease expires, and which lease it names. */
5
+ interface ExpiryNode {
6
+ readonly expiresAt: number;
7
+ readonly leaseId: string;
8
+ }
9
+
4
10
  /**
5
- * In-memory reservation lease store with lazy TTL cleanup. The bounded sweep
6
- * runs before acquisition so abandoned local metadata cannot grow forever.
11
+ * Binary min-heap on `expiresAt`.
12
+ *
13
+ * The sweep only ever needs the *earliest* expiring reservations, so a heap
14
+ * answers "which nodes are expired right now" by examining heads only — the
15
+ * previous implementation walked the entire lease map per sweep interval.
16
+ * Stale nodes (a lease released before its expiry) are harmless: the sweep
17
+ * verifies against the map before deleting and drops the node.
18
+ */
19
+ class ExpiryHeap {
20
+ private nodes: ExpiryNode[] = [];
21
+
22
+ get size(): number {
23
+ return this.nodes.length;
24
+ }
25
+
26
+ insert(node: ExpiryNode): void {
27
+ this.nodes.push(node);
28
+ this.bubbleUp(this.nodes.length - 1);
29
+ }
30
+
31
+ /** The earliest-expiring node, without removing it. */
32
+ peek(): ExpiryNode | undefined {
33
+ return this.nodes[0];
34
+ }
35
+
36
+ pop(): ExpiryNode | undefined {
37
+ const top = this.nodes[0];
38
+ const last = this.nodes.pop();
39
+ if (this.nodes.length > 0 && last !== undefined) {
40
+ this.nodes[0] = last;
41
+ this.bubbleDown(0);
42
+ }
43
+ return top;
44
+ }
45
+
46
+ private bubbleUp(index: number): void {
47
+ let current = index;
48
+ while (current > 0) {
49
+ const parent = (current - 1) >> 1;
50
+ if (this.nodes[current]!.expiresAt >= this.nodes[parent]!.expiresAt) break;
51
+ [this.nodes[current], this.nodes[parent]] = [this.nodes[parent]!, this.nodes[current]!];
52
+ current = parent;
53
+ }
54
+ }
55
+
56
+ private bubbleDown(index: number): void {
57
+ let current = index;
58
+ for (;;) {
59
+ const left = current * 2 + 1;
60
+ const right = left + 1;
61
+ let smallest = current;
62
+ if (left < this.nodes.length && this.nodes[left]!.expiresAt < this.nodes[smallest]!.expiresAt) {
63
+ smallest = left;
64
+ }
65
+ if (
66
+ right < this.nodes.length &&
67
+ this.nodes[right]!.expiresAt < this.nodes[smallest]!.expiresAt
68
+ ) {
69
+ smallest = right;
70
+ }
71
+ if (smallest === current) break;
72
+ [this.nodes[current], this.nodes[smallest]] = [this.nodes[smallest]!, this.nodes[current]!];
73
+ current = smallest;
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * In-memory reservation lease store with expiry-ordered cleanup.
80
+ *
81
+ * Acquisitions insert into an expiry min-heap; the bounded sweep examines only
82
+ * expired heads and verifies the map still holds that exact reservation before
83
+ * deleting (a released entry leaves a harmless stale node behind, dropped on
84
+ * the next sweep that reaches it or by periodic compaction). Stale nodes are
85
+ * compacted when they dominate the heap, so a long stream of short-lived
86
+ * released reservations cannot grow the heap without bound.
87
+ *
88
+ * The sweep interval stays 30 s and the lease TTL stays 30 s by default. Live
89
+ * reservations are not capped — capacity is admission's job, not the lease
90
+ * bookkeeping's. `expires_at` is NOT used to skip admission release: the
91
+ * admission counters protect the real limits and carry their own TTL
92
+ * (`INFLIGHT_TTL_SECONDS`); this map is local bookkeeping only.
7
93
  */
8
94
  export class ReservationManager {
9
95
  private leases = new Map<string, Reservation>();
96
+ private expiry = new ExpiryHeap();
10
97
  private lastSweepAt = 0;
11
98
  private static readonly SWEEP_INTERVAL_MS = 30_000;
12
99
 
100
+ constructor(private readonly now: () => number = Date.now) {}
101
+
13
102
  private sweepExpired(now: number): void {
14
103
  if (now - this.lastSweepAt < ReservationManager.SWEEP_INTERVAL_MS) return;
15
104
  this.lastSweepAt = now;
16
- for (const [leaseId, reservation] of this.leases) {
17
- if (reservation.expires_at < now) this.leases.delete(leaseId);
105
+ // Only expired heads are examined: once the head expires later than `now`,
106
+ // every remaining node does too, so the loop stops without touching them.
107
+ while (this.expiry.size > 0) {
108
+ const head = this.expiry.peek()!;
109
+ if (head.expiresAt >= now) break;
110
+ this.expiry.pop();
111
+ const lease = this.leases.get(head.leaseId);
112
+ // A released reservation is a harmless stale node: nothing to delete.
113
+ // (Lease identity is the unique lease id, so a reused id cannot alias —
114
+ // the id embeds acquisition time plus randomness.)
115
+ if (lease && lease.expires_at === head.expiresAt && lease.expires_at < now) {
116
+ this.leases.delete(head.leaseId);
117
+ }
118
+ }
119
+ // Periodic compaction of stale nodes (entries whose lease was released or
120
+ // re-keyed before expiry): rebuild the heap only when stale nodes make up
121
+ // the bulk of it, so the common all-live case pays nothing.
122
+ if (this.expiry.size > this.leases.size * 2) {
123
+ const live: ExpiryNode[] = [];
124
+ for (const [leaseId, lease] of this.leases) {
125
+ live.push({ expiresAt: lease.expires_at, leaseId });
126
+ }
127
+ this.expiry = new ExpiryHeap();
128
+ for (const node of live) this.expiry.insert(node);
18
129
  }
19
130
  }
20
131
 
21
132
  acquire(candidate: RouteCandidate, _revision: RoutingRevision, ttlMs = 30_000): Reservation {
22
- const now = Date.now();
133
+ const now = this.now();
23
134
  this.sweepExpired(now);
24
135
  const lease_id = `${candidate.provider_id}:${candidate.model_id}:${now}:${Math.random().toString(16).slice(2)}`;
25
136
  const r: Reservation = {
@@ -29,17 +140,20 @@ export class ReservationManager {
29
140
  expires_at: now + ttlMs,
30
141
  };
31
142
  this.leases.set(lease_id, r);
143
+ this.expiry.insert({ expiresAt: r.expires_at, leaseId: lease_id });
32
144
  return r;
33
145
  }
34
146
 
35
147
  release(lease_id: string): void {
148
+ // The heap node stays until its expiry passes — the sweep treats it as
149
+ // stale and drops it. Cheaper than a heap search per release.
36
150
  this.leases.delete(lease_id);
37
151
  }
38
152
 
39
153
  get(lease_id: string): Reservation | undefined {
40
154
  const r = this.leases.get(lease_id);
41
155
  if (!r) return undefined;
42
- if (r.expires_at < Date.now()) {
156
+ if (r.expires_at < this.now()) {
43
157
  this.leases.delete(lease_id);
44
158
  return undefined;
45
159
  }
@@ -1,5 +1,6 @@
1
1
  // Round-robin rotation state and its bounded per-key LRU.
2
2
  import type { RouteCandidate } from "./route-model";
3
+ import { BoundedLruMap } from "./bounded-lru";
3
4
 
4
5
  export class RoundRobinState {
5
6
  private idx = 0;
@@ -22,29 +23,16 @@ export class RoundRobinState {
22
23
  * combinations would retain one entry forever. 1000 is far above the working
23
24
  * set of any real catalog, so eviction only ever targets genuinely cold keys.
24
25
  */
25
- const MAX_ROUND_ROBIN_ENTRIES = 1000;
26
+ export const MAX_ROUND_ROBIN_ENTRIES = 1000;
26
27
 
27
28
  /**
28
- * Bounded LRU accessor for round-robin state: an existing key is refreshed to
29
- * the tail (most-recently-used), and inserting past the cap evicts the head
30
- * (least-recently-used). Keeps the hot routing working set while guaranteeing
31
- * the map cannot grow without bound.
29
+ * Creates the bounded LRU map the router keeps round-robin state in.
30
+ *
31
+ * The bound is the same 1000-entry cap the hand-rolled version enforced;
32
+ * `BoundedLruMap` is now the single implementation of that behavior, so the
33
+ * eviction rule (get refreshes recency, overflow evicts the LRU head) cannot
34
+ * drift between this cache and the capability-profile cache.
32
35
  */
33
- export function getOrCreateRoundRobin(
34
- map: Map<string, RoundRobinState>,
35
- key: string,
36
- ): RoundRobinState {
37
- const existing = map.get(key);
38
- if (existing) {
39
- map.delete(key);
40
- map.set(key, existing);
41
- return existing;
42
- }
43
- if (map.size >= MAX_ROUND_ROBIN_ENTRIES) {
44
- const oldest = map.keys().next().value;
45
- if (oldest !== undefined) map.delete(oldest);
46
- }
47
- const created = new RoundRobinState();
48
- map.set(key, created);
49
- return created;
36
+ export function createRoundRobinMap(): BoundedLruMap<string, RoundRobinState> {
37
+ return new BoundedLruMap<string, RoundRobinState>(MAX_ROUND_ROBIN_ENTRIES);
50
38
  }