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
@@ -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
+ }
@@ -61,6 +61,7 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
61
61
  request.apiKeyId,
62
62
  String(LEASE_TTL_MS),
63
63
  String(LEASE_KEY_TTL_SECONDS),
64
+ String(Math.max(0, request.concurrencyTolerance ?? 0)),
64
65
  );
65
66
  assertResult(result, "reserve");
66
67
  }
@@ -142,11 +143,20 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
142
143
  * Drops every counter for one key (revocation). Active in-flight leases
143
144
  * settle through the release script first so their concurrency slots do
144
145
  * not leak; remaining lease rows expire via TTL. Best-effort by contract.
146
+ *
147
+ * Bounded per invocation: a keyspace that has grown for months cannot turn
148
+ * one revocation into an unbounded walk — lease-scan pages, leases released,
149
+ * and counter-scan pages each stop cleanly at their budget, and the rest
150
+ * ages out via TTL (the fallback this contract already relies on). A later
151
+ * revocation or the lease sweeper picks up what a budgeted pass left.
145
152
  */
146
153
  async purge(apiKeyId: string): Promise<void> {
147
154
  try {
155
+ let leaseScanPages = 0;
156
+ let leasesReleased = 0;
148
157
  let cursor = "0";
149
158
  do {
159
+ if (leaseScanPages >= MAX_PURGE_LEASE_SCAN_PAGES) break;
150
160
  const [nextCursor, keys] = await this.redis.scan(
151
161
  cursor,
152
162
  "MATCH",
@@ -155,6 +165,7 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
155
165
  100,
156
166
  );
157
167
  cursor = String(nextCursor);
168
+ leaseScanPages += 1;
158
169
  const leaseKeys = keys as string[];
159
170
  if (leaseKeys.length === 0) continue;
160
171
  // One pipeline for the whole scan page instead of an HGET per lease:
@@ -172,15 +183,22 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
172
183
  return Array.isArray(entry) && entry[1] === apiKeyId;
173
184
  });
174
185
  for (const leaseKey of owned) {
186
+ if (leasesReleased >= MAX_PURGE_LEASES_RELEASED) break;
175
187
  await redisEvalNumber(this.redis, RELEASE_SCRIPT, 1, leaseKey, apiKeyId).catch(
176
188
  () => -1,
177
189
  );
190
+ leasesReleased += 1;
178
191
  }
192
+ // Releasing is the point of the lease phase; once its budget is spent
193
+ // there is nothing more this pass can usefully do with further pages.
194
+ if (leasesReleased >= MAX_PURGE_LEASES_RELEASED) break;
179
195
  } while (cursor !== "0");
196
+ let counterScanPages = 0;
180
197
  let counterCursor = "0";
181
198
  const patterns = [`admission:*:${apiKeyId}`, `admission:*:${apiKeyId}:*`];
182
199
  for (const pattern of patterns) {
183
200
  do {
201
+ if (counterScanPages >= MAX_PURGE_COUNTER_SCAN_PAGES) break;
184
202
  const [nextCursor, keys] = await this.redis.scan(
185
203
  counterCursor,
186
204
  "MATCH",
@@ -189,8 +207,10 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
189
207
  100,
190
208
  );
191
209
  counterCursor = String(nextCursor);
210
+ counterScanPages += 1;
192
211
  if ((keys as string[]).length > 0) await this.redis.del(...(keys as string[]));
193
212
  } while (counterCursor !== "0");
213
+ if (counterScanPages >= MAX_PURGE_COUNTER_SCAN_PAGES) break;
194
214
  }
195
215
  } catch {
196
216
  // Best-effort: revocation already succeeded; stale counters expire via TTL.
@@ -198,6 +218,16 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
198
218
  }
199
219
  }
200
220
 
221
+ /**
222
+ * Per-invocation purge budgets. Chosen so one full-budgeted pass still costs
223
+ * well under a second of Redis time (hundreds of small commands), while a
224
+ * normal revocation — tens of leases, a handful of counters — never notices
225
+ * them. Mirrors the lease sweeper's own bounded-pass philosophy.
226
+ */
227
+ export const MAX_PURGE_LEASE_SCAN_PAGES = 40;
228
+ export const MAX_PURGE_LEASES_RELEASED = 100;
229
+ export const MAX_PURGE_COUNTER_SCAN_PAGES = 40;
230
+
201
231
  /**
202
232
  * Releases a lease identified only by its Redis key (used by the sweeper).
203
233
  * Reads the owning api key id from the lease hash and runs RELEASE_SCRIPT,
@@ -35,6 +35,14 @@ export class ApiKeyAdmissionService {
35
35
  ) => Promise<number | null> | number | null = () => null,
36
36
  private readonly persistLifetimeUsage?: LifetimeUsagePersister,
37
37
  private readonly freshLifetimeConsumed?: FreshLifetimeConsumedReader,
38
+ /**
39
+ * Hidden allowance above each key's concurrency limit. Injected rather
40
+ * than read from the snapshot so the number is operator configuration,
41
+ * not something a key can carry about itself.
42
+ */
43
+ private readonly concurrencyTolerance?: (
44
+ tenantId: string,
45
+ ) => Promise<number | null> | number | null,
38
46
  ) {
39
47
  if (!store) throw new Error("ApiKeyAdmissionService requires an admission store");
40
48
  }
@@ -96,6 +104,15 @@ export class ApiKeyAdmissionService {
96
104
  const reservationId = `${apiKeyId}:${now}:${crypto.randomUUID()}`;
97
105
  const rawTenantLimit = this.tenantConcurrencyProvider(snapshot.tenant_id);
98
106
  const tenantLimit = rawTenantLimit instanceof Promise ? await rawTenantLimit : rawTenantLimit;
107
+ /*
108
+ * One number, one place. The allowance used to be overridable per key,
109
+ * so the tenant-wide value was only the answer for a key that had never
110
+ * been given one — an operator who raised it in Settings could not tell
111
+ * why one key still refused early, because the override lived in a modal
112
+ * the Settings page does not show. It is tenant-wide only now.
113
+ */
114
+ const rawTolerance = this.concurrencyTolerance?.(snapshot.tenant_id);
115
+ const tolerance = rawTolerance instanceof Promise ? await rawTolerance : rawTolerance;
99
116
 
100
117
  try {
101
118
  await this.store.reserve({
@@ -113,6 +130,7 @@ export class ApiKeyAdmissionService {
113
130
  lifetimeBudget: snapshot.lifetime_token_budget ?? null,
114
131
  lifetimeConsumed: snapshot.lifetime_tokens_consumed ?? 0,
115
132
  concurrencyLimit: snapshot.max_concurrent ?? null,
133
+ concurrencyTolerance: tolerance ?? null,
116
134
  tenantId: snapshot.tenant_id,
117
135
  tenantConcurrencyLimit: tenantLimit,
118
136
  // Fresh lifetime reader is invoked by the store only when the counter
@@ -0,0 +1,96 @@
1
+ /** Bounded process-local authorization cache; shared-cache I/O stays in the auth service. */
2
+ export const AUTH_CACHE_TTL_MS = 3_000;
3
+ const AUTH_CACHE_MAX_ENTRIES = 10_000;
4
+ const AUTH_NEGATIVE_CACHE_TTL_MS = 1_500;
5
+ const AUTH_FAILURE_COOLDOWN_MS = 1_000;
6
+
7
+ interface CacheEntry<T> {
8
+ readonly at: number;
9
+ readonly value: T;
10
+ readonly keyId: string;
11
+ }
12
+
13
+ type Lookup<T> =
14
+ | { readonly kind: "positive"; readonly value: T }
15
+ | { readonly kind: "negative" }
16
+ | { readonly kind: "miss" };
17
+
18
+ /** Create the process-local positive/negative authorization cache. */
19
+ export function createApiKeyAuthorizationCache<T>() {
20
+ const entries = new Map<string, CacheEntry<T>>();
21
+ const hashesByKeyId = new Map<string, Set<string>>();
22
+ const negativeEntries = new Map<string, number>();
23
+ let failureUntil = 0;
24
+
25
+ const evictOldest = (): void => {
26
+ while (entries.size > AUTH_CACHE_MAX_ENTRIES) {
27
+ const oldest = entries.keys().next().value;
28
+ if (oldest === undefined) return;
29
+ const entry = entries.get(oldest);
30
+ entries.delete(oldest);
31
+ if (entry === undefined) continue;
32
+ const hashes = hashesByKeyId.get(entry.keyId);
33
+ if (hashes === undefined) continue;
34
+ hashes.delete(oldest);
35
+ if (hashes.size === 0) hashesByKeyId.delete(entry.keyId);
36
+ }
37
+ };
38
+
39
+ const evictNegativeOldest = (): void => {
40
+ while (negativeEntries.size > AUTH_CACHE_MAX_ENTRIES) {
41
+ const oldest = negativeEntries.keys().next().value;
42
+ if (oldest === undefined) return;
43
+ negativeEntries.delete(oldest);
44
+ }
45
+ };
46
+
47
+ return {
48
+ lookup(hash: string, now: number): Lookup<T> {
49
+ const negativeAt = negativeEntries.get(hash);
50
+ if (negativeAt !== undefined) {
51
+ if (now - negativeAt < AUTH_NEGATIVE_CACHE_TTL_MS) return { kind: "negative" };
52
+ negativeEntries.delete(hash);
53
+ }
54
+ const cached = entries.get(hash);
55
+ if (cached === undefined || now - cached.at >= AUTH_CACHE_TTL_MS) return { kind: "miss" };
56
+ entries.delete(hash);
57
+ entries.set(hash, cached);
58
+ return { kind: "positive", value: cached.value };
59
+ },
60
+
61
+ rememberPositive(hash: string, keyId: string, value: T, now: number): void {
62
+ entries.set(hash, { at: now, value, keyId });
63
+ negativeEntries.delete(hash);
64
+ const hashes = hashesByKeyId.get(keyId) ?? new Set<string>();
65
+ hashes.add(hash);
66
+ hashesByKeyId.set(keyId, hashes);
67
+ evictOldest();
68
+ },
69
+
70
+ rememberNegative(hash: string, now: number): void {
71
+ negativeEntries.set(hash, now);
72
+ evictNegativeOldest();
73
+ },
74
+
75
+ invalidate(keyId?: string): void {
76
+ negativeEntries.clear();
77
+ if (keyId === undefined) {
78
+ entries.clear();
79
+ hashesByKeyId.clear();
80
+ return;
81
+ }
82
+ const hashes = hashesByKeyId.get(keyId);
83
+ if (hashes === undefined) return;
84
+ for (const hash of hashes) entries.delete(hash);
85
+ hashesByKeyId.delete(keyId);
86
+ },
87
+
88
+ markStoreUnavailable(now: number): void {
89
+ failureUntil = now + AUTH_FAILURE_COOLDOWN_MS;
90
+ },
91
+
92
+ isStoreUnavailable(now: number): boolean {
93
+ return now < failureUntil;
94
+ },
95
+ };
96
+ }