cartethyia 2.4.6 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/README.md +45 -13
  2. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  3. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-Bcy150Jo.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DyNXefph.js → CliToolsPage-D3xqZbN-.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  8. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  9. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CATpAEOz.js → ConsoleLogPage-u6W1UXSc.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-D6jEU7nL.js → CustomizationPage-CTTpCb25.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-CZkpn0a7.js → HealthEventsModal-C5XpWlvy.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BJlMVN4U.js → LoginPage-DHoB3Agn.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-Br3CXEau.js → ModelPicker-oQ-SDXp8.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  25. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  26. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  27. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  28. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  29. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  30. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  31. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  32. package/dist/dashboard/assets/{SettingsPage-BHW4Zt06.js → SettingsPage-CvjZxHE4.js} +1 -1
  33. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  34. package/dist/dashboard/assets/{SetupPage-VOYC_lIa.js → SetupPage-D3nza7LH.js} +1 -1
  35. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  36. package/dist/dashboard/assets/SortableList-Dtt12WT1.js +1 -0
  37. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-BNIDLiU6.js → StudioPage-CbK3hXvU.js} +4 -4
  39. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  40. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  41. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BETfaMYZ.js → api-keys-BmDbsior.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  44. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  45. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  47. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-BTyGk_DL.js → badge-DpXT4Ixt.js} +1 -1
  49. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-bujTWYM4.js → banner-CSAAWLJv.js} +1 -1
  51. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-B0Bnx5CA.js → card-DEhRXXw4.js} +1 -1
  53. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-DE2ug812.js → cli-tools-B0wBq-RL.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-R_rArH8S.js → clipboard-button-DOo6EU8O.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-BWIP_EvW.js → dialog-BvQeBMfP.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BKcgVugF.js → drawer-CMwPXwQB.js} +1 -1
  61. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-CcWkGR4a.js → entry-D4O2229X.js} +1 -1
  63. package/dist/dashboard/assets/{entry-CcWkGR4a.js.gz → entry-D4O2229X.js.gz} +0 -0
  64. package/dist/dashboard/assets/{entry-kTiTFrvh.js → entry-DKvr5Q9K.js} +1 -1
  65. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  67. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-Booc5mNe.js → github-badge-zliLvBwr.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-D70pChfn.js → index-CXm3FIyt.js} +2 -2
  71. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-D1TCqM2D.js → inline-D1IlZZ8H.js} +1 -1
  73. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-B9oyU93y.js → input-BpVZdrbc.js} +1 -1
  75. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-BArzXirQ.js → layout-BioLW7gN.js} +1 -1
  77. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-BVZzaoHF.js → list-row-CwVcGAIc.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-EmuIayqp.js → live-yfEJFF9T.js} +1 -1
  81. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CSPQ9xB0.js → model-bans-CT-TxSNX.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  84. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  85. package/dist/dashboard/assets/providers/trae.webp +0 -0
  86. package/dist/dashboard/assets/{routing-BExgxxBU.js → routing-Cs6jl49L.js} +1 -1
  87. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  88. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  89. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  90. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  91. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  92. package/dist/dashboard/assets/{stack-Ca5HxI5i.js → stack-C9_uUjaA.js} +1 -1
  93. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  94. package/dist/dashboard/assets/{state-CzUPfLXo.js → state-DA0B95UQ.js} +1 -1
  95. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  96. package/dist/dashboard/assets/{switch-DOUIky_i.js → switch-Cittxy-x.js} +1 -1
  97. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  98. package/dist/dashboard/assets/{toast-C76WrMhc.js → toast-CNt_jzO4.js} +1 -1
  99. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-D87FZwH0.js → useMutation-BQKMcJ1H.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -52
  105. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  106. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  107. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  108. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  109. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  110. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  111. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  112. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  113. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  114. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  115. package/package.json +3 -2
  116. package/scripts/cli/command/console-menu.ts +30 -11
  117. package/scripts/cli/command/start.ts +77 -13
  118. package/scripts/cli/command/update.ts +1 -1
  119. package/scripts/cli/gateway/instance-lock.ts +105 -0
  120. package/scripts/cli/gateway/spawn.ts +173 -101
  121. package/scripts/cli/ui/background.ts +110 -49
  122. package/src/boot/app.ts +20 -6
  123. package/src/boot/dependencies.ts +19 -0
  124. package/src/boot/elysia-typebox-bridge.ts +37 -0
  125. package/src/boot/lifecycle.ts +2 -0
  126. package/src/config/cooldowns.ts +138 -138
  127. package/src/config/providers.ts +5 -5
  128. package/src/config/runtime.ts +19 -3
  129. package/src/config/spec.ts +9 -0
  130. package/src/console/api-keys/contracts.ts +414 -391
  131. package/src/console/api-keys/routes.ts +880 -870
  132. package/src/console/auth/account-reset-store.ts +14 -0
  133. package/src/console/auth/account-reset.ts +3 -2
  134. package/src/console/auth/setup-service.ts +6 -3
  135. package/src/console/domain-registration.ts +9 -2
  136. package/src/console/observability/contracts.ts +475 -455
  137. package/src/console/observability/store.ts +1015 -952
  138. package/src/console/providers/catalog/contracts.ts +31 -0
  139. package/src/console/providers/catalog/provider-operations.ts +8 -0
  140. package/src/console/providers/catalog/store.ts +1532 -1525
  141. package/src/console/providers/oauth/callback-listener.ts +18 -3
  142. package/src/console/providers/oauth/routes.ts +38 -15
  143. package/src/console/quota/refresh.ts +19 -0
  144. package/src/console/updates/routes.ts +162 -4
  145. package/src/main.ts +3 -0
  146. package/src/network/http2-bidi.ts +85 -0
  147. package/src/network/outbound-fetch.ts +1 -26
  148. package/src/network/pool/agent.ts +478 -460
  149. package/src/network/pool/resolver.ts +1104 -544
  150. package/src/network/pool-health-machine.ts +12 -5
  151. package/src/observability/payload-capture.ts +344 -324
  152. package/src/persistence/db-pg.ts +9 -3
  153. package/src/persistence/db-sqlite.ts +497 -495
  154. package/src/persistence/health-event-flush.ts +137 -0
  155. package/src/persistence/migrate.ts +181 -129
  156. package/src/persistence/schema.ts +1360 -1295
  157. package/src/persistence/settings-revision.ts +25 -0
  158. package/src/persistence/shared-cache.ts +217 -0
  159. package/src/persistence/stores/share-store.ts +556 -528
  160. package/src/persistence/usage-flush.ts +61 -1
  161. package/src/protocol/canonical-model.ts +23 -1
  162. package/src/protocol/response/chat.ts +143 -10
  163. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  164. package/src/providers/default-registry.ts +173 -1
  165. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  166. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  167. package/src/providers/discovery/probing-service.ts +1022 -1001
  168. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +39 -0
  169. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  170. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +18 -5
  171. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  172. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  173. package/src/providers/integrations/buddy/codebuddy/shared.ts +15 -6
  174. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  175. package/src/providers/integrations/buddy/workbuddy/shared.ts +3 -3
  176. package/src/providers/integrations/claude/claude.ts +606 -582
  177. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  178. package/src/providers/integrations/cline/cline.ts +539 -468
  179. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  180. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  181. package/src/providers/integrations/commandcode/quota.ts +160 -0
  182. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  183. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  184. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  185. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  186. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  187. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  188. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  189. package/src/providers/integrations/cursor/cursor.ts +960 -0
  190. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  191. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  192. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  193. package/src/providers/integrations/github/github.ts +4 -4
  194. package/src/providers/integrations/hermes.ts +79 -8
  195. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  196. package/src/providers/integrations/kilo/kilo.ts +153 -146
  197. package/src/providers/integrations/opencode/provider.ts +532 -538
  198. package/src/providers/integrations/openrouter/provider.ts +139 -281
  199. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  200. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -257
  201. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  202. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  203. package/src/providers/integrations/qoder/qoder.ts +865 -730
  204. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  205. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  206. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  207. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  208. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  209. package/src/providers/integrations/trae/trae.ts +458 -0
  210. package/src/providers/operations/account-health-service.ts +107 -10
  211. package/src/providers/operations/client-versions.ts +133 -6
  212. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  213. package/src/providers/operations/provider-credential-service.ts +46 -7
  214. package/src/providers/provider-contracts.ts +76 -0
  215. package/src/providers/provider-metadata.ts +78 -6
  216. package/src/providers/provider-model-definition.ts +373 -349
  217. package/src/security/api-key-auth.ts +126 -8
  218. package/src/transport/contracts.ts +3 -0
  219. package/src/transport/dispatch/attempt-finalize.ts +32 -1
  220. package/src/transport/dispatch/attempt-loop.ts +530 -515
  221. package/src/transport/dispatch/generation.ts +88 -21
  222. package/src/transport/dispatch/leases.ts +99 -2
  223. package/src/transport/dispatch/proxy-request.ts +60 -3
  224. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  225. package/src/transport/dispatch/streaming/stream.ts +22 -2
  226. package/src/transport/dispatch/upstream.ts +19 -1
  227. package/src/transport/guards/auth-guard.ts +26 -1
  228. package/src/transport/guards/body-policy.ts +5 -4
  229. package/src/transport/request/preparer.ts +103 -0
  230. package/src/transport/request/state.ts +14 -0
  231. package/src/transport/routing/plan-gate.ts +45 -0
  232. package/src/transport/routing/route-catalog.ts +799 -777
  233. package/src/transport/routing/route-model.ts +52 -1
  234. package/src/transport/routing/router.ts +51 -0
  235. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js +0 -1
  236. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
  237. package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
  238. package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
  239. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +0 -1
  240. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
  241. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
  242. package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
  243. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
  244. package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
  245. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
  246. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js +0 -1
  247. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
  248. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +0 -3
  249. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
  250. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  251. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
  252. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +0 -1
  253. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
  254. package/dist/dashboard/assets/ProxyPage-CXiumA53.js +0 -7
  255. package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
  256. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +0 -1
  257. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
  258. package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
  259. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
  260. package/dist/dashboard/assets/SortableList-DpOTnGuH.js +0 -1
  261. package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
  262. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
  263. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js +0 -56
  264. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
  265. package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
  266. package/dist/dashboard/assets/app-BLCvGdzb.css +0 -2
  267. package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
  268. package/dist/dashboard/assets/assert-7J6_LVD3.js +0 -1
  269. package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
  270. package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
  271. package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
  272. package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
  273. package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
  274. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
  275. package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
  276. package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
  277. package/dist/dashboard/assets/entry-7dpgvpJ3.js +0 -4
  278. package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
  279. package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
  280. package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
  281. package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
  282. package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
  283. package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
  284. package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
  285. package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
  286. package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
  287. package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
  288. package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
  289. package/dist/dashboard/assets/select-C9mMoEpJ.js +0 -41
  290. package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
  291. package/dist/dashboard/assets/settings-C4_Gn_wq.js +0 -1
  292. package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
  293. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  294. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
  295. package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
  296. package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
  297. package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
  298. package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
  299. package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
  300. package/src/network/tls/bun-client-hello.ts +0 -112
  301. /package/dist/dashboard/assets/{app-Cjq7nrwu.js → app-C-Y4oZT0.js} +0 -0
  302. /package/dist/dashboard/assets/{app-Cjq7nrwu.js.gz → app-C-Y4oZT0.js.gz} +0 -0
@@ -1,952 +1,1015 @@
1
- import { loadavg, cpus, totalmem } from "node:os";
2
- import { and, desc, eq, inArray } from "drizzle-orm";
3
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
4
- import type { RedisClient } from "../../persistence/redis";
5
- import { apiKeys, networkPools, telemetryPayloads } from "../../persistence/schema";
6
- import {
7
- queryTelemetryEvents,
8
- type TelemetryEventRecord,
9
- } from "../../observability/event-log";
10
- import { isGatewayError } from "../../observability/telemetry-status";
11
- import { cachedReadWithEtag, type CachedResult } from "../shared/read-through-cache";
12
- import { decodeDatedCursor, encodeCursor } from "../shared/cursor";
13
- import { CARTETHYIA_VERSION } from "../../version";
14
- import { CachedPreferencesReader, DrizzlePreferencesReader, type PreferencesReader } from "../../persistence/tenant-preferences";
15
- import type {
16
- ObservabilityStore,
17
- UsageDimension,
18
- SystemHealthResponse,
19
- TelemetryEventView,
20
- TelemetryEventDetail,
21
- TelemetryEventListPage,
22
- UsageByResponse,
23
- UsageCacheResponse,
24
- UsageChartBucket,
25
- UsageChartResponse,
26
- UsageRequestDetail,
27
- UsageRequestItem,
28
- UsageRequestsResponse,
29
- UsageResponse,
30
- UsageSummaryResponse,
31
- } from "./contracts";
32
- import { maskClientIp } from "../../observability/redaction";
33
- import { splitEndpointConfig } from "../../network/pool/agent";
34
- import { resolveDbMode } from "../../persistence/db-mode";
35
- import { payloadReferenceFromRow, readPayloadFrame } from "../../observability/payload-store";
36
- import { shouldMaskClientIp } from "../shared/ip-privacy";
37
-
38
- function mapTelemetryEventRow(event: TelemetryEventRecord): TelemetryEventView {
39
- return {
40
- id: event.id,
41
- createdAt: event.createdAt,
42
- tenantId: event.tenantId ?? "",
43
- requestId: event.requestId,
44
- ...(event.sourceSurface ? { sourceSurface: event.sourceSurface } : {}),
45
- ...(event.requestedModel ? { requestedModel: event.requestedModel } : {}),
46
- ...(event.reportedModel ? { reportedModel: event.reportedModel } : {}),
47
- ...(event.providerId ? { providerId: event.providerId } : {}),
48
- ...(event.latencyMs !== null ? { latencyMs: event.latencyMs ?? undefined } : {}),
49
- ...(event.status ? { status: event.status } : {}),
50
- ...(event.inputTokens !== null ? { inputTokens: event.inputTokens ?? undefined } : {}),
51
- ...(event.outputTokens !== null ? { outputTokens: event.outputTokens ?? undefined } : {}),
52
- ...(event.estimatedCostUsd !== null
53
- ? { estimatedCost: Number(event.estimatedCostUsd ?? 0) }
54
- : {}),
55
- ...(event.tokensPerSec !== null ? { tokensPerSec: Number(event.tokensPerSec) } : {}),
56
- ...(event.firstContentDeltaAtMs !== null ? { firstContentDeltaAtMs: event.firstContentDeltaAtMs } : {}),
57
- ...(event.lastEventAtMs !== null ? { lastEventAtMs: event.lastEventAtMs } : {}),
58
- };
59
- }
60
- /** The HTTP status a row renders as when the wire status was never recorded. */
61
- function effectiveHttpStatus(event: TelemetryEventRecord): number {
62
- if (event.httpStatus !== null) return event.httpStatus;
63
- if (event.status === "completed") return 200;
64
- if (event.status === "cancelled") return 499;
65
- if (event.status === "truncated") return 502;
66
- return 500;
67
- }
68
-
69
- /**
70
- * Opaque, monotonic (createdAt, id) cursor. `createdAt` alone is not unique
71
- * — two telemetry events in the same millisecond would silently skip one
72
- * row across pages — so we tie-break on id and encode both, matching
73
- * `DrizzleAuditReadStore`'s cursor.
74
- */
75
-
76
- /** Resolves a `usage` period token ("24h", "7d", "30d", "all") to its inclusive start date. */
77
- function periodStartDate(period: string): Date | undefined {
78
- const match = /^(\d+)([hd])$/.exec(period);
79
- if (!match) return undefined;
80
- const amount = Number(match[1]);
81
- const unitMs = match[2] === "h" ? 3_600_000 : 86_400_000;
82
- return new Date(Date.now() - amount * unitMs);
83
- }
84
-
85
-
86
- /**
87
- * Client identity straight from the stored `User-Agent`: the first RFC 9110
88
- * product token carries both the client and its version (`bun/1.2.3`,
89
- * `codex-cli/0.1.0`), so no prefix catalog can drift from what the client
90
- * actually sent. Browser agents start with the historical `Mozilla/5.0`
91
- * token, which names no real client, so they report as `Browser`.
92
- */
93
- export function clientNameFromUserAgent(userAgent: string | null | undefined): string {
94
- const raw = userAgent?.trim();
95
- if (!raw) return "unknown";
96
- if (/^mozilla\//i.test(raw)) return "Browser";
97
- const product = raw.split(/\s+/, 1)[0] ?? "";
98
- const slash = product.indexOf("/");
99
- const token = slash <= 0 ? product : product.slice(0, slash + 1).concat(product.slice(slash + 1).split(/[;,)]/, 1)[0] ?? "");
100
- return token || "unknown";
101
- }
102
- /** Time-series bucket width for the usage chart, chosen from the window length. */
103
- function chartBucketSeconds(period: string): number {
104
- const since = periodStartDate(period);
105
- if (!since) return 86_400;
106
- const windowMs = Date.now() - since.getTime();
107
- if (windowMs <= 2 * 3_600_000) return 300;
108
- if (windowMs <= 2 * 86_400_000) return 3_600;
109
- if (windowMs <= 10 * 86_400_000) return 21_600;
110
- return 86_400;
111
- }
112
-
113
- /**
114
- * The model id with the client's requested reasoning effort as a display
115
- * suffix — only when the request actually carried one.
116
- */
117
- function modelWithEffort(event: TelemetryEventRecord): string | undefined {
118
- const model = event.requestedModel;
119
- if (!model) return undefined;
120
- return event.requestedEffort ? `${model} (${event.requestedEffort})` : model;
121
- }
122
-
123
- function mapUsageRequestItem(
124
- event: TelemetryEventRecord,
125
- hideClientIp: boolean,
126
- ): UsageRequestItem {
127
- const input = event.inputTokens ?? 0;
128
- const output = event.outputTokens ?? 0;
129
- return {
130
- requestId: event.requestId,
131
- ...(event.sourceSurface ? { surface: event.sourceSurface } : {}),
132
- ...(event.providerId ? { providerId: event.providerId } : {}),
133
- ...(event.accountId ? { accountId: event.accountId } : {}),
134
- ...(modelWithEffort(event) === undefined ? {} : { model: modelWithEffort(event) as string }),
135
- ...(event.reportedModel ? { reportedModel: event.reportedModel } : {}),
136
- status: event.status ?? "unknown",
137
- ...(event.errorCategory ? { errorKind: event.errorCategory } : {}),
138
- ...(event.errorOrigin ? { errorOrigin: event.errorOrigin } : {}),
139
- httpStatus: effectiveHttpStatus(event),
140
- mode: event.stream ? "stream" : "non_stream",
141
- startedAt: event.createdAt,
142
- ...(event.latencyMs !== null ? { durationMs: event.latencyMs ?? undefined } : {}),
143
- ...(event.inputTokens !== null ? { inputTokens: event.inputTokens ?? undefined } : {}),
144
- ...(event.outputTokens !== null ? { outputTokens: event.outputTokens ?? undefined } : {}),
145
- ...(event.cachedInputTokens !== null
146
- ? { cachedTokens: event.cachedInputTokens ?? undefined }
147
- : {}),
148
- ...(event.reasoningTokens !== null
149
- ? { reasoningTokens: event.reasoningTokens ?? undefined }
150
- : {}),
151
- ...(event.endpoint ? { endpoint: event.endpoint } : {}),
152
- ...(event.apiKeyId ? { apiKeyId: event.apiKeyId } : {}),
153
- ...(event.userAgent ? { userAgent: event.userAgent } : {}),
154
- ...(event.userAgent ? { clientName: clientNameFromUserAgent(event.userAgent) } : {}),
155
- ...(event.clientIp
156
- ? { clientIp: hideClientIp ? maskClientIp(event.clientIp) : event.clientIp }
157
- : {}),
158
- ...(input + output > 0 ? { totalTokens: input + output } : {}),
159
- ...(event.ttfbMs !== null ? { ttfbMs: event.ttfbMs ?? undefined } : {}),
160
- ...(event.tokensPerSec !== null ? { tokensPerSec: Number(event.tokensPerSec) } : {}),
161
- ...(event.firstContentDeltaAtMs !== null ? { firstContentDeltaAtMs: event.firstContentDeltaAtMs } : {}),
162
- ...(event.lastEventAtMs !== null ? { lastEventAtMs: event.lastEventAtMs } : {}),
163
- ...(event.estimatedCostUsd !== null && event.estimatedCostUsd !== undefined
164
- ? { estimatedCost: Number(event.estimatedCostUsd) }
165
- : {}),
166
- ...(event.creditUsed !== null && event.creditUsed !== undefined
167
- ? { creditUsed: Number(event.creditUsed) }
168
- : {}),
169
- ...(event.resolveMs !== null && event.resolveMs !== undefined
170
- ? { resolveMs: event.resolveMs }
171
- : {}),
172
- // Why the drawer is empty for this request — see the contract field. This is
173
- // what makes the blank panels diagnosable without server logs.
174
- ...(event.payloadCaptureState ? { payloadCaptureState: event.payloadCaptureState } : {}),
175
- };
176
- }
177
- async function resolveProxyLabel(
178
- db: CartethyiaDatabase,
179
- tenantId: string,
180
- poolId: string,
181
- ): Promise<string> {
182
- const rows = await db
183
- .select({ id: networkPools.id, endpointConfig: networkPools.endpointConfig })
184
- .from(networkPools)
185
- .where(and(eq(networkPools.id, poolId), eq(networkPools.tenantId, tenantId)))
186
- .limit(1);
187
- const row = rows[0];
188
- if (!row) return `${poolId.slice(0, 8)}…`;
189
- const { endpoint, label } = splitEndpointConfig((row.endpointConfig ?? {}) as Record<string, unknown>);
190
- return label ?? endpoint ?? `${poolId.slice(0, 8)}…`;
191
- }
192
- const HEALTH_WINDOW_MS = 24 * 3_600_000;
193
- const TOP_N = 50;
194
- /**
195
- * How long a parsed window is shared. A dashboard poll fires ~8 usage
196
- * endpoints at once, and a slow parse used to outlive a 1.5 s window — so the
197
- * endpoints that missed it each re-read and re-parsed the same files, which is
198
- * how one page load became several full parses of the retention window.
199
- */
200
- const PERIOD_MEMO_TTL_MS = 5_000;
201
-
202
- /** Nearest-rank percentile over a sorted array, matching `percentile_cont` closely enough for a health gauge. */
203
- function percentile(sorted: readonly number[], fraction: number): number {
204
- if (sorted.length === 0) return 0;
205
- const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
206
- return Math.round(sorted[index] ?? 0);
207
- }
208
-
209
- export interface UsagePerformanceSummary {
210
- avgResolveMs: number;
211
- avgDurationMs: number;
212
- latencyP50Ms: number;
213
- latencyP95Ms: number;
214
- latencyP99Ms: number;
215
- /** Events each series was computed from; see `UsageSummaryTotals`. */
216
- latencySampleCount: number;
217
- resolveSampleCount: number;
218
- }
219
-
220
- /** Summarizes routing delay and request latency for an already-filtered period. */
221
- export function summarizeUsagePerformance(
222
- events: readonly Pick<TelemetryEventRecord, "latencyMs" | "resolveMs">[],
223
- ): UsagePerformanceSummary {
224
- const latencies: number[] = [];
225
- let latencySum = 0;
226
- let resolveSum = 0;
227
- let resolveCount = 0;
228
- for (const event of events) {
229
- if (event.latencyMs !== null) {
230
- latencies.push(event.latencyMs);
231
- latencySum += event.latencyMs;
232
- }
233
- if (event.resolveMs !== null) {
234
- resolveSum += event.resolveMs;
235
- resolveCount += 1;
236
- }
237
- }
238
- latencies.sort((a, b) => a - b);
239
- return {
240
- avgResolveMs: resolveCount > 0 ? resolveSum / resolveCount : 0,
241
- avgDurationMs: latencies.length > 0 ? latencySum / latencies.length : 0,
242
- latencyP50Ms: percentile(latencies, 0.5),
243
- latencyP95Ms: percentile(latencies, 0.95),
244
- latencyP99Ms: percentile(latencies, 0.99),
245
- latencySampleCount: latencies.length,
246
- resolveSampleCount: resolveCount,
247
- };
248
- }
249
-
250
- /** The largest entries of a count map, capped. */
251
- function topCounts(counts: ReadonlyMap<string, number>, limit: number): Array<[string, number]> {
252
- return [...counts].sort((a, b) => b[1] - a[1]).slice(0, limit);
253
- }
254
-
255
- /** The grouping value for one usage breakdown dimension. */
256
- function dimensionName(dimension: UsageDimension, event: TelemetryEventRecord): string | null {
257
- if (dimension === "model") return event.requestedModel;
258
- if (dimension === "key") return event.apiKeyId;
259
- if (dimension === "client") return event.userAgent;
260
- if (dimension === "client_ip") return event.clientIp;
261
- return event.providerId;
262
- }
263
-
264
- interface BreakdownBucket {
265
- name: string;
266
- requests: number;
267
- input: number;
268
- output: number;
269
- cached: number;
270
- errors: number;
271
- cost: number | null;
272
- rateSum: number;
273
- rateCount: number;
274
- }
275
-
276
- function emptyBreakdown(name: string): BreakdownBucket {
277
- return { name, requests: 0, input: 0, output: 0, cached: 0, errors: 0, cost: null, rateSum: 0, rateCount: 0 };
278
- }
279
-
280
- /** Real Drizzle-backed observability repository used by the production console. */
281
- export class DrizzleObservabilityStore implements ObservabilityStore {
282
- /**
283
- * Per-(tenant, period) parsed event set, shared by the ~8 usage endpoints
284
- * one dashboard poll fires in parallel. 1.5 s window: a poll burst shares
285
- * one file parse; the next poll re-reads. In-flight shares one promise.
286
- */
287
- private readonly periodMemo = new Map<string, { at: number; promise: Promise<readonly TelemetryEventRecord[]> }>();
288
- constructor(
289
- private readonly db: CartethyiaDatabase,
290
- private readonly redis: RedisClient | undefined,
291
- /**
292
- * Tenant preferences, revision-cached. The Usage page reads the privacy
293
- * gate on the list, detail and breakdown paths, so a direct SELECT here
294
- * re-read the same row per request; the cache collapses that.
295
- */
296
- private readonly preferences: PreferencesReader = new CachedPreferencesReader(
297
- new DrizzlePreferencesReader(db),
298
- ),
299
- ) {}
300
-
301
- async health(tenantId: string): Promise<CachedResult<SystemHealthResponse>> {
302
- // The only usage-family read that used to bypass both the 5 s in-process
303
- // memo and the Redis read-through, so it re-parsed 24 hourly files every
304
- // page load. Same 10 s TTL as `/system/usage`.
305
- return this.readThroughWithEtag(
306
- this.usageCacheKey(tenantId, "24h", "health"),
307
- () => this.computeHealth(tenantId),
308
- );
309
- }
310
-
311
- private async computeHealth(tenantId: string): Promise<SystemHealthResponse> {
312
- const memory = process.memoryUsage();
313
- const cpuPercent = Math.round((loadavg()[0]! / Math.max(cpus().length, 1)) * 10000) / 100;
314
-
315
- let databaseHealthy = true;
316
- let total = 0;
317
- let errors = 0;
318
- let avg = 0;
319
- let p95 = 0;
320
- let p99 = 0;
321
- let cacheHitRate = 0;
322
- let avgTokensPerSec = 0;
323
- try {
324
- // Bounded 24h rolling window (matches the Overview copy). The file is the
325
- // source, so the percentiles are computed over the retained events.
326
- const since = new Date(Date.now() - HEALTH_WINDOW_MS);
327
- const events = await queryTelemetryEvents({ tenantId, since });
328
- const latencies: number[] = [];
329
- let latencySum = 0;
330
- let inputTokens = 0;
331
- let cachedTokens = 0;
332
- let outputTokens = 0;
333
- let tokensPerSecSum = 0;
334
- let tokensPerSecCount = 0;
335
- for (const event of events) {
336
- total += 1;
337
- if (isGatewayError(event.status, event.httpStatus)) errors += 1;
338
- if (event.latencyMs !== null) {
339
- latencies.push(event.latencyMs);
340
- latencySum += event.latencyMs;
341
- }
342
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
343
- inputTokens += event.inputTokens ?? 0;
344
- cachedTokens += event.inputTokens ?? 0;
345
- outputTokens += event.outputTokens ?? 0;
346
- }
347
- if (event.tokensPerSec !== null) {
348
- tokensPerSecSum += event.tokensPerSec;
349
- tokensPerSecCount += 1;
350
- }
351
- }
352
- latencies.sort((a, b) => a - b);
353
- avg = latencies.length > 0 ? Math.round(latencySum / latencies.length) : 0;
354
- p95 = percentile(latencies, 0.95);
355
- p99 = percentile(latencies, 0.99);
356
- // Cached share of the prompt: cached/input, the same honest definition
357
- // usageSummary uses, so the health card and the Usage page cannot
358
- // disagree. (This used to be prompt-share input/(input+output), which
359
- // read ~99% and was mislabeled a cache hit rate.)
360
- cacheHitRate = inputTokens > 0 ? Math.round((cachedTokens / inputTokens) * 10000) / 100 : 0;
361
- avgTokensPerSec = tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0;
362
- } catch {
363
- databaseHealthy = false;
364
- }
365
-
366
- // No client means the memory backend, which is healthy by construction.
367
- let redisHealthy = this.redis === undefined;
368
- if (this.redis !== undefined) {
369
- try {
370
- redisHealthy = (await this.redis.ping()) === "PONG";
371
- } catch {
372
- redisHealthy = false;
373
- }
374
- }
375
-
376
- const status: SystemHealthResponse["status"] = !databaseHealthy
377
- ? "unhealthy"
378
- : !redisHealthy
379
- ? "degraded"
380
- : "healthy";
381
-
382
- return {
383
- version: CARTETHYIA_VERSION,
384
- status,
385
- uptime_seconds: Math.floor(process.uptime()),
386
- database_healthy: databaseHealthy,
387
- redis_healthy: redisHealthy,
388
- redis_configured: this.redis !== undefined,
389
- memory_heap_bytes: memory.heapUsed,
390
- memory_external_bytes: memory.external,
391
- memory_bytes: memory.rss,
392
- memory_percent: Math.round((memory.rss / Math.max(totalmem(), 1)) * 10000) / 100,
393
- // Reported so the console can explain a large RSS instead of leaving the
394
- // operator to read it as the gateway alone. In lite mode this process
395
- // hosts the database too, so the resident set covers both.
396
- database_in_process: resolveDbMode() === "lite",
397
- cpu_percent: cpuPercent,
398
- cpu_cores: cpus().length,
399
- pid: process.pid,
400
- platform: `Bun ${Bun.version} · ${process.platform}`,
401
- request_count: total,
402
- error_count: errors,
403
- latency_avg_ms: avg,
404
- latency_p95_ms: p95,
405
- latency_p99_ms: p99,
406
- cache_hit_rate_percent: cacheHitRate,
407
- avg_tokens_per_sec: avgTokensPerSec,
408
- };
409
- }
410
- async usage(tenantId: string, period: string): Promise<CachedResult<UsageResponse>> {
411
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "usage"), () =>
412
- this.computeUsage(tenantId, period),
413
- );
414
- }
415
-
416
- /**
417
- * Cache key for one usage response. Versioned so a shape change can never be
418
- * read back from a deployment that still holds the old one.
419
- */
420
- private usageCacheKey(tenantId: string, period: string, shape: string): string {
421
- return `cartethyia:usage:v1:${shape}:${tenantId}:${period}`;
422
- }
423
-
424
- /**
425
- * Reads a usage response through Redis — computing when no cache is
426
- * configured — and returns the strong validator alongside it, so a repeated
427
- * poll can be answered with a `304` instead of the whole payload.
428
- */
429
- private readThroughWithEtag<T>(key: string, compute: () => Promise<T>): Promise<CachedResult<T>> {
430
- return cachedReadWithEtag(this.redis, key, compute);
431
- }
432
-
433
- private async computeUsage(tenantId: string, period: string): Promise<UsageResponse> {
434
- const events = await this.periodEvents(tenantId, period);
435
- let requestsTotal = 0;
436
- let requestsSucceeded = 0;
437
- let requestsFailed = 0;
438
- let tokensUsed = 0;
439
- let estimatedCost = 0;
440
- const models = new Map<string, number>();
441
- const providers = new Map<string, number>();
442
- for (const event of events) {
443
- requestsTotal += 1;
444
- if (event.status === "completed") requestsSucceeded += 1;
445
- if (isGatewayError(event.status, event.httpStatus)) requestsFailed += 1;
446
- tokensUsed += (event.inputTokens ?? 0) + (event.outputTokens ?? 0);
447
- estimatedCost += event.estimatedCostUsd ?? 0;
448
- if (event.requestedModel) models.set(event.requestedModel, (models.get(event.requestedModel) ?? 0) + 1);
449
- if (event.providerId) providers.set(event.providerId, (providers.get(event.providerId) ?? 0) + 1);
450
- }
451
- return {
452
- tenantId,
453
- period,
454
- requestsTotal,
455
- requestsSucceeded,
456
- requestsFailed,
457
- tokensUsed,
458
- estimatedCost,
459
- topModels: topCounts(models, TOP_N).map(([modelId, count]) => ({ modelId, count })),
460
- topProviders: topCounts(providers, TOP_N).map(([providerId, count]) => ({ providerId, count })),
461
- };
462
- }
463
-
464
- /** The tenant's retained events inside a usage period. `all` keeps everything. */
465
- private periodEvents(tenantId: string, period: string): Promise<readonly TelemetryEventRecord[]> {
466
- const key = `${tenantId}:${period}`;
467
- const now = Date.now();
468
- const hit = this.periodMemo.get(key);
469
- if (hit && now - hit.at < PERIOD_MEMO_TTL_MS) return hit.promise;
470
- const since = periodStartDate(period);
471
- const promise = queryTelemetryEvents(since === undefined ? { tenantId } : { tenantId, since });
472
- this.periodMemo.set(key, { at: now, promise });
473
- // A rejected parse must not poison the memo for the next poll.
474
- void promise.catch(() => {
475
- if (this.periodMemo.get(key)?.promise === promise) this.periodMemo.delete(key);
476
- });
477
- return promise;
478
- }
479
-
480
- async usageSummary(tenantId: string, period: string): Promise<CachedResult<UsageSummaryResponse>> {
481
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "summary"), () =>
482
- this.computeSummary(tenantId, period),
483
- );
484
- }
485
-
486
- private async computeSummary(tenantId: string, period: string): Promise<UsageSummaryResponse> {
487
- const events = await this.periodEvents(tenantId, period);
488
- const performance = summarizeUsagePerformance(events);
489
- let requests = 0;
490
- let inputTokens = 0;
491
- let cachedTokens = 0;
492
- let outputTokens = 0;
493
- let errors = 0;
494
- let cancelled = 0;
495
- let truncated = 0;
496
- let estimatedCostUsd = 0;
497
- let unpriced = 0;
498
- let tokensPerSecSum = 0;
499
- let tokensPerSecCount = 0;
500
- const statuses = new Map<number, number>();
501
- for (const event of events) {
502
- requests += 1;
503
- inputTokens += event.inputTokens ?? 0;
504
- outputTokens += event.outputTokens ?? 0;
505
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
506
- cachedTokens += event.inputTokens ?? 0;
507
- }
508
- if (isGatewayError(event.status, event.httpStatus)) errors += 1;
509
- if (event.status === "cancelled") cancelled += 1;
510
- if (event.status === "truncated") truncated += 1;
511
- if (event.estimatedCostUsd !== null) estimatedCostUsd += event.estimatedCostUsd;
512
- else if (event.status === "completed") unpriced += 1;
513
- if (event.tokensPerSec !== null) {
514
- tokensPerSecSum += event.tokensPerSec;
515
- tokensPerSecCount += 1;
516
- }
517
- const status = effectiveHttpStatus(event);
518
- statuses.set(status, (statuses.get(status) ?? 0) + 1);
519
- }
520
- return {
521
- period,
522
- totals: {
523
- statusCounts: [...statuses].sort(([a], [b]) => a - b).map(([status, count]) => ({ status, count })),
524
- requests,
525
- inputTokens,
526
- cachedTokens,
527
- outputTokens,
528
- errors,
529
- cancelled,
530
- truncated,
531
- ...performance,
532
- estimatedCostUsd,
533
- partial: unpriced > 0,
534
- cacheHitRate: inputTokens > 0 ? (cachedTokens / inputTokens) * 100 : 0,
535
- avgTokensPerSec: tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0,
536
- },
537
- };
538
- }
539
-
540
- async usageChart(tenantId: string, period: string): Promise<CachedResult<UsageChartResponse>> {
541
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "chart"), () =>
542
- this.computeChart(tenantId, period),
543
- );
544
- }
545
-
546
- private async computeChart(tenantId: string, period: string): Promise<UsageChartResponse> {
547
- const since = periodStartDate(period);
548
- const bucketSeconds = chartBucketSeconds(period);
549
- const stepMs = bucketSeconds * 1000;
550
- const events = await this.periodEvents(tenantId, period);
551
- const byTime = new Map<number, UsageChartBucket>();
552
- for (const event of events) {
553
- const at = new Date(event.createdAt).getTime();
554
- if (!Number.isFinite(at)) continue;
555
- const start = Math.floor(at / stepMs) * stepMs;
556
- const bucket = byTime.get(start) ?? { t: new Date(start).toISOString(), requests: 0, input: 0, cached: 0, output: 0 };
557
- bucket.requests += 1;
558
- bucket.input += event.inputTokens ?? 0;
559
- bucket.cached += event.cachedInputTokens !== null && event.cachedInputTokens > 0 ? (event.inputTokens ?? 0) : 0;
560
- bucket.output += event.outputTokens ?? 0;
561
- byTime.set(start, bucket);
562
- }
563
- const rows = [...byTime.keys()];
564
- const first = rows.length > 0 ? Math.min(...byTime.keys()) : undefined;
565
- const startMs = since ? since.getTime() : first;
566
- if (startMs === undefined) return { buckets: [] };
567
- const aligned = Math.floor(startMs / stepMs) * stepMs;
568
- const buckets: UsageChartBucket[] = [];
569
- for (let at = aligned; at <= Date.now(); at += stepMs) {
570
- buckets.push(
571
- byTime.get(at) ?? { t: new Date(at).toISOString(), requests: 0, input: 0, cached: 0, output: 0 },
572
- );
573
- }
574
- return { buckets };
575
- }
576
-
577
- async usageBy(
578
- tenantId: string,
579
- dimension: UsageDimension,
580
- period: string,
581
- ): Promise<CachedResult<UsageByResponse>> {
582
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, `${dimension}:${period}`, "by"), () =>
583
- this.computeBy(tenantId, dimension, period),
584
- );
585
- }
586
-
587
- private async computeBy(
588
- tenantId: string,
589
- dimension: UsageDimension,
590
- period: string,
591
- ): Promise<UsageByResponse> {
592
- const events = await this.periodEvents(tenantId, period);
593
- const groups = new Map<string, BreakdownBucket>();
594
- for (const event of events) {
595
- const raw = dimensionName(dimension, event);
596
- if (raw === null || raw.length === 0) continue;
597
- const name = dimension === "client" ? clientNameFromUserAgent(raw) : raw;
598
- const bucket = groups.get(name) ?? emptyBreakdown(name);
599
- bucket.requests += 1;
600
- bucket.input += event.inputTokens ?? 0;
601
- bucket.output += event.outputTokens ?? 0;
602
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
603
- bucket.cached += event.inputTokens ?? 0;
604
- }
605
- if (isGatewayError(event.status, event.httpStatus)) bucket.errors += 1;
606
- if (event.estimatedCostUsd !== null) bucket.cost = (bucket.cost ?? 0) + event.estimatedCostUsd;
607
- if (event.tokensPerSec !== null) {
608
- bucket.rateSum += event.tokensPerSec;
609
- bucket.rateCount += 1;
610
- }
611
- groups.set(name, bucket);
612
- }
613
- const mapped = [...groups.values()]
614
- .map((bucket) => ({
615
- name: bucket.name,
616
- requests: bucket.requests,
617
- input: bucket.input,
618
- output: bucket.output,
619
- cached: bucket.cached,
620
- total: bucket.input + bucket.output,
621
- errors: bucket.errors,
622
- costUsd: bucket.cost,
623
- cacheHitRate: bucket.input > 0 ? (bucket.cached / bucket.input) * 100 : 0,
624
- avgTokensPerSec: bucket.rateCount > 0 ? bucket.rateSum / bucket.rateCount : 0,
625
- }))
626
- .sort((a, b) => b.requests - a.requests)
627
- .slice(0, 100);
628
- if (dimension === "client_ip") {
629
- // Same fail-closed gate as the request list: storage keeps the raw
630
- // address, only presentation masks it.
631
- const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
632
- if (!hideClientIp) return { dimension, rows: mapped };
633
- // Grouping happened on the raw address, so masking can collapse distinct
634
- // hosts into one display name (`203.0.113.7` and `203.0.113.9` both become
635
- // `203.0.113.xxx`). Re-aggregate by the masked name: two rows the operator
636
- // cannot tell apart are one row, and the totals must not be understated.
637
- const merged = new Map<string, (typeof mapped)[number]>();
638
- for (const row of mapped) {
639
- const masked = maskClientIp(row.name);
640
- const existing = merged.get(masked);
641
- if (existing === undefined) {
642
- merged.set(masked, { ...row, name: masked });
643
- continue;
644
- }
645
- const input = existing.input + row.input;
646
- const output = existing.output + row.output;
647
- merged.set(masked, {
648
- ...existing,
649
- requests: existing.requests + row.requests,
650
- input,
651
- output,
652
- cached: existing.cached + row.cached,
653
- total: input + output,
654
- errors: existing.errors + row.errors,
655
- costUsd:
656
- existing.costUsd === null && row.costUsd === null
657
- ? null
658
- : (existing.costUsd ?? 0) + (row.costUsd ?? 0),
659
- cacheHitRate: input > 0 ? (existing.cached + row.cached) / input * 100 : 0,
660
- });
661
- }
662
- return {
663
- dimension,
664
- rows: [...merged.values()].sort((a, b) => b.requests - a.requests),
665
- };
666
- }
667
- if (dimension !== "key") return { dimension, rows: mapped };
668
- const ids = [...new Set(mapped.map((row) => row.name))];
669
- const labels = ids.length > 0 ? await this.apiKeyLabels(tenantId, ids) : new Map<string, string>();
670
- return {
671
- dimension,
672
- rows: mapped.map((row) => {
673
- const label = labels.get(row.name);
674
- return label === undefined ? row : { ...row, label };
675
- }),
676
- };
677
- }
678
-
679
- private async apiKeyLabels(tenantId: string, ids: readonly string[]): Promise<Map<string, string>> {
680
- const unique = [...new Set(ids)];
681
- if (unique.length === 0) return new Map<string, string>();
682
- const keyRows = await this.db
683
- .select({ id: apiKeys.id, label: apiKeys.label })
684
- .from(apiKeys)
685
- .where(and(inArray(apiKeys.id, [...unique]), eq(apiKeys.tenantId, tenantId)));
686
- return new Map(keyRows.map((keyRow) => [keyRow.id, keyRow.label] as const));
687
- }
688
-
689
- async usageCache(tenantId: string, period: string): Promise<CachedResult<UsageCacheResponse>> {
690
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "cache"), () =>
691
- this.computeCache(tenantId, period),
692
- );
693
- }
694
-
695
- private async computeCache(tenantId: string, period: string): Promise<UsageCacheResponse> {
696
- const events = await this.periodEvents(tenantId, period);
697
- let inputTokens = 0;
698
- let cachedTokens = 0;
699
- let outputTokens = 0;
700
- for (const event of events) {
701
- inputTokens += event.inputTokens ?? 0;
702
- outputTokens += event.outputTokens ?? 0;
703
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
704
- cachedTokens += event.inputTokens ?? 0;
705
- }
706
- }
707
- const tokenTotal = inputTokens + outputTokens;
708
- return {
709
- period,
710
- inputTokens,
711
- cachedTokens,
712
- cacheWriteTokens: 0,
713
- hitRate: tokenTotal > 0 ? (cachedTokens / tokenTotal) * 100 : 0,
714
- };
715
- }
716
-
717
- async usageRequests(
718
- tenantId: string,
719
- period: string,
720
- limit: number,
721
- httpStatus?: number,
722
- ): Promise<CachedResult<UsageRequestsResponse>> {
723
- // Ask the reader for `limit` rows instead of folding the whole window and
724
- // slicing it. It stops at the newest file that fills the page, so the
725
- // console's most-polled endpoint no longer parses the retention window just
726
- // to show 50 rows.
727
- const since = periodStartDate(period);
728
- const events = await queryTelemetryEvents({
729
- tenantId,
730
- limit,
731
- ...(httpStatus === undefined ? {} : { httpStatus }),
732
- ...(since === undefined ? {} : { since }),
733
- });
734
- const rows = events.slice(0, limit);
735
- const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
736
- // Cached like its siblings, so a repeated poll costs a `304` instead of
737
- // another read. The mask flag is part of the key: it is a privacy setting,
738
- // so a page must never be served from an entry computed under the other
739
- // value. The rows were already read above — this bounds the label lookup
740
- // and the mapping, not the telemetry read.
741
- return this.readThroughWithEtag(
742
- this.usageCacheKey(
743
- tenantId,
744
- `${period}:${limit}:${httpStatus ?? "*"}:${hideClientIp}`,
745
- "requests",
746
- ),
747
- async () => {
748
- const keyIds = [...new Set(rows.map((row) => row.apiKeyId).filter((id): id is string => id !== null))];
749
- const labels = await this.apiKeyLabels(tenantId, keyIds);
750
- return {
751
- items: rows.map((row) => {
752
- const item = mapUsageRequestItem(row, hideClientIp);
753
- const label = row.apiKeyId ? labels.get(row.apiKeyId) : undefined;
754
- return label === undefined ? item : { ...item, apiKeyLabel: label };
755
- }),
756
- };
757
- },
758
- );
759
- }
760
- async usageRequestDetail(
761
- tenantId: string,
762
- requestId: string,
763
- ): Promise<UsageRequestDetail | undefined> {
764
- const rows = await queryTelemetryEvents({ tenantId, requestId });
765
- const event = rows[0];
766
- if (!event) return undefined;
767
- // The label, proxy, and mask lookups are independent: run them together
768
- // instead of three sequential round trips.
769
- const [hideClientIp, keyLabels, proxy] = await Promise.all([
770
- shouldMaskClientIp(this.preferences, tenantId),
771
- event.apiKeyId ? this.apiKeyLabels(tenantId, [event.apiKeyId]) : undefined,
772
- event.networkPoolId ? resolveProxyLabel(this.db, tenantId, event.networkPoolId) : ("direct" as const),
773
- ]);
774
- const item = mapUsageRequestItem(event, hideClientIp);
775
- const keyLabel = event.apiKeyId ? keyLabels?.get(event.apiKeyId) : undefined;
776
- const labeled = keyLabel === undefined ? item : { ...item, apiKeyLabel: keyLabel };
777
- const presented = { ...labeled, proxy };
778
- // Filter the payload table directly instead of joining `telemetry_events`
779
- // only to re-apply the tenant predicate: `telemetry_payloads` carries
780
- // `tenant_id` itself, so the join added a second table's rows to filter for
781
- // no additional constraint. This matches `getEvent` above and uses
782
- // `telemetry_payloads_tenant_request_idx`.
783
- const linkedPayloadRows = await this.db
784
- .select({ payload: telemetryPayloads })
785
- .from(telemetryPayloads)
786
- .where(
787
- and(
788
- eq(telemetryPayloads.requestId, requestId),
789
- eq(telemetryPayloads.tenantId, tenantId),
790
- ),
791
- )
792
- .orderBy(desc(telemetryPayloads.capturedAt))
793
- .limit(1);
794
- const payloadRow = linkedPayloadRows[0]?.payload;
795
- const captured = await readCapturedBodies(payloadRow);
796
- if (!captured) {
797
- return { ...presented, payloads: null };
798
- }
799
- return {
800
- ...presented,
801
- payloads: {
802
- ...(captured.request !== undefined ? { request: captured.request } : {}),
803
- ...(captured.response !== undefined ? { response: captured.response } : {}),
804
- ...(captured.clientResponse !== undefined ? { clientResponse: captured.clientResponse } : {}),
805
- ...(captured.providerRequest !== undefined
806
- ? { providerRequest: captured.providerRequest }
807
- : {}),
808
- ...(captured.providerResponse !== undefined
809
- ? { providerResponse: captured.providerResponse }
810
- : {}),
811
- },
812
- ...(captured.signals === undefined ? {} : { payloadSignals: captured.signals }),
813
- };
814
- }
815
-
816
- async listEvents(
817
- tenantId: string,
818
- limit: number,
819
- cursor?: string,
820
- ): Promise<TelemetryEventListPage> {
821
- const before = decodeDatedCursor(cursor);
822
- // The cursor becomes an `until` bound inside the query, so `limit` applies
823
- // to every page and not only the first. Filtering after the read forced the
824
- // previous shape: a cursor page had to fetch the whole window to know where
825
- // it ended, so `limit` was dropped the moment a cursor appeared.
826
- //
827
- // `until` is inclusive while the cursor is exclusive, so rows at the exact
828
- // cursor timestamp are still compared by id here — the cheap part of the
829
- // predicate, now over one timestamp's worth of rows instead of the window.
830
- const events = await queryTelemetryEvents({
831
- tenantId,
832
- limit: limit + 1,
833
- ...(before === undefined ? {} : { until: new Date(before.createdAt) }),
834
- });
835
- const rows = (before
836
- ? events.filter((event) => event.createdAt < before.createdAt || (event.createdAt === before.createdAt && event.id < before.id))
837
- : events
838
- ).slice(0, limit + 1);
839
- const overflow = rows.length > limit;
840
- const trimmed = overflow ? rows.slice(0, limit) : rows;
841
- const entries = trimmed.map(mapTelemetryEventRow);
842
- const lastEntry = entries[entries.length - 1];
843
- const nextCursor =
844
- overflow && lastEntry !== undefined
845
- ? encodeCursor({ createdAt: lastEntry.createdAt, id: lastEntry.id })
846
- : undefined;
847
- return nextCursor ? { entries, nextCursor } : { entries };
848
- }
849
-
850
- async getEvent(
851
- tenantId: string,
852
- eventId: string,
853
- includePayload = false,
854
- createdAt?: string,
855
- ): Promise<TelemetryEventDetail | undefined> {
856
- // Matched by `id`, which is the field the list cursor encodes — not by
857
- // `requestId`, which is a different column that merely defaults to the
858
- // same value. Predicating on it lets the reader stop at the first hit
859
- // instead of parsing the retention window to find one row.
860
- const events = await queryTelemetryEvents({ tenantId, id: eventId });
861
- const event = events.find(
862
- (candidate) =>
863
- candidate.id === eventId && (createdAt === undefined || candidate.createdAt === createdAt),
864
- );
865
- if (!event) return undefined;
866
- const base = mapTelemetryEventRow(event);
867
- if (!includePayload) return base;
868
- const payloadRows = await this.db
869
- .select()
870
- .from(telemetryPayloads)
871
- .where(
872
- and(
873
- eq(telemetryPayloads.requestId, event.requestId),
874
- eq(telemetryPayloads.tenantId, tenantId),
875
- ),
876
- )
877
- .orderBy(desc(telemetryPayloads.capturedAt))
878
- .limit(1);
879
- const payloadRow = payloadRows[0];
880
- if (!payloadRow) return base;
881
- const captured = await readCapturedBodies(payloadRow);
882
- if (!captured) return base;
883
- return {
884
- ...base,
885
- payload: {
886
- ...(captured.request !== undefined ? { requestBody: captured.request } : {}),
887
- ...(captured.response !== undefined ? { responseBody: captured.response } : {}),
888
- },
889
- };
890
- }
891
- }
892
-
893
- /**
894
- * Reads the captured bodies behind one `telemetry_payloads` row.
895
- *
896
- * The row stores typed frame-reference columns only; every body lives in the
897
- * frame file those columns name. Returns `undefined` when there is no row, no
898
- * usable reference, or the frame cannot be read — all three mean "nothing to
899
- * show", and the caller renders the request without a payload section. The raw
900
- * reference is never returned to a caller, so it cannot reach the dashboard.
901
- */
902
- async function readCapturedBodies(
903
- row:
904
- | {
905
- readonly storage: string;
906
- readonly file: string;
907
- readonly offset: number;
908
- readonly length: number;
909
- readonly checksum: string;
910
- readonly version: number;
911
- }
912
- | null
913
- | undefined,
914
- ): Promise<
915
- | {
916
- request: unknown;
917
- response: unknown;
918
- clientResponse: unknown;
919
- providerRequest: unknown;
920
- providerResponse: unknown;
921
- signals?: { toolCalls: number; images: number; attachments: number };
922
- }
923
- | undefined
924
- > {
925
- if (!row) return undefined;
926
- const reference = payloadReferenceFromRow(row);
927
- if (!reference) return undefined;
928
- try {
929
- const stored = await readPayloadFrame(reference);
930
- if (!stored || typeof stored !== "object") return undefined;
931
- const record = stored as Record<string, unknown>;
932
- const bodies = {
933
- request: record["request_body"] ?? undefined,
934
- response: record["response_body"] ?? undefined,
935
- clientResponse: record["client_response_body"] ?? undefined,
936
- providerRequest: record["provider_request_body"] ?? undefined,
937
- providerResponse: record["provider_response_body"] ?? undefined,
938
- };
939
- const signals =
940
- typeof record["signals"] === "object" &&
941
- record["signals"] !== null &&
942
- !Array.isArray(record["signals"])
943
- ? (record["signals"] as { toolCalls: number; images: number; attachments: number })
944
- : undefined;
945
- return {
946
- ...bodies,
947
- ...(signals === undefined ? {} : { signals }),
948
- };
949
- } catch {
950
- return undefined;
951
- }
952
- }
1
+ import { loadavg, cpus, totalmem } from "node:os";
2
+ import { and, desc, eq, inArray } from "drizzle-orm";
3
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
4
+ import type { RedisClient } from "../../persistence/redis";
5
+ import { apiKeys, networkPools, telemetryPayloads } from "../../persistence/schema";
6
+ import {
7
+ queryTelemetryEvents,
8
+ type TelemetryEventRecord,
9
+ } from "../../observability/event-log";
10
+ import { isGatewayError } from "../../observability/telemetry-status";
11
+ import { cachedReadWithEtag, type CachedResult } from "../shared/read-through-cache";
12
+ import { decodeDatedCursor, encodeCursor } from "../shared/cursor";
13
+ import { CARTETHYIA_VERSION } from "../../version";
14
+ import { CachedPreferencesReader, DrizzlePreferencesReader, type PreferencesReader } from "../../persistence/tenant-preferences";
15
+ import type {
16
+ ObservabilityStore,
17
+ UsageDimension,
18
+ SystemHealthResponse,
19
+ TelemetryEventView,
20
+ TelemetryEventDetail,
21
+ TelemetryEventListPage,
22
+ UsageByResponse,
23
+ UsageCacheResponse,
24
+ UsageChartBucket,
25
+ UsageChartResponse,
26
+ UsageRequestDetail,
27
+ UsageRequestItem,
28
+ UsageRequestsResponse,
29
+ UsageResponse,
30
+ UsageSummaryResponse,
31
+ } from "./contracts";
32
+ import { maskClientIp } from "../../observability/redaction";
33
+ import { splitEndpointConfig } from "../../network/pool/agent";
34
+ import { resolveDbMode } from "../../persistence/db-mode";
35
+ import { payloadReferenceFromRow, readPayloadFrame } from "../../observability/payload-store";
36
+ import { shouldMaskClientIp } from "../shared/ip-privacy";
37
+
38
+ function mapTelemetryEventRow(event: TelemetryEventRecord): TelemetryEventView {
39
+ return {
40
+ id: event.id,
41
+ createdAt: event.createdAt,
42
+ tenantId: event.tenantId ?? "",
43
+ requestId: event.requestId,
44
+ ...(event.sourceSurface ? { sourceSurface: event.sourceSurface } : {}),
45
+ ...(event.requestedModel ? { requestedModel: event.requestedModel } : {}),
46
+ ...(event.reportedModel ? { reportedModel: event.reportedModel } : {}),
47
+ ...(event.providerId ? { providerId: event.providerId } : {}),
48
+ ...(event.latencyMs !== null ? { latencyMs: event.latencyMs ?? undefined } : {}),
49
+ ...(event.status ? { status: event.status } : {}),
50
+ ...(event.inputTokens !== null ? { inputTokens: event.inputTokens ?? undefined } : {}),
51
+ ...(event.outputTokens !== null ? { outputTokens: event.outputTokens ?? undefined } : {}),
52
+ ...(event.estimatedCostUsd !== null
53
+ ? { estimatedCost: Number(event.estimatedCostUsd ?? 0) }
54
+ : {}),
55
+ ...(event.tokensPerSec !== null ? { tokensPerSec: Number(event.tokensPerSec) } : {}),
56
+ ...(event.firstContentDeltaAtMs !== null ? { firstContentDeltaAtMs: event.firstContentDeltaAtMs } : {}),
57
+ ...(event.lastEventAtMs !== null ? { lastEventAtMs: event.lastEventAtMs } : {}),
58
+ };
59
+ }
60
+ /** The HTTP status a row renders as when the wire status was never recorded. */
61
+ function effectiveHttpStatus(event: TelemetryEventRecord): number {
62
+ if (event.httpStatus !== null) return event.httpStatus;
63
+ if (event.status === "completed") return 200;
64
+ if (event.status === "cancelled") return 499;
65
+ if (event.status === "truncated") return 502;
66
+ return 500;
67
+ }
68
+
69
+ /**
70
+ * Opaque, monotonic (createdAt, id) cursor. `createdAt` alone is not unique
71
+ * — two telemetry events in the same millisecond would silently skip one
72
+ * row across pages — so we tie-break on id and encode both, matching
73
+ * `DrizzleAuditReadStore`'s cursor.
74
+ */
75
+
76
+ /** Resolves a `usage` period token ("24h", "7d", "30d", "all") to its inclusive start date. */
77
+ function periodStartDate(period: string): Date | undefined {
78
+ const match = /^(\d+)([hd])$/.exec(period);
79
+ if (!match) return undefined;
80
+ const amount = Number(match[1]);
81
+ const unitMs = match[2] === "h" ? 3_600_000 : 86_400_000;
82
+ return new Date(Date.now() - amount * unitMs);
83
+ }
84
+
85
+
86
+ /**
87
+ * Client identity straight from the stored `User-Agent`: the first RFC 9110
88
+ * product token carries both the client and its version (`bun/1.2.3`,
89
+ * `codex-cli/0.1.0`), so no prefix catalog can drift from what the client
90
+ * actually sent. Browser agents start with the historical `Mozilla/5.0`
91
+ * token, which names no real client, so they report as `Browser`.
92
+ */
93
+ export function clientNameFromUserAgent(userAgent: string | null | undefined): string {
94
+ const raw = userAgent?.trim();
95
+ if (!raw) return "unknown";
96
+ if (/^mozilla\//i.test(raw)) return "Browser";
97
+ const product = raw.split(/\s+/, 1)[0] ?? "";
98
+ const slash = product.indexOf("/");
99
+ const token = slash <= 0 ? product : product.slice(0, slash + 1).concat(product.slice(slash + 1).split(/[;,)]/, 1)[0] ?? "");
100
+ return token || "unknown";
101
+ }
102
+ /** Time-series bucket width for the usage chart, chosen from the window length. */
103
+ function chartBucketSeconds(period: string): number {
104
+ const since = periodStartDate(period);
105
+ if (!since) return 86_400;
106
+ const windowMs = Date.now() - since.getTime();
107
+ if (windowMs <= 2 * 3_600_000) return 300;
108
+ if (windowMs <= 2 * 86_400_000) return 3_600;
109
+ if (windowMs <= 10 * 86_400_000) return 21_600;
110
+ return 86_400;
111
+ }
112
+
113
+ /**
114
+ * The model id with the client's requested reasoning effort as a display
115
+ * suffix — only when the request actually carried one.
116
+ */
117
+ function modelWithEffort(event: TelemetryEventRecord): string | undefined {
118
+ const model = event.requestedModel;
119
+ if (!model) return undefined;
120
+ return event.requestedEffort ? `${model} (${event.requestedEffort})` : model;
121
+ }
122
+
123
+ function mapUsageRequestItem(
124
+ event: TelemetryEventRecord,
125
+ hideClientIp: boolean,
126
+ ): UsageRequestItem {
127
+ const input = event.inputTokens ?? 0;
128
+ const output = event.outputTokens ?? 0;
129
+ return {
130
+ requestId: event.requestId,
131
+ ...(event.sourceSurface ? { surface: event.sourceSurface } : {}),
132
+ ...(event.providerId ? { providerId: event.providerId } : {}),
133
+ ...(event.accountId ? { accountId: event.accountId } : {}),
134
+ ...(modelWithEffort(event) === undefined ? {} : { model: modelWithEffort(event) as string }),
135
+ ...(event.reportedModel ? { reportedModel: event.reportedModel } : {}),
136
+ status: event.status ?? "unknown",
137
+ ...(event.errorCategory ? { errorKind: event.errorCategory } : {}),
138
+ ...(event.errorOrigin ? { errorOrigin: event.errorOrigin } : {}),
139
+ httpStatus: effectiveHttpStatus(event),
140
+ mode: event.stream ? "stream" : "non_stream",
141
+ startedAt: event.createdAt,
142
+ ...(event.latencyMs !== null ? { durationMs: event.latencyMs ?? undefined } : {}),
143
+ ...(event.inputTokens !== null ? { inputTokens: event.inputTokens ?? undefined } : {}),
144
+ ...(event.outputTokens !== null ? { outputTokens: event.outputTokens ?? undefined } : {}),
145
+ ...(event.cachedInputTokens !== null
146
+ ? { cachedTokens: event.cachedInputTokens ?? undefined }
147
+ : {}),
148
+ ...(event.reasoningTokens !== null
149
+ ? { reasoningTokens: event.reasoningTokens ?? undefined }
150
+ : {}),
151
+ ...(event.endpoint ? { endpoint: event.endpoint } : {}),
152
+ ...(event.apiKeyId ? { apiKeyId: event.apiKeyId } : {}),
153
+ ...(event.userAgent ? { userAgent: event.userAgent } : {}),
154
+ ...(event.userAgent ? { clientName: clientNameFromUserAgent(event.userAgent) } : {}),
155
+ ...(event.clientIp
156
+ ? { clientIp: hideClientIp ? maskClientIp(event.clientIp) : event.clientIp }
157
+ : {}),
158
+ ...(input + output > 0 ? { totalTokens: input + output } : {}),
159
+ ...(event.ttfbMs !== null ? { ttfbMs: event.ttfbMs ?? undefined } : {}),
160
+ ...(event.tokensPerSec !== null ? { tokensPerSec: Number(event.tokensPerSec) } : {}),
161
+ ...(event.firstContentDeltaAtMs !== null ? { firstContentDeltaAtMs: event.firstContentDeltaAtMs } : {}),
162
+ ...(event.lastEventAtMs !== null ? { lastEventAtMs: event.lastEventAtMs } : {}),
163
+ ...(event.estimatedCostUsd !== null && event.estimatedCostUsd !== undefined
164
+ ? { estimatedCost: Number(event.estimatedCostUsd) }
165
+ : {}),
166
+ ...(event.creditUsed !== null && event.creditUsed !== undefined
167
+ ? { creditUsed: Number(event.creditUsed) }
168
+ : {}),
169
+ ...(event.resolveMs !== null && event.resolveMs !== undefined
170
+ ? { resolveMs: event.resolveMs }
171
+ : {}),
172
+ // Why the drawer is empty for this request — see the contract field. This is
173
+ // what makes the blank panels diagnosable without server logs.
174
+ ...(event.payloadCaptureState ? { payloadCaptureState: event.payloadCaptureState } : {}),
175
+ };
176
+ }
177
+ async function resolveProxyLabel(
178
+ db: CartethyiaDatabase,
179
+ tenantId: string,
180
+ poolId: string,
181
+ ): Promise<string> {
182
+ const rows = await db
183
+ .select({ id: networkPools.id, endpointConfig: networkPools.endpointConfig })
184
+ .from(networkPools)
185
+ .where(and(eq(networkPools.id, poolId), eq(networkPools.tenantId, tenantId)))
186
+ .limit(1);
187
+ const row = rows[0];
188
+ if (!row) return `${poolId.slice(0, 8)}…`;
189
+ const { endpoint, label } = splitEndpointConfig((row.endpointConfig ?? {}) as Record<string, unknown>);
190
+ return label ?? endpoint ?? `${poolId.slice(0, 8)}…`;
191
+ }
192
+ const HEALTH_WINDOW_MS = 24 * 3_600_000;
193
+ const TOP_N = 50;
194
+ /**
195
+ * How long a parsed window is shared. A dashboard poll fires ~8 usage
196
+ * endpoints at once, and a slow parse used to outlive a 1.5 s window — so the
197
+ * endpoints that missed it each re-read and re-parsed the same files, which is
198
+ * how one page load became several full parses of the retention window.
199
+ */
200
+ const PERIOD_MEMO_TTL_MS = 5_000;
201
+
202
+ /** Nearest-rank percentile over a sorted array, matching `percentile_cont` closely enough for a health gauge. */
203
+ function percentile(sorted: readonly number[], fraction: number): number {
204
+ if (sorted.length === 0) return 0;
205
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
206
+ return Math.round(sorted[index] ?? 0);
207
+ }
208
+
209
+ export interface UsagePerformanceSummary {
210
+ avgResolveMs: number;
211
+ avgDurationMs: number;
212
+ latencyP50Ms: number;
213
+ latencyP95Ms: number;
214
+ latencyP99Ms: number;
215
+ /** Events each series was computed from; see `UsageSummaryTotals`. */
216
+ latencySampleCount: number;
217
+ resolveSampleCount: number;
218
+ }
219
+
220
+ /** Summarizes routing delay and request latency for an already-filtered period. */
221
+ export function summarizeUsagePerformance(
222
+ events: readonly Pick<TelemetryEventRecord, "latencyMs" | "resolveMs">[],
223
+ ): UsagePerformanceSummary {
224
+ const latencies: number[] = [];
225
+ let latencySum = 0;
226
+ let resolveSum = 0;
227
+ let resolveCount = 0;
228
+ for (const event of events) {
229
+ if (event.latencyMs !== null) {
230
+ latencies.push(event.latencyMs);
231
+ latencySum += event.latencyMs;
232
+ }
233
+ if (event.resolveMs !== null) {
234
+ resolveSum += event.resolveMs;
235
+ resolveCount += 1;
236
+ }
237
+ }
238
+ latencies.sort((a, b) => a - b);
239
+ return {
240
+ avgResolveMs: resolveCount > 0 ? resolveSum / resolveCount : 0,
241
+ avgDurationMs: latencies.length > 0 ? latencySum / latencies.length : 0,
242
+ latencyP50Ms: percentile(latencies, 0.5),
243
+ latencyP95Ms: percentile(latencies, 0.95),
244
+ latencyP99Ms: percentile(latencies, 0.99),
245
+ latencySampleCount: latencies.length,
246
+ resolveSampleCount: resolveCount,
247
+ };
248
+ }
249
+
250
+ /** The largest entries of a count map, capped. */
251
+ function topCounts(counts: ReadonlyMap<string, number>, limit: number): Array<[string, number]> {
252
+ return [...counts].sort((a, b) => b[1] - a[1]).slice(0, limit);
253
+ }
254
+
255
+ /** The grouping value for one usage breakdown dimension. */
256
+ function dimensionName(dimension: UsageDimension, event: TelemetryEventRecord): string | null {
257
+ if (dimension === "model") return event.requestedModel;
258
+ if (dimension === "key") return event.apiKeyId;
259
+ if (dimension === "client") return event.userAgent;
260
+ if (dimension === "client_ip") return event.clientIp;
261
+ return event.providerId;
262
+ }
263
+
264
+ interface BreakdownBucket {
265
+ name: string;
266
+ requests: number;
267
+ input: number;
268
+ output: number;
269
+ cached: number;
270
+ errors: number;
271
+ cost: number | null;
272
+ rateSum: number;
273
+ rateCount: number;
274
+ }
275
+
276
+ function emptyBreakdown(name: string): BreakdownBucket {
277
+ return { name, requests: 0, input: 0, output: 0, cached: 0, errors: 0, cost: null, rateSum: 0, rateCount: 0 };
278
+ }
279
+
280
+ /** Real Drizzle-backed observability repository used by the production console. */
281
+ export class DrizzleObservabilityStore implements ObservabilityStore {
282
+ /**
283
+ * Per-(tenant, period) parsed event set, shared by the ~8 usage endpoints
284
+ * one dashboard poll fires in parallel. 1.5 s window: a poll burst shares
285
+ * one file parse; the next poll re-reads. In-flight shares one promise.
286
+ */
287
+ private readonly periodMemo = new Map<string, { at: number; promise: Promise<readonly TelemetryEventRecord[]> }>();
288
+ constructor(
289
+ private readonly db: CartethyiaDatabase,
290
+ private readonly redis: RedisClient | undefined,
291
+ /**
292
+ * Tenant preferences, revision-cached. The Usage page reads the privacy
293
+ * gate on the list, detail and breakdown paths, so a direct SELECT here
294
+ * re-read the same row per request; the cache collapses that.
295
+ */
296
+ private readonly preferences: PreferencesReader = new CachedPreferencesReader(
297
+ new DrizzlePreferencesReader(db),
298
+ ),
299
+ ) {}
300
+
301
+ async health(tenantId: string): Promise<CachedResult<SystemHealthResponse>> {
302
+ // The only usage-family read that used to bypass both the 5 s in-process
303
+ // memo and the Redis read-through, so it re-parsed 24 hourly files every
304
+ // page load. Same 10 s TTL as `/system/usage`.
305
+ return this.readThroughWithEtag(
306
+ this.usageCacheKey(tenantId, "24h", "health"),
307
+ () => this.computeHealth(tenantId),
308
+ );
309
+ }
310
+
311
+ /**
312
+ * Same two numbers `computeHealth` reports, read fresh.
313
+ *
314
+ * Deliberately not `computeHealth`: that also scans 24 h of telemetry files,
315
+ * which is exactly the cost the cache exists to avoid, and a memory watcher
316
+ * polling every 10 s must not pay it. Kept in the store so the percentage
317
+ * has one definition rather than two.
318
+ */
319
+ async liveMemory(): Promise<{ memory_bytes: number; memory_percent: number }> {
320
+ const memory = process.memoryUsage();
321
+ return {
322
+ memory_bytes: memory.rss,
323
+ memory_percent: Math.round((memory.rss / Math.max(totalmem(), 1)) * 10000) / 100,
324
+ };
325
+ }
326
+
327
+ private async computeHealth(tenantId: string): Promise<SystemHealthResponse> {
328
+ const memory = process.memoryUsage();
329
+ // `loadavg()` is [0, 0, 0] on every Windows build — there is no such
330
+ // syscall and Node reports a zeroed tuple rather than failing. Divided by
331
+ // cores that is a confident-looking 0.0% on a busy gateway, which is worse
332
+ // than saying nothing: an operator reads it as "idle" and stops looking.
333
+ // Reported as unavailable instead of as a measurement.
334
+ const load = loadavg()[0] ?? 0;
335
+ const cpuPercent =
336
+ process.platform === "win32" || load === 0
337
+ ? Number.NaN
338
+ : Math.round((load / Math.max(cpus().length, 1)) * 10000) / 100;
339
+
340
+ let databaseHealthy = true;
341
+ let total = 0;
342
+ let errors = 0;
343
+ let avg = 0;
344
+ let p95 = 0;
345
+ let p99 = 0;
346
+ let cacheHitRate = 0;
347
+ let avgTokensPerSec = 0;
348
+ try {
349
+ // Bounded 24h rolling window (matches the Overview copy). The file is the
350
+ // source, so the percentiles are computed over the retained events.
351
+ const since = new Date(Date.now() - HEALTH_WINDOW_MS);
352
+ const events = await queryTelemetryEvents({ tenantId, since });
353
+ const latencies: number[] = [];
354
+ let latencySum = 0;
355
+ let inputTokens = 0;
356
+ let cachedTokens = 0;
357
+ let outputTokens = 0;
358
+ let tokensPerSecSum = 0;
359
+ let tokensPerSecCount = 0;
360
+ for (const event of events) {
361
+ total += 1;
362
+ if (isGatewayError(event.status, event.httpStatus)) errors += 1;
363
+ if (event.latencyMs !== null) {
364
+ latencies.push(event.latencyMs);
365
+ latencySum += event.latencyMs;
366
+ }
367
+ if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
368
+ inputTokens += event.inputTokens ?? 0;
369
+ cachedTokens += event.inputTokens ?? 0;
370
+ outputTokens += event.outputTokens ?? 0;
371
+ }
372
+ if (event.tokensPerSec !== null) {
373
+ tokensPerSecSum += event.tokensPerSec;
374
+ tokensPerSecCount += 1;
375
+ }
376
+ }
377
+ latencies.sort((a, b) => a - b);
378
+ avg = latencies.length > 0 ? Math.round(latencySum / latencies.length) : 0;
379
+ p95 = percentile(latencies, 0.95);
380
+ p99 = percentile(latencies, 0.99);
381
+ // Cached share of the prompt: cached/input, the same honest definition
382
+ // usageSummary uses, so the health card and the Usage page cannot
383
+ // disagree. (This used to be prompt-share input/(input+output), which
384
+ // read ~99% and was mislabeled a cache hit rate.)
385
+ cacheHitRate = inputTokens > 0 ? Math.round((cachedTokens / inputTokens) * 10000) / 100 : 0;
386
+ avgTokensPerSec = tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0;
387
+ } catch {
388
+ databaseHealthy = false;
389
+ }
390
+
391
+ // No client means the memory backend, which is healthy by construction.
392
+ let redisHealthy = this.redis === undefined;
393
+ if (this.redis !== undefined) {
394
+ try {
395
+ redisHealthy = (await this.redis.ping()) === "PONG";
396
+ } catch {
397
+ redisHealthy = false;
398
+ }
399
+ }
400
+
401
+ const status: SystemHealthResponse["status"] = !databaseHealthy
402
+ ? "unhealthy"
403
+ : !redisHealthy
404
+ ? "degraded"
405
+ : "healthy";
406
+
407
+ return {
408
+ version: CARTETHYIA_VERSION,
409
+ status,
410
+ uptime_seconds: Math.floor(process.uptime()),
411
+ database_healthy: databaseHealthy,
412
+ redis_healthy: redisHealthy,
413
+ redis_configured: this.redis !== undefined,
414
+ memory_heap_bytes: memory.heapUsed,
415
+ memory_external_bytes: memory.external,
416
+ memory_bytes: memory.rss,
417
+ memory_percent: Math.round((memory.rss / Math.max(totalmem(), 1)) * 10000) / 100,
418
+ // Reported so the console can explain a large RSS instead of leaving the
419
+ // operator to read it as the gateway alone. In lite mode this process
420
+ // hosts the database too, so the resident set covers both.
421
+ database_in_process: resolveDbMode() === "lite",
422
+ cpu_percent: cpuPercent,
423
+ cpu_cores: cpus().length,
424
+ pid: process.pid,
425
+ platform: `Bun ${Bun.version} · ${process.platform}`,
426
+ request_count: total,
427
+ error_count: errors,
428
+ latency_avg_ms: avg,
429
+ latency_p95_ms: p95,
430
+ latency_p99_ms: p99,
431
+ cache_hit_rate_percent: cacheHitRate,
432
+ avg_tokens_per_sec: avgTokensPerSec,
433
+ };
434
+ }
435
+ async usage(tenantId: string, period: string): Promise<CachedResult<UsageResponse>> {
436
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "usage"), () =>
437
+ this.computeUsage(tenantId, period),
438
+ );
439
+ }
440
+
441
+ /**
442
+ * Cache key for one usage response. Versioned so a shape change can never be
443
+ * read back from a deployment that still holds the old one.
444
+ */
445
+ private usageCacheKey(tenantId: string, period: string, shape: string): string {
446
+ return `cartethyia:usage:v1:${shape}:${tenantId}:${period}`;
447
+ }
448
+
449
+ /**
450
+ * Reads a usage response through Redis — computing when no cache is
451
+ * configured — and returns the strong validator alongside it, so a repeated
452
+ * poll can be answered with a `304` instead of the whole payload.
453
+ */
454
+ private readThroughWithEtag<T>(key: string, compute: () => Promise<T>): Promise<CachedResult<T>> {
455
+ return cachedReadWithEtag(this.redis, key, compute);
456
+ }
457
+
458
+ private async computeUsage(tenantId: string, period: string): Promise<UsageResponse> {
459
+ const events = await this.periodEvents(tenantId, period);
460
+ let requestsTotal = 0;
461
+ let requestsSucceeded = 0;
462
+ let requestsFailed = 0;
463
+ let tokensUsed = 0;
464
+ let estimatedCost = 0;
465
+ const models = new Map<string, number>();
466
+ const providers = new Map<string, number>();
467
+ for (const event of events) {
468
+ requestsTotal += 1;
469
+ if (event.status === "completed") requestsSucceeded += 1;
470
+ if (isGatewayError(event.status, event.httpStatus)) requestsFailed += 1;
471
+ tokensUsed += (event.inputTokens ?? 0) + (event.outputTokens ?? 0);
472
+ estimatedCost += event.estimatedCostUsd ?? 0;
473
+ if (event.requestedModel) models.set(event.requestedModel, (models.get(event.requestedModel) ?? 0) + 1);
474
+ if (event.providerId) providers.set(event.providerId, (providers.get(event.providerId) ?? 0) + 1);
475
+ }
476
+ return {
477
+ tenantId,
478
+ period,
479
+ requestsTotal,
480
+ requestsSucceeded,
481
+ requestsFailed,
482
+ tokensUsed,
483
+ estimatedCost,
484
+ topModels: topCounts(models, TOP_N).map(([modelId, count]) => ({ modelId, count })),
485
+ topProviders: topCounts(providers, TOP_N).map(([providerId, count]) => ({ providerId, count })),
486
+ };
487
+ }
488
+
489
+ /** The tenant's retained events inside a usage period. `all` keeps everything. */
490
+ private periodEvents(tenantId: string, period: string): Promise<readonly TelemetryEventRecord[]> {
491
+ const key = `${tenantId}:${period}`;
492
+ const now = Date.now();
493
+ const hit = this.periodMemo.get(key);
494
+ if (hit && now - hit.at < PERIOD_MEMO_TTL_MS) return hit.promise;
495
+ const since = periodStartDate(period);
496
+ const promise = queryTelemetryEvents(since === undefined ? { tenantId } : { tenantId, since });
497
+ this.periodMemo.set(key, { at: now, promise });
498
+ // A rejected parse must not poison the memo for the next poll.
499
+ void promise.catch(() => {
500
+ if (this.periodMemo.get(key)?.promise === promise) this.periodMemo.delete(key);
501
+ });
502
+ return promise;
503
+ }
504
+
505
+ async usageSummary(tenantId: string, period: string): Promise<CachedResult<UsageSummaryResponse>> {
506
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "summary"), () =>
507
+ this.computeSummary(tenantId, period),
508
+ );
509
+ }
510
+
511
+ private async computeSummary(tenantId: string, period: string): Promise<UsageSummaryResponse> {
512
+ const events = await this.periodEvents(tenantId, period);
513
+ const performance = summarizeUsagePerformance(events);
514
+ let requests = 0;
515
+ let inputTokens = 0;
516
+ let cachedTokens = 0;
517
+ let outputTokens = 0;
518
+ let errors = 0;
519
+ let cancelled = 0;
520
+ let truncated = 0;
521
+ let estimatedCostUsd = 0;
522
+ let unpriced = 0;
523
+ let tokensPerSecSum = 0;
524
+ let tokensPerSecCount = 0;
525
+ const statuses = new Map<number, number>();
526
+ for (const event of events) {
527
+ requests += 1;
528
+ inputTokens += event.inputTokens ?? 0;
529
+ outputTokens += event.outputTokens ?? 0;
530
+ if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
531
+ cachedTokens += event.inputTokens ?? 0;
532
+ }
533
+ if (isGatewayError(event.status, event.httpStatus)) errors += 1;
534
+ if (event.status === "cancelled") cancelled += 1;
535
+ if (event.status === "truncated") truncated += 1;
536
+ if (event.estimatedCostUsd !== null) estimatedCostUsd += event.estimatedCostUsd;
537
+ else if (event.status === "completed") unpriced += 1;
538
+ if (event.tokensPerSec !== null) {
539
+ tokensPerSecSum += event.tokensPerSec;
540
+ tokensPerSecCount += 1;
541
+ }
542
+ const status = effectiveHttpStatus(event);
543
+ statuses.set(status, (statuses.get(status) ?? 0) + 1);
544
+ }
545
+ return {
546
+ period,
547
+ totals: {
548
+ statusCounts: [...statuses].sort(([a], [b]) => a - b).map(([status, count]) => ({ status, count })),
549
+ requests,
550
+ inputTokens,
551
+ cachedTokens,
552
+ outputTokens,
553
+ errors,
554
+ cancelled,
555
+ truncated,
556
+ ...performance,
557
+ estimatedCostUsd,
558
+ partial: unpriced > 0,
559
+ cacheHitRate: inputTokens > 0 ? (cachedTokens / inputTokens) * 100 : 0,
560
+ avgTokensPerSec: tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0,
561
+ },
562
+ };
563
+ }
564
+
565
+ async usageChart(tenantId: string, period: string): Promise<CachedResult<UsageChartResponse>> {
566
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "chart"), () =>
567
+ this.computeChart(tenantId, period),
568
+ );
569
+ }
570
+
571
+ private async computeChart(tenantId: string, period: string): Promise<UsageChartResponse> {
572
+ const since = periodStartDate(period);
573
+ const bucketSeconds = chartBucketSeconds(period);
574
+ const stepMs = bucketSeconds * 1000;
575
+ const events = await this.periodEvents(tenantId, period);
576
+ const byTime = new Map<number, UsageChartBucket>();
577
+ for (const event of events) {
578
+ const at = new Date(event.createdAt).getTime();
579
+ if (!Number.isFinite(at)) continue;
580
+ const start = Math.floor(at / stepMs) * stepMs;
581
+ const bucket = byTime.get(start) ?? { t: new Date(start).toISOString(), requests: 0, input: 0, cached: 0, output: 0 };
582
+ bucket.requests += 1;
583
+ bucket.input += event.inputTokens ?? 0;
584
+ bucket.cached += event.cachedInputTokens !== null && event.cachedInputTokens > 0 ? (event.inputTokens ?? 0) : 0;
585
+ bucket.output += event.outputTokens ?? 0;
586
+ byTime.set(start, bucket);
587
+ }
588
+ const rows = [...byTime.keys()];
589
+ const first = rows.length > 0 ? Math.min(...byTime.keys()) : undefined;
590
+ const startMs = since ? since.getTime() : first;
591
+ if (startMs === undefined) return { buckets: [] };
592
+ const aligned = Math.floor(startMs / stepMs) * stepMs;
593
+ const buckets: UsageChartBucket[] = [];
594
+ for (let at = aligned; at <= Date.now(); at += stepMs) {
595
+ buckets.push(
596
+ byTime.get(at) ?? { t: new Date(at).toISOString(), requests: 0, input: 0, cached: 0, output: 0 },
597
+ );
598
+ }
599
+ return { buckets };
600
+ }
601
+
602
+ async usageBy(
603
+ tenantId: string,
604
+ dimension: UsageDimension,
605
+ period: string,
606
+ ): Promise<CachedResult<UsageByResponse>> {
607
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, `${dimension}:${period}`, "by"), () =>
608
+ this.computeBy(tenantId, dimension, period),
609
+ );
610
+ }
611
+
612
+ private async computeBy(
613
+ tenantId: string,
614
+ dimension: UsageDimension,
615
+ period: string,
616
+ ): Promise<UsageByResponse> {
617
+ const events = await this.periodEvents(tenantId, period);
618
+ const groups = new Map<string, BreakdownBucket>();
619
+ for (const event of events) {
620
+ const raw = dimensionName(dimension, event);
621
+ if (raw === null || raw.length === 0) continue;
622
+ const name = dimension === "client" ? clientNameFromUserAgent(raw) : raw;
623
+ const bucket = groups.get(name) ?? emptyBreakdown(name);
624
+ bucket.requests += 1;
625
+ bucket.input += event.inputTokens ?? 0;
626
+ bucket.output += event.outputTokens ?? 0;
627
+ if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
628
+ bucket.cached += event.inputTokens ?? 0;
629
+ }
630
+ if (isGatewayError(event.status, event.httpStatus)) bucket.errors += 1;
631
+ if (event.estimatedCostUsd !== null) bucket.cost = (bucket.cost ?? 0) + event.estimatedCostUsd;
632
+ if (event.tokensPerSec !== null) {
633
+ bucket.rateSum += event.tokensPerSec;
634
+ bucket.rateCount += 1;
635
+ }
636
+ groups.set(name, bucket);
637
+ }
638
+ const mapped = [...groups.values()]
639
+ .map((bucket) => ({
640
+ name: bucket.name,
641
+ requests: bucket.requests,
642
+ input: bucket.input,
643
+ output: bucket.output,
644
+ cached: bucket.cached,
645
+ total: bucket.input + bucket.output,
646
+ errors: bucket.errors,
647
+ costUsd: bucket.cost,
648
+ cacheHitRate: bucket.input > 0 ? (bucket.cached / bucket.input) * 100 : 0,
649
+ avgTokensPerSec: bucket.rateCount > 0 ? bucket.rateSum / bucket.rateCount : 0,
650
+ }))
651
+ .sort((a, b) => b.requests - a.requests)
652
+ .slice(0, 100);
653
+ if (dimension === "client_ip") {
654
+ // Same fail-closed gate as the request list: storage keeps the raw
655
+ // address, only presentation masks it.
656
+ const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
657
+ if (!hideClientIp) return { dimension, rows: mapped };
658
+ // Grouping happened on the raw address, so masking can collapse distinct
659
+ // hosts into one display name (`203.0.113.7` and `203.0.113.9` both become
660
+ // `203.0.113.xxx`). Re-aggregate by the masked name: two rows the operator
661
+ // cannot tell apart are one row, and the totals must not be understated.
662
+ const merged = new Map<string, (typeof mapped)[number]>();
663
+ for (const row of mapped) {
664
+ const masked = maskClientIp(row.name);
665
+ const existing = merged.get(masked);
666
+ if (existing === undefined) {
667
+ merged.set(masked, { ...row, name: masked });
668
+ continue;
669
+ }
670
+ const input = existing.input + row.input;
671
+ const output = existing.output + row.output;
672
+ merged.set(masked, {
673
+ ...existing,
674
+ requests: existing.requests + row.requests,
675
+ input,
676
+ output,
677
+ cached: existing.cached + row.cached,
678
+ total: input + output,
679
+ errors: existing.errors + row.errors,
680
+ costUsd:
681
+ existing.costUsd === null && row.costUsd === null
682
+ ? null
683
+ : (existing.costUsd ?? 0) + (row.costUsd ?? 0),
684
+ cacheHitRate: input > 0 ? (existing.cached + row.cached) / input * 100 : 0,
685
+ });
686
+ }
687
+ return {
688
+ dimension,
689
+ rows: [...merged.values()].sort((a, b) => b.requests - a.requests),
690
+ };
691
+ }
692
+ if (dimension !== "key") return { dimension, rows: mapped };
693
+ const ids = [...new Set(mapped.map((row) => row.name))];
694
+ const labels = ids.length > 0 ? await this.apiKeyLabels(tenantId, ids) : new Map<string, string>();
695
+ return {
696
+ dimension,
697
+ rows: mapped.map((row) => {
698
+ const label = labels.get(row.name);
699
+ return label === undefined ? row : { ...row, label };
700
+ }),
701
+ };
702
+ }
703
+
704
+ private async apiKeyLabels(tenantId: string, ids: readonly string[]): Promise<Map<string, string>> {
705
+ const unique = [...new Set(ids)];
706
+ if (unique.length === 0) return new Map<string, string>();
707
+ const keyRows = await this.db
708
+ .select({ id: apiKeys.id, label: apiKeys.label })
709
+ .from(apiKeys)
710
+ .where(and(inArray(apiKeys.id, [...unique]), eq(apiKeys.tenantId, tenantId)));
711
+ return new Map(keyRows.map((keyRow) => [keyRow.id, keyRow.label] as const));
712
+ }
713
+
714
+ async usageCache(tenantId: string, period: string): Promise<CachedResult<UsageCacheResponse>> {
715
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "cache"), () =>
716
+ this.computeCache(tenantId, period),
717
+ );
718
+ }
719
+
720
+ private async computeCache(tenantId: string, period: string): Promise<UsageCacheResponse> {
721
+ const events = await this.periodEvents(tenantId, period);
722
+ let inputTokens = 0;
723
+ let cachedTokens = 0;
724
+ let outputTokens = 0;
725
+ for (const event of events) {
726
+ inputTokens += event.inputTokens ?? 0;
727
+ outputTokens += event.outputTokens ?? 0;
728
+ if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
729
+ cachedTokens += event.inputTokens ?? 0;
730
+ }
731
+ }
732
+ const tokenTotal = inputTokens + outputTokens;
733
+ return {
734
+ period,
735
+ inputTokens,
736
+ cachedTokens,
737
+ cacheWriteTokens: 0,
738
+ hitRate: tokenTotal > 0 ? (cachedTokens / tokenTotal) * 100 : 0,
739
+ };
740
+ }
741
+
742
+ async usageRequests(
743
+ tenantId: string,
744
+ period: string,
745
+ limit: number,
746
+ httpStatus?: number,
747
+ ): Promise<CachedResult<UsageRequestsResponse>> {
748
+ // Ask the reader for `limit` rows instead of folding the whole window and
749
+ // slicing it. It stops at the newest file that fills the page, so the
750
+ // console's most-polled endpoint no longer parses the retention window just
751
+ // to show 50 rows.
752
+ const since = periodStartDate(period);
753
+ const events = await queryTelemetryEvents({
754
+ tenantId,
755
+ limit,
756
+ ...(httpStatus === undefined ? {} : { httpStatus }),
757
+ ...(since === undefined ? {} : { since }),
758
+ });
759
+ const rows = events.slice(0, limit);
760
+ const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
761
+ // Cached like its siblings, so a repeated poll costs a `304` instead of
762
+ // another read. The mask flag is part of the key: it is a privacy setting,
763
+ // so a page must never be served from an entry computed under the other
764
+ // value. The rows were already read above — this bounds the label lookup
765
+ // and the mapping, not the telemetry read.
766
+ return this.readThroughWithEtag(
767
+ this.usageCacheKey(
768
+ tenantId,
769
+ `${period}:${limit}:${httpStatus ?? "*"}:${hideClientIp}`,
770
+ "requests",
771
+ ),
772
+ async () => {
773
+ const keyIds = [...new Set(rows.map((row) => row.apiKeyId).filter((id): id is string => id !== null))];
774
+ const labels = await this.apiKeyLabels(tenantId, keyIds);
775
+ // One indexed read for the whole page, not one per row: presence lives
776
+ // on the payload row so the list can render it without inflating a
777
+ // frame for every row it shows.
778
+ const requestIds = [...new Set(rows.map((row) => row.requestId).filter((id): id is string => id !== null))];
779
+ const stages = await this.payloadStages(tenantId, requestIds);
780
+ return {
781
+ items: rows.map((row) => {
782
+ const item = mapUsageRequestItem(row, hideClientIp);
783
+ const label = row.apiKeyId ? labels.get(row.apiKeyId) : undefined;
784
+ const labeled = label === undefined ? item : { ...item, apiKeyLabel: label };
785
+ const stage = stages.get(row.requestId);
786
+ // Omitted rather than zero when the row is unknown: absence means
787
+ // "no payload was captured", and 0 would say the same thing while
788
+ // claiming it had been measured.
789
+ return stage === undefined ? labeled : { ...labeled, payloadStages: stage };
790
+ }),
791
+ };
792
+ },
793
+ );
794
+ }
795
+
796
+ /**
797
+ * Captured-stage bitmask per request, newest payload row winning.
798
+ * One indexed read for a whole page. Presence is on the payload row
799
+ * specifically so the list never has to inflate a frame to answer "does this
800
+ * panel have content".
801
+ */
802
+ private async payloadStages(tenantId: string, requestIds: readonly string[]): Promise<Map<string, number>> {
803
+ if (requestIds.length === 0) return new Map();
804
+ const rows = await this.db
805
+ .select({ requestId: telemetryPayloads.requestId, stages: telemetryPayloads.stages })
806
+ .from(telemetryPayloads)
807
+ .where(
808
+ and(
809
+ eq(telemetryPayloads.tenantId, tenantId),
810
+ inArray(telemetryPayloads.requestId, [...requestIds]),
811
+ ),
812
+ )
813
+ .orderBy(desc(telemetryPayloads.capturedAt));
814
+ const byRequest = new Map<string, number>();
815
+ for (const row of rows) {
816
+ if (row.requestId === null || row.stages === null) continue;
817
+ // Ordered newest-first, so the first hit is the one the drawer reads.
818
+ if (!byRequest.has(row.requestId)) byRequest.set(row.requestId, row.stages);
819
+ }
820
+ return byRequest;
821
+ }
822
+
823
+ async usageRequestDetail(
824
+ tenantId: string,
825
+ requestId: string,
826
+ ): Promise<UsageRequestDetail | undefined> {
827
+ const rows = await queryTelemetryEvents({ tenantId, requestId });
828
+ const event = rows[0];
829
+ if (!event) return undefined;
830
+ // The label, proxy, and mask lookups are independent: run them together
831
+ // instead of three sequential round trips.
832
+ const [hideClientIp, keyLabels, proxy] = await Promise.all([
833
+ shouldMaskClientIp(this.preferences, tenantId),
834
+ event.apiKeyId ? this.apiKeyLabels(tenantId, [event.apiKeyId]) : undefined,
835
+ event.networkPoolId ? resolveProxyLabel(this.db, tenantId, event.networkPoolId) : ("direct" as const),
836
+ ]);
837
+ const item = mapUsageRequestItem(event, hideClientIp);
838
+ const keyLabel = event.apiKeyId ? keyLabels?.get(event.apiKeyId) : undefined;
839
+ const labeled = keyLabel === undefined ? item : { ...item, apiKeyLabel: keyLabel };
840
+ const presented = { ...labeled, proxy };
841
+ // Filter the payload table directly instead of joining `telemetry_events`
842
+ // only to re-apply the tenant predicate: `telemetry_payloads` carries
843
+ // `tenant_id` itself, so the join added a second table's rows to filter for
844
+ // no additional constraint. This matches `getEvent` above and uses
845
+ // `telemetry_payloads_tenant_request_idx`.
846
+ const linkedPayloadRows = await this.db
847
+ .select({ payload: telemetryPayloads })
848
+ .from(telemetryPayloads)
849
+ .where(
850
+ and(
851
+ eq(telemetryPayloads.requestId, requestId),
852
+ eq(telemetryPayloads.tenantId, tenantId),
853
+ ),
854
+ )
855
+ .orderBy(desc(telemetryPayloads.capturedAt))
856
+ .limit(1);
857
+ const payloadRow = linkedPayloadRows[0]?.payload;
858
+ const captured = await readCapturedBodies(payloadRow);
859
+ if (!captured) {
860
+ return { ...presented, payloads: null };
861
+ }
862
+ return {
863
+ ...presented,
864
+ payloads: {
865
+ ...(captured.request !== undefined ? { request: captured.request } : {}),
866
+ ...(captured.response !== undefined ? { response: captured.response } : {}),
867
+ ...(captured.clientResponse !== undefined ? { clientResponse: captured.clientResponse } : {}),
868
+ ...(captured.providerRequest !== undefined
869
+ ? { providerRequest: captured.providerRequest }
870
+ : {}),
871
+ ...(captured.providerResponse !== undefined
872
+ ? { providerResponse: captured.providerResponse }
873
+ : {}),
874
+ },
875
+ ...(captured.signals === undefined ? {} : { payloadSignals: captured.signals }),
876
+ };
877
+ }
878
+
879
+ async listEvents(
880
+ tenantId: string,
881
+ limit: number,
882
+ cursor?: string,
883
+ ): Promise<TelemetryEventListPage> {
884
+ const before = decodeDatedCursor(cursor);
885
+ // The cursor becomes an `until` bound inside the query, so `limit` applies
886
+ // to every page and not only the first. Filtering after the read forced the
887
+ // previous shape: a cursor page had to fetch the whole window to know where
888
+ // it ended, so `limit` was dropped the moment a cursor appeared.
889
+ //
890
+ // `until` is inclusive while the cursor is exclusive, so rows at the exact
891
+ // cursor timestamp are still compared by id here — the cheap part of the
892
+ // predicate, now over one timestamp's worth of rows instead of the window.
893
+ const events = await queryTelemetryEvents({
894
+ tenantId,
895
+ limit: limit + 1,
896
+ ...(before === undefined ? {} : { until: new Date(before.createdAt) }),
897
+ });
898
+ const rows = (before
899
+ ? events.filter((event) => event.createdAt < before.createdAt || (event.createdAt === before.createdAt && event.id < before.id))
900
+ : events
901
+ ).slice(0, limit + 1);
902
+ const overflow = rows.length > limit;
903
+ const trimmed = overflow ? rows.slice(0, limit) : rows;
904
+ const entries = trimmed.map(mapTelemetryEventRow);
905
+ const lastEntry = entries[entries.length - 1];
906
+ const nextCursor =
907
+ overflow && lastEntry !== undefined
908
+ ? encodeCursor({ createdAt: lastEntry.createdAt, id: lastEntry.id })
909
+ : undefined;
910
+ return nextCursor ? { entries, nextCursor } : { entries };
911
+ }
912
+
913
+ async getEvent(
914
+ tenantId: string,
915
+ eventId: string,
916
+ includePayload = false,
917
+ createdAt?: string,
918
+ ): Promise<TelemetryEventDetail | undefined> {
919
+ // Matched by `id`, which is the field the list cursor encodes — not by
920
+ // `requestId`, which is a different column that merely defaults to the
921
+ // same value. Predicating on it lets the reader stop at the first hit
922
+ // instead of parsing the retention window to find one row.
923
+ const events = await queryTelemetryEvents({ tenantId, id: eventId });
924
+ const event = events.find(
925
+ (candidate) =>
926
+ candidate.id === eventId && (createdAt === undefined || candidate.createdAt === createdAt),
927
+ );
928
+ if (!event) return undefined;
929
+ const base = mapTelemetryEventRow(event);
930
+ if (!includePayload) return base;
931
+ const payloadRows = await this.db
932
+ .select()
933
+ .from(telemetryPayloads)
934
+ .where(
935
+ and(
936
+ eq(telemetryPayloads.requestId, event.requestId),
937
+ eq(telemetryPayloads.tenantId, tenantId),
938
+ ),
939
+ )
940
+ .orderBy(desc(telemetryPayloads.capturedAt))
941
+ .limit(1);
942
+ const payloadRow = payloadRows[0];
943
+ if (!payloadRow) return base;
944
+ const captured = await readCapturedBodies(payloadRow);
945
+ if (!captured) return base;
946
+ return {
947
+ ...base,
948
+ payload: {
949
+ ...(captured.request !== undefined ? { requestBody: captured.request } : {}),
950
+ ...(captured.response !== undefined ? { responseBody: captured.response } : {}),
951
+ },
952
+ };
953
+ }
954
+ }
955
+
956
+ /**
957
+ * Reads the captured bodies behind one `telemetry_payloads` row.
958
+ *
959
+ * The row stores typed frame-reference columns only; every body lives in the
960
+ * frame file those columns name. Returns `undefined` when there is no row, no
961
+ * usable reference, or the frame cannot be read — all three mean "nothing to
962
+ * show", and the caller renders the request without a payload section. The raw
963
+ * reference is never returned to a caller, so it cannot reach the dashboard.
964
+ */
965
+ async function readCapturedBodies(
966
+ row:
967
+ | {
968
+ readonly storage: string;
969
+ readonly file: string;
970
+ readonly offset: number;
971
+ readonly length: number;
972
+ readonly checksum: string;
973
+ readonly version: number;
974
+ }
975
+ | null
976
+ | undefined,
977
+ ): Promise<
978
+ | {
979
+ request: unknown;
980
+ response: unknown;
981
+ clientResponse: unknown;
982
+ providerRequest: unknown;
983
+ providerResponse: unknown;
984
+ signals?: { toolCalls: number; images: number; attachments: number };
985
+ }
986
+ | undefined
987
+ > {
988
+ if (!row) return undefined;
989
+ const reference = payloadReferenceFromRow(row);
990
+ if (!reference) return undefined;
991
+ try {
992
+ const stored = await readPayloadFrame(reference);
993
+ if (!stored || typeof stored !== "object") return undefined;
994
+ const record = stored as Record<string, unknown>;
995
+ const bodies = {
996
+ request: record["request_body"] ?? undefined,
997
+ response: record["response_body"] ?? undefined,
998
+ clientResponse: record["client_response_body"] ?? undefined,
999
+ providerRequest: record["provider_request_body"] ?? undefined,
1000
+ providerResponse: record["provider_response_body"] ?? undefined,
1001
+ };
1002
+ const signals =
1003
+ typeof record["signals"] === "object" &&
1004
+ record["signals"] !== null &&
1005
+ !Array.isArray(record["signals"])
1006
+ ? (record["signals"] as { toolCalls: number; images: number; attachments: number })
1007
+ : undefined;
1008
+ return {
1009
+ ...bodies,
1010
+ ...(signals === undefined ? {} : { signals }),
1011
+ };
1012
+ } catch {
1013
+ return undefined;
1014
+ }
1015
+ }