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,40 +1,40 @@
1
- /**
2
- * The update path.
3
- *
4
- * npm cannot replace this package's directory while a process is executing out
5
- * of it (EBUSY on Windows). So we spawn the installer as a detached process,
6
- * print a short notice, and exit — the installer runs after this process is gone.
7
- * After install, it attempts to restart the gateway (systemd if available, else
8
- * `cartethyia start`), so the new version comes up automatically.
9
- * Cross-OS: uses npm.cmd on Windows.
10
- */
11
- import { spawn } from "node:child_process";
12
-
13
- export function updateCommand(): string {
14
- return "npm install -g cartethyia@latest --prefer-online";
15
- }
16
-
17
- export function runDetachedUpdate(): void {
18
- const isWin = process.platform === "win32";
19
- const shell = isWin ? "cmd.exe" : "/bin/sh";
20
- const install = "npm install -g cartethyia@latest --prefer-online";
21
- const restart = isWin
22
- ? "cartethyia start"
23
- : "systemctl restart cartethyia 2>/dev/null || cartethyia start";
24
- const script = `${install} && ${restart}`;
25
- const args = isWin ? ["/c", script] : ["-c", script];
26
- const child = spawn(shell, args, {
27
- detached: true,
28
- stdio: "ignore",
29
- });
30
- child.unref();
31
- }
32
-
33
- export function printUpdateInstructions(from: string, to: string | null): void {
34
- console.log("");
35
- console.log(`Update available: ${from} \u2192 ${to ?? "latest"}`);
36
- console.log("");
37
- console.log("Starting detached update; this process will exit now.");
38
- console.log("The gateway will restart automatically after the install.");
39
- console.log("");
1
+ /**
2
+ * The update path.
3
+ *
4
+ * npm cannot replace this package's directory while a process is executing out
5
+ * of it (EBUSY on Windows). So we spawn the installer as a detached process,
6
+ * print a short notice, and exit — the installer runs after this process is gone.
7
+ * After install, it attempts to restart the gateway (systemd if available, else
8
+ * `cartethyia start`), so the new version comes up automatically.
9
+ * Cross-OS: uses npm.cmd on Windows.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+
13
+ export function updateCommand(): string {
14
+ return "npm install -g cartethyia@latest --prefer-online";
15
+ }
16
+
17
+ export function runDetachedUpdate(): void {
18
+ const isWin = process.platform === "win32";
19
+ const shell = isWin ? "cmd.exe" : "/bin/sh";
20
+ const install = "npm install -g cartethyia@latest --prefer-online";
21
+ const restart = isWin
22
+ ? "cartethyia start"
23
+ : "systemctl restart cartethyia 2>/dev/null || cartethyia start";
24
+ const script = `${install} && ${restart}`;
25
+ const args = isWin ? ["/c", script] : ["-c", script];
26
+ const child = spawn(shell, args, {
27
+ detached: true,
28
+ stdio: "ignore",
29
+ });
30
+ child.unref();
31
+ }
32
+
33
+ export function printUpdateInstructions(from: string, to: string | null): void {
34
+ console.log("");
35
+ console.log(`Update available: ${from} \u2192 ${to ?? "latest"}`);
36
+ console.log("");
37
+ console.log("Starting detached update; this process will exit now.");
38
+ console.log("The gateway will restart automatically after the install.");
39
+ console.log("");
40
40
  }
@@ -1,67 +1,67 @@
1
- import { resolvePackagePath } from "../../src/config/package-paths";
2
-
3
- export async function readRequiredBunVersion(): Promise<string> {
4
- const metadata: unknown = await Bun.file(resolvePackagePath("package.json")).json();
5
- if (metadata === null || typeof metadata !== "object" || !("engines" in metadata) ||
6
- metadata.engines === null || typeof metadata.engines !== "object" ||
7
- !("bun" in metadata.engines) || typeof metadata.engines.bun !== "string") {
8
- throw new Error("Cartethyia's required Bun version is unavailable in package.json");
9
- }
10
- const minimum = metadata.engines.bun.trim().replace(/^>=\s*/, "");
11
- if (!/^\d+(?:\.\d+){0,2}$/.test(minimum)) throw new Error(`Unsupported Bun engine requirement: ${metadata.engines.bun}`);
12
- return minimum;
13
- }
14
-
15
- export function bunVersionAtLeast(actual: string, minimum: string): boolean {
16
- const left = actual.split(".").map((part) => Number.parseInt(part, 10) || 0);
17
- const right = minimum.split(".").map((part) => Number.parseInt(part, 10) || 0);
18
- const length = Math.max(left.length, right.length);
19
- for (let i = 0; i < length; i += 1) {
20
- const a = left[i] ?? 0;
21
- const b = right[i] ?? 0;
22
- if (a !== b) return a > b;
23
- }
24
- return true;
25
- }
26
-
27
- export async function assertBunVersion(): Promise<void> {
28
- const minimum = await readRequiredBunVersion();
29
- if (bunVersionAtLeast(Bun.version, minimum)) return;
30
- throw new Error(`Bun ${minimum} or newer is required (found ${Bun.version}). Install it from https://bun.sh and run this command again.`);
31
- }
32
-
33
- /** Reads the version from the installed package manifest. */
34
- export async function readInstalledVersion(): Promise<string> {
35
- const metadata: unknown = await Bun.file(resolvePackagePath("package.json")).json();
36
- if (
37
- metadata === null ||
38
- typeof metadata !== "object" ||
39
- !("version" in metadata) ||
40
- typeof metadata.version !== "string"
41
- ) {
42
- throw new Error("Cartethyia package version is unavailable");
43
- }
44
- return metadata.version;
45
- }
46
-
47
- /** Asks the npm registry for the newest published version, or null on failure. */
48
- export async function readLatestPublishedVersion(): Promise<string | null> {
49
- try {
50
- const response = await fetch("https://registry.npmjs.org/cartethyia/latest", {
51
- signal: AbortSignal.timeout(4_000),
52
- });
53
- if (!response.ok) return null;
54
- const metadata: unknown = await response.json();
55
- if (
56
- metadata !== null &&
57
- typeof metadata === "object" &&
58
- "version" in metadata &&
59
- typeof metadata.version === "string"
60
- ) {
61
- return metadata.version;
62
- }
63
- return null;
64
- } catch {
65
- return null;
66
- }
67
- }
1
+ import { resolvePackagePath } from "../../src/config/package-paths";
2
+
3
+ export async function readRequiredBunVersion(): Promise<string> {
4
+ const metadata: unknown = await Bun.file(resolvePackagePath("package.json")).json();
5
+ if (metadata === null || typeof metadata !== "object" || !("engines" in metadata) ||
6
+ metadata.engines === null || typeof metadata.engines !== "object" ||
7
+ !("bun" in metadata.engines) || typeof metadata.engines.bun !== "string") {
8
+ throw new Error("Cartethyia's required Bun version is unavailable in package.json");
9
+ }
10
+ const minimum = metadata.engines.bun.trim().replace(/^>=\s*/, "");
11
+ if (!/^\d+(?:\.\d+){0,2}$/.test(minimum)) throw new Error(`Unsupported Bun engine requirement: ${metadata.engines.bun}`);
12
+ return minimum;
13
+ }
14
+
15
+ export function bunVersionAtLeast(actual: string, minimum: string): boolean {
16
+ const left = actual.split(".").map((part) => Number.parseInt(part, 10) || 0);
17
+ const right = minimum.split(".").map((part) => Number.parseInt(part, 10) || 0);
18
+ const length = Math.max(left.length, right.length);
19
+ for (let i = 0; i < length; i += 1) {
20
+ const a = left[i] ?? 0;
21
+ const b = right[i] ?? 0;
22
+ if (a !== b) return a > b;
23
+ }
24
+ return true;
25
+ }
26
+
27
+ export async function assertBunVersion(): Promise<void> {
28
+ const minimum = await readRequiredBunVersion();
29
+ if (bunVersionAtLeast(Bun.version, minimum)) return;
30
+ throw new Error(`Bun ${minimum} or newer is required (found ${Bun.version}). Install it from https://bun.sh and run this command again.`);
31
+ }
32
+
33
+ /** Reads the version from the installed package manifest. */
34
+ export async function readInstalledVersion(): Promise<string> {
35
+ const metadata: unknown = await Bun.file(resolvePackagePath("package.json")).json();
36
+ if (
37
+ metadata === null ||
38
+ typeof metadata !== "object" ||
39
+ !("version" in metadata) ||
40
+ typeof metadata.version !== "string"
41
+ ) {
42
+ throw new Error("Cartethyia package version is unavailable");
43
+ }
44
+ return metadata.version;
45
+ }
46
+
47
+ /** Asks the npm registry for the newest published version, or null on failure. */
48
+ export async function readLatestPublishedVersion(): Promise<string | null> {
49
+ try {
50
+ const response = await fetch("https://registry.npmjs.org/cartethyia/latest", {
51
+ signal: AbortSignal.timeout(4_000),
52
+ });
53
+ if (!response.ok) return null;
54
+ const metadata: unknown = await response.json();
55
+ if (
56
+ metadata !== null &&
57
+ typeof metadata === "object" &&
58
+ "version" in metadata &&
59
+ typeof metadata.version === "string"
60
+ ) {
61
+ return metadata.version;
62
+ }
63
+ return null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
@@ -1,58 +1,58 @@
1
- /**
2
- * The console wordmark.
3
- *
4
- * Rendered with half-block characters so each letter is two pixel rows tall in
5
- * one character cell, and coloured with a horizontal gradient across the
6
- * Cartethyia blue-grey range. The caller decides whether to print it: a
7
- * non-colour terminal, or one narrower than the wordmark, should skip it.
8
- */
9
-
10
- const WORDMARK: readonly string[] = [
11
- " ██████╗ █████╗ ██████╗ ████████╗███████╗████████╗██╗ ██╗██╗ ██╗██╗ █████╗ ",
12
- "██╔════╝██╔══██╗██╔══██╗╚══██╔══╝██╔════╝╚══██╔══╝██║ ██║╚██╗ ██╔╝██║██╔══██╗",
13
- "██║ ███████║██████╔╝ ██║ █████╗ ██║ ███████║ ╚████╔╝ ██║███████║",
14
- "██║ ██╔══██║██╔══██╗ ██║ ██╔══╝ ██║ ██╔══██║ ╚██╔╝ ██║██╔══██║",
15
- "╚██████╗██║ ██║██║ ██║ ██║ ███████╗ ██║ ██║ ██║ ██║ ██║██║ ██║",
16
- " ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝",
17
- ];
18
-
19
- /** Blue-grey gradient: dim at the edges, brighter through the middle. */
20
- const GRADIENT: readonly [number, number, number][] = [
21
- [90, 120, 150],
22
- [120, 160, 200],
23
- [150, 200, 240],
24
- [120, 160, 200],
25
- [90, 120, 150],
26
- ];
27
-
28
- const RESET = "\u001b[0m";
29
-
30
- /**
31
- * Returns the wordmark lines with per-column colour, or an empty array when the
32
- * terminal is too narrow or colour is disabled.
33
- */
34
- export function renderWordmark(columns = process.stdout.columns ?? 80): string[] {
35
- if (process.env.NO_COLOR !== undefined) return [];
36
- const width = WORDMARK[0]!.length;
37
- if (columns < width) return [];
38
-
39
- const coloured: string[] = [];
40
- for (const row of WORDMARK) {
41
- let line = "";
42
- let current = -1;
43
- for (let column = 0; column < row.length; column += 1) {
44
- const band = Math.min(
45
- GRADIENT.length - 1,
46
- Math.floor((column / width) * GRADIENT.length),
47
- );
48
- const [r, g, b] = GRADIENT[band]!;
49
- if (band !== current) {
50
- line += `\u001b[38;2;${r};${g};${b}m`;
51
- current = band;
52
- }
53
- line += row[column];
54
- }
55
- coloured.push(`${line}${RESET}`);
56
- }
57
- return coloured;
58
- }
1
+ /**
2
+ * The console wordmark.
3
+ *
4
+ * Rendered with half-block characters so each letter is two pixel rows tall in
5
+ * one character cell, and coloured with a horizontal gradient across the
6
+ * Cartethyia blue-grey range. The caller decides whether to print it: a
7
+ * non-colour terminal, or one narrower than the wordmark, should skip it.
8
+ */
9
+
10
+ const WORDMARK: readonly string[] = [
11
+ " ██████╗ █████╗ ██████╗ ████████╗███████╗████████╗██╗ ██╗██╗ ██╗██╗ █████╗ ",
12
+ "██╔════╝██╔══██╗██╔══██╗╚══██╔══╝██╔════╝╚══██╔══╝██║ ██║╚██╗ ██╔╝██║██╔══██╗",
13
+ "██║ ███████║██████╔╝ ██║ █████╗ ██║ ███████║ ╚████╔╝ ██║███████║",
14
+ "██║ ██╔══██║██╔══██╗ ██║ ██╔══╝ ██║ ██╔══██║ ╚██╔╝ ██║██╔══██║",
15
+ "╚██████╗██║ ██║██║ ██║ ██║ ███████╗ ██║ ██║ ██║ ██║ ██║██║ ██║",
16
+ " ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝",
17
+ ];
18
+
19
+ /** Blue-grey gradient: dim at the edges, brighter through the middle. */
20
+ const GRADIENT: readonly [number, number, number][] = [
21
+ [90, 120, 150],
22
+ [120, 160, 200],
23
+ [150, 200, 240],
24
+ [120, 160, 200],
25
+ [90, 120, 150],
26
+ ];
27
+
28
+ const RESET = "\u001b[0m";
29
+
30
+ /**
31
+ * Returns the wordmark lines with per-column colour, or an empty array when the
32
+ * terminal is too narrow or colour is disabled.
33
+ */
34
+ export function renderWordmark(columns = process.stdout.columns ?? 80): string[] {
35
+ if (process.env.NO_COLOR !== undefined) return [];
36
+ const width = WORDMARK[0]!.length;
37
+ if (columns < width) return [];
38
+
39
+ const coloured: string[] = [];
40
+ for (const row of WORDMARK) {
41
+ let line = "";
42
+ let current = -1;
43
+ for (let column = 0; column < row.length; column += 1) {
44
+ const band = Math.min(
45
+ GRADIENT.length - 1,
46
+ Math.floor((column / width) * GRADIENT.length),
47
+ );
48
+ const [r, g, b] = GRADIENT[band]!;
49
+ if (band !== current) {
50
+ line += `\u001b[38;2;${r};${g};${b}m`;
51
+ current = band;
52
+ }
53
+ line += row[column];
54
+ }
55
+ coloured.push(`${line}${RESET}`);
56
+ }
57
+ return coloured;
58
+ }
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(
@@ -24,6 +24,30 @@ export function resolvePackagePath(...segments: string[]): string {
24
24
  return resolve(PACKAGE_ROOT, ...segments);
25
25
  }
26
26
 
27
+ /** Outfile `bun build --compile` writes when no `--outfile` is given. */
28
+ export const DEFAULT_BINARY_OUTFILE = "dist/cartethyia";
29
+
30
+ /**
31
+ * The artifact path `bun build --compile` actually writes for `outfile`.
32
+ *
33
+ * Bun appends `.exe` when it compiles for Windows, so the extensionless
34
+ * `dist/cartethyia` handed to it never exists on that platform. This is the one
35
+ * place that knows where the compiled binary lands; `scripts/build/` resolves
36
+ * its target from here instead of re-deriving the name, because a launcher that
37
+ * probed the requested path reported "run bun run build first" immediately
38
+ * after a successful build.
39
+ *
40
+ * It sits in `src/config/` rather than `scripts/build/` because the launcher
41
+ * ships in the npm package and `scripts/build/` does not: a shipped module that
42
+ * imported the build script crashed every installed copy of `carte` with
43
+ * `Cannot find module '../build/compile'`.
44
+ */
45
+ export function compiledBinaryPath(outfile: string = DEFAULT_BINARY_OUTFILE): string {
46
+ return process.platform === "win32" && !outfile.toLowerCase().endsWith(".exe")
47
+ ? `${outfile}.exe`
48
+ : outfile;
49
+ }
50
+
27
51
  /** User-writable directory for the npm-installed CLI's configuration. */
28
52
  export function resolveUserConfigDir(env: NodeJS.ProcessEnv = process.env): string {
29
53
  if (process.platform === "win32") {