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
@@ -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,