cartethyia 2.5.10 → 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 (367) hide show
  1. package/.env.example +8 -0
  2. package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-BaCIdAUt.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
  37. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  38. package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-BFME-eba.js} +2 -2
  39. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  42. package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
  43. package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
  44. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  47. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
  49. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
  51. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
  53. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
  61. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
  63. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
  71. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
  73. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
  75. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
  77. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  81. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  84. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  85. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  86. package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
  87. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  88. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  89. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  90. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  91. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CxVVB__N.js → select-C980c4cz.js} +1 -1
  93. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  95. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
  97. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
  99. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
  101. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  104. package/dist/dashboard/index.html +1 -1
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/README.md +52 -52
  108. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  109. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  110. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  111. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  112. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  113. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  114. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  115. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  116. package/package.json +96 -95
  117. package/scripts/cli/admin.ts +126 -128
  118. package/scripts/cli/autostart.ts +237 -237
  119. package/scripts/cli/background.ts +120 -110
  120. package/scripts/cli/bin/carte.cjs +8 -8
  121. package/scripts/cli/bin/cartethyia.cjs +167 -167
  122. package/scripts/cli/console-menu.ts +39 -30
  123. package/scripts/cli/controller.ts +145 -0
  124. package/scripts/cli/env.ts +99 -99
  125. package/scripts/cli/gateway.ts +264 -260
  126. package/scripts/cli/instance.ts +234 -224
  127. package/scripts/cli/main.ts +188 -167
  128. package/scripts/cli/platform.ts +64 -64
  129. package/scripts/cli/prompt.ts +217 -79
  130. package/scripts/cli/services.ts +88 -88
  131. package/scripts/cli/setup.ts +445 -445
  132. package/scripts/cli/start.ts +85 -85
  133. package/scripts/cli/startup-signal.ts +23 -23
  134. package/scripts/cli/telemetry.ts +70 -0
  135. package/scripts/cli/tray/index.ts +263 -263
  136. package/scripts/cli/tray/windows.ps1 +107 -107
  137. package/scripts/cli/tray.ts +29 -4
  138. package/scripts/cli/update.ts +39 -39
  139. package/scripts/cli/version.ts +67 -67
  140. package/scripts/cli/wordmark.ts +58 -58
  141. package/src/boot/app.ts +16 -1
  142. package/src/boot/dependencies.ts +128 -25
  143. package/src/config/resources.ts +137 -0
  144. package/src/config/runtime.ts +133 -76
  145. package/src/config/spec.ts +30 -29
  146. package/src/config/telemetry.ts +0 -25
  147. package/src/console/api-keys/bucket-spend.ts +14 -17
  148. package/src/console/api-keys/contracts.ts +410 -410
  149. package/src/console/api-keys/routes.ts +12 -0
  150. package/src/console/audit/store.ts +2 -3
  151. package/src/console/auth/setup-service.ts +8 -1
  152. package/src/console/backup/routes.ts +7 -0
  153. package/src/console/backup/service.ts +463 -463
  154. package/src/console/backup/store.ts +16 -11
  155. package/src/console/backup/validate.ts +1 -4
  156. package/src/console/cli-tools/fs-ops.ts +18 -2
  157. package/src/console/data-management/contracts.ts +1 -8
  158. package/src/console/data-management/operations.ts +61 -48
  159. package/src/console/data-management/routes.ts +4 -2
  160. package/src/console/domain-registration.ts +25 -3
  161. package/src/console/inferhub/client.ts +139 -0
  162. package/src/console/inferhub/key-store.ts +129 -0
  163. package/src/console/inferhub/normalize.ts +347 -0
  164. package/src/console/inferhub/routes.ts +411 -0
  165. package/src/console/inferhub/store.ts +109 -0
  166. package/src/console/inferhub/types.ts +183 -0
  167. package/src/console/observability/contracts.ts +2 -41
  168. package/src/console/observability/store.ts +176 -371
  169. package/src/console/providers/catalog/public-model-store.ts +0 -0
  170. package/src/console/providers/catalog/store.ts +23 -36
  171. package/src/console/quota/account-quota-shared.ts +52 -52
  172. package/src/console/quota/account-quota-tenant.ts +16 -24
  173. package/src/console/quota/refresh.ts +441 -441
  174. package/src/console/routing/pools/import-parse.ts +206 -206
  175. package/src/console/routing/pools/operations.ts +543 -543
  176. package/src/console/routing/pools/probe-verdict.ts +132 -132
  177. package/src/console/routing/pools/routes.ts +181 -181
  178. package/src/console/settings/contracts.ts +42 -40
  179. package/src/console/settings/store.ts +25 -25
  180. package/src/console/share/share-stats.ts +22 -4
  181. package/src/console/share/share-usage.ts +30 -4
  182. package/src/console/shared/cursor.ts +15 -0
  183. package/src/console/updates/routes.ts +12 -0
  184. package/src/main.ts +13 -5
  185. package/src/network/pool/resolver.ts +1104 -1104
  186. package/src/observability/client-name.ts +22 -0
  187. package/src/observability/event-log.ts +5 -29
  188. package/src/observability/event-query.ts +690 -0
  189. package/src/observability/log-ring.ts +26 -2
  190. package/src/observability/log-sanitize.ts +118 -0
  191. package/src/observability/logger.ts +17 -13
  192. package/src/observability/metrics.ts +35 -0
  193. package/src/observability/payload-capture.ts +114 -41
  194. package/src/observability/payload-store.ts +249 -24
  195. package/src/observability/raw-event-writer.ts +118 -0
  196. package/src/observability/runtime-metrics.ts +71 -1
  197. package/src/observability/telemetry-buffer.ts +100 -17
  198. package/src/observability/telemetry-reconciliation.ts +417 -0
  199. package/src/persistence/boot.ts +20 -9
  200. package/src/persistence/db-pg.ts +13 -0
  201. package/src/persistence/db-sqlite.ts +569 -497
  202. package/src/persistence/dialect.ts +3 -2
  203. package/src/persistence/migrate.ts +181 -181
  204. package/src/persistence/schema.ts +1496 -1406
  205. package/src/persistence/shared-cache.ts +5 -1
  206. package/src/persistence/stores/api-key-store.ts +581 -581
  207. package/src/persistence/stores/telemetry-store.ts +124 -63
  208. package/src/protocol/reasoning-text.ts +30 -0
  209. package/src/protocol/response/chat.ts +33 -7
  210. package/src/protocol/response/codex.ts +14 -2
  211. package/src/protocol/response/messages.ts +5 -2
  212. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  213. package/src/providers/bundled-discovery-providers.ts +122 -122
  214. package/src/providers/default-registry.ts +10 -0
  215. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  216. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  217. package/src/providers/discovery/probe-phases.ts +43 -7
  218. package/src/providers/discovery/probing-service.ts +70 -32
  219. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  220. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  221. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  222. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  223. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  224. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  225. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  226. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  227. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  228. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  229. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  230. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  231. package/src/providers/integrations/claude/claude.ts +3 -4
  232. package/src/providers/integrations/cline/cline.ts +94 -8
  233. package/src/providers/integrations/codex/codex.ts +61 -37
  234. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  235. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  236. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  237. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  238. package/src/providers/integrations/cursor/cursor.ts +29 -17
  239. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  240. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  241. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  242. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  243. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  244. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  245. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  246. package/src/providers/integrations/hermes.ts +7 -1
  247. package/src/providers/integrations/inferhub.ts +201 -73
  248. package/src/providers/integrations/kilo/kilo.ts +152 -152
  249. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  250. package/src/providers/integrations/openrouter/provider.ts +129 -129
  251. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  252. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  253. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  254. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  255. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  256. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  257. package/src/providers/integrations/zcode/oauth.ts +1 -1
  258. package/src/providers/operations/account-health-service.ts +605 -279
  259. package/src/providers/provider-metadata.ts +12 -1
  260. package/src/providers/provider-model-definition.ts +373 -373
  261. package/src/providers/quota/quota-contracts.ts +3 -7
  262. package/src/providers/search/search-provider.ts +2 -6
  263. package/src/security/admission/contracts.ts +10 -0
  264. package/src/security/admission/in-memory-store.ts +89 -14
  265. package/src/security/admission/lua.ts +191 -182
  266. package/src/security/admission/redis-store.ts +30 -0
  267. package/src/security/admission/service.ts +18 -0
  268. package/src/security/api-key-auth-cache.ts +96 -0
  269. package/src/security/api-key-auth.ts +11 -100
  270. package/src/security/client-router-fingerprint.ts +3 -7
  271. package/src/security/ip-boundary.ts +7 -3
  272. package/src/shared/coerce.ts +67 -0
  273. package/src/shared/hash.ts +6 -0
  274. package/src/shared/time.ts +7 -0
  275. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  276. package/src/transport/dispatch/attempt-loop.ts +103 -81
  277. package/src/transport/dispatch/capture-policy.ts +78 -0
  278. package/src/transport/dispatch/proxy-request.ts +4 -2
  279. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  280. package/src/transport/dispatch/streaming/stream.ts +19 -2
  281. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  282. package/src/transport/failure-policy.ts +27 -0
  283. package/src/transport/guards/abuse-guard.ts +2 -2
  284. package/src/transport/guards/auth-guard.ts +17 -0
  285. package/src/transport/guards/body-policy.ts +3 -2
  286. package/src/transport/guards/concurrency-guard.ts +89 -13
  287. package/src/transport/guards/early-rejection-capture.ts +43 -3
  288. package/src/transport/guards/error-lifecycle.ts +10 -2
  289. package/src/transport/guards/guard-errors.ts +23 -0
  290. package/src/transport/guards/headroom-guard.ts +41 -7
  291. package/src/transport/guards/pipeline.ts +24 -0
  292. package/src/transport/guards/request-context.ts +2 -1
  293. package/src/transport/routing/admission.ts +51 -9
  294. package/src/transport/routing/bounded-lru.ts +49 -0
  295. package/src/transport/routing/candidate-index.ts +88 -0
  296. package/src/transport/routing/reservations.ts +121 -7
  297. package/src/transport/routing/round-robin.ts +10 -22
  298. package/src/transport/routing/route-catalog.ts +26 -13
  299. package/src/transport/routing/route-model.ts +22 -2
  300. package/src/transport/routing/router.ts +53 -31
  301. package/src/transport/surface/chat/encode.ts +2 -3
  302. package/src/transport/surface/chat/parse.ts +2 -1
  303. package/src/transport/surface/dialects.ts +11 -3
  304. package/src/transport/surface/messages/encode.ts +2 -1
  305. package/src/transport/surface/messages/parse.ts +3 -1
  306. package/src/transport/surface/messages/stream.ts +2 -6
  307. package/src/workers/maintenance-lease.ts +200 -0
  308. package/src/workers/scheduler.ts +101 -1
  309. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  310. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  311. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  312. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  313. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  314. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  315. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
  316. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  317. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  318. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  319. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
  320. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  321. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
  322. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  323. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  324. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  325. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
  326. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  327. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
  328. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  329. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  330. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  331. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  332. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  333. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  334. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  335. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  336. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  337. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  338. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  339. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  340. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  341. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  342. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  343. package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
  344. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  346. package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
  347. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  348. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  349. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  350. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  351. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  352. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  353. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  354. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  355. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  356. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  357. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  358. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  359. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  360. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  361. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  362. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  363. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  364. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  365. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  366. package/src/observability/event-compaction.ts +0 -317
  367. package/src/observability/event-day-index.ts +0 -439
@@ -1,4 +1,5 @@
1
1
  import { decodeJwtPayload } from "../authentication/oauth-flow-store";
2
+ import { asCoercedNumber, asRecord as sharedAsRecord } from "../../shared/coerce";
2
3
 
3
4
  export interface ProviderQuotaWindow {
4
5
  readonly kind: string;
@@ -112,9 +113,7 @@ function creditWindowBalance(
112
113
  const TIMEOUT_MS = 15_000;
113
114
 
114
115
  export function record(value: unknown): Record<string, unknown> | null {
115
- return typeof value === "object" && value !== null && !Array.isArray(value)
116
- ? (value as Record<string, unknown>)
117
- : null;
116
+ return sharedAsRecord(value) ?? null;
118
117
  }
119
118
 
120
119
  export function text(value: unknown): string | null {
@@ -122,10 +121,7 @@ export function text(value: unknown): string | null {
122
121
  }
123
122
 
124
123
  export function number(value: unknown): number | null {
125
- if (typeof value === "number" && Number.isFinite(value)) return value;
126
- if (typeof value === "string" && value.trim() && Number.isFinite(Number(value)))
127
- return Number(value);
128
- return null;
124
+ return asCoercedNumber(value) ?? null;
129
125
  }
130
126
 
131
127
  export function isoDate(value: unknown): string | null {
@@ -1,3 +1,5 @@
1
+ import { asRecord } from "../../shared/coerce";
2
+
1
3
  /**
2
4
  * Declarative web-search provider adapters.
3
5
  *
@@ -42,12 +44,6 @@ export interface SearchProviderSpec {
42
44
  readonly normalize: (data: unknown) => readonly WebSearchResult[];
43
45
  }
44
46
 
45
- function asRecord(value: unknown): Record<string, unknown> | undefined {
46
- return typeof value === "object" && value !== null && !Array.isArray(value)
47
- ? (value as Record<string, unknown>)
48
- : undefined;
49
- }
50
-
51
47
  function stringField(record: Record<string, unknown> | undefined, key: string): string {
52
48
  const value = record?.[key];
53
49
  return typeof value === "string" ? value : "";
@@ -44,6 +44,16 @@ export interface AdmissionReserveRequest {
44
44
  readonly lifetimeBudget: number | null;
45
45
  readonly lifetimeConsumed: number;
46
46
  readonly concurrencyLimit: number | null;
47
+ /**
48
+ * Hidden allowance added on top of `concurrencyLimit`.
49
+ *
50
+ * The configured limit is what an operator tunes and what the error names;
51
+ * this is deliberate slack so a run that lands a few requests over does not
52
+ * get refused at the number it was told. Effective max is
53
+ * `concurrencyLimit + concurrencyTolerance`. Only requests *beyond* that are
54
+ * refused — never ones already admitted.
55
+ */
56
+ readonly concurrencyTolerance?: number | null | undefined;
47
57
  readonly tenantId: string;
48
58
  readonly tenantConcurrencyLimit: number | null;
49
59
  /**
@@ -20,6 +20,16 @@ interface InMemoryReservation {
20
20
  readonly monthlyKey: string;
21
21
  readonly tracksLifetime: boolean;
22
22
  readonly tracksConcurrency: boolean;
23
+ /**
24
+ * The key the concurrency counter was incremented under.
25
+ *
26
+ * Not `apiKeyId`: a share child reserves under its own id while the
27
+ * reservation is recorded against its parent, so charging and refunding
28
+ * through `apiKeyId` incremented one counter and decremented another. The
29
+ * child's count then never fell — after `limit` requests it was permanently
30
+ * full and refused everything, which is the "never invoked again" symptom.
31
+ */
32
+ readonly concurrencyKey: string;
23
33
  readonly tenantId: string;
24
34
  readonly tracksTenantConcurrency: boolean;
25
35
  state: "active" | "committed" | "released";
@@ -36,9 +46,57 @@ const TERMINAL_RESERVATION_CAP = 10_000;
36
46
  const COUNTER_MAP_CAP = 20_000;
37
47
 
38
48
 
49
+ /**
50
+ * Per-key RPM timestamp queue.
51
+ *
52
+ * Entries are appended in nondecreasing `now` order (requests are admitted
53
+ * under the store's exclusive gate, each stamped with its own `now`), so the
54
+ * window prefix is monotone: pruning advances a head index past every entry
55
+ * `<= cutoff` without copying the surviving suffix, and the array is compacted
56
+ * only when the consumed prefix exceeds half of it. Per request this is O(1)
57
+ * amortized instead of the previous per-request full-array rewrite.
58
+ *
59
+ * Exposed surface mirrors what the reserve path needs: `length` (window count
60
+ * for the limit check), `push` (append on admit), and `prune` (advance the
61
+ * window). `purge()` drops the whole key from the outer map.
62
+ */
63
+ class RpmTimestampQueue {
64
+ private entries: number[] = [];
65
+ private head = 0;
66
+
67
+ get length(): number {
68
+ return this.entries.length - this.head;
69
+ }
70
+
71
+ push(now: number): void {
72
+ this.entries.push(now);
73
+ }
74
+
75
+ /**
76
+ * Drops every entry `<= cutoff`. An entry exactly one window old is expired
77
+ * (the existing exact-boundary semantics: `cutoff = now - window`, so an
78
+ * entry at `now - window` equals the cutoff and is dropped).
79
+ */
80
+ prune(now: number, windowMs: number): void {
81
+ const cutoff = now - windowMs;
82
+ while (this.head < this.entries.length && this.entries[this.head]! <= cutoff) {
83
+ this.head += 1;
84
+ }
85
+ if (this.head > this.entries.length / 2) {
86
+ this.entries = this.entries.slice(this.head);
87
+ this.head = 0;
88
+ }
89
+ }
90
+
91
+ /** True when no live entries remain — the caller deletes the bucket. */
92
+ isEmpty(): boolean {
93
+ return this.length === 0;
94
+ }
95
+ }
96
+
39
97
  /** Atomic single-process admission store used by tests and single-process development. */
40
98
  export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
41
- private readonly rpmTimestamps = new Map<string, number[]>();
99
+ private readonly rpmTimestamps = new Map<string, RpmTimestampQueue>();
42
100
  private readonly daily = new Map<string, number>();
43
101
  private readonly monthly = new Map<string, number>();
44
102
  private readonly lifetime = new Map<string, number>();
@@ -145,8 +203,19 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
145
203
  lifetime + request.estimatedTokens > request.lifetimeBudget
146
204
  )
147
205
  throw reasonToGatewayError("lifetime-token-budget", { limit: request.lifetimeBudget });
148
- if (request.concurrencyLimit != null && concurrent >= request.concurrencyLimit)
149
- throw reasonToGatewayError("concurrency-limit", { limit: request.concurrencyLimit });
206
+ // The tolerance is slack above the configured limit, so the ceiling is
207
+ // the sum. It widens admission only: requests already inside are never
208
+ // revisited, and the refusal still names the configured limit.
209
+ const concurrencyCeiling =
210
+ request.concurrencyLimit !== null
211
+ ? request.concurrencyLimit + Math.max(0, request.concurrencyTolerance ?? 0)
212
+ : null;
213
+ if (concurrencyCeiling !== null && concurrent >= concurrencyCeiling)
214
+ throw reasonToGatewayError("concurrency-limit", {
215
+ limit: request.concurrencyLimit,
216
+ tolerance: request.concurrencyTolerance ?? 0,
217
+ effectiveLimit: concurrencyCeiling,
218
+ });
150
219
  if (
151
220
  request.tenantConcurrencyLimit != null &&
152
221
  tenantConcurrent >= request.tenantConcurrencyLimit
@@ -182,6 +251,7 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
182
251
  monthlyKey,
183
252
  tracksLifetime: request.lifetimeBudget != null,
184
253
  tracksConcurrency: request.concurrencyLimit != null,
254
+ concurrencyKey: rpmConcId,
185
255
  tenantId: request.tenantId,
186
256
  tracksTenantConcurrency: request.tenantConcurrencyLimit != null,
187
257
  state: "active",
@@ -205,7 +275,7 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
205
275
  this.adjust(this.daily, reservation.dailyKey, delta, reservation.tracksDaily);
206
276
  this.adjust(this.monthly, reservation.monthlyKey, delta, reservation.tracksMonthly);
207
277
  this.adjust(this.lifetime, apiKeyId, delta, reservation.tracksLifetime);
208
- if (reservation.tracksConcurrency) this.decrement(this.concurrent, apiKeyId);
278
+ if (reservation.tracksConcurrency) this.decrement(this.concurrent, reservation.concurrencyKey);
209
279
  if (reservation.tracksTenantConcurrency)
210
280
  this.decrement(this.tenantConcurrent, reservation.tenantId);
211
281
  reservation.state = "committed";
@@ -276,7 +346,7 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
276
346
  }
277
347
 
278
348
  /** Caps a bucketed counter map; oldest-inserted buckets evicted first. */
279
- private evictCounter(store: Map<string, number> | Map<string, number[]>): void {
349
+ private evictCounter(store: Map<string, number> | Map<string, RpmTimestampQueue>): void {
280
350
  if (store.size < COUNTER_MAP_CAP) return;
281
351
  let toEvict = store.size - COUNTER_MAP_CAP + 1;
282
352
  for (const key of store.keys()) {
@@ -290,7 +360,7 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
290
360
  this.adjust(this.daily, reservation.dailyKey, -reservation.reserved, reservation.tracksDaily);
291
361
  this.adjust(this.monthly, reservation.monthlyKey, -reservation.reserved, reservation.tracksMonthly);
292
362
  this.adjust(this.lifetime, reservation.apiKeyId, -reservation.reserved, reservation.tracksLifetime);
293
- if (reservation.tracksConcurrency) this.decrement(this.concurrent, reservation.apiKeyId);
363
+ if (reservation.tracksConcurrency) this.decrement(this.concurrent, reservation.concurrencyKey);
294
364
  if (reservation.tracksTenantConcurrency)
295
365
  this.decrement(this.tenantConcurrent, reservation.tenantId);
296
366
  reservation.state = "released";
@@ -316,21 +386,26 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
316
386
  private value(store: ReadonlyMap<string, number>, key: string): number {
317
387
  return store.get(key) ?? 0;
318
388
  }
319
- private pruneRpm(apiKeyId: string, now: number): number[] {
320
- const cutoff = now - (this.options.windowMs ?? 60_000);
389
+ private pruneRpm(apiKeyId: string, now: number): RpmTimestampQueue {
321
390
  const existing = this.rpmTimestamps.get(apiKeyId);
322
391
  if (!existing) {
323
- const fresh: number[] = [];
392
+ const fresh = new RpmTimestampQueue();
324
393
  this.rpmTimestamps.set(apiKeyId, fresh);
325
394
  this.evictCounter(this.rpmTimestamps);
326
395
  return fresh;
327
396
  }
328
- // In-place compaction: `.filter` allocated a new array per request.
329
- let write = 0;
330
- for (let read = 0; read < existing.length; read += 1) {
331
- if (existing[read]! > cutoff) existing[write++] = existing[read]!;
397
+ // Amortized: advances the head past expired entries; compaction of the
398
+ // consumed prefix happens only when it exceeds half the array.
399
+ existing.prune(now, this.options.windowMs ?? 60_000);
400
+ if (existing.isEmpty()) {
401
+ // Drop the emptied bucket so the map cannot pin one dead queue per key
402
+ // the process ever served, then hand the caller a fresh queue — the
403
+ // caller pushes this request's timestamp into whatever it gets back, and
404
+ // pushing into the detached object would silently drop the admission.
405
+ const fresh = new RpmTimestampQueue();
406
+ this.rpmTimestamps.set(apiKeyId, fresh);
407
+ return fresh;
332
408
  }
333
- existing.length = write;
334
409
  return existing;
335
410
  }
336
411
  private requireReservation(
@@ -1,182 +1,191 @@
1
- import type { AdmissionRejectionReason } from "./contracts";
2
- import { reasonToGatewayError } from "./reasons";
3
- import {
4
- CONCURRENCY_TTL_SECONDS,
5
- DAILY_COUNTER_TTL_SECONDS,
6
- LIFETIME_COUNTER_TTL_SECONDS,
7
- MONTHLY_COUNTER_TTL_SECONDS,
8
- RPM_WINDOW_TTL_SECONDS,
9
- } from "./ttl";
10
-
11
- /**
12
- * The three atomic scripts every Redis admission operation runs.
13
- *
14
- * They live apart from the store that calls them because they are the
15
- * enforcement itself: a limit check and its counter write must be one
16
- * indivisible step, which is why the logic is Lua rather than TypeScript.
17
- * Reading them next to each other is the only way to see that reserve,
18
- * reconcile, and release agree on the lease hash fields.
19
- */
20
- /**
21
- * RESERVE — the atomic admission algorithm. Runs as one Redis Lua script so
22
- * every limit is validated and the reservation created in a single atomic
23
- * step: no read-modify-write race can let two requests through the last
24
- * remaining slot, and no partially applied reservation can survive (all
25
- * counter writes + the lease hash commit together or not at all).
26
- *
27
- * Idempotency: an existing lease hash in any state short-circuits to `1`
28
- * (already reserved) so a retried admission cannot double-charge counters.
29
- *
30
- * Limit model — rate limits vs concurrency:
31
- * - RPM (KEYS[1], a ZSET of timestamps): sliding 60s window, pruned with
32
- * ZREMRANGEBYSCORE before counting. Admission is count-based, not
33
- * token-based.
34
- * - Daily / monthly / lifetime token budgets (KEYS[2]/[3]/[5]): the
35
- * *estimated* token count is pre-credited at reserve time and reconciled
36
- * to actual usage later (RECONCILE_SCRIPT), so a request that would exceed
37
- * its budget is rejected before dispatch, never after.
38
- * - Concurrency (KEYS[4], per API key) and tenant concurrency (KEYS[7]) are
39
- * classic INCR/DECR slots held for the request duration; the lease hash
40
- * (KEYS[6]) records which counters each reservation tracks so release
41
- * reverses exactly those.
42
- *
43
- * Key naming: `admission:<kind>[:<apiKeyId>|<tenantId>][:<bucket>]` —
44
- * `admission:rpm:<key>`, `admission:daily:<key>:<YYYY-MM-DD>`,
45
- * `admission:monthly:<key>:<YYYY-MM>`, `admission:lifetime:<key>`,
46
- * `admission:concurrent:<key>`, `admission:tenant_concurrent:<tenant>`,
47
- * `admission:lease:<reservationId>`. All counters carry TTLs (roughly one
48
- * bucket + slack) so a process death cannot leak counters forever.
49
- *
50
- * Return codes: `0` reserved; `1` idempotent replay; `-1..-6` limit
51
- * rejections (mapped to GatewayErrors by `assertResult`); `-99` corrupt
52
- * counter state (never bypassed — admission fails closed).
53
- */
54
- export const RESERVE_SCRIPT = `
55
- local state = redis.call('HGET', KEYS[6], 'state')
56
- if state == 'active' or state == 'committed' or state == 'released' then return 1 end
57
- redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', tonumber(ARGV[1]) - 60000)
58
- local rpm = redis.call('ZCARD', KEYS[1])
59
- local daily = tonumber(redis.call('GET', KEYS[2]) or '0')
60
- local monthly = tonumber(redis.call('GET', KEYS[3]) or '0')
61
- local concurrent = tonumber(redis.call('GET', KEYS[4]) or '0')
62
- local tenantConcurrent = tonumber(redis.call('GET', KEYS[7]) or '0')
63
- local lifetime = 0
64
- if tonumber(ARGV[6]) then
65
- local rawLifetime = redis.call('GET', KEYS[5])
66
- lifetime = rawLifetime and tonumber(rawLifetime) or tonumber(ARGV[9])
67
- if not lifetime then return -99 end
68
- if not rawLifetime then redis.call('SET', KEYS[5], lifetime, 'EX', ${LIFETIME_COUNTER_TTL_SECONDS}) end
69
- end
70
- local estimated = tonumber(ARGV[2])
71
- if not estimated or estimated < 0 then return -99 end
72
- if tonumber(ARGV[3]) and rpm >= tonumber(ARGV[3]) then return -1 end
73
- if tonumber(ARGV[4]) and daily + estimated > tonumber(ARGV[4]) then return -2 end
74
- if tonumber(ARGV[5]) and monthly + estimated > tonumber(ARGV[5]) then return -3 end
75
- if tonumber(ARGV[6]) and lifetime + estimated > tonumber(ARGV[6]) then return -4 end
76
- if tonumber(ARGV[7]) and concurrent >= tonumber(ARGV[7]) then return -5 end
77
- if tonumber(ARGV[10]) and tenantConcurrent >= tonumber(ARGV[10]) then return -6 end
78
- redis.call('ZADD', KEYS[1], ARGV[1], ARGV[8])
79
- redis.call('EXPIRE', KEYS[1], ${RPM_WINDOW_TTL_SECONDS})
80
- if tonumber(ARGV[4]) then redis.call('INCRBY', KEYS[2], estimated); redis.call('EXPIRE', KEYS[2], ${DAILY_COUNTER_TTL_SECONDS}) end
81
- if tonumber(ARGV[5]) then redis.call('INCRBY', KEYS[3], estimated); redis.call('EXPIRE', KEYS[3], ${MONTHLY_COUNTER_TTL_SECONDS}) end
82
- if tonumber(ARGV[6]) then redis.call('INCRBY', KEYS[5], estimated); redis.call('EXPIRE', KEYS[5], ${LIFETIME_COUNTER_TTL_SECONDS}) end
83
- if tonumber(ARGV[7]) then redis.call('INCR', KEYS[4]); redis.call('EXPIRE', KEYS[4], ${CONCURRENCY_TTL_SECONDS}) end
84
- if tonumber(ARGV[10]) then redis.call('INCR', KEYS[7]); redis.call('EXPIRE', KEYS[7], ${CONCURRENCY_TTL_SECONDS}) end
85
- redis.call('HSET', KEYS[6], 'state', 'active', 'reserved', estimated, 'daily', tonumber(ARGV[4]) and KEYS[2] or '', 'monthly', tonumber(ARGV[5]) and KEYS[3] or '', 'lifetime', tonumber(ARGV[6]) and KEYS[5] or '', 'concurrent', tonumber(ARGV[7]) and '1' or '0', 'tenant_concurrent', tonumber(ARGV[10]) and '1' or '0', 'tenant_id', ARGV[11], 'api_key_id', ARGV[12], 'expires_at', tonumber(ARGV[1]) + tonumber(ARGV[13]))
86
- redis.call('EXPIRE', KEYS[6], tonumber(ARGV[14]))
87
- return 0
88
- `;
89
-
90
- /**
91
- * RECONCILE — replaces the reservation's estimated token credit with actual
92
- * provider usage, atomically. Runs only on an `active` lease; reads which
93
- * counters the reservation credited from the lease hash, computes
94
- * `delta = actual - reserved`, validates the resulting counter values (no
95
- * negative drift), applies them, and decrements the held concurrency slots.
96
- * `actual` may be less than `reserved` (over-estimate refunded) or more
97
- * (under-estimate charged). Marks the lease `committed` — a second reconcile
98
- * or release is an idempotent no-op (`1`).
99
- */
100
- export const RECONCILE_SCRIPT = `
101
- local state = redis.call('HGET', KEYS[1], 'state')
102
- if state == 'committed' or state == 'released' then return 1 end
103
- if state ~= 'active' then return -99 end
104
- local reserved = tonumber(redis.call('HGET', KEYS[1], 'reserved'))
105
- local actual = tonumber(ARGV[1])
106
- if not reserved or not actual or reserved < 0 or actual < 0 or reserved ~= math.floor(reserved) or actual ~= math.floor(actual) then return -99 end
107
- local delta = actual - reserved
108
- local updates = {}
109
- for _, field in ipairs({'daily', 'monthly', 'lifetime'}) do
110
- local key = redis.call('HGET', KEYS[1], field)
111
- if key and key ~= '' then
112
- local value = tonumber(redis.call('GET', key))
113
- if not value or value < 0 or value + delta < 0 or value + delta ~= math.floor(value + delta) then return -99 end
114
- table.insert(updates, key)
115
- end
116
- end
117
- local concurrentKey = 'admission:concurrent:' .. ARGV[2]
118
- local hasConcurrency = redis.call('HGET', KEYS[1], 'concurrent') == '1'
119
- if hasConcurrency then
120
- local value = tonumber(redis.call('GET', concurrentKey))
121
- if not value or value < 1 or value ~= math.floor(value) then return -99 end
122
- end
123
- local tenantKey = ''
124
- local hasTenantConcurrency = redis.call('HGET', KEYS[1], 'tenant_concurrent') == '1'
125
- if hasTenantConcurrency then
126
- local tenantId = redis.call('HGET', KEYS[1], 'tenant_id')
127
- if not tenantId or tenantId == '' then return -99 end
128
- tenantKey = 'admission:tenant_concurrent:' .. tenantId
129
- local value = tonumber(redis.call('GET', tenantKey))
130
- if not value or value < 1 or value ~= math.floor(value) then return -99 end
131
- end
132
- -- INCRBY, never SET: a plain SET replaces the key and drops its TTL, so the
133
- -- first reconcile after admission would strip the bucket's expiry and the
134
- -- counter would outlive its window forever. INCRBY adjusts the value in place
135
- -- and leaves the TTL intact. (KEEPTTL would also work but is unavailable on
136
- -- the oldest Redis this is documented against.)
137
- for _, key in ipairs(updates) do redis.call('INCRBY', key, delta) end
138
- if hasConcurrency then redis.call('DECR', concurrentKey) end
139
- if hasTenantConcurrency then redis.call('DECR', tenantKey) end
140
- redis.call('HSET', KEYS[1], 'state', 'committed', 'actual', actual)
141
- return 0
142
- `;
143
-
144
- /**
145
- * RELEASE — reverses an uncommitted reservation exactly once: refunds the
146
- * pre-credited token estimates, decrements the held concurrency and tenant
147
- * slots, and marks the lease `released` (subsequent release/reconcile
148
- * returns `1`). Unlike the normal release path, crash-recovery releases
149
- * (`releaseAdmissionLease` / the sweeper) run this same script keyed only by
150
- * the lease hash; concurrency keys are rebuilt from `admission:concurrent:`
151
- * + the lease's `api_key_id` / `tenant_id` fields, so recovery never needs
152
- * the caller to remember which counters were held.
153
- */
154
- export const RELEASE_SCRIPT = `
155
- local state = redis.call('HGET', KEYS[1], 'state')
156
- if state == 'released' or state == 'committed' then return 1 end
157
- if state ~= 'active' then return -99 end
158
- local reserved = tonumber(redis.call('HGET', KEYS[1], 'reserved'))
159
- if not reserved or reserved < 0 then return -99 end
160
- for _, field in ipairs({'daily', 'monthly', 'lifetime'}) do local key = redis.call('HGET', KEYS[1], field); if key and key ~= '' then local value = tonumber(redis.call('GET', key) or '0'); if not value or value < reserved then return -99 end; redis.call('DECRBY', key, reserved) end end
161
- if redis.call('HGET', KEYS[1], 'concurrent') == '1' then local key = 'admission:concurrent:' .. ARGV[1]; local value = tonumber(redis.call('GET', key) or '0'); if not value or value < 1 then return -99 end; redis.call('DECR', key) end
162
- if redis.call('HGET', KEYS[1], 'tenant_concurrent') == '1' then local tenantId = redis.call('HGET', KEYS[1], 'tenant_id'); if not tenantId or tenantId == '' then return -99 end; local key = 'admission:tenant_concurrent:' .. tenantId; local value = tonumber(redis.call('GET', key) or '0'); if not value or value < 1 then return -99 end; redis.call('DECR', key) end
163
- redis.call('HSET', KEYS[1], 'state', 'released')
164
- return 0
165
- `;
166
- const RESULT_REASON: Record<number, AdmissionRejectionReason> = {
167
- [-1]: "rpm-exhausted",
168
- [-2]: "daily-token-limit",
169
- [-3]: "monthly-token-limit",
170
- [-4]: "lifetime-token-budget",
171
- [-5]: "concurrency-limit",
172
- [-6]: "tenant-capacity-exhausted",
173
- };
174
-
175
- export function assertResult(result: number, operation: string): void {
176
- if (result === -99) throw new Error(`corrupt admission counter during ${operation}`);
177
- const reason = RESULT_REASON[result];
178
- if (reason) {
179
- throw reasonToGatewayError(reason, { operation });
180
- }
181
- if (result < 0) throw new Error(`admission rejected during ${operation}: ${result}`);
182
- }
1
+ import type { AdmissionRejectionReason } from "./contracts";
2
+ import { reasonToGatewayError } from "./reasons";
3
+ import {
4
+ CONCURRENCY_TTL_SECONDS,
5
+ DAILY_COUNTER_TTL_SECONDS,
6
+ LIFETIME_COUNTER_TTL_SECONDS,
7
+ MONTHLY_COUNTER_TTL_SECONDS,
8
+ RPM_WINDOW_TTL_SECONDS,
9
+ } from "./ttl";
10
+
11
+ /**
12
+ * The three atomic scripts every Redis admission operation runs.
13
+ *
14
+ * They live apart from the store that calls them because they are the
15
+ * enforcement itself: a limit check and its counter write must be one
16
+ * indivisible step, which is why the logic is Lua rather than TypeScript.
17
+ * Reading them next to each other is the only way to see that reserve,
18
+ * reconcile, and release agree on the lease hash fields.
19
+ */
20
+ /**
21
+ * RESERVE — the atomic admission algorithm. Runs as one Redis Lua script so
22
+ * every limit is validated and the reservation created in a single atomic
23
+ * step: no read-modify-write race can let two requests through the last
24
+ * remaining slot, and no partially applied reservation can survive (all
25
+ * counter writes + the lease hash commit together or not at all).
26
+ *
27
+ * Idempotency: an existing lease hash in any state short-circuits to `1`
28
+ * (already reserved) so a retried admission cannot double-charge counters.
29
+ *
30
+ * Limit model — rate limits vs concurrency:
31
+ * - RPM (KEYS[1], a ZSET of timestamps): sliding 60s window, pruned with
32
+ * ZREMRANGEBYSCORE before counting. Admission is count-based, not
33
+ * token-based.
34
+ * - Daily / monthly / lifetime token budgets (KEYS[2]/[3]/[5]): the
35
+ * *estimated* token count is pre-credited at reserve time and reconciled
36
+ * to actual usage later (RECONCILE_SCRIPT), so a request that would exceed
37
+ * its budget is rejected before dispatch, never after.
38
+ * - Concurrency (KEYS[4], per API key) and tenant concurrency (KEYS[7]) are
39
+ * classic INCR/DECR slots held for the request duration; the lease hash
40
+ * (KEYS[6]) records which counters each reservation tracks so release
41
+ * reverses exactly those.
42
+ *
43
+ * Key naming: `admission:<kind>[:<apiKeyId>|<tenantId>][:<bucket>]` —
44
+ * `admission:rpm:<key>`, `admission:daily:<key>:<YYYY-MM-DD>`,
45
+ * `admission:monthly:<key>:<YYYY-MM>`, `admission:lifetime:<key>`,
46
+ * `admission:concurrent:<key>`, `admission:tenant_concurrent:<tenant>`,
47
+ * `admission:lease:<reservationId>`. All counters carry TTLs (roughly one
48
+ * bucket + slack) so a process death cannot leak counters forever.
49
+ *
50
+ * Return codes: `0` reserved; `1` idempotent replay; `-1..-6` limit
51
+ * rejections (mapped to GatewayErrors by `assertResult`); `-99` corrupt
52
+ * counter state (never bypassed — admission fails closed).
53
+ */
54
+ export const RESERVE_SCRIPT = `
55
+ local state = redis.call('HGET', KEYS[6], 'state')
56
+ if state == 'active' or state == 'committed' or state == 'released' then return 1 end
57
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', tonumber(ARGV[1]) - 60000)
58
+ local rpm = redis.call('ZCARD', KEYS[1])
59
+ local daily = tonumber(redis.call('GET', KEYS[2]) or '0')
60
+ local monthly = tonumber(redis.call('GET', KEYS[3]) or '0')
61
+ local concurrent = tonumber(redis.call('GET', KEYS[4]) or '0')
62
+ local tenantConcurrent = tonumber(redis.call('GET', KEYS[7]) or '0')
63
+ local lifetime = 0
64
+ if tonumber(ARGV[6]) then
65
+ local rawLifetime = redis.call('GET', KEYS[5])
66
+ lifetime = rawLifetime and tonumber(rawLifetime) or tonumber(ARGV[9])
67
+ if not lifetime then return -99 end
68
+ if not rawLifetime then redis.call('SET', KEYS[5], lifetime, 'EX', ${LIFETIME_COUNTER_TTL_SECONDS}) end
69
+ end
70
+ local estimated = tonumber(ARGV[2])
71
+ if not estimated or estimated < 0 then return -99 end
72
+ if tonumber(ARGV[3]) and rpm >= tonumber(ARGV[3]) then return -1 end
73
+ if tonumber(ARGV[4]) and daily + estimated > tonumber(ARGV[4]) then return -2 end
74
+ if tonumber(ARGV[5]) and monthly + estimated > tonumber(ARGV[5]) then return -3 end
75
+ if tonumber(ARGV[6]) and lifetime + estimated > tonumber(ARGV[6]) then return -4 end
76
+ -- ARGV[15] is the tolerance: slack above the configured limit, so the ceiling
77
+ -- is the sum. It widens admission only; already-admitted requests are never
78
+ -- revisited.
79
+ if tonumber(ARGV[7]) and concurrent >= tonumber(ARGV[7]) + (tonumber(ARGV[15]) or 0) then return -5 end
80
+ if tonumber(ARGV[10]) and tenantConcurrent >= tonumber(ARGV[10]) then return -6 end
81
+ redis.call('ZADD', KEYS[1], ARGV[1], ARGV[8])
82
+ redis.call('EXPIRE', KEYS[1], ${RPM_WINDOW_TTL_SECONDS})
83
+ if tonumber(ARGV[4]) then redis.call('INCRBY', KEYS[2], estimated); redis.call('EXPIRE', KEYS[2], ${DAILY_COUNTER_TTL_SECONDS}) end
84
+ if tonumber(ARGV[5]) then redis.call('INCRBY', KEYS[3], estimated); redis.call('EXPIRE', KEYS[3], ${MONTHLY_COUNTER_TTL_SECONDS}) end
85
+ if tonumber(ARGV[6]) then redis.call('INCRBY', KEYS[5], estimated); redis.call('EXPIRE', KEYS[5], ${LIFETIME_COUNTER_TTL_SECONDS}) end
86
+ if tonumber(ARGV[7]) then redis.call('INCR', KEYS[4]); redis.call('EXPIRE', KEYS[4], ${CONCURRENCY_TTL_SECONDS}) end
87
+ if tonumber(ARGV[10]) then redis.call('INCR', KEYS[7]); redis.call('EXPIRE', KEYS[7], ${CONCURRENCY_TTL_SECONDS}) end
88
+ redis.call('HSET', KEYS[6], 'state', 'active', 'reserved', estimated, 'daily', tonumber(ARGV[4]) and KEYS[2] or '', 'monthly', tonumber(ARGV[5]) and KEYS[3] or '', 'lifetime', tonumber(ARGV[6]) and KEYS[5] or '', 'concurrent', tonumber(ARGV[7]) and '1' or '0', 'concurrent_key', tonumber(ARGV[7]) and KEYS[4] or '', 'tenant_concurrent', tonumber(ARGV[10]) and '1' or '0', 'tenant_id', ARGV[11], 'api_key_id', ARGV[12], 'expires_at', tonumber(ARGV[1]) + tonumber(ARGV[13]))
89
+ redis.call('EXPIRE', KEYS[6], tonumber(ARGV[14]))
90
+ return 0
91
+ `;
92
+
93
+ /**
94
+ * RECONCILE — replaces the reservation's estimated token credit with actual
95
+ * provider usage, atomically. Runs only on an `active` lease; reads which
96
+ * counters the reservation credited from the lease hash, computes
97
+ * `delta = actual - reserved`, validates the resulting counter values (no
98
+ * negative drift), applies them, and decrements the held concurrency slots.
99
+ * `actual` may be less than `reserved` (over-estimate refunded) or more
100
+ * (under-estimate charged). Marks the lease `committed` — a second reconcile
101
+ * or release is an idempotent no-op (`1`).
102
+ */
103
+ export const RECONCILE_SCRIPT = `
104
+ local state = redis.call('HGET', KEYS[1], 'state')
105
+ if state == 'committed' or state == 'released' then return 1 end
106
+ if state ~= 'active' then return -99 end
107
+ local reserved = tonumber(redis.call('HGET', KEYS[1], 'reserved'))
108
+ local actual = tonumber(ARGV[1])
109
+ if not reserved or not actual or reserved < 0 or actual < 0 or reserved ~= math.floor(reserved) or actual ~= math.floor(actual) then return -99 end
110
+ local delta = actual - reserved
111
+ local updates = {}
112
+ for _, field in ipairs({'daily', 'monthly', 'lifetime'}) do
113
+ local key = redis.call('HGET', KEYS[1], field)
114
+ if key and key ~= '' then
115
+ local value = tonumber(redis.call('GET', key))
116
+ if not value or value < 0 or value + delta < 0 or value + delta ~= math.floor(value + delta) then return -99 end
117
+ table.insert(updates, key)
118
+ end
119
+ end
120
+ -- The key that was CHARGED, not one rebuilt from the caller's identity: a share
121
+ -- child reserves under its own id while the lease is recorded against its
122
+ -- parent, so rebuilding from ARGV incremented one counter and decremented
123
+ -- another. The child's count never fell and the key went permanently over its
124
+ -- limit. Fall back to ARGV only for leases written before this field existed.
125
+ local concurrentKey = redis.call('HGET', KEYS[1], 'concurrent_key')
126
+ if not concurrentKey or concurrentKey == '' then concurrentKey = 'admission:concurrent:' .. ARGV[2] end
127
+ local hasConcurrency = redis.call('HGET', KEYS[1], 'concurrent') == '1'
128
+ if hasConcurrency then
129
+ local value = tonumber(redis.call('GET', concurrentKey))
130
+ if not value or value < 1 or value ~= math.floor(value) then return -99 end
131
+ end
132
+ local tenantKey = ''
133
+ local hasTenantConcurrency = redis.call('HGET', KEYS[1], 'tenant_concurrent') == '1'
134
+ if hasTenantConcurrency then
135
+ local tenantId = redis.call('HGET', KEYS[1], 'tenant_id')
136
+ if not tenantId or tenantId == '' then return -99 end
137
+ tenantKey = 'admission:tenant_concurrent:' .. tenantId
138
+ local value = tonumber(redis.call('GET', tenantKey))
139
+ if not value or value < 1 or value ~= math.floor(value) then return -99 end
140
+ end
141
+ -- INCRBY, never SET: a plain SET replaces the key and drops its TTL, so the
142
+ -- first reconcile after admission would strip the bucket's expiry and the
143
+ -- counter would outlive its window forever. INCRBY adjusts the value in place
144
+ -- and leaves the TTL intact. (KEEPTTL would also work but is unavailable on
145
+ -- the oldest Redis this is documented against.)
146
+ for _, key in ipairs(updates) do redis.call('INCRBY', key, delta) end
147
+ if hasConcurrency then redis.call('DECR', concurrentKey) end
148
+ if hasTenantConcurrency then redis.call('DECR', tenantKey) end
149
+ redis.call('HSET', KEYS[1], 'state', 'committed', 'actual', actual)
150
+ return 0
151
+ `;
152
+
153
+ /**
154
+ * RELEASE — reverses an uncommitted reservation exactly once: refunds the
155
+ * pre-credited token estimates, decrements the held concurrency and tenant
156
+ * slots, and marks the lease `released` (subsequent release/reconcile
157
+ * returns `1`). Unlike the normal release path, crash-recovery releases
158
+ * (`releaseAdmissionLease` / the sweeper) run this same script keyed only by
159
+ * the lease hash; concurrency keys are rebuilt from `admission:concurrent:`
160
+ * + the lease's `api_key_id` / `tenant_id` fields, so recovery never needs
161
+ * the caller to remember which counters were held.
162
+ */
163
+ export const RELEASE_SCRIPT = `
164
+ local state = redis.call('HGET', KEYS[1], 'state')
165
+ if state == 'released' or state == 'committed' then return 1 end
166
+ if state ~= 'active' then return -99 end
167
+ local reserved = tonumber(redis.call('HGET', KEYS[1], 'reserved'))
168
+ if not reserved or reserved < 0 then return -99 end
169
+ for _, field in ipairs({'daily', 'monthly', 'lifetime'}) do local key = redis.call('HGET', KEYS[1], field); if key and key ~= '' then local value = tonumber(redis.call('GET', key) or '0'); if not value or value < reserved then return -99 end; redis.call('DECRBY', key, reserved) end end
170
+ if redis.call('HGET', KEYS[1], 'concurrent') == '1' then local key = redis.call('HGET', KEYS[1], 'concurrent_key'); if not key or key == '' then key = 'admission:concurrent:' .. ARGV[1] end; local value = tonumber(redis.call('GET', key) or '0'); if not value or value < 1 then return -99 end; redis.call('DECR', key) end
171
+ if redis.call('HGET', KEYS[1], 'tenant_concurrent') == '1' then local tenantId = redis.call('HGET', KEYS[1], 'tenant_id'); if not tenantId or tenantId == '' then return -99 end; local key = 'admission:tenant_concurrent:' .. tenantId; local value = tonumber(redis.call('GET', key) or '0'); if not value or value < 1 then return -99 end; redis.call('DECR', key) end
172
+ redis.call('HSET', KEYS[1], 'state', 'released')
173
+ return 0
174
+ `;
175
+ const RESULT_REASON: Record<number, AdmissionRejectionReason> = {
176
+ [-1]: "rpm-exhausted",
177
+ [-2]: "daily-token-limit",
178
+ [-3]: "monthly-token-limit",
179
+ [-4]: "lifetime-token-budget",
180
+ [-5]: "concurrency-limit",
181
+ [-6]: "tenant-capacity-exhausted",
182
+ };
183
+
184
+ export function assertResult(result: number, operation: string): void {
185
+ if (result === -99) throw new Error(`corrupt admission counter during ${operation}`);
186
+ const reason = RESULT_REASON[result];
187
+ if (reason) {
188
+ throw reasonToGatewayError(reason, { operation });
189
+ }
190
+ if (result < 0) throw new Error(`admission rejected during ${operation}: ${result}`);
191
+ }