cartethyia 2.7.0 → 2.7.1

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 (320) hide show
  1. package/.env.example +107 -106
  2. package/README.md +25 -2
  3. package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-D-J75hBM.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CVe6RaBH.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-Dd8ZSamp.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-QoJ6foxo.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-CKUFTcW4.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  17. package/dist/dashboard/assets/LoginPage-DubdQcvX.js +1 -0
  18. package/dist/dashboard/assets/LoginPage-DubdQcvX.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-DuWzAQFL.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +4 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.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-Bwotu8UJ.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +9 -0
  30. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
  31. package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-ATpaxrbl.js} +1 -1
  32. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CETophpx.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-BdgECjqq.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  37. package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-CAK-EN9z.js} +1 -1
  38. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-B7W2_RhT.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-6T4EFDjW.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-CWBY5A0f.js → entry-BLBLxX0h.js} +3 -3
  64. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  65. package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
  66. package/dist/dashboard/assets/entry-Bqy8RFvM.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-mpBPqAQ8.js +1 -0
  70. package/dist/dashboard/assets/formatters-mpBPqAQ8.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-D6QzEC_n.js} +2 -2
  74. package/dist/dashboard/assets/index-D6QzEC_n.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-Cj5Nw43g.js} +1 -1
  96. package/dist/dashboard/assets/select-Cj5Nw43g.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/console-router.ts +233 -225
  125. package/src/console/domain-registration.ts +398 -381
  126. package/src/console/providers/catalog/catalog-projections.ts +2 -6
  127. package/src/console/providers/catalog/contracts.ts +19 -0
  128. package/src/console/providers/catalog/provider-operations.ts +38 -0
  129. package/src/console/providers/catalog/routes.ts +14 -0
  130. package/src/console/providers/catalog/store-helpers.ts +326 -0
  131. package/src/console/providers/catalog/store.ts +1292 -1563
  132. package/src/console/quota/account-quota-global.ts +2 -2
  133. package/src/console/quota/account-quota-shared.ts +1 -1
  134. package/src/console/quota/account-quota-tenant.ts +35 -12
  135. package/src/console/quota/quota-view.ts +2 -2
  136. package/src/console/routing/pools/contracts.ts +41 -1
  137. package/src/console/routing/pools/operations.ts +183 -4
  138. package/src/console/routing/pools/relay-deploy.ts +17 -4
  139. package/src/console/routing/pools/routes.ts +30 -0
  140. package/src/console/routing/pools/store.ts +98 -8
  141. package/src/main.ts +183 -167
  142. package/src/network/outbound-fetch.ts +226 -365
  143. package/src/network/pool/agent.ts +8 -0
  144. package/src/network/pool/pool-state.ts +7 -0
  145. package/src/network/pool/selector.ts +110 -2
  146. package/src/network/pool-cooldown-store.ts +252 -0
  147. package/src/network/pool-health-machine.ts +5 -3
  148. package/src/network/pool-health.ts +36 -9
  149. package/src/observability/event-query.ts +57 -0
  150. package/src/observability/metrics.ts +467 -467
  151. package/src/observability/runtime-metrics.ts +269 -269
  152. package/src/persistence/health-event-flush.ts +2 -0
  153. package/src/persistence/schema.ts +19 -0
  154. package/src/persistence/usage-flush.ts +8 -3
  155. package/src/protocol/canonical-model.ts +1 -1
  156. package/src/protocol/dsml.ts +377 -0
  157. package/src/protocol/gateway-error.ts +232 -232
  158. package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
  159. package/src/protocol/request/chat.ts +7 -7
  160. package/src/protocol/request/codex.ts +2 -2
  161. package/src/protocol/request/gemini.ts +1 -1
  162. package/src/protocol/request/messages.ts +6 -6
  163. package/src/protocol/request/responses.ts +7 -7
  164. package/src/protocol/response/dsml.ts +12 -133
  165. package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
  166. package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
  167. package/src/{transport → protocol}/translation/capabilities.ts +2 -2
  168. package/src/{transport → protocol}/translation/normalize.ts +1 -1
  169. package/src/{transport → protocol}/translation/quirks.ts +1 -1
  170. package/src/{transport → protocol}/translation/search.ts +1 -1
  171. package/src/{transport → protocol}/translation/thinking.ts +23 -1
  172. package/src/{transport → protocol}/translation/tool-id.ts +1 -1
  173. package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
  174. package/src/protocol/upstream-parsing.ts +176 -0
  175. package/src/providers/adapters/compatible-adapter.ts +11 -2
  176. package/src/providers/authentication/oauth-refresh-service.ts +9 -5
  177. package/src/providers/default-registry.ts +21 -5
  178. package/src/providers/discovery/probe-phases.ts +16 -3
  179. package/src/providers/discovery/probing-service.ts +4 -1
  180. package/src/providers/integrations/anthropic.ts +1 -1
  181. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
  182. package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
  183. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
  184. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
  185. package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
  186. package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
  187. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
  188. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
  189. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
  190. package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
  191. package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
  192. package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
  193. package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
  194. package/src/providers/integrations/claude/claude.ts +1 -1
  195. package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
  196. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  197. package/src/providers/integrations/codex/codex.ts +1 -1
  198. package/src/providers/integrations/factory/factory.ts +1 -1
  199. package/src/providers/integrations/github/github.ts +1 -1
  200. package/src/providers/integrations/grok/grok.ts +1 -1
  201. package/src/providers/integrations/inception/provider.ts +91 -0
  202. package/src/providers/integrations/inferhub.ts +1 -1
  203. package/src/providers/integrations/kiro/kiro-request.ts +1 -1
  204. package/src/providers/integrations/mistral.ts +193 -19
  205. package/src/providers/integrations/opencode/fingerprint.ts +19 -2
  206. package/src/providers/integrations/opencode/prompt.ts +111 -0
  207. package/src/providers/integrations/opencode/provider.ts +101 -23
  208. package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
  209. package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
  210. package/src/providers/integrations/qoder/qoder.ts +1641 -1458
  211. package/src/providers/integrations/shared/install-id.ts +2 -1
  212. package/src/providers/integrations/trae/trae-cache.ts +110 -0
  213. package/src/providers/integrations/trae/trae-oauth.ts +29 -4
  214. package/src/providers/integrations/trae/trae-protocol.ts +2 -1
  215. package/src/providers/integrations/trae/trae.ts +106 -23
  216. package/src/providers/operations/account-error-classification.ts +1660 -0
  217. package/src/providers/operations/account-health-service.ts +621 -2097
  218. package/src/providers/operations/account-reset-service.ts +2 -0
  219. package/src/providers/provider-contracts.ts +0 -2
  220. package/src/providers/provider-metadata.ts +25 -4
  221. package/src/providers/provider-probe.ts +2 -2
  222. package/src/{console → providers}/quota/cache.ts +1 -1
  223. package/src/{console → providers}/quota/refresh.ts +5 -5
  224. package/src/transport/dispatch/attempt-finalize.ts +114 -12
  225. package/src/transport/dispatch/attempt-loop.ts +3 -0
  226. package/src/transport/dispatch/empty-stream.ts +224 -217
  227. package/src/transport/dispatch/generation.ts +37 -55
  228. package/src/transport/dispatch/handler-deps.ts +32 -0
  229. package/src/transport/dispatch/leases.ts +9 -4
  230. package/src/transport/dispatch/proxy-request.ts +6 -30
  231. package/src/transport/dispatch/responses-compact.ts +22 -43
  232. package/src/transport/dispatch/streaming/finalize.ts +3 -0
  233. package/src/transport/dispatch/streaming/stream.ts +0 -8
  234. package/src/transport/dispatch/systemone.ts +22 -42
  235. package/src/transport/dispatch/token-count.ts +23 -39
  236. package/src/transport/dispatch/upstream.ts +1 -3
  237. package/src/transport/dispatch/websearch.ts +22 -41
  238. package/src/transport/failure-policy.ts +59 -178
  239. package/src/transport/guards/abuse-guard.ts +10 -0
  240. package/src/transport/guards/auth-guard.ts +109 -109
  241. package/src/transport/guards/error-lifecycle.ts +1 -1
  242. package/src/transport/guards/pipeline.ts +220 -220
  243. package/src/transport/guards/request-context.ts +245 -238
  244. package/src/transport/request/degradation.ts +20 -1
  245. package/src/transport/request/inflight.ts +4 -114
  246. package/src/transport/request/preparer.ts +6 -6
  247. package/src/transport/request/state.ts +66 -38
  248. package/src/transport/request/tenant-preferences.ts +1 -1
  249. package/src/transport/routing/route-catalog.ts +41 -31
  250. package/src/transport/routing/router.ts +2 -2
  251. package/src/transport/surface/messages/parse.ts +1 -1
  252. package/src/workers/buddy-growth-tasks.ts +346 -425
  253. package/src/workers/daily-checkin.ts +17 -6
  254. package/src/workers/quota-refresh-sweep.ts +2 -2
  255. package/src/workers/scheduler.ts +17 -1
  256. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  257. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  258. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  259. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  260. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
  261. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  262. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  263. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
  264. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  265. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
  266. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  267. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  268. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
  269. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  270. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
  271. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  272. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
  273. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  274. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
  275. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  276. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
  277. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  278. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  279. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  280. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
  281. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  282. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  283. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  284. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
  285. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  286. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  287. package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
  288. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  289. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  290. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  291. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  292. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  293. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  294. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  295. package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
  296. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  297. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  298. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  299. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  300. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  301. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  302. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  303. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  304. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  305. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  306. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  307. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  308. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  309. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  310. package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
  311. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  312. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  313. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  314. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  315. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  316. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  317. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  318. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  319. /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
  320. /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
@@ -28,6 +28,13 @@ export interface ProxyCooldownEntry {
28
28
  export interface PoolRotation {
29
29
  readonly key: string;
30
30
  readonly rotateCount: number;
31
+ /**
32
+ * The strategy that produced this rotation request. `round_robin` serves
33
+ * `rotateCount` requests per pool before advancing; `random` scatters each
34
+ * selection across the eligible pools in a fresh random order. Absent means
35
+ * the default weighted least-loaded scan.
36
+ */
37
+ readonly strategy?: "round_robin" | "random";
31
38
  }
32
39
 
33
40
  export interface PoolUsageSnapshot {
@@ -10,6 +10,7 @@
10
10
  import { redisEvalNumber, type RedisClient } from "../../persistence/redis";
11
11
  import { resolveInflightTtlSeconds } from "../../config/timeouts";
12
12
  import { log } from "../../observability/logger";
13
+ import { providerRateLimitIsIpScoped } from "../../providers/provider-metadata";
13
14
  import {
14
15
  COOLDOWN_CLEAR_SCRIPT,
15
16
  COOLDOWN_FLAG_SCRIPT,
@@ -67,6 +68,21 @@ export function effectiveConcurrency(limit: number): number {
67
68
  return limit;
68
69
  }
69
70
 
71
+ /**
72
+ * In-place Fisher–Yates shuffle. The `random` pool strategy uses it to scatter
73
+ * each selection across the eligible pools instead of always scanning from a
74
+ * fixed (or cursor-driven) order, so a burst does not clump onto the first
75
+ * pool and burn it while siblings sit idle.
76
+ */
77
+ function shuffleArray<T>(items: T[]): void {
78
+ for (let i = items.length - 1; i > 0; i -= 1) {
79
+ const j = Math.floor(Math.random() * (i + 1));
80
+ const swap = items[i]!;
81
+ items[i] = items[j]!;
82
+ items[j] = swap;
83
+ }
84
+ }
85
+
70
86
  function normalizedWeight(weight: number | undefined): number {
71
87
  if (weight === undefined) return DEFAULT_PROXY_WEIGHT;
72
88
  if (!Number.isInteger(weight) || weight < 1 || weight > MAX_PROXY_WEIGHT) return 0;
@@ -257,6 +273,20 @@ export class NetworkPoolSelector {
257
273
  return `${poolId}::${providerId.toLowerCase()}`;
258
274
  }
259
275
 
276
+ /**
277
+ * Seeds one cooldown entry into the local map without touching Redis.
278
+ *
279
+ * Boot restore is the only writer: it replays the durable markers the
280
+ * previous process recorded (`network_pools.provider_cooldowns`, see
281
+ * `pool-cooldown-store.ts`) so dispatch starts remembering the egresses the
282
+ * upstream is still refusing. Anything whose window already passed is
283
+ * dropped by `restore…` before this is reached.
284
+ */
285
+ hydrateProviderCooldown(entry: ProxyCooldownEntry): void {
286
+ enforceCooldownLimit(this.state.cooldowns);
287
+ this.state.cooldowns.set(this.cooldownKey(entry.poolId, entry.providerId), entry);
288
+ }
289
+
260
290
  async flagProviderCooldown(
261
291
  poolId: string,
262
292
  providerId: string,
@@ -308,12 +338,32 @@ export class NetworkPoolSelector {
308
338
  * The entry uses the same storage as a cooldown so every reader that already
309
339
  * skips a cooling pool skips this one too; `reason` is what the console
310
340
  * renders to tell an operator-blocked proxy from a throttled one.
341
+ *
342
+ * Refuses to retire an egress for a provider whose limits belong to a
343
+ * credential. An address-scoped verdict only exists for providers that count
344
+ * requests by egress (`ipScopedRateLimit` — opencode); for everyone else a
345
+ * refusal belongs to the account that was dialed, so retiring the pool would
346
+ * sideline an address that is still serving every sibling account. Callers
347
+ * keep their own guard, and this one is the backstop that makes the rule
348
+ * impossible to route around from a new call site.
311
349
  */
312
350
  async flagProviderBlocked(
313
351
  poolId: string,
314
352
  providerId: string,
315
353
  reason = "Upstream refused this egress address",
316
354
  ): Promise<ProxyCooldownEntry> {
355
+ if (!providerRateLimitIsIpScoped(providerId)) {
356
+ log.warn("[pool-selector] refused to block an egress for a non-IP-scoped provider", {
357
+ networkPoolId: poolId,
358
+ providerId,
359
+ });
360
+ return {
361
+ poolId,
362
+ providerId: providerId.toLowerCase(),
363
+ until: Date.now(),
364
+ reason,
365
+ };
366
+ }
317
367
  const entry = await this.flagProviderCooldown(poolId, providerId, PROVIDER_BLOCK_MS, reason);
318
368
  const blocked: ProxyCooldownEntry = { ...entry, blocked: true };
319
369
  // Rewrite both copies with the flag: the cooldown write above is shared
@@ -455,6 +505,38 @@ export class NetworkPoolSelector {
455
505
  await this.clearRedisCooldown(poolId, providerId);
456
506
  }
457
507
 
508
+ /**
509
+ * Releases a *retired* egress only, leaving a cooldown intact.
510
+ *
511
+ * The two are one key, so `clearProviderCooldown` cannot be reused: it would
512
+ * also drop a live 429 cooldown and hand a spent address back to dispatch.
513
+ * This is the correction a 429 probe is entitled to make — the address is
514
+ * answering its own rate limiter, so it was never retired — and nothing more.
515
+ *
516
+ * No-op when the marker is a cooldown, and no-op when there is no marker.
517
+ */
518
+ async clearProviderBlockedOnly(poolId: string, providerId: string): Promise<void> {
519
+ const key = this.cooldownKey(poolId, providerId);
520
+ const local = this.state.cooldowns.get(key);
521
+ if (local !== undefined) {
522
+ if (local.blocked !== true) return;
523
+ this.state.cooldowns.delete(key);
524
+ }
525
+ if (!this.redis) return;
526
+ try {
527
+ const raw = await this.redis.get(proxyCooldownKey(poolId, providerId));
528
+ const entry = raw ? parseCooldownEntry(raw) : undefined;
529
+ // A missing or unparseable marker says nothing about the address, and
530
+ // deleting a key we cannot read would drop a cooldown this is forbidden
531
+ // from touching. Only a marker that names itself a block is released.
532
+ if (entry?.blocked !== true) return;
533
+ await this.clearRedisCooldown(poolId, providerId);
534
+ } catch {
535
+ // The local copy is already corrected; the Redis mirror stays until its
536
+ // TTL or the next write.
537
+ }
538
+ }
539
+
458
540
  /**
459
541
  * Removes one cooldown marker and its index entry in one atomic step.
460
542
  *
@@ -564,6 +646,26 @@ export class NetworkPoolSelector {
564
646
  if (scored.length === 0) return undefined;
565
647
 
566
648
  if (!this.redis) {
649
+ if (rotation?.strategy === "random") {
650
+ // Random scatter: shuffle the eligible pools and take the first one
651
+ // with a free slot, falling through like the ordered scans do.
652
+ shuffleArray(scored);
653
+ for (const candidate of scored) {
654
+ if (this.getInflight(candidate.poolId) >= candidate.capacity) continue;
655
+ const slot =
656
+ providerCapacity === undefined
657
+ ? this.acquire(candidate.poolId, candidate.capacity)
658
+ : this.acquireProvider(
659
+ candidate.poolId,
660
+ providerId,
661
+ candidate.capacity,
662
+ providerCapacity,
663
+ );
664
+ if (!slot.acquired) continue;
665
+ return { poolId: candidate.poolId, release: slot.release };
666
+ }
667
+ return undefined;
668
+ }
567
669
  if (rotation) {
568
670
  // Round robin: scan from the tenant cursor; a pool that is full falls
569
671
  // through to the next offset (failover). Only a successful admission
@@ -662,7 +764,13 @@ export class NetworkPoolSelector {
662
764
  // Original (pre-sort) index = absolute rotation position and
663
765
  // deterministic tie-breaking.
664
766
  const absoluteIndex = new Map(withCounts.map((candidate, index) => [candidate.poolId, index]));
665
- if (rotation) {
767
+ if (rotation?.strategy === "random") {
768
+ // Random scatter: shuffle before the admit loop, which still enforces
769
+ // capacity and fails over to the next pool exactly like the ordered
770
+ // scans — but the order each request tries is fresh, so a burst does
771
+ // not gang up on the first pools in a fixed order.
772
+ shuffleArray(withCounts);
773
+ } else if (rotation) {
666
774
  // Round robin orders strictly by the tenant cursor; the CAS admit below
667
775
  // still enforces capacity, so a full pool falls through to the next
668
776
  // offset exactly like account failover.
@@ -715,7 +823,7 @@ export class NetworkPoolSelector {
715
823
  // to the next pool, which is the whole point of the ceiling: the request
716
824
  // moves to an egress that still has room for this provider.
717
825
  if (result !== 1) continue;
718
- if (rotation) {
826
+ if (rotation && rotation.strategy !== "random") {
719
827
  advanceRotation(
720
828
  this.state.rotationCursors,
721
829
  rotation.key,
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The durable mirror of the pool selector's provider cooldown state.
3
+ *
4
+ * The selector's own map is process-local, mirrored to Redis only when Redis
5
+ * exists — so the usual installation ran with the flags surviving just a
6
+ * restart, while the upstream's refusal of the egress did not. The result was
7
+ * the report that motivated this: no cooldown shown on the proxy page, and
8
+ * still-429 requests, because dispatch re-picked a spent egress it no longer
9
+ * remembered. `network_pools.provider_cooldowns` is that state's home between
10
+ * boots: dispatch flaggers write each marker, boot reads them all back.
11
+ *
12
+ * One entry per cooling provider, keyed by the lowercased provider id — the
13
+ * same normalization the selector applies to its in-memory key.
14
+ */
15
+ import { randomUUID } from "node:crypto";
16
+ import { eq } from "drizzle-orm";
17
+ import type { CartethyiaDatabase } from "../persistence/db-handle";
18
+ import { networkPools, healthEvents } from "../persistence/schema";
19
+ import { log } from "../observability/logger";
20
+ import { providerRateLimitIsIpScoped } from "../providers/provider-metadata";
21
+ import { PROVIDER_BLOCK_MS, type ProxyCooldownEntry } from "./pool/selector";
22
+
23
+ type PoolProviderCooldownMap = NonNullable<
24
+ (typeof networkPools.$inferSelect)["providerCooldowns"]
25
+ >;
26
+
27
+ function readMarkerMap(
28
+ db: CartethyiaDatabase,
29
+ poolId: string,
30
+ ): Promise<PoolProviderCooldownMap> {
31
+ return db
32
+ .select({ providerCooldowns: networkPools.providerCooldowns })
33
+ .from(networkPools)
34
+ .where(eq(networkPools.id, poolId))
35
+ .limit(1)
36
+ .then((rows) => rows[0]?.providerCooldowns ?? {});
37
+ }
38
+
39
+ function writeMarkerMap(
40
+ db: CartethyiaDatabase,
41
+ poolId: string,
42
+ map: PoolProviderCooldownMap,
43
+ ): Promise<unknown> {
44
+ return db.update(networkPools).set({ providerCooldowns: map }).where(eq(networkPools.id, poolId));
45
+ }
46
+
47
+ function entryOf(marker: Omit<ProxyCooldownEntry, "poolId" | "providerId">): {
48
+ until: number;
49
+ reason: string;
50
+ blocked?: boolean;
51
+ } {
52
+ return marker.blocked === true
53
+ ? { until: marker.until, reason: marker.reason, blocked: true }
54
+ : { until: marker.until, reason: marker.reason };
55
+ }
56
+
57
+ /**
58
+ * Writes up (or replaces) one provider's marker on the pool row.
59
+ *
60
+ * Fill-in, not replace: an entry for another cooling provider must survive.
61
+ * Failures are logged and swallowed — the in-memory/Redis state stays
62
+ * conservative for the running process, and only the *next* restart would miss
63
+ * a flag this write failed to land.
64
+ */
65
+ export async function upsertProviderCooldownMarker(
66
+ db: CartethyiaDatabase,
67
+ poolId: string,
68
+ providerId: string,
69
+ marker: { until: number; reason: string; blocked?: boolean },
70
+ ): Promise<void> {
71
+ try {
72
+ const key = providerId.toLowerCase();
73
+ const map = await readMarkerMap(db, poolId);
74
+ map[key] = entryOf(marker);
75
+ await writeMarkerMap(db, poolId, map);
76
+ } catch (error) {
77
+ log.warn("pool provider cooldown mirror write failed", { networkPoolId: poolId, providerId, error: String(error) });
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Removes one provider's marker, optionally only when it is a *block*.
83
+ *
84
+ * `onlyBlocked` mirrors `clearProviderBlockedOnly`: a probe that answered 429
85
+ * may correct a retired claim but must not release a live cooldown, and the
86
+ * same rule applies to the durable copy.
87
+ */
88
+ export async function dropProviderCooldownMarker(
89
+ db: CartethyiaDatabase,
90
+ poolId: string,
91
+ providerId: string,
92
+ onlyBlocked = false,
93
+ ): Promise<void> {
94
+ try {
95
+ const key = providerId.toLowerCase();
96
+ const map = await readMarkerMap(db, poolId);
97
+ const current = map[key];
98
+ if (current === undefined) return;
99
+ if (onlyBlocked && current.blocked !== true) return;
100
+ delete map[key];
101
+ await writeMarkerMap(db, poolId, map);
102
+ } catch (error) {
103
+ log.warn("pool provider cooldown mirror clear failed", { networkPoolId: poolId, providerId, error: String(error) });
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Removes every provider's marker from one pool row (disable/replace paths).
109
+ */
110
+ export async function dropAllProviderCooldownMarkers(
111
+ db: CartethyiaDatabase,
112
+ poolId: string,
113
+ ): Promise<void> {
114
+ try {
115
+ const map = await readMarkerMap(db, poolId);
116
+ if (Object.keys(map).length === 0) return;
117
+ await writeMarkerMap(db, poolId, {});
118
+ } catch (error) {
119
+ log.warn("pool provider cooldown mirror purge failed", { networkPoolId: poolId, error: String(error) });
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Writes a probe-measured verdict the same way a dispatch-side event would be
125
+ * recorded: one mirror marker and one audit row.
126
+ *
127
+ * The probe is the only writer that can flag a cooldown from the control
128
+ * plane — its 429 is a fresh measurement of "this egress is out of allowance"
129
+ * (a cooldown marker), and its 403 is a fresh measurement of "this egress was
130
+ * retired" (a block marker). Without this, an operator who tested a
131
+ * rate-limited or retired pool saw the verdict once, refreshing lost it, and
132
+ * the activity panel showed nothing: the fact existed only in the remembered
133
+ * HTTP response.
134
+ *
135
+ * Not coalesced: a probe is operator-initiated and one row per click is the
136
+ * visible history the panel exists for.
137
+ */
138
+ export async function recordProbeVerdict(
139
+ db: CartethyiaDatabase,
140
+ poolId: string,
141
+ providerId: string,
142
+ marker: { until: number; reason: string; blocked?: boolean },
143
+ ): Promise<void> {
144
+ await upsertProviderCooldownMarker(db, poolId, providerId, marker);
145
+ try {
146
+ await db.insert(healthEvents).values({
147
+ id: randomUUID(),
148
+ entityKind: "pool",
149
+ networkPoolId: poolId,
150
+ fromStatus: null,
151
+ toStatus: "cooldown",
152
+ reason: `provider ${providerId}: ${marker.reason}`,
153
+ errorCategory: marker.blocked === true ? "egress_blocked" : "rate_limit_transient",
154
+ createdAt: new Date(),
155
+ });
156
+ } catch (error) {
157
+ log.warn("pool probe event failed", { networkPoolId: poolId, providerId, error: String(error) });
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Reads every live marker back into the selector at boot.
163
+ *
164
+ * Only future-dated entries are restored: an expired marker describes an
165
+ * allowance that refilled while the process was down, and resurrecting it
166
+ * would sideline a working address. Repeat 429s re-insert markers, so nothing
167
+ * is lost by dropping the expired ones.
168
+ */
169
+ export async function restoreProviderCooldownMarkers(
170
+ selector: { hydrateProviderCooldown(entry: ProxyCooldownEntry): void },
171
+ db: CartethyiaDatabase,
172
+ ): Promise<number> {
173
+ const rows = await db
174
+ .select({ id: networkPools.id, providerCooldowns: networkPools.providerCooldowns })
175
+ .from(networkPools);
176
+ let restored = 0;
177
+ for (const row of rows) {
178
+ const map = row.providerCooldowns;
179
+ if (map === null || map === undefined) continue;
180
+ for (const [providerId, marker] of Object.entries(map)) {
181
+ if (Date.now() >= marker.until) continue;
182
+ selector.hydrateProviderCooldown({
183
+ poolId: row.id,
184
+ providerId,
185
+ until: marker.until,
186
+ reason: marker.reason,
187
+ ...(marker.blocked === true ? { blocked: true } : {}),
188
+ });
189
+ restored += 1;
190
+ }
191
+ }
192
+ return restored;
193
+ }
194
+
195
+ /** The columns the probe-verdict reconciliation reads, mirroring the row's own read path. */
196
+ function probeEndpointOf(endpointConfig: unknown): string {
197
+ const raw = (endpointConfig ?? {}) as Record<string, unknown>;
198
+ return typeof raw["endpoint"] === "string" ? raw["endpoint"] : "";
199
+ }
200
+
201
+ /**
202
+ * Re-enforces the stored probe verdicts at boot (new: replay, read-ahead).
203
+ *
204
+ * A stored `blocked` verdict is a *state*, not a decoration. If the marker the
205
+ * probe flagged failed to persist, or the verdict predated the marker column,
206
+ * the egress would return to rotation after restart and get refused again —
207
+ * dispatch spending a real request to rediscover a refusal the row already
208
+ * knew. So the boot read reconciles: a fresh (endpoint still matches) `blocked`
209
+ * verdict for an address-keyed provider becomes a block flag, even when the
210
+ * mirror has nothing (or has an older, cooler entry — the retirement claim is
211
+ * the sharper fact and wins).
212
+ *
213
+ * `throttled` is deliberately **not** replayed into a cooldown: the cooldown
214
+ * the probe flagged already carries the measured window, and blind-replaying
215
+ * one from a verdict that predates the restart could outlive the upstream's
216
+ * actual window.
217
+ *
218
+ * Returns how many verdicts were replayed.
219
+ */
220
+ export async function restorePoolProbeVerdicts(
221
+ selector: { hydrateProviderCooldown(entry: ProxyCooldownEntry): void },
222
+ db: CartethyiaDatabase,
223
+ ): Promise<number> {
224
+ const rows = await db
225
+ .select({
226
+ id: networkPools.id,
227
+ status: networkPools.status,
228
+ endpointConfig: networkPools.endpointConfig,
229
+ providerProbeProviderId: networkPools.providerProbeProviderId,
230
+ providerProbeStatus: networkPools.providerProbeStatus,
231
+ providerProbeReason: networkPools.providerProbeReason,
232
+ providerProbeEndpoint: networkPools.providerProbeEndpoint,
233
+ })
234
+ .from(networkPools);
235
+ let replayed = 0;
236
+ for (const row of rows) {
237
+ if (row.status !== "active") continue;
238
+ if (row.providerProbeStatus !== "blocked") continue;
239
+ if (row.providerProbeEndpoint !== probeEndpointOf(row.endpointConfig)) continue;
240
+ const providerId = row.providerProbeProviderId;
241
+ if (typeof providerId !== "string" || !providerRateLimitIsIpScoped(providerId)) continue;
242
+ selector.hydrateProviderCooldown({
243
+ poolId: row.id,
244
+ providerId: providerId.toLowerCase(),
245
+ until: Date.now() + PROVIDER_BLOCK_MS,
246
+ reason: row.providerProbeReason ?? "Upstream refused this egress address",
247
+ blocked: true,
248
+ });
249
+ replayed += 1;
250
+ }
251
+ return replayed;
252
+ }
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { healthEventBuffer } from "../persistence/health-event-flush";
2
3
  import { and, desc, eq, inArray, isNotNull, lte, sql } from "drizzle-orm";
3
4
  import type { CartethyiaDatabase } from "../persistence/db-handle";
@@ -140,7 +141,7 @@ export async function recordPoolDispatchOutcome(
140
141
  createdAt: now,
141
142
  };
142
143
  if (buffer !== undefined) buffer.enqueue(row);
143
- else await db.insert(healthEvents).values(row as never);
144
+ else await db.insert(healthEvents).values({ id: randomUUID(), ...row } as never);
144
145
  }
145
146
  if (healthChanged) {
146
147
  publishPoolHealth({
@@ -198,6 +199,7 @@ export async function disablePoolForProxyHttpStatus(
198
199
  if (!updated[0]) return false;
199
200
 
200
201
  await db.insert(healthEvents).values({
202
+ id: randomUUID(),
201
203
  entityKind: "pool",
202
204
  networkPoolId: poolId,
203
205
  fromStatus: pool.status,
@@ -226,7 +228,7 @@ export async function recoverNetworkPool(db: CartethyiaDatabase, tenantId: strin
226
228
  if (!pool) return false;
227
229
  const now = new Date();
228
230
  await db.update(networkPools).set({ status: "active", consecutiveFailures: 0, cooldownUntil: null, lastRecoveredAt: now }).where(and(eq(networkPools.tenantId, tenantId), eq(networkPools.id, poolId)));
229
- await db.insert(healthEvents).values({ entityKind: "pool", networkPoolId: poolId, fromStatus: pool.status, toStatus: "active", reason: "Manual operator recovery", errorCategory: null, createdAt: now });
231
+ await db.insert(healthEvents).values({ id: randomUUID(), entityKind: "pool", networkPoolId: poolId, fromStatus: pool.status, toStatus: "active", reason: "Manual operator recovery", errorCategory: null, createdAt: now });
230
232
  publishPoolHealth({ poolId, tenantId: pool.tenantId, status: "active", consecutiveFailures: 0 });
231
233
  return true;
232
234
  }
@@ -248,7 +250,7 @@ export async function sweepExpiredPoolCooldowns(db: CartethyiaDatabase): Promise
248
250
  const expired = await db.select({ id: networkPools.id, tenantId: networkPools.tenantId, status: networkPools.status }).from(networkPools).where(and(inArray(networkPools.status, ["cooldown"]), isNotNull(networkPools.cooldownUntil), lte(networkPools.cooldownUntil, now))).limit(POOL_COOLDOWN_RECOVERY_BATCH);
249
251
  if (expired.length === 0) return 0;
250
252
  await db.update(networkPools).set({ status: "active", consecutiveFailures: 0, cooldownUntil: null, lastRecoveredAt: now }).where(inArray(networkPools.id, expired.map((row) => row.id)));
251
- await db.insert(healthEvents).values(expired.map((row) => ({ entityKind: "pool" as const, networkPoolId: row.id, fromStatus: row.status, toStatus: "active" as const, reason: "Cooldown elapsed — auto-recovered", errorCategory: null, createdAt: now })));
253
+ await db.insert(healthEvents).values(expired.map((row) => ({ id: randomUUID(), entityKind: "pool" as const, networkPoolId: row.id, fromStatus: row.status, toStatus: "active" as const, reason: "Cooldown elapsed — auto-recovered", errorCategory: null, createdAt: now })));
252
254
  for (const row of expired) publishPoolHealth({ poolId: row.id, tenantId: row.tenantId, status: "active", consecutiveFailures: 0 });
253
255
  return expired.length;
254
256
  }
@@ -1,21 +1,26 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import type { CartethyiaDatabase } from "../persistence/db-handle";
2
3
  import { healthEvents } from "../persistence/schema";
3
4
  import type { NetworkPoolSelector } from "./pool/selector";
4
5
  import type { GatewayError } from "../protocol/gateway-error";
5
6
  import { metrics } from "../observability/metrics";
6
7
  import { log } from "../observability/logger";
8
+ import { upsertProviderCooldownMarker } from "./pool-cooldown-store";
9
+ import { resolvePoolProviderRateLimitCooldownMs } from "../config/cooldowns";
7
10
 
8
11
  /**
9
12
  * Durable pool rate-limit history.
10
13
  *
11
14
  * Per-(pool, provider) 429 cooldowns live in `NetworkPoolSelector` (fast,
12
- * volatile, consulted on every dispatch). This recorder mirrors each flag
13
- * into the append-only `health_events` table (`entity_kind: "pool"`) — the
14
- * same merged log the account recorder writes to for accounts — so
15
- * operators can audit which pool throttled which provider and when. It
16
- * never touches `network_pools.status`: a single provider's 429 must not
17
- * flip the whole pool to `cooldown` (that would head-of-line-block every
18
- * other provider on the pool).
15
+ * volatile, consulted on every dispatch). This recorder mirrors each flag into
16
+ * two durable homes: the append-only `health_events` table
17
+ * (`entity_kind: "pool"`, the same merged log the account recorder writes to
18
+ * for accounts) for audit, and the `provider_cooldowns` column on the pool row
19
+ * as the state boot restores — a restart must not make dispatch forget the
20
+ * egresses the upstream is still refusing. It never touches
21
+ * `network_pools.status`: a single provider's 429 must not flip the whole pool
22
+ * to `cooldown` (that would head-of-line-block every other provider on the
23
+ * pool).
19
24
  */
20
25
 
21
26
  /**
@@ -41,12 +46,14 @@ async function recordPoolCooldownEvent(
41
46
  if (last !== undefined && now - last < POOL_COOLDOWN_EVENT_WINDOW_MS) return;
42
47
  try {
43
48
  await db.insert(healthEvents).values({
49
+ id: randomUUID(),
44
50
  entityKind: "pool",
45
51
  networkPoolId: poolId,
46
52
  fromStatus: null,
47
53
  toStatus: "cooldown",
48
54
  reason: `provider ${providerId}: ${reason}`,
49
55
  errorCategory: "rate_limit_transient",
56
+ createdAt: new Date(),
50
57
  });
51
58
  } catch {
52
59
  // Non-fatal: observability writes must never fail dispatch. The
@@ -85,6 +92,12 @@ export function flagPoolBlocked(
85
92
  const reason = `Provider ${providerId} refused this egress`;
86
93
  void poolSelector
87
94
  .flagProviderBlocked(poolId, providerId, reason)
95
+ .then((entry) =>
96
+ // Persisted even when the flag was refused (a credential-keyed
97
+ // provider): the selector returned an inert entry, so the mirror
98
+ // records nothing a reader would act on.
99
+ upsertProviderCooldownMarker(db, poolId, providerId, entry),
100
+ )
88
101
  .catch((cause: unknown) => {
89
102
  metrics.pool_cooldown_record_failed.inc(1);
90
103
  log.warn("pool block flag failed", {
@@ -119,12 +132,14 @@ async function recordPoolBlockEvent(
119
132
  error: GatewayError,
120
133
  ): Promise<void> {
121
134
  await db.insert(healthEvents).values({
135
+ id: randomUUID(),
122
136
  entityKind: "pool",
123
137
  networkPoolId: poolId,
124
138
  fromStatus: null,
125
139
  toStatus: "cooldown",
126
140
  reason: `provider ${providerId}: ${reason}${providerCodeSuffix(error)}`,
127
141
  errorCategory: "egress_blocked",
142
+ createdAt: new Date(),
128
143
  });
129
144
  }
130
145
 
@@ -137,9 +152,20 @@ function providerCodeSuffix(error: GatewayError): string {
137
152
  /** Cooldown applied when an upstream 429 carries no `retryAfterMs`. */
138
153
  const DEFAULT_PROVIDER_COOLDOWN_MS = 15 * 60 * 1000;
139
154
 
155
+ /**
156
+ * The fallback window for a provider-scoped egress cooldown. Upstream `Retry-After`
157
+ * always wins; the configured default exists because a request-counted free
158
+ * tier — the one provider that flags at all — stays refused for hours after
159
+ * the 15-minute guess this function used before.
160
+ */
161
+ function providerCooldownFallbackMs(): number {
162
+ return Math.max(resolvePoolProviderRateLimitCooldownMs(), DEFAULT_PROVIDER_COOLDOWN_MS);
163
+ }
164
+
140
165
  /**
141
166
  * Records a provider-scoped 429 cooldown: the volatile per-(pool, provider)
142
- * flag consulted on every dispatch, plus its durable audit row.
167
+ * flag consulted on every dispatch, its durable audit row, and the durable
168
+ * state boot restores from.
143
169
  *
144
170
  * Both paths that reach this decision — the non-streaming attempt loop and
145
171
  * the streaming failure path — call this one function, because each write
@@ -161,9 +187,10 @@ export function flagPoolCooldown(
161
187
  const retryAfterMs =
162
188
  typeof error.details.retryAfterMs === "number"
163
189
  ? error.details.retryAfterMs
164
- : DEFAULT_PROVIDER_COOLDOWN_MS;
190
+ : providerCooldownFallbackMs();
165
191
  void poolSelector
166
192
  .flagProviderCooldown(poolId, providerId, retryAfterMs, reason)
193
+ .then((entry) => upsertProviderCooldownMarker(db, poolId, providerId, entry))
167
194
  .catch((cause: unknown) => {
168
195
  metrics.pool_cooldown_record_failed.inc(1);
169
196
  log.warn("pool cooldown flag failed", {
@@ -537,6 +537,63 @@ export async function chartTelemetry(
537
537
  }));
538
538
  }
539
539
 
540
+ /** One day × model usage cell for a single provider — the Activity Matrix feed. */
541
+ export interface ProviderUsageDayRow {
542
+ /** UTC day, `YYYY-MM-DD` (epoch-day buckets, so the same key on both backends). */
543
+ readonly day: string;
544
+ /** What the client asked for; `null` when the event carried no model. */
545
+ readonly model: string | null;
546
+ readonly requests: number;
547
+ readonly inputTokens: number;
548
+ readonly cachedInputTokens: number;
549
+ readonly outputTokens: number;
550
+ readonly costUsd: number;
551
+ }
552
+
553
+ /**
554
+ * Per-provider daily usage grouped by `requested_model`, day-bucketed like
555
+ * {@link chartTelemetry} but with a second grouping dimension (requested
556
+ * model) and the totals the tooltip/summary strip need. Provider rows carry
557
+ * no catalog foreign keys, so an unknown or deleted provider id returns empty
558
+ * rather than erroring.
559
+ */
560
+ export async function providerUsageMatrix(
561
+ db: CartethyiaDatabase,
562
+ tenantId: string,
563
+ providerId: string,
564
+ since: Date,
565
+ ): Promise<ProviderUsageDayRow[]> {
566
+ // Same inlining rule as chartTelemetry: SELECT and GROUP BY must spell the
567
+ // identical expression or Postgres 42803-rejects the grouped reference.
568
+ const w = sql.raw(String(86400));
569
+ const dayExpression = isPostgres(db)
570
+ ? sql<number>`(cast(extract(epoch from ${telemetryEvents.createdAt}) as bigint) / ${w}) * ${w}`
571
+ : sql<number>`(cast(strftime('%s', ${telemetryEvents.createdAt}) as integer) / ${w}) * ${w}`;
572
+ const rows = await db
573
+ .select({
574
+ day: dayExpression,
575
+ model: telemetryEvents.requestedModel,
576
+ requests: sql<number>`count(*)`,
577
+ inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
578
+ cachedInputTokens: sql<number>`coalesce(sum(${telemetryEvents.cachedInputTokens}), 0)`,
579
+ outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
580
+ costUsd: sql<number>`coalesce(sum(${telemetryEvents.estimatedCostUsd}), 0)`,
581
+ })
582
+ .from(telemetryEvents)
583
+ .where(and(scoped(tenantId, since), eq(telemetryEvents.providerId, providerId)))
584
+ .groupBy(dayExpression, telemetryEvents.requestedModel)
585
+ .orderBy(dayExpression);
586
+ return rows.map((row) => ({
587
+ day: new Date(numeric(row.day) * 1000).toISOString().slice(0, 10),
588
+ model: row.model,
589
+ requests: numeric(row.requests),
590
+ inputTokens: numeric(row.inputTokens),
591
+ cachedInputTokens: numeric(row.cachedInputTokens),
592
+ outputTokens: numeric(row.outputTokens),
593
+ costUsd: numeric(row.costUsd),
594
+ }));
595
+ }
596
+
540
597
  /** Overview/health totals for one tenant window. */
541
598
  export async function healthTelemetry(
542
599
  db: CartethyiaDatabase,