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
package/src/boot/app.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  import { createTokenCountHandler } from "../transport/dispatch/token-count";
46
46
 
47
47
  import { createTransportPipeline } from "../transport/guards/pipeline";
48
+ import { defaultProcessConcurrencyController } from "../transport/guards/concurrency-guard";
48
49
  import { createDrainHandler, isLoopbackPeer, tokenMatches } from "../transport/drain-endpoint";
49
50
  import { createConsoleAccountResetHandler } from "../console/auth/account-reset";
50
51
  import { resetConsoleAuthState } from "../console/auth/account-reset-store";
@@ -244,6 +245,10 @@ export function createGatewayApp(deps: GatewayAppDeps) {
244
245
  deps.mode === "production" ? deps.shutdownCoordinator : undefined,
245
246
  );
246
247
  deps.runtimeMetricsSampler?.bindInFlight(() => requestStateStore.inFlightCount());
248
+ // The concurrency ceiling is derived (memory limit ÷ body budget), so it is
249
+ // published as a gauge: an operator reading "shedding at 16 in flight" needs
250
+ // to see that 16 IS the limit on this box, not that the process is broken.
251
+ deps.runtimeMetricsSampler?.bindConcurrencyLimit(defaultProcessConcurrencyController.limit());
247
252
  // Shutdown ordering: abort in-flight proxy controllers first so the
248
253
  // bounded drain observes cancellation and finalizers run before
249
254
  // telemetry flush and pool close. The reason is threaded through so the
@@ -379,6 +384,16 @@ export function createGatewayApp(deps: GatewayAppDeps) {
379
384
  telemetry: deps.telemetryBuffer,
380
385
  ...(deps.modelStrikes ? { modelStrikes: deps.modelStrikes } : {}),
381
386
  ...(deps.consoleApi?.redis ? { redis: deps.consoleApi.redis } : {}),
387
+ // Bound at construction so the guard holds the sampler's live snapshot
388
+ // rather than a value captured at boot.
389
+ ...(deps.runtimeMetricsSampler
390
+ ? { memorySnapshot: () => deps.runtimeMetricsSampler?.memorySnapshot() }
391
+ : {}),
392
+ // Reads the cached route snapshot, which the settings route invalidates
393
+ // when the switch is saved — so flipping it takes effect on the next
394
+ // request and adds no query to the proxy path.
395
+ isInvokeDisabled: async (tenantId) =>
396
+ (await deps.snapshotService.getSnapshot())?.invokeDisabledTenants?.has(tenantId),
382
397
  });
383
398
  transportPipeline.mountRoot(app);
384
399
  const proxyDeps: ProviderProxyHandlerDeps = {
@@ -657,7 +672,7 @@ export function createGatewayApp(deps: GatewayAppDeps) {
657
672
  createShareRouter({
658
673
  db: deps.db,
659
674
  shareStore: new DrizzleShareLinkStore(deps.db),
660
- stats: createShareStatsPort({ redis: deps.consoleApi?.redis }),
675
+ stats: createShareStatsPort({ db: deps.db, redis: deps.consoleApi?.redis }),
661
676
  resolveClientIp: (request) => {
662
677
  const peer = peerAddresses.get(request);
663
678
  return peer
@@ -3,6 +3,9 @@ import { assertPoolFitsServerCapacity, bootDatabase, ensureMigrated } from "../p
3
3
  import { poolMaxFromEnv } from "../persistence/db-pg";
4
4
  import type { CartethyiaDatabase } from "../persistence/db-handle";
5
5
  import { resolveRedisBackend, resolveRedisClient } from "../persistence/redis";
6
+ import { resolveProcessTopology, type ProcessTopology } from "../config/runtime";
7
+ import { createRedisMaintenanceLeaseStore } from "../workers/maintenance-lease";
8
+ import { resolveDbMode } from "../persistence/db-mode";
6
9
  import type { RedisClient } from "../persistence/redis";
7
10
  import {
8
11
  bundledModelCatalog,
@@ -71,8 +74,6 @@ import { preferencesReaderFor } from "../persistence/tenant-preferences";
71
74
  import { sweepExpiredCooldowns } from "../providers/operations/account-health-service";
72
75
  import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
73
76
  import { TelemetryPayloadCapture } from "../observability/payload-capture";
74
- import { foldDayIndex, listEventDays } from "../observability/event-day-index";
75
- import { resolveTelemetryCompactIntervalMs } from "../config/telemetry";
76
77
  import { sweepExpiredPoolCooldowns } from "../network/pool-health-machine";
77
78
  import { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
78
79
  import { RuntimeMetricsSampler } from "../observability/runtime-metrics";
@@ -86,6 +87,7 @@ import {
86
87
  import { resolveSsrfPolicy, resolveTrustedProxyBoundary } from "../config/network";
87
88
  import type { TrustedProxyBoundary } from "../config/network";
88
89
  import { resolveTelemetryRetentionDays } from "../config/telemetry";
90
+ import { resolveConcurrencyTolerance } from "../config/resources";
89
91
  import { CONFIG_SPEC, readInt, readList } from "../config/spec";
90
92
  import type { ProviderAdapter } from "../providers/provider-contracts";
91
93
  import type { ProviderRegistry } from "../providers/provider-registry";
@@ -145,9 +147,89 @@ const OAUTH_REFRESH_INTER_ITEM_DELAY_MS = 1_500;
145
147
  const CLIENT_VERSION_MONITOR_INTERVAL_MS = 30 * 60_000;
146
148
  const CLIENT_VERSION_INITIAL_DELAY_MS = 10_000;
147
149
 
150
+ /** Bound on the boot-time Redis probe: a start must fail, not hang. */
151
+ const REDIS_PROBE_TIMEOUT_MS = 2_000;
152
+
153
+ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
154
+ const { promise: timeout, reject } = Promise.withResolvers<never>();
155
+ const timer = setTimeout(() => reject(new Error("timeout")), ms);
156
+ try {
157
+ return await Promise.race([promise, timeout]);
158
+ } finally {
159
+ clearTimeout(timer);
160
+ }
161
+ }
162
+
163
+ /** Bounded Redis reachability probe: fails rather than hangs. */
164
+ async function redisIsReachable(): Promise<boolean> {
165
+ const client = resolveRedisClient();
166
+ if (!client) return false;
167
+ try {
168
+ const result = await withTimeout(client.ping(), REDIS_PROBE_TIMEOUT_MS);
169
+ return result === "PONG";
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Refuses to start a multi-process deployment whose shared state is not
177
+ * actually shared.
178
+ *
179
+ * Every process shares one port via `SO_REUSEPORT`, so the kernel balances
180
+ * across them — and each process keeps its own in-memory admission counters,
181
+ * quota cache, and cooldown map. Those are only correct when they live in
182
+ * Redis. Starting anyway does not fail loudly; it enforces every per-key limit
183
+ * N times over, which then reads as "the limit did not work".
184
+ *
185
+ * Redis is checked by PING with a hard bound, not merely for being configured:
186
+ * a configured-but-unreachable Redis is the failure mode that matters, and an
187
+ * unbounded connect at boot would hang the start instead of failing it.
188
+ */
189
+ async function assertMultiProcessPreconditions(
190
+ topology: ProcessTopology,
191
+ redisReachable: () => Promise<boolean>,
192
+ ): Promise<void> {
193
+ if (!topology.multiProcess) return;
194
+ if (process.platform !== "linux") {
195
+ throw Object.assign(
196
+ new Error(
197
+ `CARTETHYIA_PROCESS_COUNT=${topology.count} requires Linux (SO_REUSEPORT); ` +
198
+ `this process is on ${process.platform}`,
199
+ ),
200
+ { code: "multi_process_unsupported_platform" },
201
+ );
202
+ }
203
+ if (resolveDbMode() !== "full") {
204
+ throw Object.assign(
205
+ new Error(
206
+ `CARTETHYIA_PROCESS_COUNT=${topology.count} requires CARTETHYIA_DB_MODE=full (PostgreSQL); ` +
207
+ "two processes on one SQLite file corrupt it — there is no cross-process lock in the driver",
208
+ ),
209
+ { code: "multi_process_requires_postgres" },
210
+ );
211
+ }
212
+ const reachable = await redisReachable();
213
+ if (!reachable) {
214
+ throw Object.assign(
215
+ new Error(
216
+ `CARTETHYIA_PROCESS_COUNT=${topology.count} requires a REACHABLE Redis: admission counters, ` +
217
+ "quotas, and cooldowns are per-process without one, so every per-key limit would be " +
218
+ `enforced ${topology.count} times over`,
219
+ ),
220
+ { code: "multi_process_requires_redis" },
221
+ );
222
+ }
223
+ }
224
+
148
225
  export async function buildProductionDeps(): Promise<ProductionDeps> {
149
226
  const startupStartedAt = performance.now();
150
227
  const startupMemoryBefore = process.memoryUsage();
228
+ // Resolved first and validated before any connection is opened: a
229
+ // half-configured multi-process deploy that starts anyway looks healthy
230
+ // until a per-key limit is enforced N times over.
231
+ const topology = resolveProcessTopology();
232
+ await assertMultiProcessPreconditions(topology, redisIsReachable);
151
233
  const handle = await measureStartupPhase("database-connect", bootDatabase);
152
234
  const db = handle.db;
153
235
  // One rule, no mode flag: REDIS_URL set → shared client, unset → memory.
@@ -157,7 +239,7 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
157
239
  await ensureMigrated();
158
240
  // Pool sizing is an external-server concern; the embedded backend has no
159
241
  // connection budget to fit into.
160
- if (handle.kind === "pg") await assertPoolFitsServerCapacity(poolMaxFromEnv());
242
+ if (handle.kind === "pg") await assertPoolFitsServerCapacity(poolMaxFromEnv(), topology.count);
161
243
  });
162
244
  await measureStartupPhase("provider-seed", () => seedBundledProviders(db));
163
245
  // Boot-only: retire global rows for providers the bundle has dropped, so a
@@ -193,7 +275,6 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
193
275
  tenantId: string | null,
194
276
  ): Promise<readonly { accountId: string; inflight: number }[]> => {
195
277
  if (providerId === "" || tenantId === null) return [];
196
- const snapshot = await routingEngine.accountInflightSnapshot();
197
278
  const accounts = await new DrizzleProviderCatalogStore(db, {
198
279
  telemetryBuffer,
199
280
  bundledModelCatalog: bundledCatalog,
@@ -202,10 +283,20 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
202
283
  snapshotInvalidator: snapshotService,
203
284
  }).listAccounts(tenantId, providerId);
204
285
  const owned = new Set(accounts.map((account) => account.id));
286
+ // Scoped read: only the provider's own candidates for this tenant are
287
+ // queried, so the console read costs one exact MGET over the accounts it
288
+ // is about to report on — not a fleet-wide `KEYS` scan whose cost is the
289
+ // size of every bucket every process ever wrote.
290
+ const routeSnapshot = await snapshotService.getSnapshot();
291
+ const ownedCandidates = routeSnapshot.candidates.filter(
292
+ (candidate) =>
293
+ candidate.provider_id === providerId &&
294
+ candidate.provider_account_id !== undefined &&
295
+ owned.has(candidate.provider_account_id),
296
+ );
297
+ const snapshot = await routingEngine.accountInflightSnapshot(ownedCandidates);
205
298
  const totals = new Map<string, number>();
206
299
  for (const [bucket, count] of snapshot) {
207
- const prefix = `${providerId}:`;
208
- if (!bucket.startsWith(prefix)) continue;
209
300
  const accountId = bucket.slice(bucket.lastIndexOf(":") + 1);
210
301
  if (!owned.has(accountId)) continue;
211
302
  totals.set(accountId, (totals.get(accountId) ?? 0) + count);
@@ -260,6 +351,13 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
260
351
  const children = await store.sumChildrenConsumed(apiKeyId);
261
352
  return (row.lifetimeTokensConsumed ?? 0) + children;
262
353
  },
354
+ // Slack above every key's concurrency limit. A tenant preference an
355
+ // operator sets in the runtime settings modal, falling back to the
356
+ // deployment default so an unset row does not silently lose the allowance.
357
+ (tenantId) =>
358
+ preferencesReaderFor(db)
359
+ .readPreferences(tenantId)
360
+ .then((prefs) => prefs?.concurrencyTolerance ?? resolveConcurrencyTolerance()),
263
361
  );
264
362
  const ipStore = redis
265
363
  ? new RedisIpAbuseStore(redis)
@@ -282,8 +380,14 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
282
380
  // One list covers every periodic task: a small deployment can cut the
283
381
  // heaviest sweeps (quota refresh, OAuth refresh, client-version polling)
284
382
  // without the codebase growing a flag per task.
383
+ // Leases coordinate the SHARED sweeps across processes. Absent Redis there is
384
+ // nothing to coordinate with, and the registry's local one-at-a-time rule is
385
+ // the correct (and only) guard.
285
386
  const scheduledTasks = new ScheduledTaskRegistry(
286
387
  readList("CARTETHYIA_DISABLED_TASKS", CONFIG_SPEC.CARTETHYIA_DISABLED_TASKS) ?? [],
388
+ {
389
+ ...(redis ? { lease: createRedisMaintenanceLeaseStore(redis) } : {}),
390
+ },
287
391
  );
288
392
  if (redis) {
289
393
  scheduledTasks.register({
@@ -295,6 +399,8 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
295
399
  scheduledTasks.register({
296
400
  name: "account-health-sweep",
297
401
  intervalMs: 30_000,
402
+ // Recovers cooldowns deployment-wide and invalidates the shared snapshot.
403
+ shared: true,
298
404
  run: async () => {
299
405
  const recovered = await sweepExpiredCooldowns(db);
300
406
  const poolRecovered = await sweepExpiredPoolCooldowns(db);
@@ -306,27 +412,11 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
306
412
  scheduledTasks.register({
307
413
  name: "telemetry-payload-cleanup",
308
414
  intervalMs: 15 * 60_000,
415
+ // Deletes payload frames for every tenant: two passes over the same
416
+ // batches is duplicated work, not speedup.
417
+ shared: true,
309
418
  run: () => payloadCapture.cleanupExpired(),
310
419
  });
311
- scheduledTasks.register({
312
- name: "telemetry-index-fold",
313
- intervalMs: resolveTelemetryCompactIntervalMs(),
314
- // Brings each day's index up to date with the bytes appended since the last
315
- // pass, so a usage read is a small JSON read rather than a parse of the
316
- // whole period. Folding is incremental and idempotent: a crash only leaves
317
- // the watermark behind, and the next pass re-reads the tail.
318
- run: async () => {
319
- const days = await listEventDays();
320
- let folded = 0;
321
- for (const day of days) {
322
- const result = await foldDayIndex(day).catch(() => ({ folded: 0 }));
323
- folded += result.folded;
324
- }
325
- if (folded > 0) {
326
- log.info(`[telemetry] indexed ${folded} event(s) across ${days.length} day(s)`);
327
- }
328
- },
329
- });
330
420
  scheduledTasks.register({
331
421
  name: "console-session-sweep",
332
422
  // Sessions live 48 h, so an hourly sweep deleted nothing 47 times out of
@@ -334,11 +424,15 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
334
424
  // be expired. Half the session lifetime is the point where a pass can
335
425
  // actually find work, and still leaves a full grace period after it.
336
426
  intervalMs: 12 * 60 * 60_000,
427
+ shared: true,
337
428
  run: () => deleteExpiredSessions(db),
338
429
  });
339
430
  scheduledTasks.register({
340
431
  name: "telemetry-retention",
341
432
  intervalMs: 6 * 60 * 60_000,
433
+ // Retention deletes rows and files for the whole deployment; a second
434
+ // concurrent pass would churn the same batches.
435
+ shared: true,
342
436
  run: () =>
343
437
  telemetryStore.pruneTelemetry(
344
438
  new Date(Date.now() - resolveTelemetryRetentionDays() * 24 * 60 * 60_000),
@@ -365,7 +459,16 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
365
459
  modelCatalogEntries: () => modelCatalogCacheSize(),
366
460
  // Late-bound via bindInFlight once the request state store exists.
367
461
  inFlight: () => 0,
462
+ // The buffer is built below; the closure keeps the sampler from holding a
463
+ // reference during construction.
464
+ telemetryBacklog: () => telemetryBuffer?.snapshot(),
368
465
  });
466
+ // One sample NOW, not on the first tick. The headroom guard reads
467
+ // `memorySnapshot()` on every gateway request and treats `undefined` as
468
+ // "unknown" — so without this the guard would spend its first interval
469
+ // (seconds, at the configured cadence) unable to shed at all, which is
470
+ // exactly the window a cold start is most likely to be short of memory.
471
+ runtimeMetricsSampler.sample();
369
472
  scheduledTasks.register({
370
473
  name: "runtime-metrics",
371
474
  intervalMs: readInt(
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Canonical resource policy: how much memory this process may use, and how
3
+ * much work it may hold at once.
4
+ *
5
+ * These two numbers were previously decided in three unrelated places — a
6
+ * hardcoded cap in `concurrency-guard.ts`, a host-wide `os.totalmem()` in the
7
+ * headroom guard, and a fallback constant in `runtime-metrics.ts` — which
8
+ * meant a container limited to 512 MiB admitted the same concurrency as a 4 GiB
9
+ * one and only started shedding once the *host* was nearly full. On a 1-core /
10
+ * 2 GiB VPS that is the difference between shedding early and being
11
+ * OOM-killed, and the OOM kills the dashboard too — the one surface that would
12
+ * have explained what happened.
13
+ *
14
+ * Everything here is derived from one limit and one body ceiling, so the
15
+ * numbers cannot disagree with each other:
16
+ *
17
+ * concurrency = floor(limit × BUDGET_FRACTION / (TRANSIENT_COPIES × body))
18
+ *
19
+ * The transient-copies factor is why this is not simply "limit / body": a
20
+ * request body is held more than once between the socket read, the JSON parse,
21
+ * and the upstream re-serialization, so budgeting one copy per request
22
+ * understates the real hold by a factor of about two.
23
+ */
24
+ import { CONFIG_SPEC, readOptionalInt } from "./spec";
25
+ import { resolveMaxBodyBytes } from "./runtime";
26
+
27
+ /** Used when no limit is discoverable at all (native dev, macOS, Windows). */
28
+ export const FALLBACK_MEMORY_LIMIT_BYTES = 512 * 1024 * 1024;
29
+
30
+ /**
31
+ * Ceiling on concurrent in-flight requests per process.
32
+ *
33
+ * A hard ceiling even when memory would allow more: this process is one core,
34
+ * and past this point queueing delay, not memory, is what degrades. It also
35
+ * bounds every per-request structure that is sized by concurrency.
36
+ */
37
+ export const MAX_PROCESS_CONCURRENCY = 64;
38
+
39
+ /**
40
+ * How many copies of a request body are resident at once.
41
+ *
42
+ * Measured, not assumed: the socket read buffer, the parsed object graph, and
43
+ * the upstream re-serialization each hold a body-sized allocation, and garbage
44
+ * collection lags rather than keeping them to one. Budgeting one copy halves
45
+ * the real peak and lets the process admit twice the concurrency it can hold.
46
+ */
47
+ export const REQUEST_BODY_TRANSIENT_COPIES = 2;
48
+
49
+ /** Share of the memory limit all in-flight request bodies together may hold. */
50
+ export const REQUEST_BODY_BUDGET_FRACTION = 0.25;
51
+
52
+ /** Share of the memory limit kept free for allocation spikes and GC slack. */
53
+ export const MEMORY_HEADROOM_FRACTION = 0.2;
54
+
55
+ /**
56
+ * Concurrency this process should admit, derived from the memory limit and the
57
+ * body ceiling rather than guessed.
58
+ *
59
+ * Worked examples, which are the point of having one formula:
60
+ * - 2 GiB limit, 4 MiB bodies → 64 (the ceiling; memory is not the binder)
61
+ * - 512 MiB limit, 4 MiB bodies → 16
62
+ * - 256 MiB limit, 16 MiB bodies → 2
63
+ *
64
+ * An explicit override is honoured, clamped to 1..MAX_PROCESS_CONCURRENCY. It
65
+ * is the operator's call and is never silently raised back to the derived
66
+ * value; exceeding the derived figure is a deliberate choice, so it is
67
+ * reported at the call site rather than corrected here.
68
+ */
69
+ export function deriveProcessConcurrency(options: {
70
+ readonly memoryLimitBytes: number;
71
+ readonly maxBodyBytes: number;
72
+ readonly override?: number | undefined;
73
+ }): number {
74
+ if (options.override !== undefined) {
75
+ if (!Number.isInteger(options.override)) return 1;
76
+ return Math.min(MAX_PROCESS_CONCURRENCY, Math.max(1, options.override));
77
+ }
78
+ const body = Math.max(1, options.maxBodyBytes);
79
+ const perRequest = REQUEST_BODY_TRANSIENT_COPIES * body;
80
+ const budget = Math.max(1, Math.floor(options.memoryLimitBytes * REQUEST_BODY_BUDGET_FRACTION));
81
+ const derived = Math.floor(budget / perRequest);
82
+ return Math.min(MAX_PROCESS_CONCURRENCY, Math.max(1, derived));
83
+ }
84
+
85
+ /**
86
+ * Bytes kept free below the memory limit.
87
+ *
88
+ * A guard that only reacts once RSS equals the limit has already lost: the
89
+ * allocation that breaches it happens between two samples, and the spike that
90
+ * matters — an upstream response landing all at once — arrives in far less
91
+ * than one sample interval.
92
+ */
93
+ export function deriveMemoryHeadroomBytes(memoryLimitBytes: number): number {
94
+ return Math.ceil(memoryLimitBytes * MEMORY_HEADROOM_FRACTION);
95
+ }
96
+
97
+ /**
98
+ * The optional operator override, read through the config spec so "unset" and
99
+ * "out of range" are treated the way every other knob treats them.
100
+ */
101
+ export function resolveProcessConcurrencyOverride(): number | undefined {
102
+ return readOptionalInt(
103
+ "CARTETHYIA_PROCESS_CONCURRENCY_LIMIT",
104
+ CONFIG_SPEC.CARTETHYIA_PROCESS_CONCURRENCY_LIMIT,
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Slack added on top of every key's concurrency limit.
110
+ *
111
+ * Operator configuration, not something a key carries about itself: it is
112
+ * deliberate hidden allowance so a run that lands a few requests over the
113
+ * number an operator chose is not refused at that number. The effective
114
+ * ceiling is `limit + tolerance`, and only requests past it are refused.
115
+ */
116
+ export function resolveConcurrencyTolerance(): number {
117
+ const value = readOptionalInt(
118
+ "CARTETHYIA_CONCURRENCY_TOLERANCE",
119
+ CONFIG_SPEC.CARTETHYIA_CONCURRENCY_TOLERANCE,
120
+ );
121
+ return value === undefined ? 0 : Math.max(0, value);
122
+ }
123
+
124
+ /**
125
+ * The effective concurrency for this process.
126
+ *
127
+ * Takes the limit as an argument rather than resolving it here: the limit
128
+ * owner is `resolveMemoryLimitBytes()` in `observability/runtime-metrics.ts`,
129
+ * and duplicating that resolution would let the two drift.
130
+ */
131
+ export function resolveProcessConcurrency(memoryLimitBytes: number): number {
132
+ return deriveProcessConcurrency({
133
+ memoryLimitBytes,
134
+ maxBodyBytes: resolveMaxBodyBytes(),
135
+ override: resolveProcessConcurrencyOverride(),
136
+ });
137
+ }
@@ -1,76 +1,133 @@
1
- /**
2
- * HTTP listener, dashboard runtime, and graceful-shutdown drain resolvers.
3
- */
4
-
5
- import { CONFIG_SPEC, readInt, readOptionalText } from "./spec";
6
- import { resolvePackagePath } from "./package-paths";
7
-
8
- /** Resolves the HTTP listener port from the process environment. */
9
- export function resolvePort(): number {
10
- return readInt("PORT", CONFIG_SPEC.PORT);
11
- }
12
-
13
- /** Resolves the dashboard static asset directory. */
14
- export function resolveDashboardDist(): string {
15
- const raw = process.env.DASHBOARD_DIST?.trim();
16
- if (!raw) return resolvePackagePath("dist", "dashboard");
17
- return raw;
18
- }
19
-
20
- /**
21
- * Resolves the configured default gateway API key (`CARTETHYIA_API_KEY`), if
22
- * one is set. Consumers that need the raw secret — first-boot seeding and the
23
- * console's key recovery from existing installations — read it here instead of
24
- * probing `process.env` directly.
25
- */
26
- export function resolveDefaultGatewayApiKey(): string | undefined {
27
- return readOptionalText("CARTETHYIA_API_KEY", CONFIG_SPEC.CARTETHYIA_API_KEY);
28
- }
29
-
30
- /** Resolves the request-body ceiling shared by the Bun listener and ingress middleware. */
31
- export function resolveMaxBodyBytes(): number {
32
- return readInt("CARTETHYIA_SERVER_MAX_BODY_BYTES", CONFIG_SPEC.CARTETHYIA_SERVER_MAX_BODY_BYTES);
33
- }
34
-
35
- /** Resolves the listener idle-socket timeout (seconds). */
36
- export function resolveIdleTimeout(): number {
37
- return readInt("CARTETHYIA_SERVER_IDLE_TIMEOUT", CONFIG_SPEC.CARTETHYIA_SERVER_IDLE_TIMEOUT);
38
- }
39
-
40
- /** Resolves the graceful-shutdown drain window: time for in-flight requests to finish before abort. */
41
- export function resolveShutdownDrainWindowMs(): number {
42
- return readInt(
43
- "CARTETHYIA_SHUTDOWN_DRAIN_WINDOW_MS",
44
- CONFIG_SPEC.CARTETHYIA_SHUTDOWN_DRAIN_WINDOW_MS,
45
- );
46
- }
47
-
48
- /** Resolves the operator drain token, or `undefined` when the drain route is disabled. */
49
- export function resolveDrainToken(): string | undefined {
50
- return readOptionalText("CARTETHYIA_DRAIN_TOKEN", CONFIG_SPEC.CARTETHYIA_DRAIN_TOKEN);
51
- }
52
-
53
- /**
54
- * Whether Elysia precompiles route handlers at startup (AOT).
55
- *
56
- * Precompilation takes the eager validator branch, which calls TypeBox's
57
- * `SchemaCompile(schema)` and then reads `.buildResult.external` off the
58
- * result. In a source-run process that field is absent — the lazy
59
- * `require("typebox/schema")` resolves to a namespace that does not carry it —
60
- * and the process dies at the first schema-bearing route with
61
- * `undefined is not an object (evaluating 'this.tb.buildResult.external')`.
62
- * `scripts/build/aot.ts` avoids this by rewriting the call site and bundling
63
- * the namespaces statically, so only the built artifact can actually take the
64
- * eager path.
65
- *
66
- * So: precompilation is on for production *builds*, and off for a source run
67
- * even when `NODE_ENV=production`. A deployment that runs
68
- * `bun run src/main.ts` must not be pushed onto the eager branch.
69
- *
70
- * `CARTETHYIA_SOURCE_RUN` marks a source run explicitly. It is set by
71
- * `scripts/deploy/*.sh`, which are the only supported way to run from source.
72
- */
73
- export function resolveElysiaPrecompile(): boolean {
74
- if (process.env.CARTETHYIA_SOURCE_RUN === "1") return false;
75
- return process.env.NODE_ENV === "production";
76
- }
1
+ /**
2
+ * HTTP listener, dashboard runtime, and graceful-shutdown drain resolvers.
3
+ */
4
+
5
+ import { CONFIG_SPEC, readInt, readOptionalText } from "./spec";
6
+ import { resolvePackagePath } from "./package-paths";
7
+
8
+ /** Resolves the HTTP listener port from the process environment. */
9
+ export function resolvePort(): number {
10
+ return readInt("PORT", CONFIG_SPEC.PORT);
11
+ }
12
+
13
+ /** Resolves the dashboard static asset directory. */
14
+ export function resolveDashboardDist(): string {
15
+ const raw = process.env.DASHBOARD_DIST?.trim();
16
+ if (!raw) return resolvePackagePath("dist", "dashboard");
17
+ return raw;
18
+ }
19
+
20
+ /**
21
+ * Resolves the configured default gateway API key (`CARTETHYIA_API_KEY`), if
22
+ * one is set. Consumers that need the raw secret — first-boot seeding and the
23
+ * console's key recovery from existing installations — read it here instead of
24
+ * probing `process.env` directly.
25
+ */
26
+ export function resolveDefaultGatewayApiKey(): string | undefined {
27
+ return readOptionalText("CARTETHYIA_API_KEY", CONFIG_SPEC.CARTETHYIA_API_KEY);
28
+ }
29
+
30
+ /** Resolves the request-body ceiling shared by the Bun listener and ingress middleware. */
31
+ export function resolveMaxBodyBytes(): number {
32
+ return readInt("CARTETHYIA_SERVER_MAX_BODY_BYTES", CONFIG_SPEC.CARTETHYIA_SERVER_MAX_BODY_BYTES);
33
+ }
34
+
35
+ /** Resolves the listener idle-socket timeout (seconds). */
36
+ export function resolveIdleTimeout(): number {
37
+ return readInt("CARTETHYIA_SERVER_IDLE_TIMEOUT", CONFIG_SPEC.CARTETHYIA_SERVER_IDLE_TIMEOUT);
38
+ }
39
+
40
+ /** Resolves the graceful-shutdown drain window: time for in-flight requests to finish before abort. */
41
+ export function resolveShutdownDrainWindowMs(): number {
42
+ return readInt(
43
+ "CARTETHYIA_SHUTDOWN_DRAIN_WINDOW_MS",
44
+ CONFIG_SPEC.CARTETHYIA_SHUTDOWN_DRAIN_WINDOW_MS,
45
+ );
46
+ }
47
+
48
+ /** Resolves the operator drain token, or `undefined` when the drain route is disabled. */
49
+ export function resolveDrainToken(): string | undefined {
50
+ return readOptionalText("CARTETHYIA_DRAIN_TOKEN", CONFIG_SPEC.CARTETHYIA_DRAIN_TOKEN);
51
+ }
52
+
53
+ /**
54
+ * Whether Elysia precompiles route handlers at startup (AOT).
55
+ *
56
+ * Precompilation takes the eager validator branch, which calls TypeBox's
57
+ * `SchemaCompile(schema)` and then reads `.buildResult.external` off the
58
+ * result. In a source-run process that field is absent — the lazy
59
+ * `require("typebox/schema")` resolves to a namespace that does not carry it —
60
+ * and the process dies at the first schema-bearing route with
61
+ * `undefined is not an object (evaluating 'this.tb.buildResult.external')`.
62
+ * `scripts/build/aot.ts` avoids this by rewriting the call site and bundling
63
+ * the namespaces statically, so only the built artifact can actually take the
64
+ * eager path.
65
+ *
66
+ * So: precompilation is on for production *builds*, and off for a source run
67
+ * even when `NODE_ENV=production`. A deployment that runs
68
+ * `bun run src/main.ts` must not be pushed onto the eager branch.
69
+ *
70
+ * `CARTETHYIA_SOURCE_RUN` marks a source run explicitly. It is set by
71
+ * `scripts/deploy/*.sh`, which are the only supported way to run from source.
72
+ */
73
+ export function resolveElysiaPrecompile(): boolean {
74
+ if (process.env.CARTETHYIA_SOURCE_RUN === "1") return false;
75
+ return process.env.NODE_ENV === "production";
76
+ }
77
+
78
+ /**
79
+ * How many gateway processes this deployment runs, and which one this is.
80
+ *
81
+ * Multi-process is opt-in and deliberately narrow. Every process shares the
82
+ * port through `SO_REUSEPORT`, so the kernel load-balances across them — which
83
+ * means state that used to be process-local is now per-process unless it lives
84
+ * somewhere shared. A second process without that sharing is not "twice the
85
+ * capacity", it is two processes disagreeing:
86
+ *
87
+ * - **PostgreSQL, not SQLite.** Two processes on one SQLite file corrupt it;
88
+ * there is no cross-process lock in the driver.
89
+ * - **Redis configured AND reachable.** Admission counters, quotas, and
90
+ * cooldowns are per-process without it, so a per-key RPM limit silently
91
+ * becomes N× the configured value.
92
+ * - **Linux.** `SO_REUSEPORT` is the mechanism; elsewhere it is a silent no-op
93
+ * and the second process simply fails to bind.
94
+ *
95
+ * These are checked at resolution time rather than left to fail at bind time,
96
+ * because a half-configured multi-process deploy that starts anyway looks fine
97
+ * until a limit is enforced twice.
98
+ */
99
+ export interface ProcessTopology {
100
+ readonly count: number;
101
+ readonly index: number;
102
+ /** True when more than one process serves this deployment. */
103
+ readonly multiProcess: boolean;
104
+ }
105
+
106
+ /**
107
+ * Resolves the topology from the environment.
108
+ *
109
+ * `count > 1` requires an explicit index: a process that does not know which
110
+ * one it is cannot own a shard of anything, and a guessed index would have two
111
+ * processes claiming the same one.
112
+ */
113
+ export function resolveProcessTopology(): ProcessTopology {
114
+ const count = readInt("CARTETHYIA_PROCESS_COUNT", CONFIG_SPEC.CARTETHYIA_PROCESS_COUNT);
115
+ if (count === 1) return { count: 1, index: 0, multiProcess: false };
116
+ // Mandatory when the count is above one: a process that does not know which
117
+ // one it is cannot own a shard of anything, and a defaulted index would have
118
+ // two processes claiming the same number. The spec's default of 0 cannot
119
+ // express "absent", so the raw value is checked here before it is read.
120
+ const rawIndex = process.env.CARTETHYIA_PROCESS_INDEX?.trim();
121
+ if (rawIndex === undefined || rawIndex.length === 0) {
122
+ throw new Error(
123
+ `CARTETHYIA_PROCESS_INDEX is required when CARTETHYIA_PROCESS_COUNT is ${count} (0 – ${count - 1})`,
124
+ );
125
+ }
126
+ const index = readInt("CARTETHYIA_PROCESS_INDEX", CONFIG_SPEC.CARTETHYIA_PROCESS_INDEX);
127
+ if (index >= count) {
128
+ throw new Error(
129
+ `CARTETHYIA_PROCESS_INDEX must be an integer between 0 and ${count - 1} (got "${rawIndex}")`,
130
+ );
131
+ }
132
+ return { count, index, multiProcess: true };
133
+ }