cartethyia 2.7.0 → 2.7.2

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 (330) hide show
  1. package/.env.example +107 -106
  2. package/README.md +25 -2
  3. package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-XaUmiBrC.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-0XUo4UMn.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-CXGS_Ct-.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-DAG5J9EY.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  17. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js +1 -0
  18. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-DKVslqG3.js → ModelPicker-BH8Tehdi.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
  26. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
  27. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +9 -0
  30. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  31. package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-7P2n--Fv.js} +1 -1
  32. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CxZ1fTkN.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  37. package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-Dqf4zpS-.js} +1 -1
  38. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-Byj2lAEm.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  43. package/dist/dashboard/assets/{api-keys-DFY_fZU4.js → api-keys-CUi6UXsR.js} +1 -1
  44. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  45. package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
  46. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-CwdnZARt.js → assert-67xXH5rf.js} +1 -1
  48. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-Dr-uf0Au.js → badge-DC2L43Mj.js} +1 -1
  50. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-BddmMdoS.js → banner-BBVcb0J3.js} +1 -1
  52. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-CK0PXWlX.js → card-Cca1XC6G.js} +1 -1
  54. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CcB6mGh2.js → cli-tools-KvsWa1Jl.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-DGb2P9Yx.js → clipboard-button-DUzEbynq.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-LkfuL50u.js → dialog-DFvWhwTa.js} +1 -1
  60. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-ZbEZ_84c.js → drawer-CFM2sMX1.js} +1 -1
  62. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  65. package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-ClCMCWWQ.js} +3 -3
  66. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  67. package/dist/dashboard/assets/{entry-CvC1-Zqx.js → entry-_xHo5x6r.js} +1 -1
  68. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  69. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js +1 -0
  70. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-BxpznFLt.js → github-badge-C9e8Ts7e.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-CDzP3ovJ.js → index-DFVFrnWW.js} +2 -2
  74. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-Ae13MQ5w.js → inline-BFUcvllj.js} +1 -1
  76. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-D23fo7cp.js → input-C_ILAvBb.js} +1 -1
  78. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  79. package/dist/dashboard/assets/{layout-CSsxjj5P.js → layout-CBIyT6Rt.js} +1 -1
  80. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-dmbwZMDY.js → list-row-ebJ_-Nsb.js} +1 -1
  82. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BG1oUsZZ.js → live-D0mm--ME.js} +1 -1
  84. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-DSF-e4ho.js → mutation-sMD965oO.js} +1 -1
  86. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-Bm_aECY7.js → numbers-BO9dKaFr.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  89. package/dist/dashboard/assets/providers/inception.svg +5 -0
  90. package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-CF56nYIV.js → query-keys-CfL_Zdoh.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CeA71n7G.js → routing-BnIpi1IJ.js} +1 -1
  94. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-KIT0rC6G.js → select-DBNVLdUu.js} +1 -1
  96. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-B_2gkePf.js → settings-BAFhDgX8.js} +1 -1
  98. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-D7WOSzcX.js → stack-EMrOMt4I.js} +1 -1
  100. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-BBxtKZOQ.js → state-CWoo7ty5.js} +1 -1
  102. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  103. package/dist/dashboard/assets/{switch-DNS_D5Ea.js → switch-CCjT6IkF.js} +1 -1
  104. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  105. package/dist/dashboard/assets/{useMutation-BgoxdoMk.js → useMutation-DjkLHUcE.js} +1 -1
  106. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  107. package/dist/dashboard/index.html +1 -1
  108. package/dist/dashboard/index.html.gz +0 -0
  109. package/dist/dashboard/precompressed-assets.json +54 -53
  110. package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
  111. package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
  112. package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
  113. package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
  114. package/package.json +96 -96
  115. package/scripts/cli/console-menu.ts +5 -2
  116. package/scripts/cli/tray.ts +21 -6
  117. package/src/boot/app.ts +684 -714
  118. package/src/boot/dependencies.ts +65 -62
  119. package/src/config/cooldowns.ts +16 -0
  120. package/src/config/providers.ts +13 -1
  121. package/src/config/resources.ts +70 -70
  122. package/src/config/security.ts +37 -37
  123. package/src/config/spec.ts +371 -358
  124. package/src/console/cli-tools/contracts.ts +10 -4
  125. package/src/console/console-router.ts +233 -225
  126. package/src/console/domain-registration.ts +398 -381
  127. package/src/console/providers/catalog/catalog-projections.ts +2 -6
  128. package/src/console/providers/catalog/contracts.ts +19 -0
  129. package/src/console/providers/catalog/provider-operations.ts +38 -0
  130. package/src/console/providers/catalog/routes.ts +14 -0
  131. package/src/console/providers/catalog/store-helpers.ts +326 -0
  132. package/src/console/providers/catalog/store.ts +1292 -1563
  133. package/src/console/quota/account-quota-global.ts +2 -2
  134. package/src/console/quota/account-quota-shared.ts +1 -1
  135. package/src/console/quota/account-quota-tenant.ts +35 -12
  136. package/src/console/quota/quota-view.ts +2 -2
  137. package/src/console/routing/pools/contracts.ts +41 -1
  138. package/src/console/routing/pools/operations.ts +183 -4
  139. package/src/console/routing/pools/relay-deploy.ts +17 -4
  140. package/src/console/routing/pools/routes.ts +30 -0
  141. package/src/console/routing/pools/store.ts +98 -8
  142. package/src/main.ts +183 -167
  143. package/src/network/outbound-fetch.ts +124 -208
  144. package/src/network/pool/agent.ts +15 -0
  145. package/src/network/pool/pool-state.ts +7 -0
  146. package/src/network/pool/selector.ts +110 -2
  147. package/src/network/pool-cooldown-store.ts +252 -0
  148. package/src/network/pool-health-machine.ts +5 -3
  149. package/src/network/pool-health.ts +36 -9
  150. package/src/observability/event-query.ts +57 -0
  151. package/src/observability/metrics.ts +467 -467
  152. package/src/observability/runtime-metrics.ts +269 -269
  153. package/src/persistence/health-event-flush.ts +2 -0
  154. package/src/persistence/schema.ts +19 -0
  155. package/src/persistence/usage-flush.ts +8 -3
  156. package/src/protocol/canonical-model.ts +1 -1
  157. package/src/protocol/dsml.ts +377 -0
  158. package/src/protocol/gateway-error.ts +232 -232
  159. package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
  160. package/src/protocol/request/chat.ts +7 -7
  161. package/src/protocol/request/codex.ts +2 -2
  162. package/src/protocol/request/gemini.ts +1 -1
  163. package/src/protocol/request/messages.ts +6 -6
  164. package/src/protocol/request/responses.ts +7 -7
  165. package/src/protocol/response/dsml.ts +12 -133
  166. package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
  167. package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
  168. package/src/{transport → protocol}/translation/capabilities.ts +2 -2
  169. package/src/{transport → protocol}/translation/normalize.ts +1 -1
  170. package/src/{transport → protocol}/translation/quirks.ts +1 -1
  171. package/src/{transport → protocol}/translation/search.ts +1 -1
  172. package/src/{transport → protocol}/translation/thinking.ts +23 -1
  173. package/src/{transport → protocol}/translation/tool-id.ts +1 -1
  174. package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
  175. package/src/protocol/upstream-parsing.ts +176 -0
  176. package/src/providers/adapters/compatible-adapter.ts +11 -2
  177. package/src/providers/authentication/oauth-refresh-service.ts +9 -5
  178. package/src/providers/default-registry.ts +22 -5
  179. package/src/providers/discovery/probe-phases.ts +16 -3
  180. package/src/providers/discovery/probing-service.ts +4 -1
  181. package/src/providers/integrations/anthropic.ts +1 -1
  182. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
  183. package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
  184. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
  185. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
  186. package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
  187. package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
  188. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
  189. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
  190. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
  191. package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
  192. package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
  193. package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
  194. package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
  195. package/src/providers/integrations/claude/claude.ts +1 -1
  196. package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
  197. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  198. package/src/providers/integrations/codex/codex.ts +1 -1
  199. package/src/providers/integrations/factory/factory.ts +1 -1
  200. package/src/providers/integrations/github/github.ts +1 -1
  201. package/src/providers/integrations/grok/grok.ts +1 -1
  202. package/src/providers/integrations/inception/provider.ts +91 -0
  203. package/src/providers/integrations/inferhub.ts +1 -1
  204. package/src/providers/integrations/kiro/kiro-request.ts +1 -1
  205. package/src/providers/integrations/mistral.ts +193 -19
  206. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  207. package/src/providers/integrations/opencode/fingerprint.ts +19 -2
  208. package/src/providers/integrations/opencode/prompt.ts +111 -0
  209. package/src/providers/integrations/opencode/provider.ts +101 -23
  210. package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
  211. package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
  212. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
  213. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  214. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  215. package/src/providers/integrations/qoder/qoder.ts +315 -400
  216. package/src/providers/integrations/shared/install-id.ts +2 -1
  217. package/src/providers/integrations/trae/trae-cache.ts +110 -0
  218. package/src/providers/integrations/trae/trae-oauth.ts +29 -4
  219. package/src/providers/integrations/trae/trae-protocol.ts +2 -1
  220. package/src/providers/integrations/trae/trae.ts +106 -23
  221. package/src/providers/operations/account-error-classification.ts +1684 -0
  222. package/src/providers/operations/account-health-service.ts +27 -1503
  223. package/src/providers/operations/account-reset-service.ts +2 -0
  224. package/src/providers/operations/provider-catalog-service.ts +11 -1
  225. package/src/providers/provider-contracts.ts +7 -4
  226. package/src/providers/provider-metadata.ts +25 -4
  227. package/src/providers/provider-probe.ts +2 -2
  228. package/src/{console → providers}/quota/cache.ts +1 -1
  229. package/src/{console → providers}/quota/refresh.ts +5 -5
  230. package/src/security/cli-client-fingerprint.ts +9 -2
  231. package/src/transport/dispatch/attempt-finalize.ts +114 -12
  232. package/src/transport/dispatch/attempt-loop.ts +3 -0
  233. package/src/transport/dispatch/empty-stream.ts +9 -2
  234. package/src/transport/dispatch/generation.ts +37 -55
  235. package/src/transport/dispatch/handler-deps.ts +32 -0
  236. package/src/transport/dispatch/leases.ts +9 -4
  237. package/src/transport/dispatch/proxy-request.ts +6 -30
  238. package/src/transport/dispatch/responses-compact.ts +22 -43
  239. package/src/transport/dispatch/streaming/finalize.ts +3 -0
  240. package/src/transport/dispatch/streaming/stream.ts +0 -8
  241. package/src/transport/dispatch/systemone.ts +22 -42
  242. package/src/transport/dispatch/token-count.ts +23 -39
  243. package/src/transport/dispatch/upstream.ts +1 -3
  244. package/src/transport/dispatch/websearch.ts +22 -41
  245. package/src/transport/failure-policy.ts +92 -178
  246. package/src/transport/guards/abuse-guard.ts +10 -0
  247. package/src/transport/guards/auth-guard.ts +109 -109
  248. package/src/transport/guards/error-lifecycle.ts +1 -1
  249. package/src/transport/guards/pipeline.ts +220 -220
  250. package/src/transport/guards/request-context.ts +245 -238
  251. package/src/transport/request/degradation.ts +20 -1
  252. package/src/transport/request/inflight.ts +4 -114
  253. package/src/transport/request/preparer.ts +6 -6
  254. package/src/transport/request/state.ts +66 -38
  255. package/src/transport/request/tenant-preferences.ts +1 -1
  256. package/src/transport/routing/route-catalog.ts +75 -43
  257. package/src/transport/routing/router.ts +30 -2
  258. package/src/transport/surface/messages/parse.ts +1 -1
  259. package/src/transport/surface/responses/encode.ts +2 -3
  260. package/src/transport/surface/responses/parse.ts +49 -7
  261. package/src/types/undici.d.ts +33 -0
  262. package/src/workers/buddy-growth-tasks.ts +346 -425
  263. package/src/workers/daily-checkin.ts +17 -6
  264. package/src/workers/quota-refresh-sweep.ts +2 -2
  265. package/src/workers/scheduler.ts +17 -1
  266. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  267. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  268. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  269. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  270. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
  271. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  272. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  273. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
  274. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  275. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
  276. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  277. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  278. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
  279. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  280. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
  281. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  282. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
  283. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  284. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
  285. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  286. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
  287. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  288. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  289. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  290. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
  291. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  292. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  293. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  294. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
  295. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  296. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  297. package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
  298. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  299. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  300. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  301. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  302. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  303. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  304. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  305. package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
  306. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  307. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  308. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  309. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  310. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  311. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  312. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  313. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  314. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  315. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  316. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  317. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  318. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  319. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  320. package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
  321. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  322. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  323. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  324. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  325. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  326. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  327. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  328. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  329. /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
  330. /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
@@ -1,269 +1,269 @@
1
- /**
2
- * Periodic runtime gauges: process memory plus bounded-collection sizes.
3
- *
4
- * Sampling is driven by `ScheduledTaskRegistry` (unref'd interval) rather than
5
- * a dedicated timer so it shares the process's existing maintenance lifecycle
6
- * and stops with it. Map/collection sizes are read through closures supplied at
7
- * composition time; this module owns no references to the routing or pool
8
- * layers, keeping the observability boundary one-directional.
9
- */
10
- import { readFileSync } from "node:fs";
11
- import os from "node:os";
12
- import { metrics } from "./metrics";
13
- import { trackMemoryUsage } from "./performance-metrics";
14
- import { log } from "./logger";
15
- import { resolveMemoryLimitOverrideBytes } from "../config/timeouts";
16
- import { CONFIG_SPEC, readFlag } from "../config/spec";
17
- import type { TelemetryBacklogSnapshot } from "./telemetry-buffer";
18
- /**
19
- * The memory figures a shed decision needs.
20
- *
21
- * Published once per sample instead of re-measured per request:
22
- * `process.memoryUsage()` is not free, and the headroom guard runs on every
23
- * request. A snapshot up to one sample interval old is the right trade — the
24
- * guard is protecting against a spike that arrives over seconds, not one
25
- * request's worth of allocation.
26
- */
27
- export interface RuntimeMemorySnapshot {
28
- readonly rssBytes: number;
29
- /** The resolved limit; `undefined` when none could be discovered. */
30
- readonly limitBytes: number | undefined;
31
- readonly sampledAtMs: number;
32
- }
33
-
34
- /** Live collection sizes sampled alongside process memory. */
35
- export interface RuntimeMetricSources {
36
- readonly routingRoundRobinEntries: () => { readonly combo: number; readonly provider: number };
37
- /**
38
- * Tracked keys in the in-memory IP abuse store, or 0 when the store is
39
- * Redis-backed. Unlike the routing maps this one is fed by client-supplied
40
- * identities, so its size is the leak signal to watch.
41
- */
42
- readonly ipAbuseKeys: () => number;
43
- /**
44
- * The telemetry backlog's current shape, or `undefined` when no buffer is
45
- * wired (tests, and the console-only shell).
46
- */
47
- readonly telemetryBacklog?: () => TelemetryBacklogSnapshot | undefined;
48
- /** Tracked keys in the in-process quota cache tier (0 when Redis-only paths are idle). */
49
- readonly quotaCacheEntries: () => number;
50
-
51
- /**
52
- * Entries in the per-provider model-catalog cache. Bounded, but a count that
53
- * sits at the cap means every rebuild is evicting something it needs.
54
- */
55
- readonly modelCatalogEntries: () => number;
56
- /**
57
- * Live in-flight request count. Zero means the gateway is idle, so a
58
- * synchronous full collection is safe (no request to stall).
59
- */
60
- readonly inFlight: () => number;
61
- }
62
-
63
- /** Cooldown between memory-pressure warnings so a sustained high-water mark does not flood logs. */
64
- const PRESSURE_LOG_WINDOW_MS = 60_000;
65
- /** Cooldown between proactive garbage collections while pressure persists. */
66
- const PRESSURE_GC_WINDOW_MS = 60_000;
67
- /** Fraction of the resolved limit at which memory pressure is reported. */
68
- const PRESSURE_THRESHOLD = 0.8;
69
- /**
70
- * Fallback limit when neither an override nor a cgroup limit is discoverable
71
- * (native development, macOS, Windows).
72
- *
73
- * A fixed 512 MiB made every machine without a cgroup look like a 512 MiB
74
- * container. On a desktop running the gateway natively that is far below what
75
- * the machine has, so the pressure check fired against a number that described
76
- * nothing — "RSS 430 MiB exceeds 80% of the 512 MiB limit" on a machine with
77
- * gigabytes free — and the concurrency cap derived from it admitted a handful
78
- * of requests while every tenant-level setting sat unreachable above it.
79
- *
80
- * The host's real memory is the honest stand-in. It is what the process can
81
- * actually grow into here, and it is what "no container limit" means.
82
- */
83
- function defaultMemoryLimitBytes(): number {
84
- try {
85
- const total = os.totalmem();
86
- if (Number.isFinite(total) && total > 0) return total;
87
- } catch {
88
- // Platform without totalmem; fall through to the last-resort constant.
89
- }
90
- return LAST_RESORT_MEMORY_LIMIT_BYTES;
91
- }
92
-
93
- /**
94
- * Used only when the host's memory cannot be read at all. Deliberately large
95
- * so an unmeasurable host is not treated as a small container.
96
- */
97
- const LAST_RESORT_MEMORY_LIMIT_BYTES = 4 * 1024 * 1024 * 1024;
98
- /** Cooldown between idle full collections. */
99
- const IDLE_GC_WINDOW_MS = 5 * 60_000;
100
- /** RSS retained above the post-collection baseline before another idle collection runs. */
101
- const IDLE_GC_MIN_GROWTH_BYTES = 32 * 1024 * 1024;
102
-
103
- /**
104
- * Resolves the effective memory limit for the process.
105
- *
106
- * Precedence: an explicit `CARTETHYIA_MEMORY_LIMIT_BYTES` override, then the
107
- * cgroup v2/v1 limit when running in a container. Falls back to
108
- * {@link DEFAULT_MEMORY_LIMIT_BYTES} when no limit is discoverable (native
109
- * development, macOS, Windows) so pressure-based reclaim still runs.
110
- */
111
- export function resolveMemoryLimitBytes(): number | undefined {
112
- const override = resolveMemoryLimitOverrideBytes();
113
- if (override !== undefined) return override;
114
- if (process.platform !== "linux") return defaultMemoryLimitBytes();
115
- for (const path of [
116
- "/sys/fs/cgroup/memory.max",
117
- "/sys/fs/cgroup/memory/memory.limit_in_bytes",
118
- ]) {
119
- try {
120
- const content = readFileSync(path, "utf8").trim();
121
- if (content === "max") continue;
122
- const value = Number(content);
123
- // The v1 unlimited sentinel is a huge finite number; treat it as unknown.
124
- if (Number.isInteger(value) && value > 0 && value < 2 ** 53) return value;
125
- } catch {
126
- // File absent (cgroup v1 vs v2) or unreadable — try the next source.
127
- }
128
- }
129
- return defaultMemoryLimitBytes();
130
- }
131
-
132
- /** Samples process memory and bounded-collection sizes into the metric registry. */
133
- export class RuntimeMetricsSampler {
134
- private readonly limitBytes: number | undefined;
135
- private readonly gcOnPressure: boolean;
136
- private lastPressureLogAt = 0;
137
- private lastGcAt = 0;
138
- private lastIdleGcAt = 0;
139
- private rssAtLastGc = 0;
140
- private inFlightSource: () => number = () => 0;
141
- /**
142
- * The latest snapshot, published once per sample. Stays undefined until the
143
- * first sample runs; consumers must treat that as "unknown", not as zero.
144
- */
145
- private snapshot: RuntimeMemorySnapshot | undefined;
146
-
147
- /**
148
- * Binds the live in-flight count after the request state store exists. The
149
- * sampler is constructed before that store, so the source is late-bound.
150
- */
151
- bindInFlight(source: () => number): void {
152
- this.inFlightSource = source;
153
- }
154
-
155
- /**
156
- * The last sampled memory state, for consumers on a hot path.
157
- *
158
- * Undefined before the first sample: a guard that read zero as "plenty of
159
- * room" would disable itself exactly when it matters, so callers must fall
160
- * back to their own judgement when this is absent.
161
- */
162
- memorySnapshot(): RuntimeMemorySnapshot | undefined {
163
- return this.snapshot;
164
- }
165
-
166
- /**
167
- * Publishes the effective concurrency ceiling.
168
- *
169
- * Late-bound like `bindInFlight`: the ceiling is derived from the memory
170
- * limit, which is known here, but the guard that owns it is constructed in
171
- * the transport layer.
172
- */
173
-
174
- constructor(
175
- private readonly sources: RuntimeMetricSources,
176
- limitBytes: number | undefined = resolveMemoryLimitBytes(),
177
- // Off by default: a forced collection is not how memory is meant to come
178
- // back. The synchronous form (`Bun.gc(true)`) stops the shared event loop
179
- // outright, so every concurrent request stalls inside it — which reads as
180
- // a latency spike with no upstream cause. And it hides the actual defect:
181
- // a process that only returns memory when something forces it to is a
182
- // process that is still holding it. Bounded caches, released streams, and
183
- // batched writers reclaim naturally; this is opt-in for an operator who
184
- // has measured that they need it.
185
- gcOnPressure = readFlag("CARTETHYIA_FORCED_GC", CONFIG_SPEC.CARTETHYIA_FORCED_GC),
186
- ) {
187
- this.limitBytes = limitBytes;
188
- this.gcOnPressure = gcOnPressure;
189
- }
190
-
191
- sample(): void {
192
- const memory = process.memoryUsage();
193
- metrics.cartethyia_memory_rss_bytes.set(memory.rss);
194
- metrics.cartethyia_memory_heap_used_bytes.set(memory.heapUsed);
195
- metrics.cartethyia_memory_heap_total_bytes.set(memory.heapTotal);
196
- metrics.cartethyia_memory_limit_bytes.set(this.limitBytes ?? 0);
197
- this.snapshot = {
198
- rssBytes: memory.rss,
199
- limitBytes: this.limitBytes,
200
- sampledAtMs: Date.now(),
201
- };
202
- trackMemoryUsage("rss", memory.rss);
203
- trackMemoryUsage("heap_used", memory.heapUsed);
204
- trackMemoryUsage("heap_total", memory.heapTotal);
205
- trackMemoryUsage("external", memory.external);
206
-
207
- const roundRobin = this.sources.routingRoundRobinEntries();
208
- metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.combo, { scope: "combo" });
209
- metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.provider, { scope: "provider" });
210
- metrics.cartethyia_ip_abuse_keys.set(this.sources.ipAbuseKeys());
211
- metrics.cartethyia_quota_cache_entries.set(this.sources.quotaCacheEntries());
212
- metrics.cartethyia_model_catalog_entries.set(this.sources.modelCatalogEntries());
213
- metrics.cartethyia_process_in_flight.set(this.inFlightSource());
214
- const backlog = this.sources.telemetryBacklog?.();
215
- if (backlog !== undefined) {
216
- metrics.cartethyia_telemetry_backlog_items.set(backlog.items);
217
- metrics.cartethyia_telemetry_backlog_bytes.set(backlog.bytes);
218
- }
219
-
220
- this.checkPressure(memory.rss);
221
- this.checkIdleReclaim(memory.rss);
222
- }
223
-
224
- private checkPressure(rss: number): void {
225
- if (this.limitBytes === undefined || rss <= this.limitBytes * PRESSURE_THRESHOLD) return;
226
- const now = Date.now();
227
- if (this.gcOnPressure && now - this.lastGcAt >= PRESSURE_GC_WINDOW_MS) {
228
- this.lastGcAt = now;
229
- // Asynchronous GC: reclaims before the limit is reached without stalling
230
- // in-flight requests on the shared event loop. A synchronous full
231
- // collection here would add a latency spike to every concurrent request.
232
- Bun.gc(false);
233
- }
234
- if (now - this.lastPressureLogAt < PRESSURE_LOG_WINDOW_MS) return;
235
- this.lastPressureLogAt = now;
236
- log.warn(
237
- `[memory] RSS ${(rss / 1024 / 1024).toFixed(1)}MiB exceeds 80% of the ` +
238
- `${(this.limitBytes / 1024 / 1024).toFixed(1)}MiB limit`,
239
- );
240
- }
241
-
242
- /**
243
- * Reclaims memory while the gateway is idle.
244
- *
245
- * Triggered on RSS growth rather than heap growth: this process holds most of
246
- * its footprint off-heap (adapters, TLS contexts, buffers), and `heapUsed`
247
- * barely moves when those are allocated — a heap-based gate never opens, which
248
- * is why an idle gateway held its startup peak indefinitely.
249
- *
250
- * A synchronous full collection is safe here because no request is in flight to
251
- * stall, and it is the only form that actually returns pages to the OS; the
252
- * async form used under pressure does not.
253
- *
254
- * Not gated on `gcOnPressure` the way `checkPressure` is. The opt-in exists
255
- * because a synchronous collection stalls concurrent requests, and this path
256
- * only runs when there is nothing in flight to stall — so the reason the flag
257
- * is off does not apply to it. Gating it meant a gateway with no traffic at
258
- * all never reclaimed anything and held its startup peak for hours.
259
- */
260
- private checkIdleReclaim(rss: number): void {
261
- if (this.inFlightSource() > 0) return;
262
- const now = Date.now();
263
- if (now - this.lastIdleGcAt < IDLE_GC_WINDOW_MS) return;
264
- if (this.rssAtLastGc !== 0 && rss - this.rssAtLastGc < IDLE_GC_MIN_GROWTH_BYTES) return;
265
- this.lastIdleGcAt = now;
266
- Bun.gc(true);
267
- this.rssAtLastGc = process.memoryUsage().rss;
268
- }
269
- }
1
+ /**
2
+ * Periodic runtime gauges: process memory plus bounded-collection sizes.
3
+ *
4
+ * Sampling is driven by `ScheduledTaskRegistry` (unref'd interval) rather than
5
+ * a dedicated timer so it shares the process's existing maintenance lifecycle
6
+ * and stops with it. Map/collection sizes are read through closures supplied at
7
+ * composition time; this module owns no references to the routing or pool
8
+ * layers, keeping the observability boundary one-directional.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import os from "node:os";
12
+ import { metrics } from "./metrics";
13
+ import { trackMemoryUsage } from "./performance-metrics";
14
+ import { log } from "./logger";
15
+ import { resolveMemoryLimitOverrideBytes } from "../config/timeouts";
16
+ import { CONFIG_SPEC, readFlag } from "../config/spec";
17
+ import type { TelemetryBacklogSnapshot } from "./telemetry-buffer";
18
+ /**
19
+ * The memory figures a shed decision needs.
20
+ *
21
+ * Published once per sample instead of re-measured per request:
22
+ * `process.memoryUsage()` is not free, and the headroom guard runs on every
23
+ * request. A snapshot up to one sample interval old is the right trade — the
24
+ * guard is protecting against a spike that arrives over seconds, not one
25
+ * request's worth of allocation.
26
+ */
27
+ export interface RuntimeMemorySnapshot {
28
+ readonly rssBytes: number;
29
+ /** The resolved limit; `undefined` when none could be discovered. */
30
+ readonly limitBytes: number | undefined;
31
+ readonly sampledAtMs: number;
32
+ }
33
+
34
+ /** Live collection sizes sampled alongside process memory. */
35
+ export interface RuntimeMetricSources {
36
+ readonly routingRoundRobinEntries: () => { readonly combo: number; readonly provider: number };
37
+ /**
38
+ * Tracked keys in the in-memory IP abuse store, or 0 when the store is
39
+ * Redis-backed. Unlike the routing maps this one is fed by client-supplied
40
+ * identities, so its size is the leak signal to watch.
41
+ */
42
+ readonly ipAbuseKeys: () => number;
43
+ /**
44
+ * The telemetry backlog's current shape, or `undefined` when no buffer is
45
+ * wired (tests, and the console-only shell).
46
+ */
47
+ readonly telemetryBacklog?: () => TelemetryBacklogSnapshot | undefined;
48
+ /** Tracked keys in the in-process quota cache tier (0 when Redis-only paths are idle). */
49
+ readonly quotaCacheEntries: () => number;
50
+
51
+ /**
52
+ * Entries in the per-provider model-catalog cache. Bounded, but a count that
53
+ * sits at the cap means every rebuild is evicting something it needs.
54
+ */
55
+ readonly modelCatalogEntries: () => number;
56
+ /**
57
+ * Live in-flight request count. Zero means the gateway is idle, so a
58
+ * synchronous full collection is safe (no request to stall).
59
+ */
60
+ readonly inFlight: () => number;
61
+ }
62
+
63
+ /** Cooldown between memory-pressure warnings so a sustained high-water mark does not flood logs. */
64
+ const PRESSURE_LOG_WINDOW_MS = 60_000;
65
+ /** Cooldown between proactive garbage collections while pressure persists. */
66
+ const PRESSURE_GC_WINDOW_MS = 60_000;
67
+ /** Fraction of the resolved limit at which memory pressure is reported. */
68
+ const PRESSURE_THRESHOLD = 0.8;
69
+ /**
70
+ * Fallback limit when neither an override nor a cgroup limit is discoverable
71
+ * (native development, macOS, Windows).
72
+ *
73
+ * A fixed 512 MiB made every machine without a cgroup look like a 512 MiB
74
+ * container. On a desktop running the gateway natively that is far below what
75
+ * the machine has, so the pressure check fired against a number that described
76
+ * nothing — "RSS 430 MiB exceeds 80% of the 512 MiB limit" on a machine with
77
+ * gigabytes free — and the concurrency cap derived from it admitted a handful
78
+ * of requests while every tenant-level setting sat unreachable above it.
79
+ *
80
+ * The host's real memory is the honest stand-in. It is what the process can
81
+ * actually grow into here, and it is what "no container limit" means.
82
+ */
83
+ function defaultMemoryLimitBytes(): number {
84
+ try {
85
+ const total = os.totalmem();
86
+ if (Number.isFinite(total) && total > 0) return total;
87
+ } catch {
88
+ // Platform without totalmem; fall through to the last-resort constant.
89
+ }
90
+ return LAST_RESORT_MEMORY_LIMIT_BYTES;
91
+ }
92
+
93
+ /**
94
+ * Used only when the host's memory cannot be read at all. Deliberately large
95
+ * so an unmeasurable host is not treated as a small container.
96
+ */
97
+ const LAST_RESORT_MEMORY_LIMIT_BYTES = 4 * 1024 * 1024 * 1024;
98
+ /** Cooldown between idle full collections. */
99
+ const IDLE_GC_WINDOW_MS = 5 * 60_000;
100
+ /** RSS retained above the post-collection baseline before another idle collection runs. */
101
+ const IDLE_GC_MIN_GROWTH_BYTES = 32 * 1024 * 1024;
102
+
103
+ /**
104
+ * Resolves the effective memory limit for the process.
105
+ *
106
+ * Precedence: an explicit `CARTETHYIA_MEMORY_LIMIT_BYTES` override, then the
107
+ * cgroup v2/v1 limit when running in a container. Falls back to
108
+ * {@link DEFAULT_MEMORY_LIMIT_BYTES} when no limit is discoverable (native
109
+ * development, macOS, Windows) so pressure-based reclaim still runs.
110
+ */
111
+ export function resolveMemoryLimitBytes(): number | undefined {
112
+ const override = resolveMemoryLimitOverrideBytes();
113
+ if (override !== undefined) return override;
114
+ if (process.platform !== "linux") return defaultMemoryLimitBytes();
115
+ for (const path of [
116
+ "/sys/fs/cgroup/memory.max",
117
+ "/sys/fs/cgroup/memory/memory.limit_in_bytes",
118
+ ]) {
119
+ try {
120
+ const content = readFileSync(path, "utf8").trim();
121
+ if (content === "max") continue;
122
+ const value = Number(content);
123
+ // The v1 unlimited sentinel is a huge finite number; treat it as unknown.
124
+ if (Number.isInteger(value) && value > 0 && value < 2 ** 53) return value;
125
+ } catch {
126
+ // File absent (cgroup v1 vs v2) or unreadable — try the next source.
127
+ }
128
+ }
129
+ return defaultMemoryLimitBytes();
130
+ }
131
+
132
+ /** Samples process memory and bounded-collection sizes into the metric registry. */
133
+ export class RuntimeMetricsSampler {
134
+ private readonly limitBytes: number | undefined;
135
+ private readonly gcOnPressure: boolean;
136
+ private lastPressureLogAt = 0;
137
+ private lastGcAt = 0;
138
+ private lastIdleGcAt = 0;
139
+ private rssAtLastGc = 0;
140
+ private inFlightSource: () => number = () => 0;
141
+ /**
142
+ * The latest snapshot, published once per sample. Stays undefined until the
143
+ * first sample runs; consumers must treat that as "unknown", not as zero.
144
+ */
145
+ private snapshot: RuntimeMemorySnapshot | undefined;
146
+
147
+ /**
148
+ * Binds the live in-flight count after the request state store exists. The
149
+ * sampler is constructed before that store, so the source is late-bound.
150
+ */
151
+ bindInFlight(source: () => number): void {
152
+ this.inFlightSource = source;
153
+ }
154
+
155
+ /**
156
+ * The last sampled memory state, for consumers on a hot path.
157
+ *
158
+ * Undefined before the first sample: a guard that read zero as "plenty of
159
+ * room" would disable itself exactly when it matters, so callers must fall
160
+ * back to their own judgement when this is absent.
161
+ */
162
+ memorySnapshot(): RuntimeMemorySnapshot | undefined {
163
+ return this.snapshot;
164
+ }
165
+
166
+ /**
167
+ * Publishes the effective concurrency ceiling.
168
+ *
169
+ * Late-bound like `bindInFlight`: the ceiling is derived from the memory
170
+ * limit, which is known here, but the guard that owns it is constructed in
171
+ * the transport layer.
172
+ */
173
+
174
+ constructor(
175
+ private readonly sources: RuntimeMetricSources,
176
+ limitBytes: number | undefined = resolveMemoryLimitBytes(),
177
+ // Off by default: a forced collection is not how memory is meant to come
178
+ // back. The synchronous form (`Bun.gc(true)`) stops the shared event loop
179
+ // outright, so every concurrent request stalls inside it — which reads as
180
+ // a latency spike with no upstream cause. And it hides the actual defect:
181
+ // a process that only returns memory when something forces it to is a
182
+ // process that is still holding it. Bounded caches, released streams, and
183
+ // batched writers reclaim naturally; this is opt-in for an operator who
184
+ // has measured that they need it.
185
+ gcOnPressure = readFlag("CARTETHYIA_FORCED_GC", CONFIG_SPEC.CARTETHYIA_FORCED_GC),
186
+ ) {
187
+ this.limitBytes = limitBytes;
188
+ this.gcOnPressure = gcOnPressure;
189
+ }
190
+
191
+ sample(): void {
192
+ const memory = process.memoryUsage();
193
+ metrics.cartethyia_memory_rss_bytes.set(memory.rss);
194
+ metrics.cartethyia_memory_heap_used_bytes.set(memory.heapUsed);
195
+ metrics.cartethyia_memory_heap_total_bytes.set(memory.heapTotal);
196
+ metrics.cartethyia_memory_limit_bytes.set(this.limitBytes ?? 0);
197
+ this.snapshot = {
198
+ rssBytes: memory.rss,
199
+ limitBytes: this.limitBytes,
200
+ sampledAtMs: Date.now(),
201
+ };
202
+ trackMemoryUsage("rss", memory.rss);
203
+ trackMemoryUsage("heap_used", memory.heapUsed);
204
+ trackMemoryUsage("heap_total", memory.heapTotal);
205
+ trackMemoryUsage("external", memory.external);
206
+
207
+ const roundRobin = this.sources.routingRoundRobinEntries();
208
+ metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.combo, { scope: "combo" });
209
+ metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.provider, { scope: "provider" });
210
+ metrics.cartethyia_ip_abuse_keys.set(this.sources.ipAbuseKeys());
211
+ metrics.cartethyia_quota_cache_entries.set(this.sources.quotaCacheEntries());
212
+ metrics.cartethyia_model_catalog_entries.set(this.sources.modelCatalogEntries());
213
+ metrics.cartethyia_process_in_flight.set(this.inFlightSource());
214
+ const backlog = this.sources.telemetryBacklog?.();
215
+ if (backlog !== undefined) {
216
+ metrics.cartethyia_telemetry_backlog_items.set(backlog.items);
217
+ metrics.cartethyia_telemetry_backlog_bytes.set(backlog.bytes);
218
+ }
219
+
220
+ this.checkPressure(memory.rss);
221
+ this.checkIdleReclaim(memory.rss);
222
+ }
223
+
224
+ private checkPressure(rss: number): void {
225
+ if (this.limitBytes === undefined || rss <= this.limitBytes * PRESSURE_THRESHOLD) return;
226
+ const now = Date.now();
227
+ if (this.gcOnPressure && now - this.lastGcAt >= PRESSURE_GC_WINDOW_MS) {
228
+ this.lastGcAt = now;
229
+ // Asynchronous GC: reclaims before the limit is reached without stalling
230
+ // in-flight requests on the shared event loop. A synchronous full
231
+ // collection here would add a latency spike to every concurrent request.
232
+ Bun.gc(false);
233
+ }
234
+ if (now - this.lastPressureLogAt < PRESSURE_LOG_WINDOW_MS) return;
235
+ this.lastPressureLogAt = now;
236
+ log.warn(
237
+ `[memory] RSS ${(rss / 1024 / 1024).toFixed(1)}MiB exceeds 80% of the ` +
238
+ `${(this.limitBytes / 1024 / 1024).toFixed(1)}MiB limit`,
239
+ );
240
+ }
241
+
242
+ /**
243
+ * Reclaims memory while the gateway is idle.
244
+ *
245
+ * Triggered on RSS growth rather than heap growth: this process holds most of
246
+ * its footprint off-heap (adapters, TLS contexts, buffers), and `heapUsed`
247
+ * barely moves when those are allocated — a heap-based gate never opens, which
248
+ * is why an idle gateway held its startup peak indefinitely.
249
+ *
250
+ * A synchronous full collection is safe here because no request is in flight to
251
+ * stall, and it is the only form that actually returns pages to the OS; the
252
+ * async form used under pressure does not.
253
+ *
254
+ * Not gated on `gcOnPressure` the way `checkPressure` is. The opt-in exists
255
+ * because a synchronous collection stalls concurrent requests, and this path
256
+ * only runs when there is nothing in flight to stall — so the reason the flag
257
+ * is off does not apply to it. Gating it meant a gateway with no traffic at
258
+ * all never reclaimed anything and held its startup peak for hours.
259
+ */
260
+ private checkIdleReclaim(rss: number): void {
261
+ if (this.inFlightSource() > 0) return;
262
+ const now = Date.now();
263
+ if (now - this.lastIdleGcAt < IDLE_GC_WINDOW_MS) return;
264
+ if (this.rssAtLastGc !== 0 && rss - this.rssAtLastGc < IDLE_GC_MIN_GROWTH_BYTES) return;
265
+ this.lastIdleGcAt = now;
266
+ Bun.gc(true);
267
+ this.rssAtLastGc = process.memoryUsage().rss;
268
+ }
269
+ }
@@ -15,6 +15,7 @@
15
15
  // Same shape as `persistence/usage-flush.ts`: enqueue and return, a timer
16
16
  // flushes, shutdown flushes what remains. A crash loses the unflushed rows and
17
17
  // nothing else — the durable state routing depends on was never in here.
18
+ import { randomUUID } from "node:crypto";
18
19
  import type { CartethyiaDatabase } from "./db-handle";
19
20
  import { healthEvents } from "./schema";
20
21
 
@@ -141,6 +142,7 @@ export class HealthEventFlushBuffer {
141
142
  try {
142
143
  await this.db.insert(healthEvents).values(
143
144
  batch.map((row) => ({
145
+ id: randomUUID(),
144
146
  entityKind: row.entityKind,
145
147
  accountId: row.accountId ?? null,
146
148
  networkPoolId: row.networkPoolId ?? null,
@@ -490,6 +490,24 @@ export const networkPools = pgTable("network_pools", {
490
490
  /** The endpoint the verdict was measured against. Compared on read, so an
491
491
  * edited endpoint reports "never probed" instead of a stale verdict. */
492
492
  providerProbeEndpoint: text("provider_probe_endpoint"),
493
+ /**
494
+ * The database mirror of the pool selector's provider cooldown state, one
495
+ * entry per cooling provider keyed by its lowercased id:
496
+ * `{ "opencode": { "until": <epoch ms>, "reason": "...", "blocked": false } }`.
497
+ *
498
+ * The selector's own map is process-local (mirrored to Redis when present),
499
+ * so a restart dropped every flag while the upstream kept refusing the
500
+ * egress — dispatch re-picked a spent pool and the console showed nothing.
501
+ * The dispatch-side flaggers write each marker here, and boot reads this
502
+ * column back into the selector, so the state survives a restart the way the
503
+ * upstream's memory of the address does.
504
+ *
505
+ * `null` means no provider was ever flagged. Expired entries are left inert
506
+ * until the next write for the pool rather than swept eagerly: a marker with
507
+ * `until` in the past is skipped by every reader.
508
+ */
509
+ providerCooldowns: jsonb("provider_cooldowns")
510
+ .$type<Record<string, { until: number; reason: string; blocked?: boolean }>>(),
493
511
  /** Metered egress totals, flushed from the in-memory socket counters on
494
512
  * every pool-touching write so the quota bar survives a restart. */
495
513
  bytesSentTotal: bigint("bytes_sent_total", { mode: "number" }).notNull().default(0),
@@ -676,6 +694,7 @@ export const providerRoutingSettings = pgTable(
676
694
  export const poolRoutingStrategy = pgEnum("pool_routing_strategy", [
677
695
  "least_loaded",
678
696
  "round_robin",
697
+ "random",
679
698
  ]);
680
699
  export type PoolRoutingStrategy = (typeof poolRoutingStrategy.enumValues)[number];
681
700
 
@@ -145,13 +145,18 @@ export class UsageFlushBuffer {
145
145
  * cost the others their delta. A key whose write fails goes back into the
146
146
  * pending map, added to anything enqueued while the flush ran, so a
147
147
  * transient failure retries instead of dropping.
148
+ *
149
+ * `force` bypasses the outage backoff. The scheduled timer never passes it —
150
+ * a failing database should not be probed on every tick — but `stop({flush:
151
+ * true})` must, or a shutdown during a backoff window would return without
152
+ * even attempting to persist the pending deltas.
148
153
  */
149
- async flush(): Promise<void> {
154
+ async flush(options?: { force?: boolean }): Promise<void> {
150
155
  if (this.flushing || this.pending.size === 0) return;
151
156
  // Backing off during a sustained outage: without this the writer probed a
152
157
  // failing database on every tick, adding write load to the very overload
153
158
  // that caused the failure.
154
- if (Date.now() < this.backoffUntil) return;
159
+ if (Date.now() < this.backoffUntil && options?.force !== true) return;
155
160
  this.flushing = true;
156
161
  const batch = new Map(this.pending);
157
162
  this.pending.clear();
@@ -210,7 +215,7 @@ export class UsageFlushBuffer {
210
215
  clearTimeout(this.timer);
211
216
  this.timer = undefined;
212
217
  }
213
- if (options?.flush) await this.flush();
218
+ if (options?.flush) await this.flush({ force: true });
214
219
  }
215
220
 
216
221
  private arm(): void {
@@ -52,7 +52,7 @@ export type ServiceKind = (typeof SERVICE_KINDS)[number];
52
52
  * their membership set from it, and the probe route's accepted subset is
53
53
  * declared separately because it is deliberately narrower.
54
54
  */
55
- export const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
55
+ export const REASONING_EFFORTS = ["none", "instant", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
56
56
 
57
57
  /** One canonical reasoning effort. */
58
58
  export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];