cartethyia 2.5.3 → 2.5.6

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 (456) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-CADDq4IU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-DuO1Z7dq.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-V5faV9V2.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
  25. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js +1 -0
  26. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D5C2raAc.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card--AtFedAq.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  60. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  61. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  62. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  63. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  64. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-B9Xvtnfj.js +1 -0
  66. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  70. package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-sltk1qUi.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-BydyKaDz.js → index-CmxZr14F.js} +2 -2
  74. package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-BhY2sSnO.js} +1 -1
  76. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-BG41et9G.js → input-V1umFiBk.js} +1 -1
  78. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  79. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  80. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-1zzZYM0B.js} +1 -1
  82. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-BivGfuUs.js} +1 -1
  84. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  85. package/dist/dashboard/assets/model-bans-CmffjLuG.js +1 -0
  86. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  87. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-46P0oWMe.js} +1 -1
  88. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  89. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  90. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-BFhGpwR1.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-CAb4F37R.js} +1 -1
  94. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-BO6h8yNv.js → select-BYR_5K2c.js} +2 -2
  96. package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-DNX9sydi.js} +1 -1
  98. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-BHTfXdz_.js} +1 -1
  100. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-mPDRlQkl.js} +1 -1
  102. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  103. package/dist/dashboard/assets/switch-DOmJxg7H.js +1 -0
  104. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  105. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  106. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  107. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  108. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  109. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  110. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  111. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-Dem0lXQj.js} +1 -1
  112. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  113. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  114. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  115. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  116. package/dist/dashboard/index.html +1 -1
  117. package/dist/dashboard/index.html.gz +0 -0
  118. package/dist/dashboard/precompressed-assets.json +57 -57
  119. package/migrations/README.md +52 -52
  120. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  121. package/package.json +96 -95
  122. package/scripts/cli/admin.ts +125 -0
  123. package/scripts/cli/autostart.ts +237 -0
  124. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  125. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -248
  126. package/scripts/cli/doctor.ts +37 -0
  127. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  128. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  129. package/scripts/cli/instance.ts +224 -0
  130. package/scripts/cli/main.ts +88 -18
  131. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  132. package/scripts/cli/port.ts +100 -0
  133. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  134. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  135. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  136. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  137. package/scripts/cli/tray/index.ts +62 -12
  138. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  139. package/scripts/cli/update.ts +40 -0
  140. package/scripts/cli/version.ts +67 -0
  141. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  142. package/src/boot/app.ts +11 -7
  143. package/src/boot/dependencies.ts +50 -19
  144. package/src/boot/timeout.ts +2 -13
  145. package/src/config/cooldowns.ts +186 -138
  146. package/src/config/network.ts +8 -14
  147. package/src/config/providers.ts +7 -10
  148. package/src/config/runtime.ts +76 -76
  149. package/src/config/security.ts +15 -0
  150. package/src/config/spec.ts +52 -7
  151. package/src/config/telemetry.ts +26 -0
  152. package/src/config/timeouts.ts +0 -6
  153. package/src/console/admin/model-abuse/contracts.ts +28 -6
  154. package/src/console/admin/model-abuse/routes.ts +56 -3
  155. package/src/console/api-keys/bucket-spend.ts +15 -4
  156. package/src/console/api-keys/contracts.ts +410 -414
  157. package/src/console/api-keys/routes.ts +881 -880
  158. package/src/console/auth/credentials.ts +3 -3
  159. package/src/console/auth/setup-service.ts +2 -2
  160. package/src/console/backup/contracts.ts +15 -2
  161. package/src/console/backup/export-endpoint.ts +6 -2
  162. package/src/console/backup/nine-router.ts +2 -5
  163. package/src/console/backup/service.ts +4 -0
  164. package/src/console/backup/store.ts +192 -29
  165. package/src/console/backup/validate.ts +3 -45
  166. package/src/console/cli-tools/fs-ops.ts +487 -487
  167. package/src/console/console-router-guards.ts +1 -1
  168. package/src/console/console-router.ts +8 -0
  169. package/src/console/data-management/contracts.ts +100 -0
  170. package/src/console/data-management/operations.ts +234 -0
  171. package/src/console/data-management/routes.ts +103 -0
  172. package/src/console/domain-registration.ts +45 -1
  173. package/src/console/observability/contracts.ts +524 -485
  174. package/src/console/observability/live.ts +22 -0
  175. package/src/console/observability/store.ts +1138 -1018
  176. package/src/console/observability/usage-dimensions.ts +4 -4
  177. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  178. package/src/console/providers/catalog/contracts.ts +6 -0
  179. package/src/console/providers/catalog/model-operations.ts +5 -2
  180. package/src/console/providers/catalog/provider-operations.ts +24 -4
  181. package/src/console/providers/catalog/public-model-store.ts +8 -11
  182. package/src/console/providers/catalog/routes.ts +7 -0
  183. package/src/console/providers/catalog/store.ts +1553 -1532
  184. package/src/console/providers/oauth/routes.ts +8 -12
  185. package/src/console/quota/account-quota-shared.ts +52 -39
  186. package/src/console/quota/account-quota-tenant.ts +6 -6
  187. package/src/console/quota/quota-view.ts +1 -1
  188. package/src/console/quota/refresh.ts +441 -429
  189. package/src/console/remote-support/contracts.ts +61 -0
  190. package/src/console/remote-support/routes.ts +65 -0
  191. package/src/console/remote-support/service.ts +207 -0
  192. package/src/console/response-compression.ts +0 -3
  193. package/src/console/routing/pools/import-parse.ts +206 -0
  194. package/src/console/routing/pools/operations.ts +543 -501
  195. package/src/console/routing/pools/probe-result.ts +3 -2
  196. package/src/console/routing/pools/probe-verdict.ts +132 -132
  197. package/src/console/routing/pools/routes.ts +181 -164
  198. package/src/console/routing/pools/store.ts +8 -8
  199. package/src/console/settings/contracts.ts +95 -2
  200. package/src/console/settings/store.ts +81 -3
  201. package/src/console/shared/cursor.ts +8 -8
  202. package/src/console/shared/errors.ts +1 -3
  203. package/src/console/updates/routes.ts +16 -0
  204. package/src/main.ts +10 -0
  205. package/src/network/http2-bidi.ts +2 -4
  206. package/src/network/pool/agent.ts +478 -478
  207. package/src/network/pool/selector.ts +5 -4
  208. package/src/network/retry.ts +1 -2
  209. package/src/network/ssrf.ts +5 -4
  210. package/src/observability/event-compaction.ts +317 -0
  211. package/src/observability/event-day-index.ts +440 -0
  212. package/src/observability/event-log.ts +155 -6
  213. package/src/observability/logger.ts +6 -6
  214. package/src/observability/payload-capture.ts +158 -33
  215. package/src/observability/payload-store.ts +82 -61
  216. package/src/observability/redaction.ts +5 -6
  217. package/src/observability/request-notify.ts +43 -0
  218. package/src/observability/telemetry-status.ts +8 -9
  219. package/src/observability/token-speed.ts +5 -4
  220. package/src/persistence/db-mode.ts +7 -7
  221. package/src/persistence/health-event-flush.ts +36 -0
  222. package/src/persistence/readiness.ts +1 -1
  223. package/src/persistence/schema.ts +75 -29
  224. package/src/persistence/shared-cache.ts +1 -2
  225. package/src/persistence/stores/api-key-store.ts +18 -6
  226. package/src/persistence/stores/share-store.ts +4 -3
  227. package/src/persistence/stores/telemetry-store.ts +33 -11
  228. package/src/protocol/canonical-model.ts +11 -3
  229. package/src/protocol/codecs.ts +139 -6
  230. package/src/protocol/gateway-error.ts +6 -4
  231. package/src/protocol/image-clamp.ts +19 -1
  232. package/src/protocol/registry.ts +8 -0
  233. package/src/protocol/request/chat.ts +21 -1
  234. package/src/protocol/request/codex.ts +10 -1
  235. package/src/protocol/request/gemini.ts +9 -2
  236. package/src/protocol/request/messages.ts +18 -7
  237. package/src/protocol/request/responses.ts +9 -1
  238. package/src/protocol/response/chat.ts +34 -4
  239. package/src/protocol/response/codex.ts +7 -4
  240. package/src/protocol/response/dsml.ts +140 -28
  241. package/src/protocol/response/responses.ts +11 -5
  242. package/src/protocol/stream-error-frames.ts +4 -5
  243. package/src/providers/adapters/compatible-adapter.ts +3 -0
  244. package/src/providers/authentication/jwt-validator.ts +9 -10
  245. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  246. package/src/providers/bundled-discovery-providers.ts +122 -119
  247. package/src/providers/configured-openai-providers.ts +4 -4
  248. package/src/providers/default-registry.ts +5 -4
  249. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  250. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  251. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  252. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  253. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  254. package/src/providers/discovery/probe-phases.ts +4 -4
  255. package/src/providers/discovery/probe-wire.ts +6 -13
  256. package/src/providers/discovery/probing-service.ts +1117 -1045
  257. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  258. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  259. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  260. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  261. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  262. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  263. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  264. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  265. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  266. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  267. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  268. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  269. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  270. package/src/providers/integrations/claude/claude.ts +608 -608
  271. package/src/providers/integrations/cline/cline-quota.ts +27 -20
  272. package/src/providers/integrations/cline/cline.ts +537 -539
  273. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  274. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  275. package/src/providers/integrations/codex/codex.ts +2 -29
  276. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  277. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  278. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  279. package/src/providers/integrations/cursor/cursor-quota.ts +309 -0
  280. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  281. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  282. package/src/providers/integrations/cursor/cursor.ts +1333 -960
  283. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  284. package/src/providers/integrations/factory/factory-models.ts +3 -5
  285. package/src/providers/integrations/factory/factory.ts +8 -1
  286. package/src/providers/integrations/gemini.ts +6 -1
  287. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  288. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  289. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  290. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  291. package/src/providers/integrations/grok/grok.ts +6 -7
  292. package/src/providers/integrations/hermes.ts +14 -18
  293. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  294. package/src/providers/integrations/kiro/kiro.ts +1 -1
  295. package/src/providers/integrations/mistral.ts +6 -6
  296. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  297. package/src/providers/integrations/opencode/provider.ts +19 -20
  298. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  299. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  300. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  301. package/src/providers/integrations/qoder/qoder.ts +884 -865
  302. package/src/providers/integrations/shared/connect.ts +6 -6
  303. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  304. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  305. package/src/providers/integrations/trae/trae.ts +2 -2
  306. package/src/providers/integrations/zcode/oauth.ts +6 -1
  307. package/src/providers/operations/account-health-service.ts +75 -27
  308. package/src/providers/operations/account-reset-service.ts +1 -1
  309. package/src/providers/operations/client-versions.ts +6 -6
  310. package/src/providers/operations/custom-cli-headers.ts +3 -4
  311. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  312. package/src/providers/operations/provider-catalog-service.ts +3 -4
  313. package/src/providers/provider-contracts.ts +7 -0
  314. package/src/providers/provider-metadata.ts +10 -8
  315. package/src/providers/provider-registry.ts +6 -7
  316. package/src/providers/provider-usage.ts +1 -1
  317. package/src/providers/quota/quota-support.ts +8 -0
  318. package/src/security/abuse.ts +15 -10
  319. package/src/security/access-control.ts +144 -146
  320. package/src/security/admission/contracts.ts +11 -0
  321. package/src/security/admission/in-memory-store.ts +4 -3
  322. package/src/security/admission/lease-sweep.ts +6 -6
  323. package/src/security/admission/reasons.ts +5 -1
  324. package/src/security/admission/redis-store.ts +211 -210
  325. package/src/security/admission/service.ts +4 -0
  326. package/src/security/api-key-auth.ts +27 -17
  327. package/src/security/ip-boundary.ts +155 -15
  328. package/src/security/model-abuse.ts +366 -94
  329. package/src/security/outbound-headers.ts +3 -4
  330. package/src/shared/sleep.ts +4 -0
  331. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  332. package/src/transport/dispatch/attempt-loop.ts +531 -530
  333. package/src/transport/dispatch/generation.ts +6 -0
  334. package/src/transport/dispatch/proxy-request.ts +24 -1
  335. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  336. package/src/transport/dispatch/stream-bridge.ts +1 -3
  337. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  338. package/src/transport/dispatch/streaming/stream.ts +19 -6
  339. package/src/transport/dispatch/upstream.ts +9 -0
  340. package/src/transport/failure-policy.ts +162 -8
  341. package/src/transport/guards/auth-guard.ts +7 -4
  342. package/src/transport/guards/concurrency-guard.ts +0 -5
  343. package/src/transport/guards/error-lifecycle.ts +13 -6
  344. package/src/transport/guards/pipeline.ts +15 -3
  345. package/src/transport/guards/request-context.ts +8 -2
  346. package/src/transport/request/inflight.ts +5 -6
  347. package/src/transport/request/preparer.ts +9 -8
  348. package/src/transport/request/state.ts +21 -8
  349. package/src/transport/request/tenant-preferences.ts +60 -0
  350. package/src/transport/routing/admission.ts +4 -0
  351. package/src/transport/routing/route-catalog.ts +65 -10
  352. package/src/transport/routing/route-model.ts +46 -14
  353. package/src/transport/routing/router.ts +21 -14
  354. package/src/transport/surface/completion.ts +2 -3
  355. package/src/transport/surface/dialects.ts +5 -6
  356. package/src/transport/surface/responses/encode.ts +38 -0
  357. package/src/transport/surface/responses/parse.ts +4 -0
  358. package/src/transport/translation/capabilities.ts +2 -3
  359. package/src/transport/translation/quirks.ts +3 -3
  360. package/src/workers/buddy-growth-tasks.ts +5 -10
  361. package/src/workers/daily-checkin.ts +17 -23
  362. package/src/workers/quota-refresh-sweep.ts +26 -26
  363. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  364. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
  365. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  366. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  367. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +0 -1
  368. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  369. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  370. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  371. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
  372. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  373. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  374. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +0 -1
  375. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  376. package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
  377. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  378. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
  379. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  380. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
  381. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  382. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
  383. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  384. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
  385. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  386. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
  387. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  388. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  389. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  390. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  391. package/dist/dashboard/assets/UsagePage-B47uOydr.js +0 -56
  392. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  393. package/dist/dashboard/assets/api-keys-uNywYlfv.js +0 -1
  394. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  395. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  396. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  397. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  398. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  399. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  400. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  401. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  402. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  403. package/dist/dashboard/assets/dialog-BtpsJU98.js +0 -1
  404. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  405. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  406. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  407. package/dist/dashboard/assets/drawer-WwwtzihF.js +0 -1
  408. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  409. package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
  410. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  411. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  412. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  413. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  414. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  415. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  416. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  417. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  418. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  419. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  420. package/dist/dashboard/assets/layout-C8mer_Ak.js +0 -1
  421. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  422. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  423. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  424. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  425. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  426. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  427. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  428. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  429. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  430. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  431. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  432. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  433. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  434. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  435. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  436. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  437. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  438. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  439. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  440. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  441. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  442. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  443. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  444. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  445. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  446. package/scripts/cli/command/admin.ts +0 -65
  447. package/scripts/cli/command/doctor.ts +0 -257
  448. package/scripts/cli/command/update.ts +0 -23
  449. package/scripts/cli/gateway/drain-token.ts +0 -78
  450. package/scripts/cli/gateway/instance-lock.ts +0 -105
  451. package/scripts/cli/gateway/stop.ts +0 -51
  452. package/scripts/cli/support/bun.ts +0 -27
  453. package/scripts/cli/support/version.ts +0 -37
  454. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-BIt446yE.js} +0 -0
  455. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-BIt446yE.js.gz} +0 -0
  456. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,1018 +1,1138 @@
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 null instead of as a measurement.
334
- //
335
- // A real zero load is *not* the same thing and is reported as zero: an
336
- // idle machine genuinely has nothing queued, and collapsing that into
337
- // "unavailable" would hide a number the platform did supply.
338
- const cpuPercent: number | null =
339
- process.platform === "win32"
340
- ? null
341
- : Math.round(((loadavg()[0] ?? 0) / Math.max(cpus().length, 1)) * 10000) / 100;
342
-
343
- let databaseHealthy = true;
344
- let total = 0;
345
- let errors = 0;
346
- let avg = 0;
347
- let p95 = 0;
348
- let p99 = 0;
349
- let cacheHitRate = 0;
350
- let avgTokensPerSec = 0;
351
- try {
352
- // Bounded 24h rolling window (matches the Overview copy). The file is the
353
- // source, so the percentiles are computed over the retained events.
354
- const since = new Date(Date.now() - HEALTH_WINDOW_MS);
355
- const events = await queryTelemetryEvents({ tenantId, since });
356
- const latencies: number[] = [];
357
- let latencySum = 0;
358
- let inputTokens = 0;
359
- let cachedTokens = 0;
360
- let outputTokens = 0;
361
- let tokensPerSecSum = 0;
362
- let tokensPerSecCount = 0;
363
- for (const event of events) {
364
- total += 1;
365
- if (isGatewayError(event.status, event.httpStatus)) errors += 1;
366
- if (event.latencyMs !== null) {
367
- latencies.push(event.latencyMs);
368
- latencySum += event.latencyMs;
369
- }
370
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
371
- inputTokens += event.inputTokens ?? 0;
372
- cachedTokens += event.inputTokens ?? 0;
373
- outputTokens += event.outputTokens ?? 0;
374
- }
375
- if (event.tokensPerSec !== null) {
376
- tokensPerSecSum += event.tokensPerSec;
377
- tokensPerSecCount += 1;
378
- }
379
- }
380
- latencies.sort((a, b) => a - b);
381
- avg = latencies.length > 0 ? Math.round(latencySum / latencies.length) : 0;
382
- p95 = percentile(latencies, 0.95);
383
- p99 = percentile(latencies, 0.99);
384
- // Cached share of the prompt: cached/input, the same honest definition
385
- // usageSummary uses, so the health card and the Usage page cannot
386
- // disagree. (This used to be prompt-share input/(input+output), which
387
- // read ~99% and was mislabeled a cache hit rate.)
388
- cacheHitRate = inputTokens > 0 ? Math.round((cachedTokens / inputTokens) * 10000) / 100 : 0;
389
- avgTokensPerSec = tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0;
390
- } catch {
391
- databaseHealthy = false;
392
- }
393
-
394
- // No client means the memory backend, which is healthy by construction.
395
- let redisHealthy = this.redis === undefined;
396
- if (this.redis !== undefined) {
397
- try {
398
- redisHealthy = (await this.redis.ping()) === "PONG";
399
- } catch {
400
- redisHealthy = false;
401
- }
402
- }
403
-
404
- const status: SystemHealthResponse["status"] = !databaseHealthy
405
- ? "unhealthy"
406
- : !redisHealthy
407
- ? "degraded"
408
- : "healthy";
409
-
410
- return {
411
- version: CARTETHYIA_VERSION,
412
- status,
413
- uptime_seconds: Math.floor(process.uptime()),
414
- database_healthy: databaseHealthy,
415
- redis_healthy: redisHealthy,
416
- redis_configured: this.redis !== undefined,
417
- memory_heap_bytes: memory.heapUsed,
418
- memory_external_bytes: memory.external,
419
- memory_bytes: memory.rss,
420
- memory_percent: Math.round((memory.rss / Math.max(totalmem(), 1)) * 10000) / 100,
421
- // Reported so the console can explain a large RSS instead of leaving the
422
- // operator to read it as the gateway alone. In lite mode this process
423
- // hosts the database too, so the resident set covers both.
424
- database_in_process: resolveDbMode() === "lite",
425
- cpu_percent: cpuPercent,
426
- cpu_cores: cpus().length,
427
- pid: process.pid,
428
- platform: `Bun ${Bun.version} · ${process.platform}`,
429
- request_count: total,
430
- error_count: errors,
431
- latency_avg_ms: avg,
432
- latency_p95_ms: p95,
433
- latency_p99_ms: p99,
434
- cache_hit_rate_percent: cacheHitRate,
435
- avg_tokens_per_sec: avgTokensPerSec,
436
- };
437
- }
438
- async usage(tenantId: string, period: string): Promise<CachedResult<UsageResponse>> {
439
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "usage"), () =>
440
- this.computeUsage(tenantId, period),
441
- );
442
- }
443
-
444
- /**
445
- * Cache key for one usage response. Versioned so a shape change can never be
446
- * read back from a deployment that still holds the old one.
447
- */
448
- private usageCacheKey(tenantId: string, period: string, shape: string): string {
449
- return `cartethyia:usage:v1:${shape}:${tenantId}:${period}`;
450
- }
451
-
452
- /**
453
- * Reads a usage response through Redis — computing when no cache is
454
- * configured — and returns the strong validator alongside it, so a repeated
455
- * poll can be answered with a `304` instead of the whole payload.
456
- */
457
- private readThroughWithEtag<T>(key: string, compute: () => Promise<T>): Promise<CachedResult<T>> {
458
- return cachedReadWithEtag(this.redis, key, compute);
459
- }
460
-
461
- private async computeUsage(tenantId: string, period: string): Promise<UsageResponse> {
462
- const events = await this.periodEvents(tenantId, period);
463
- let requestsTotal = 0;
464
- let requestsSucceeded = 0;
465
- let requestsFailed = 0;
466
- let tokensUsed = 0;
467
- let estimatedCost = 0;
468
- const models = new Map<string, number>();
469
- const providers = new Map<string, number>();
470
- for (const event of events) {
471
- requestsTotal += 1;
472
- if (event.status === "completed") requestsSucceeded += 1;
473
- if (isGatewayError(event.status, event.httpStatus)) requestsFailed += 1;
474
- tokensUsed += (event.inputTokens ?? 0) + (event.outputTokens ?? 0);
475
- estimatedCost += event.estimatedCostUsd ?? 0;
476
- if (event.requestedModel) models.set(event.requestedModel, (models.get(event.requestedModel) ?? 0) + 1);
477
- if (event.providerId) providers.set(event.providerId, (providers.get(event.providerId) ?? 0) + 1);
478
- }
479
- return {
480
- tenantId,
481
- period,
482
- requestsTotal,
483
- requestsSucceeded,
484
- requestsFailed,
485
- tokensUsed,
486
- estimatedCost,
487
- topModels: topCounts(models, TOP_N).map(([modelId, count]) => ({ modelId, count })),
488
- topProviders: topCounts(providers, TOP_N).map(([providerId, count]) => ({ providerId, count })),
489
- };
490
- }
491
-
492
- /** The tenant's retained events inside a usage period. `all` keeps everything. */
493
- private periodEvents(tenantId: string, period: string): Promise<readonly TelemetryEventRecord[]> {
494
- const key = `${tenantId}:${period}`;
495
- const now = Date.now();
496
- const hit = this.periodMemo.get(key);
497
- if (hit && now - hit.at < PERIOD_MEMO_TTL_MS) return hit.promise;
498
- const since = periodStartDate(period);
499
- const promise = queryTelemetryEvents(since === undefined ? { tenantId } : { tenantId, since });
500
- this.periodMemo.set(key, { at: now, promise });
501
- // A rejected parse must not poison the memo for the next poll.
502
- void promise.catch(() => {
503
- if (this.periodMemo.get(key)?.promise === promise) this.periodMemo.delete(key);
504
- });
505
- return promise;
506
- }
507
-
508
- async usageSummary(tenantId: string, period: string): Promise<CachedResult<UsageSummaryResponse>> {
509
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "summary"), () =>
510
- this.computeSummary(tenantId, period),
511
- );
512
- }
513
-
514
- private async computeSummary(tenantId: string, period: string): Promise<UsageSummaryResponse> {
515
- const events = await this.periodEvents(tenantId, period);
516
- const performance = summarizeUsagePerformance(events);
517
- let requests = 0;
518
- let inputTokens = 0;
519
- let cachedTokens = 0;
520
- let outputTokens = 0;
521
- let errors = 0;
522
- let cancelled = 0;
523
- let truncated = 0;
524
- let estimatedCostUsd = 0;
525
- let unpriced = 0;
526
- let tokensPerSecSum = 0;
527
- let tokensPerSecCount = 0;
528
- const statuses = new Map<number, number>();
529
- for (const event of events) {
530
- requests += 1;
531
- inputTokens += event.inputTokens ?? 0;
532
- outputTokens += event.outputTokens ?? 0;
533
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
534
- cachedTokens += event.inputTokens ?? 0;
535
- }
536
- if (isGatewayError(event.status, event.httpStatus)) errors += 1;
537
- if (event.status === "cancelled") cancelled += 1;
538
- if (event.status === "truncated") truncated += 1;
539
- if (event.estimatedCostUsd !== null) estimatedCostUsd += event.estimatedCostUsd;
540
- else if (event.status === "completed") unpriced += 1;
541
- if (event.tokensPerSec !== null) {
542
- tokensPerSecSum += event.tokensPerSec;
543
- tokensPerSecCount += 1;
544
- }
545
- const status = effectiveHttpStatus(event);
546
- statuses.set(status, (statuses.get(status) ?? 0) + 1);
547
- }
548
- return {
549
- period,
550
- totals: {
551
- statusCounts: [...statuses].sort(([a], [b]) => a - b).map(([status, count]) => ({ status, count })),
552
- requests,
553
- inputTokens,
554
- cachedTokens,
555
- outputTokens,
556
- errors,
557
- cancelled,
558
- truncated,
559
- ...performance,
560
- estimatedCostUsd,
561
- partial: unpriced > 0,
562
- cacheHitRate: inputTokens > 0 ? (cachedTokens / inputTokens) * 100 : 0,
563
- avgTokensPerSec: tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0,
564
- },
565
- };
566
- }
567
-
568
- async usageChart(tenantId: string, period: string): Promise<CachedResult<UsageChartResponse>> {
569
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "chart"), () =>
570
- this.computeChart(tenantId, period),
571
- );
572
- }
573
-
574
- private async computeChart(tenantId: string, period: string): Promise<UsageChartResponse> {
575
- const since = periodStartDate(period);
576
- const bucketSeconds = chartBucketSeconds(period);
577
- const stepMs = bucketSeconds * 1000;
578
- const events = await this.periodEvents(tenantId, period);
579
- const byTime = new Map<number, UsageChartBucket>();
580
- for (const event of events) {
581
- const at = new Date(event.createdAt).getTime();
582
- if (!Number.isFinite(at)) continue;
583
- const start = Math.floor(at / stepMs) * stepMs;
584
- const bucket = byTime.get(start) ?? { t: new Date(start).toISOString(), requests: 0, input: 0, cached: 0, output: 0 };
585
- bucket.requests += 1;
586
- bucket.input += event.inputTokens ?? 0;
587
- bucket.cached += event.cachedInputTokens !== null && event.cachedInputTokens > 0 ? (event.inputTokens ?? 0) : 0;
588
- bucket.output += event.outputTokens ?? 0;
589
- byTime.set(start, bucket);
590
- }
591
- const rows = [...byTime.keys()];
592
- const first = rows.length > 0 ? Math.min(...byTime.keys()) : undefined;
593
- const startMs = since ? since.getTime() : first;
594
- if (startMs === undefined) return { buckets: [] };
595
- const aligned = Math.floor(startMs / stepMs) * stepMs;
596
- const buckets: UsageChartBucket[] = [];
597
- for (let at = aligned; at <= Date.now(); at += stepMs) {
598
- buckets.push(
599
- byTime.get(at) ?? { t: new Date(at).toISOString(), requests: 0, input: 0, cached: 0, output: 0 },
600
- );
601
- }
602
- return { buckets };
603
- }
604
-
605
- async usageBy(
606
- tenantId: string,
607
- dimension: UsageDimension,
608
- period: string,
609
- ): Promise<CachedResult<UsageByResponse>> {
610
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, `${dimension}:${period}`, "by"), () =>
611
- this.computeBy(tenantId, dimension, period),
612
- );
613
- }
614
-
615
- private async computeBy(
616
- tenantId: string,
617
- dimension: UsageDimension,
618
- period: string,
619
- ): Promise<UsageByResponse> {
620
- const events = await this.periodEvents(tenantId, period);
621
- const groups = new Map<string, BreakdownBucket>();
622
- for (const event of events) {
623
- const raw = dimensionName(dimension, event);
624
- if (raw === null || raw.length === 0) continue;
625
- const name = dimension === "client" ? clientNameFromUserAgent(raw) : raw;
626
- const bucket = groups.get(name) ?? emptyBreakdown(name);
627
- bucket.requests += 1;
628
- bucket.input += event.inputTokens ?? 0;
629
- bucket.output += event.outputTokens ?? 0;
630
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
631
- bucket.cached += event.inputTokens ?? 0;
632
- }
633
- if (isGatewayError(event.status, event.httpStatus)) bucket.errors += 1;
634
- if (event.estimatedCostUsd !== null) bucket.cost = (bucket.cost ?? 0) + event.estimatedCostUsd;
635
- if (event.tokensPerSec !== null) {
636
- bucket.rateSum += event.tokensPerSec;
637
- bucket.rateCount += 1;
638
- }
639
- groups.set(name, bucket);
640
- }
641
- const mapped = [...groups.values()]
642
- .map((bucket) => ({
643
- name: bucket.name,
644
- requests: bucket.requests,
645
- input: bucket.input,
646
- output: bucket.output,
647
- cached: bucket.cached,
648
- total: bucket.input + bucket.output,
649
- errors: bucket.errors,
650
- costUsd: bucket.cost,
651
- cacheHitRate: bucket.input > 0 ? (bucket.cached / bucket.input) * 100 : 0,
652
- avgTokensPerSec: bucket.rateCount > 0 ? bucket.rateSum / bucket.rateCount : 0,
653
- }))
654
- .sort((a, b) => b.requests - a.requests)
655
- .slice(0, 100);
656
- if (dimension === "client_ip") {
657
- // Same fail-closed gate as the request list: storage keeps the raw
658
- // address, only presentation masks it.
659
- const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
660
- if (!hideClientIp) return { dimension, rows: mapped };
661
- // Grouping happened on the raw address, so masking can collapse distinct
662
- // hosts into one display name (`203.0.113.7` and `203.0.113.9` both become
663
- // `203.0.113.xxx`). Re-aggregate by the masked name: two rows the operator
664
- // cannot tell apart are one row, and the totals must not be understated.
665
- const merged = new Map<string, (typeof mapped)[number]>();
666
- for (const row of mapped) {
667
- const masked = maskClientIp(row.name);
668
- const existing = merged.get(masked);
669
- if (existing === undefined) {
670
- merged.set(masked, { ...row, name: masked });
671
- continue;
672
- }
673
- const input = existing.input + row.input;
674
- const output = existing.output + row.output;
675
- merged.set(masked, {
676
- ...existing,
677
- requests: existing.requests + row.requests,
678
- input,
679
- output,
680
- cached: existing.cached + row.cached,
681
- total: input + output,
682
- errors: existing.errors + row.errors,
683
- costUsd:
684
- existing.costUsd === null && row.costUsd === null
685
- ? null
686
- : (existing.costUsd ?? 0) + (row.costUsd ?? 0),
687
- cacheHitRate: input > 0 ? (existing.cached + row.cached) / input * 100 : 0,
688
- });
689
- }
690
- return {
691
- dimension,
692
- rows: [...merged.values()].sort((a, b) => b.requests - a.requests),
693
- };
694
- }
695
- if (dimension !== "key") return { dimension, rows: mapped };
696
- const ids = [...new Set(mapped.map((row) => row.name))];
697
- const labels = ids.length > 0 ? await this.apiKeyLabels(tenantId, ids) : new Map<string, string>();
698
- return {
699
- dimension,
700
- rows: mapped.map((row) => {
701
- const label = labels.get(row.name);
702
- return label === undefined ? row : { ...row, label };
703
- }),
704
- };
705
- }
706
-
707
- private async apiKeyLabels(tenantId: string, ids: readonly string[]): Promise<Map<string, string>> {
708
- const unique = [...new Set(ids)];
709
- if (unique.length === 0) return new Map<string, string>();
710
- const keyRows = await this.db
711
- .select({ id: apiKeys.id, label: apiKeys.label })
712
- .from(apiKeys)
713
- .where(and(inArray(apiKeys.id, [...unique]), eq(apiKeys.tenantId, tenantId)));
714
- return new Map(keyRows.map((keyRow) => [keyRow.id, keyRow.label] as const));
715
- }
716
-
717
- async usageCache(tenantId: string, period: string): Promise<CachedResult<UsageCacheResponse>> {
718
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "cache"), () =>
719
- this.computeCache(tenantId, period),
720
- );
721
- }
722
-
723
- private async computeCache(tenantId: string, period: string): Promise<UsageCacheResponse> {
724
- const events = await this.periodEvents(tenantId, period);
725
- let inputTokens = 0;
726
- let cachedTokens = 0;
727
- let outputTokens = 0;
728
- for (const event of events) {
729
- inputTokens += event.inputTokens ?? 0;
730
- outputTokens += event.outputTokens ?? 0;
731
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
732
- cachedTokens += event.inputTokens ?? 0;
733
- }
734
- }
735
- const tokenTotal = inputTokens + outputTokens;
736
- return {
737
- period,
738
- inputTokens,
739
- cachedTokens,
740
- cacheWriteTokens: 0,
741
- hitRate: tokenTotal > 0 ? (cachedTokens / tokenTotal) * 100 : 0,
742
- };
743
- }
744
-
745
- async usageRequests(
746
- tenantId: string,
747
- period: string,
748
- limit: number,
749
- httpStatus?: number,
750
- ): Promise<CachedResult<UsageRequestsResponse>> {
751
- // Ask the reader for `limit` rows instead of folding the whole window and
752
- // slicing it. It stops at the newest file that fills the page, so the
753
- // console's most-polled endpoint no longer parses the retention window just
754
- // to show 50 rows.
755
- const since = periodStartDate(period);
756
- const events = await queryTelemetryEvents({
757
- tenantId,
758
- limit,
759
- ...(httpStatus === undefined ? {} : { httpStatus }),
760
- ...(since === undefined ? {} : { since }),
761
- });
762
- const rows = events.slice(0, limit);
763
- const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
764
- // Cached like its siblings, so a repeated poll costs a `304` instead of
765
- // another read. The mask flag is part of the key: it is a privacy setting,
766
- // so a page must never be served from an entry computed under the other
767
- // value. The rows were already read above — this bounds the label lookup
768
- // and the mapping, not the telemetry read.
769
- return this.readThroughWithEtag(
770
- this.usageCacheKey(
771
- tenantId,
772
- `${period}:${limit}:${httpStatus ?? "*"}:${hideClientIp}`,
773
- "requests",
774
- ),
775
- async () => {
776
- const keyIds = [...new Set(rows.map((row) => row.apiKeyId).filter((id): id is string => id !== null))];
777
- const labels = await this.apiKeyLabels(tenantId, keyIds);
778
- // One indexed read for the whole page, not one per row: presence lives
779
- // on the payload row so the list can render it without inflating a
780
- // frame for every row it shows.
781
- const requestIds = [...new Set(rows.map((row) => row.requestId).filter((id): id is string => id !== null))];
782
- const stages = await this.payloadStages(tenantId, requestIds);
783
- return {
784
- items: rows.map((row) => {
785
- const item = mapUsageRequestItem(row, hideClientIp);
786
- const label = row.apiKeyId ? labels.get(row.apiKeyId) : undefined;
787
- const labeled = label === undefined ? item : { ...item, apiKeyLabel: label };
788
- const stage = stages.get(row.requestId);
789
- // Omitted rather than zero when the row is unknown: absence means
790
- // "no payload was captured", and 0 would say the same thing while
791
- // claiming it had been measured.
792
- return stage === undefined ? labeled : { ...labeled, payloadStages: stage };
793
- }),
794
- };
795
- },
796
- );
797
- }
798
-
799
- /**
800
- * Captured-stage bitmask per request, newest payload row winning.
801
- * One indexed read for a whole page. Presence is on the payload row
802
- * specifically so the list never has to inflate a frame to answer "does this
803
- * panel have content".
804
- */
805
- private async payloadStages(tenantId: string, requestIds: readonly string[]): Promise<Map<string, number>> {
806
- if (requestIds.length === 0) return new Map();
807
- const rows = await this.db
808
- .select({ requestId: telemetryPayloads.requestId, stages: telemetryPayloads.stages })
809
- .from(telemetryPayloads)
810
- .where(
811
- and(
812
- eq(telemetryPayloads.tenantId, tenantId),
813
- inArray(telemetryPayloads.requestId, [...requestIds]),
814
- ),
815
- )
816
- .orderBy(desc(telemetryPayloads.capturedAt));
817
- const byRequest = new Map<string, number>();
818
- for (const row of rows) {
819
- if (row.requestId === null || row.stages === null) continue;
820
- // Ordered newest-first, so the first hit is the one the drawer reads.
821
- if (!byRequest.has(row.requestId)) byRequest.set(row.requestId, row.stages);
822
- }
823
- return byRequest;
824
- }
825
-
826
- async usageRequestDetail(
827
- tenantId: string,
828
- requestId: string,
829
- ): Promise<UsageRequestDetail | undefined> {
830
- const rows = await queryTelemetryEvents({ tenantId, requestId });
831
- const event = rows[0];
832
- if (!event) return undefined;
833
- // The label, proxy, and mask lookups are independent: run them together
834
- // instead of three sequential round trips.
835
- const [hideClientIp, keyLabels, proxy] = await Promise.all([
836
- shouldMaskClientIp(this.preferences, tenantId),
837
- event.apiKeyId ? this.apiKeyLabels(tenantId, [event.apiKeyId]) : undefined,
838
- event.networkPoolId ? resolveProxyLabel(this.db, tenantId, event.networkPoolId) : ("direct" as const),
839
- ]);
840
- const item = mapUsageRequestItem(event, hideClientIp);
841
- const keyLabel = event.apiKeyId ? keyLabels?.get(event.apiKeyId) : undefined;
842
- const labeled = keyLabel === undefined ? item : { ...item, apiKeyLabel: keyLabel };
843
- const presented = { ...labeled, proxy };
844
- // Filter the payload table directly instead of joining `telemetry_events`
845
- // only to re-apply the tenant predicate: `telemetry_payloads` carries
846
- // `tenant_id` itself, so the join added a second table's rows to filter for
847
- // no additional constraint. This matches `getEvent` above and uses
848
- // `telemetry_payloads_tenant_request_idx`.
849
- const linkedPayloadRows = await this.db
850
- .select({ payload: telemetryPayloads })
851
- .from(telemetryPayloads)
852
- .where(
853
- and(
854
- eq(telemetryPayloads.requestId, requestId),
855
- eq(telemetryPayloads.tenantId, tenantId),
856
- ),
857
- )
858
- .orderBy(desc(telemetryPayloads.capturedAt))
859
- .limit(1);
860
- const payloadRow = linkedPayloadRows[0]?.payload;
861
- const captured = await readCapturedBodies(payloadRow);
862
- if (!captured) {
863
- return { ...presented, payloads: null };
864
- }
865
- return {
866
- ...presented,
867
- payloads: {
868
- ...(captured.request !== undefined ? { request: captured.request } : {}),
869
- ...(captured.response !== undefined ? { response: captured.response } : {}),
870
- ...(captured.clientResponse !== undefined ? { clientResponse: captured.clientResponse } : {}),
871
- ...(captured.providerRequest !== undefined
872
- ? { providerRequest: captured.providerRequest }
873
- : {}),
874
- ...(captured.providerResponse !== undefined
875
- ? { providerResponse: captured.providerResponse }
876
- : {}),
877
- },
878
- ...(captured.signals === undefined ? {} : { payloadSignals: captured.signals }),
879
- };
880
- }
881
-
882
- async listEvents(
883
- tenantId: string,
884
- limit: number,
885
- cursor?: string,
886
- ): Promise<TelemetryEventListPage> {
887
- const before = decodeDatedCursor(cursor);
888
- // The cursor becomes an `until` bound inside the query, so `limit` applies
889
- // to every page and not only the first. Filtering after the read forced the
890
- // previous shape: a cursor page had to fetch the whole window to know where
891
- // it ended, so `limit` was dropped the moment a cursor appeared.
892
- //
893
- // `until` is inclusive while the cursor is exclusive, so rows at the exact
894
- // cursor timestamp are still compared by id here — the cheap part of the
895
- // predicate, now over one timestamp's worth of rows instead of the window.
896
- const events = await queryTelemetryEvents({
897
- tenantId,
898
- limit: limit + 1,
899
- ...(before === undefined ? {} : { until: new Date(before.createdAt) }),
900
- });
901
- const rows = (before
902
- ? events.filter((event) => event.createdAt < before.createdAt || (event.createdAt === before.createdAt && event.id < before.id))
903
- : events
904
- ).slice(0, limit + 1);
905
- const overflow = rows.length > limit;
906
- const trimmed = overflow ? rows.slice(0, limit) : rows;
907
- const entries = trimmed.map(mapTelemetryEventRow);
908
- const lastEntry = entries[entries.length - 1];
909
- const nextCursor =
910
- overflow && lastEntry !== undefined
911
- ? encodeCursor({ createdAt: lastEntry.createdAt, id: lastEntry.id })
912
- : undefined;
913
- return nextCursor ? { entries, nextCursor } : { entries };
914
- }
915
-
916
- async getEvent(
917
- tenantId: string,
918
- eventId: string,
919
- includePayload = false,
920
- createdAt?: string,
921
- ): Promise<TelemetryEventDetail | undefined> {
922
- // Matched by `id`, which is the field the list cursor encodes — not by
923
- // `requestId`, which is a different column that merely defaults to the
924
- // same value. Predicating on it lets the reader stop at the first hit
925
- // instead of parsing the retention window to find one row.
926
- const events = await queryTelemetryEvents({ tenantId, id: eventId });
927
- const event = events.find(
928
- (candidate) =>
929
- candidate.id === eventId && (createdAt === undefined || candidate.createdAt === createdAt),
930
- );
931
- if (!event) return undefined;
932
- const base = mapTelemetryEventRow(event);
933
- if (!includePayload) return base;
934
- const payloadRows = await this.db
935
- .select()
936
- .from(telemetryPayloads)
937
- .where(
938
- and(
939
- eq(telemetryPayloads.requestId, event.requestId),
940
- eq(telemetryPayloads.tenantId, tenantId),
941
- ),
942
- )
943
- .orderBy(desc(telemetryPayloads.capturedAt))
944
- .limit(1);
945
- const payloadRow = payloadRows[0];
946
- if (!payloadRow) return base;
947
- const captured = await readCapturedBodies(payloadRow);
948
- if (!captured) return base;
949
- return {
950
- ...base,
951
- payload: {
952
- ...(captured.request !== undefined ? { requestBody: captured.request } : {}),
953
- ...(captured.response !== undefined ? { responseBody: captured.response } : {}),
954
- },
955
- };
956
- }
957
- }
958
-
959
- /**
960
- * Reads the captured bodies behind one `telemetry_payloads` row.
961
- *
962
- * The row stores typed frame-reference columns only; every body lives in the
963
- * frame file those columns name. Returns `undefined` when there is no row, no
964
- * usable reference, or the frame cannot be read — all three mean "nothing to
965
- * show", and the caller renders the request without a payload section. The raw
966
- * reference is never returned to a caller, so it cannot reach the dashboard.
967
- */
968
- async function readCapturedBodies(
969
- row:
970
- | {
971
- readonly storage: string;
972
- readonly file: string;
973
- readonly offset: number;
974
- readonly length: number;
975
- readonly checksum: string;
976
- readonly version: number;
977
- }
978
- | null
979
- | undefined,
980
- ): Promise<
981
- | {
982
- request: unknown;
983
- response: unknown;
984
- clientResponse: unknown;
985
- providerRequest: unknown;
986
- providerResponse: unknown;
987
- signals?: { toolCalls: number; images: number; attachments: number };
988
- }
989
- | undefined
990
- > {
991
- if (!row) return undefined;
992
- const reference = payloadReferenceFromRow(row);
993
- if (!reference) return undefined;
994
- try {
995
- const stored = await readPayloadFrame(reference);
996
- if (!stored || typeof stored !== "object") return undefined;
997
- const record = stored as Record<string, unknown>;
998
- const bodies = {
999
- request: record["request_body"] ?? undefined,
1000
- response: record["response_body"] ?? undefined,
1001
- clientResponse: record["client_response_body"] ?? undefined,
1002
- providerRequest: record["provider_request_body"] ?? undefined,
1003
- providerResponse: record["provider_response_body"] ?? undefined,
1004
- };
1005
- const signals =
1006
- typeof record["signals"] === "object" &&
1007
- record["signals"] !== null &&
1008
- !Array.isArray(record["signals"])
1009
- ? (record["signals"] as { toolCalls: number; images: number; attachments: number })
1010
- : undefined;
1011
- return {
1012
- ...bodies,
1013
- ...(signals === undefined ? {} : { signals }),
1014
- };
1015
- } catch {
1016
- return undefined;
1017
- }
1018
- }
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 { queryIndexedBuckets, type IndexedBucket } from "../../observability/event-day-index";
12
+ import {
13
+ LATENCY_BUCKETS_MS,
14
+ numeric,
15
+ percentileFromHistogram,
16
+ } from "../../observability/event-compaction";
17
+ import { cachedReadWithEtag, type CachedResult } from "../shared/read-through-cache";
18
+ import { decodeDatedCursor, encodeCursor } from "../shared/cursor";
19
+ import { CARTETHYIA_VERSION } from "../../version";
20
+ import { CachedPreferencesReader, DrizzlePreferencesReader, type PreferencesReader } from "../../persistence/tenant-preferences";
21
+ import type {
22
+ ObservabilityStore,
23
+ UsageDimension,
24
+ SystemHealthResponse,
25
+ TelemetryEventView,
26
+ TelemetryEventDetail,
27
+ TelemetryEventListPage,
28
+ UsageByResponse,
29
+ UsageCacheResponse,
30
+ UsageChartBucket,
31
+ UsageChartResponse,
32
+ UsageRequestDetail,
33
+ UsageRequestItem,
34
+ UsageRequestsResponse,
35
+ UsageResponse,
36
+ UsageSummaryResponse,
37
+ } from "./contracts";
38
+ import { maskClientIp } from "../../observability/redaction";
39
+ import { splitEndpointConfig } from "../../network/pool/agent";
40
+ import { resolveDbMode } from "../../persistence/db-mode";
41
+ import { payloadReferenceFromRow, readPayloadFrame } from "../../observability/payload-store";
42
+ import { shouldMaskClientIp } from "../shared/ip-privacy";
43
+
44
+ function mapTelemetryEventRow(event: TelemetryEventRecord): TelemetryEventView {
45
+ return {
46
+ id: event.id,
47
+ createdAt: event.createdAt,
48
+ tenantId: event.tenantId ?? "",
49
+ requestId: event.requestId,
50
+ ...(event.sourceSurface ? { sourceSurface: event.sourceSurface } : {}),
51
+ ...(event.requestedModel ? { requestedModel: event.requestedModel } : {}),
52
+ ...(event.reportedModel ? { reportedModel: event.reportedModel } : {}),
53
+ ...(event.providerId ? { providerId: event.providerId } : {}),
54
+ ...(event.latencyMs !== null ? { latencyMs: event.latencyMs ?? undefined } : {}),
55
+ ...(event.status ? { status: event.status } : {}),
56
+ ...(event.inputTokens !== null ? { inputTokens: event.inputTokens ?? undefined } : {}),
57
+ ...(event.outputTokens !== null ? { outputTokens: event.outputTokens ?? undefined } : {}),
58
+ ...(event.estimatedCostUsd !== null
59
+ ? { estimatedCost: Number(event.estimatedCostUsd ?? 0) }
60
+ : {}),
61
+ ...(event.tokensPerSec !== null ? { tokensPerSec: Number(event.tokensPerSec) } : {}),
62
+ ...(event.firstContentDeltaAtMs !== null ? { firstContentDeltaAtMs: event.firstContentDeltaAtMs } : {}),
63
+ ...(event.lastEventAtMs !== null ? { lastEventAtMs: event.lastEventAtMs } : {}),
64
+ };
65
+ }
66
+ /** The HTTP status a row renders as when the wire status was never recorded. */
67
+ function effectiveHttpStatus(event: TelemetryEventRecord): number {
68
+ if (event.httpStatus !== null) return event.httpStatus;
69
+ if (event.status === "completed") return 200;
70
+ if (event.status === "cancelled") return 499;
71
+ if (event.status === "truncated") return 502;
72
+ return 500;
73
+ }
74
+
75
+ /**
76
+ * Opaque, monotonic (createdAt, id) cursor. `createdAt` alone is not unique
77
+ * — two telemetry events in the same millisecond would silently skip one
78
+ * row across pages — so we tie-break on id and encode both, matching
79
+ * `DrizzleAuditReadStore`'s cursor.
80
+ */
81
+
82
+ /** Resolves a `usage` period token ("24h", "7d", "30d", "all") to its inclusive start date. */
83
+ function periodStartDate(period: string): Date | undefined {
84
+ const match = /^(\d+)([hd])$/.exec(period);
85
+ if (!match) return undefined;
86
+ const amount = Number(match[1]);
87
+ const unitMs = match[2] === "h" ? 3_600_000 : 86_400_000;
88
+ return new Date(Date.now() - amount * unitMs);
89
+ }
90
+
91
+
92
+ /**
93
+ * Client identity straight from the stored `User-Agent`: the first RFC 9110
94
+ * product token carries both the client and its version (`bun/1.2.3`,
95
+ * `codex-cli/0.1.0`), so no prefix catalog can drift from what the client
96
+ * actually sent. Browser agents start with the historical `Mozilla/5.0`
97
+ * token, which names no real client, so they report as `Browser`.
98
+ */
99
+ export function clientNameFromUserAgent(userAgent: string | null | undefined): string {
100
+ const raw = userAgent?.trim();
101
+ if (!raw) return "unknown";
102
+ if (/^mozilla\//i.test(raw)) return "Browser";
103
+ const product = raw.split(/\s+/, 1)[0] ?? "";
104
+ const slash = product.indexOf("/");
105
+ const token = slash <= 0 ? product : product.slice(0, slash + 1).concat(product.slice(slash + 1).split(/[;,)]/, 1)[0] ?? "");
106
+ return token || "unknown";
107
+ }
108
+ /** Time-series bucket width for the usage chart, chosen from the window length. */
109
+ function chartBucketSeconds(period: string): number {
110
+ const since = periodStartDate(period);
111
+ if (!since) return 86_400;
112
+ const windowMs = Date.now() - since.getTime();
113
+ if (windowMs <= 2 * 3_600_000) return 300;
114
+ if (windowMs <= 2 * 86_400_000) return 3_600;
115
+ if (windowMs <= 10 * 86_400_000) return 21_600;
116
+ return 86_400;
117
+ }
118
+
119
+ /**
120
+ * The model id with the client's requested reasoning effort as a display
121
+ * suffix — only when the request actually carried one.
122
+ */
123
+ function modelWithEffort(event: TelemetryEventRecord): string | undefined {
124
+ const model = event.requestedModel;
125
+ if (!model) return undefined;
126
+ return event.requestedEffort ? `${model} (${event.requestedEffort})` : model;
127
+ }
128
+
129
+ function mapUsageRequestItem(
130
+ event: TelemetryEventRecord,
131
+ hideClientIp: boolean,
132
+ ): UsageRequestItem {
133
+ const input = event.inputTokens ?? 0;
134
+ const output = event.outputTokens ?? 0;
135
+ return {
136
+ requestId: event.requestId,
137
+ ...(event.sourceSurface ? { surface: event.sourceSurface } : {}),
138
+ ...(event.providerId ? { providerId: event.providerId } : {}),
139
+ ...(event.accountId ? { accountId: event.accountId } : {}),
140
+ ...(modelWithEffort(event) === undefined ? {} : { model: modelWithEffort(event) as string }),
141
+ ...(event.reportedModel ? { reportedModel: event.reportedModel } : {}),
142
+ status: event.status ?? "unknown",
143
+ ...(event.errorCategory ? { errorKind: event.errorCategory } : {}),
144
+ ...(event.errorOrigin ? { errorOrigin: event.errorOrigin } : {}),
145
+ httpStatus: effectiveHttpStatus(event),
146
+ mode: event.stream ? "stream" : "non_stream",
147
+ startedAt: event.createdAt,
148
+ ...(event.latencyMs !== null ? { durationMs: event.latencyMs ?? undefined } : {}),
149
+ ...(event.inputTokens !== null ? { inputTokens: event.inputTokens ?? undefined } : {}),
150
+ ...(event.outputTokens !== null ? { outputTokens: event.outputTokens ?? undefined } : {}),
151
+ ...(event.cachedInputTokens !== null
152
+ ? { cachedTokens: event.cachedInputTokens ?? undefined }
153
+ : {}),
154
+ ...(event.reasoningTokens !== null
155
+ ? { reasoningTokens: event.reasoningTokens ?? undefined }
156
+ : {}),
157
+ ...(event.endpoint ? { endpoint: event.endpoint } : {}),
158
+ ...(event.apiKeyId ? { apiKeyId: event.apiKeyId } : {}),
159
+ ...(event.userAgent ? { userAgent: event.userAgent } : {}),
160
+ ...(event.userAgent ? { clientName: clientNameFromUserAgent(event.userAgent) } : {}),
161
+ ...(event.clientIp
162
+ ? { clientIp: hideClientIp ? maskClientIp(event.clientIp) : event.clientIp }
163
+ : {}),
164
+ ...(input + output > 0 ? { totalTokens: input + output } : {}),
165
+ ...(event.ttfbMs !== null ? { ttfbMs: event.ttfbMs ?? undefined } : {}),
166
+ ...(event.tokensPerSec !== null ? { tokensPerSec: Number(event.tokensPerSec) } : {}),
167
+ ...(event.firstContentDeltaAtMs !== null ? { firstContentDeltaAtMs: event.firstContentDeltaAtMs } : {}),
168
+ ...(event.lastEventAtMs !== null ? { lastEventAtMs: event.lastEventAtMs } : {}),
169
+ ...(event.estimatedCostUsd !== null && event.estimatedCostUsd !== undefined
170
+ ? { estimatedCost: Number(event.estimatedCostUsd) }
171
+ : {}),
172
+ ...(event.creditUsed !== null && event.creditUsed !== undefined
173
+ ? { creditUsed: Number(event.creditUsed) }
174
+ : {}),
175
+ ...(event.resolveMs !== null && event.resolveMs !== undefined
176
+ ? { resolveMs: event.resolveMs }
177
+ : {}),
178
+ // Why the drawer is empty for this request — see the contract field. This is
179
+ // what makes the blank panels diagnosable without server logs.
180
+ ...(event.payloadCaptureState ? { payloadCaptureState: event.payloadCaptureState } : {}),
181
+ };
182
+ }
183
+ async function resolveProxyLabel(
184
+ db: CartethyiaDatabase,
185
+ tenantId: string,
186
+ poolId: string,
187
+ ): Promise<string> {
188
+ const rows = await db
189
+ .select({ id: networkPools.id, endpointConfig: networkPools.endpointConfig })
190
+ .from(networkPools)
191
+ .where(and(eq(networkPools.id, poolId), eq(networkPools.tenantId, tenantId)))
192
+ .limit(1);
193
+ const row = rows[0];
194
+ if (!row) return `${poolId.slice(0, 8)}…`;
195
+ const { endpoint, label } = splitEndpointConfig((row.endpointConfig ?? {}) as Record<string, unknown>);
196
+ return label ?? endpoint ?? `${poolId.slice(0, 8)}…`;
197
+ }
198
+ const HEALTH_WINDOW_MS = 24 * 3_600_000;
199
+ const TOP_N = 50;
200
+ /**
201
+ * How long a parsed window is shared. A dashboard poll fires ~8 usage
202
+ * endpoints at once, and a slow parse outlives a 1.5 s window — so the
203
+ * endpoints that miss it each re-read and re-parse the same files, which is how
204
+ * one page load becomes several full parses of the retention window.
205
+ */
206
+ const PERIOD_MEMO_TTL_MS = 5_000;
207
+
208
+ /** Nearest-rank percentile over a sorted array, matching `percentile_cont` closely enough for a health gauge. */
209
+ function percentile(sorted: readonly number[], fraction: number): number {
210
+ if (sorted.length === 0) return 0;
211
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
212
+ return Math.round(sorted[index] ?? 0);
213
+ }
214
+
215
+ export interface UsagePerformanceSummary {
216
+ avgResolveMs: number;
217
+ avgDurationMs: number;
218
+ latencyP50Ms: number;
219
+ latencyP95Ms: number;
220
+ latencyP99Ms: number;
221
+ /** Events each series was computed from; see `UsageSummaryTotals`. */
222
+ latencySampleCount: number;
223
+ resolveSampleCount: number;
224
+ }
225
+
226
+ /** Summarizes routing delay and request latency for an already-filtered period. */
227
+ export function summarizeUsagePerformance(
228
+ events: readonly Pick<TelemetryEventRecord, "latencyMs" | "resolveMs">[],
229
+ ): UsagePerformanceSummary {
230
+ const latencies: number[] = [];
231
+ let latencySum = 0;
232
+ let resolveSum = 0;
233
+ let resolveCount = 0;
234
+ for (const event of events) {
235
+ if (event.latencyMs !== null) {
236
+ latencies.push(event.latencyMs);
237
+ latencySum += event.latencyMs;
238
+ }
239
+ if (event.resolveMs !== null) {
240
+ resolveSum += event.resolveMs;
241
+ resolveCount += 1;
242
+ }
243
+ }
244
+ latencies.sort((a, b) => a - b);
245
+ return {
246
+ avgResolveMs: resolveCount > 0 ? resolveSum / resolveCount : 0,
247
+ avgDurationMs: latencies.length > 0 ? latencySum / latencies.length : 0,
248
+ latencyP50Ms: percentile(latencies, 0.5),
249
+ latencyP95Ms: percentile(latencies, 0.95),
250
+ latencyP99Ms: percentile(latencies, 0.99),
251
+ latencySampleCount: latencies.length,
252
+ resolveSampleCount: resolveCount,
253
+ };
254
+ }
255
+
256
+ /**
257
+ * The performance summary from indexed buckets rather than from events.
258
+ *
259
+ * The index keeps a latency sum, a sample count, and a histogram, so the mean
260
+ * stays exact and the percentiles stay bucketed — the same precision the
261
+ * per-event version had, without parsing the period's requests.
262
+ */
263
+ export function summarizeBucketPerformance(
264
+ buckets: readonly IndexedBucket[],
265
+ ): UsagePerformanceSummary {
266
+ let latencySum = 0;
267
+ let latencyCount = 0;
268
+ let resolveSum = 0;
269
+ let resolveCount = 0;
270
+ let latencyMaxMs: number | null = null;
271
+ const histogram: number[] = [];
272
+ for (const bucket of buckets) {
273
+ latencySum += bucket.latencySum;
274
+ latencyCount += bucket.latencyCount;
275
+ // Read defensively: an index written before these fields existed carries
276
+ // neither, and summing `undefined` poisons the whole period with NaN —
277
+ // including the days that do have the data.
278
+ resolveSum += numeric(bucket.resolveSumMs);
279
+ resolveCount += numeric(bucket.resolveCount);
280
+ if (bucket.latencyMaxMs !== null)
281
+ latencyMaxMs = latencyMaxMs === null ? bucket.latencyMaxMs : Math.max(latencyMaxMs, bucket.latencyMaxMs);
282
+ for (let index = 0; index < bucket.latencyHistogram.length; index += 1) {
283
+ histogram[index] = (histogram[index] ?? 0) + (bucket.latencyHistogram[index] ?? 0);
284
+ }
285
+ }
286
+ const fill =
287
+ histogram.length > 0
288
+ ? histogram
289
+ : new Array<number>(LATENCY_BUCKETS_MS.length + 1).fill(0);
290
+ return {
291
+ avgResolveMs: resolveCount > 0 ? resolveSum / resolveCount : 0,
292
+ avgDurationMs: latencyCount > 0 ? latencySum / latencyCount : 0,
293
+ // `latencyMaxMs` is passed as the overflow answer: a percentile past the
294
+ // histogram's last edge is at most the slowest request actually recorded.
295
+ // Without it an overflow returned `null`, and `?? 0` turned a P95 of 40 s
296
+ // into "0 ms" — the slow tail reported as nothing at all.
297
+ latencyP50Ms: percentileFromHistogram(fill, 50, latencyMaxMs) ?? 0,
298
+ latencyP95Ms: percentileFromHistogram(fill, 95, latencyMaxMs) ?? 0,
299
+ latencyP99Ms: percentileFromHistogram(fill, 99, latencyMaxMs) ?? 0,
300
+ latencySampleCount: latencyCount,
301
+ resolveSampleCount: resolveCount,
302
+ };
303
+ }
304
+
305
+ /** The largest entries of a count map, capped. */
306
+ function topCounts(counts: ReadonlyMap<string, number>, limit: number): Array<[string, number]> {
307
+ return [...counts].sort((a, b) => b[1] - a[1]).slice(0, limit);
308
+ }
309
+
310
+ /** The grouping value for one usage breakdown dimension. */
311
+ function dimensionName(dimension: UsageDimension, event: TelemetryEventRecord): string | null {
312
+ if (dimension === "model") return event.requestedModel;
313
+ if (dimension === "key") return event.apiKeyId;
314
+ if (dimension === "client") return event.userAgent;
315
+ if (dimension === "client_ip") return event.clientIp;
316
+ return event.providerId;
317
+ }
318
+
319
+ interface BreakdownBucket {
320
+ name: string;
321
+ requests: number;
322
+ input: number;
323
+ output: number;
324
+ cached: number;
325
+ errors: number;
326
+ cost: number | null;
327
+ rateSum: number;
328
+ rateCount: number;
329
+ }
330
+
331
+ function emptyBreakdown(name: string): BreakdownBucket {
332
+ return { name, requests: 0, input: 0, output: 0, cached: 0, errors: 0, cost: null, rateSum: 0, rateCount: 0 };
333
+ }
334
+
335
+ /** Real Drizzle-backed observability repository used by the production console. */
336
+ export class DrizzleObservabilityStore implements ObservabilityStore {
337
+ /**
338
+ * Per-(tenant, period) parsed event set, shared by the ~8 usage endpoints
339
+ * one dashboard poll fires in parallel. 1.5 s window: a poll burst shares
340
+ * one file parse; the next poll re-reads. In-flight shares one promise.
341
+ */
342
+ private readonly periodMemo = new Map<string, { at: number; promise: Promise<readonly TelemetryEventRecord[]> }>();
343
+ private readonly periodBucketMemo = new Map<string, { at: number; promise: Promise<readonly IndexedBucket[]> }>();
344
+ constructor(
345
+ private readonly db: CartethyiaDatabase,
346
+ private readonly redis: RedisClient | undefined,
347
+ /**
348
+ * Tenant preferences, revision-cached. The Usage page reads the privacy
349
+ * gate on the list, detail and breakdown paths, so a direct SELECT here
350
+ * re-read the same row per request; the cache collapses that.
351
+ */
352
+ private readonly preferences: PreferencesReader = new CachedPreferencesReader(
353
+ new DrizzlePreferencesReader(db),
354
+ ),
355
+ ) {}
356
+
357
+ async health(tenantId: string): Promise<CachedResult<SystemHealthResponse>> {
358
+ // Cached like its siblings: reading this directly re-parses 24 hourly files
359
+ // every page load. Same 10 s TTL as `/system/usage`.
360
+ return this.readThroughWithEtag(
361
+ this.usageCacheKey(tenantId, "24h", "health"),
362
+ () => this.computeHealth(tenantId),
363
+ );
364
+ }
365
+
366
+ /**
367
+ * Same two numbers `computeHealth` reports, read fresh.
368
+ *
369
+ * Deliberately not `computeHealth`: that also scans 24 h of telemetry files,
370
+ * which is exactly the cost the cache exists to avoid, and a memory watcher
371
+ * polling every 10 s must not pay it. Kept in the store so the percentage
372
+ * has one definition rather than two.
373
+ */
374
+ async liveMemory(): Promise<{ memory_bytes: number; memory_percent: number }> {
375
+ const memory = process.memoryUsage();
376
+ return {
377
+ memory_bytes: memory.rss,
378
+ memory_percent: Math.round((memory.rss / Math.max(totalmem(), 1)) * 10000) / 100,
379
+ };
380
+ }
381
+
382
+ private async computeHealth(tenantId: string): Promise<SystemHealthResponse> {
383
+ const memory = process.memoryUsage();
384
+ // `loadavg()` is [0, 0, 0] on every Windows build — there is no such
385
+ // syscall and Node reports a zeroed tuple rather than failing. Divided by
386
+ // cores that is a confident-looking 0.0% on a busy gateway, which is worse
387
+ // than saying nothing: an operator reads it as "idle" and stops looking.
388
+ // Reported as null instead of as a measurement.
389
+ //
390
+ // A real zero load is *not* the same thing and is reported as zero: an
391
+ // idle machine genuinely has nothing queued, and collapsing that into
392
+ // "unavailable" would hide a number the platform did supply.
393
+ const cpuPercent: number | null =
394
+ process.platform === "win32"
395
+ ? null
396
+ : Math.round(((loadavg()[0] ?? 0) / Math.max(cpus().length, 1)) * 10000) / 100;
397
+
398
+ let databaseHealthy = true;
399
+ let total = 0;
400
+ let errors = 0;
401
+ let avg = 0;
402
+ let p95 = 0;
403
+ let p99 = 0;
404
+ let cacheHitRate = 0;
405
+ let avgTokensPerSec = 0;
406
+ try {
407
+ // Bounded 24h rolling window (matches the Overview copy). The file is the
408
+ // source, so the percentiles are computed over the retained events.
409
+ const since = new Date(Date.now() - HEALTH_WINDOW_MS);
410
+ const events = await queryTelemetryEvents({ tenantId, since });
411
+ const latencies: number[] = [];
412
+ let latencySum = 0;
413
+ let inputTokens = 0;
414
+ let cachedTokens = 0;
415
+ let outputTokens = 0;
416
+ let tokensPerSecSum = 0;
417
+ let tokensPerSecCount = 0;
418
+ for (const event of events) {
419
+ total += 1;
420
+ if (isGatewayError(event.status, event.httpStatus)) errors += 1;
421
+ if (event.latencyMs !== null) {
422
+ latencies.push(event.latencyMs);
423
+ latencySum += event.latencyMs;
424
+ }
425
+ if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
426
+ inputTokens += event.inputTokens ?? 0;
427
+ cachedTokens += event.inputTokens ?? 0;
428
+ outputTokens += event.outputTokens ?? 0;
429
+ }
430
+ if (event.tokensPerSec !== null) {
431
+ tokensPerSecSum += event.tokensPerSec;
432
+ tokensPerSecCount += 1;
433
+ }
434
+ }
435
+ latencies.sort((a, b) => a - b);
436
+ avg = latencies.length > 0 ? Math.round(latencySum / latencies.length) : 0;
437
+ p95 = percentile(latencies, 0.95);
438
+ p99 = percentile(latencies, 0.99);
439
+ // Cached share of the prompt: cached/input, the same honest definition
440
+ // usageSummary uses, so the health card and the Usage page cannot
441
+ // disagree. (Cached share, not prompt share: input/(input+output) reads
442
+ // ~99% and mislabels it a cache hit rate.)
443
+ cacheHitRate = inputTokens > 0 ? Math.round((cachedTokens / inputTokens) * 10000) / 100 : 0;
444
+ avgTokensPerSec = tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0;
445
+ } catch {
446
+ databaseHealthy = false;
447
+ }
448
+
449
+ // No client means the memory backend, which is healthy by construction.
450
+ let redisHealthy = this.redis === undefined;
451
+ if (this.redis !== undefined) {
452
+ try {
453
+ redisHealthy = (await this.redis.ping()) === "PONG";
454
+ } catch {
455
+ redisHealthy = false;
456
+ }
457
+ }
458
+
459
+ const status: SystemHealthResponse["status"] = !databaseHealthy
460
+ ? "unhealthy"
461
+ : !redisHealthy
462
+ ? "degraded"
463
+ : "healthy";
464
+
465
+ return {
466
+ version: CARTETHYIA_VERSION,
467
+ status,
468
+ uptime_seconds: Math.floor(process.uptime()),
469
+ database_healthy: databaseHealthy,
470
+ redis_healthy: redisHealthy,
471
+ redis_configured: this.redis !== undefined,
472
+ memory_heap_bytes: memory.heapUsed,
473
+ memory_external_bytes: memory.external,
474
+ memory_bytes: memory.rss,
475
+ memory_percent: Math.round((memory.rss / Math.max(totalmem(), 1)) * 10000) / 100,
476
+ // Reported so the console can explain a large RSS instead of leaving the
477
+ // operator to read it as the gateway alone. In lite mode this process
478
+ // hosts the database too, so the resident set covers both.
479
+ database_in_process: resolveDbMode() === "lite",
480
+ cpu_percent: cpuPercent,
481
+ cpu_cores: cpus().length,
482
+ pid: process.pid,
483
+ platform: `Bun ${Bun.version} · ${process.platform}`,
484
+ request_count: total,
485
+ error_count: errors,
486
+ latency_avg_ms: avg,
487
+ latency_p95_ms: p95,
488
+ latency_p99_ms: p99,
489
+ cache_hit_rate_percent: cacheHitRate,
490
+ avg_tokens_per_sec: avgTokensPerSec,
491
+ };
492
+ }
493
+ async usage(tenantId: string, period: string): Promise<CachedResult<UsageResponse>> {
494
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "usage"), () =>
495
+ this.computeUsage(tenantId, period),
496
+ );
497
+ }
498
+
499
+ /**
500
+ * Cache key for one usage response. Versioned so a shape change can never be
501
+ * read back from a deployment that still holds the old one.
502
+ */
503
+ private usageCacheKey(tenantId: string, period: string, shape: string): string {
504
+ return `cartethyia:usage:v1:${shape}:${tenantId}:${period}`;
505
+ }
506
+
507
+ /**
508
+ * Reads a usage response through Redis — computing when no cache is
509
+ * configured — and returns the strong validator alongside it, so a repeated
510
+ * poll can be answered with a `304` instead of the whole payload.
511
+ */
512
+ private readThroughWithEtag<T>(key: string, compute: () => Promise<T>): Promise<CachedResult<T>> {
513
+ return cachedReadWithEtag(this.redis, key, compute);
514
+ }
515
+
516
+ private async computeUsage(tenantId: string, period: string): Promise<UsageResponse> {
517
+ const events = await this.periodEvents(tenantId, period);
518
+ let requestsTotal = 0;
519
+ let requestsSucceeded = 0;
520
+ let requestsFailed = 0;
521
+ let tokensUsed = 0;
522
+ let estimatedCost = 0;
523
+ const models = new Map<string, number>();
524
+ const providers = new Map<string, number>();
525
+ for (const event of events) {
526
+ requestsTotal += 1;
527
+ if (event.status === "completed") requestsSucceeded += 1;
528
+ if (isGatewayError(event.status, event.httpStatus)) requestsFailed += 1;
529
+ tokensUsed += (event.inputTokens ?? 0) + (event.outputTokens ?? 0);
530
+ estimatedCost += event.estimatedCostUsd ?? 0;
531
+ if (event.requestedModel) models.set(event.requestedModel, (models.get(event.requestedModel) ?? 0) + 1);
532
+ if (event.providerId) providers.set(event.providerId, (providers.get(event.providerId) ?? 0) + 1);
533
+ }
534
+ return {
535
+ tenantId,
536
+ period,
537
+ requestsTotal,
538
+ requestsSucceeded,
539
+ requestsFailed,
540
+ tokensUsed,
541
+ estimatedCost,
542
+ topModels: topCounts(models, TOP_N).map(([modelId, count]) => ({ modelId, count })),
543
+ topProviders: topCounts(providers, TOP_N).map(([providerId, count]) => ({ providerId, count })),
544
+ };
545
+ }
546
+
547
+ /** The tenant's retained events inside a usage period. `all` keeps everything. */
548
+ /**
549
+ * The indexed buckets covering a period, for one tenant.
550
+ *
551
+ * Reads the per-day index files instead of the event files. A usage query no
552
+ * longer parses every request in the window and filters it in memory, so its
553
+ * cost no longer grows with traffic.
554
+ */
555
+ private periodBuckets(tenantId: string, period: string): Promise<readonly IndexedBucket[]> {
556
+ const key = `${tenantId}:${period}`;
557
+ const now = Date.now();
558
+ const hit = this.periodBucketMemo.get(key);
559
+ if (hit && now - hit.at < PERIOD_MEMO_TTL_MS) return hit.promise;
560
+ const since = periodStartDate(period);
561
+ // Fold before reading. Events appended since the scheduler last ran are
562
+ // otherwise invisible, and a usage page that under-reports is worse than one
563
+ // that is a moment slow.
564
+ const promise = queryIndexedBuckets({
565
+ tenantId,
566
+ foldFirst: true,
567
+ ...(since === undefined ? {} : { since }),
568
+ });
569
+ this.periodBucketMemo.set(key, { at: now, promise });
570
+ void promise.catch(() => {
571
+ if (this.periodBucketMemo.get(key)?.promise === promise) this.periodBucketMemo.delete(key);
572
+ });
573
+ return promise;
574
+ }
575
+
576
+ private periodEvents(tenantId: string, period: string): Promise<readonly TelemetryEventRecord[]> {
577
+ const key = `${tenantId}:${period}`;
578
+ const now = Date.now();
579
+ const hit = this.periodMemo.get(key);
580
+ if (hit && now - hit.at < PERIOD_MEMO_TTL_MS) return hit.promise;
581
+ const since = periodStartDate(period);
582
+ const promise = queryTelemetryEvents(since === undefined ? { tenantId } : { tenantId, since });
583
+ this.periodMemo.set(key, { at: now, promise });
584
+ // A rejected parse must not poison the memo for the next poll.
585
+ void promise.catch(() => {
586
+ if (this.periodMemo.get(key)?.promise === promise) this.periodMemo.delete(key);
587
+ });
588
+ return promise;
589
+ }
590
+
591
+ async usageSummary(tenantId: string, period: string): Promise<CachedResult<UsageSummaryResponse>> {
592
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "summary"), () =>
593
+ this.computeSummary(tenantId, period),
594
+ );
595
+ }
596
+
597
+ private async computeSummary(tenantId: string, period: string): Promise<UsageSummaryResponse> {
598
+ const buckets = await this.periodBuckets(tenantId, period);
599
+ const performance = summarizeBucketPerformance(buckets);
600
+ let requests = 0;
601
+ let inputTokens = 0;
602
+ let cachedTokens = 0;
603
+ let outputTokens = 0;
604
+ let errors = 0;
605
+ let cancelled = 0;
606
+ let truncated = 0;
607
+ let estimatedCostUsd = 0;
608
+ let unpriced = 0;
609
+ let tokensPerSecSum = 0;
610
+ let tokensPerSecCount = 0;
611
+ const statuses = new Map<number, number>();
612
+ for (const row of buckets) {
613
+ // Folded from the index's own per-status counts. These used to be left
614
+ // empty: the loop below never filled `statuses`, so every status chip and
615
+ // the requests-list filters read zero for any day whose events had been
616
+ // folded — while `requests` beside them read the real total.
617
+ for (const [status, count] of Object.entries(row.byStatus ?? {})) {
618
+ const code = Number(status);
619
+ if (!Number.isFinite(code)) continue;
620
+ statuses.set(code, (statuses.get(code) ?? 0) + count);
621
+ }
622
+ requests += row.requests;
623
+ inputTokens += row.inputTokens;
624
+ outputTokens += row.outputTokens;
625
+ cachedTokens += row.cachedTokens;
626
+ errors += row.failed;
627
+ cancelled += row.cancelled;
628
+ truncated += row.truncated;
629
+ estimatedCostUsd += row.estimatedCostUsd;
630
+ unpriced += row.unpriced;
631
+ tokensPerSecSum += row.tokensPerSecSum;
632
+ tokensPerSecCount += row.tokensPerSecCount;
633
+ }
634
+ return {
635
+ period,
636
+ totals: {
637
+ statusCounts: [...statuses].sort(([a], [b]) => a - b).map(([status, count]) => ({ status, count })),
638
+ requests,
639
+ inputTokens,
640
+ cachedTokens,
641
+ outputTokens,
642
+ errors,
643
+ cancelled,
644
+ truncated,
645
+ ...performance,
646
+ estimatedCostUsd,
647
+ partial: unpriced > 0,
648
+ cacheHitRate: inputTokens > 0 ? (cachedTokens / inputTokens) * 100 : 0,
649
+ avgTokensPerSec: tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0,
650
+ },
651
+ };
652
+ }
653
+
654
+ async usageChart(tenantId: string, period: string): Promise<CachedResult<UsageChartResponse>> {
655
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "chart"), () =>
656
+ this.computeChart(tenantId, period),
657
+ );
658
+ }
659
+
660
+ private async computeChart(tenantId: string, period: string): Promise<UsageChartResponse> {
661
+ const since = periodStartDate(period);
662
+ const bucketSeconds = chartBucketSeconds(period);
663
+ const stepMs = bucketSeconds * 1000;
664
+ const events = await this.periodEvents(tenantId, period);
665
+ const byTime = new Map<number, UsageChartBucket>();
666
+ for (const event of events) {
667
+ const at = new Date(event.createdAt).getTime();
668
+ if (!Number.isFinite(at)) continue;
669
+ const start = Math.floor(at / stepMs) * stepMs;
670
+ const bucket = byTime.get(start) ?? { t: new Date(start).toISOString(), requests: 0, input: 0, cached: 0, output: 0 };
671
+ bucket.requests += 1;
672
+ bucket.input += event.inputTokens ?? 0;
673
+ bucket.cached += event.cachedInputTokens !== null && event.cachedInputTokens > 0 ? (event.inputTokens ?? 0) : 0;
674
+ bucket.output += event.outputTokens ?? 0;
675
+ byTime.set(start, bucket);
676
+ }
677
+ const rows = [...byTime.keys()];
678
+ const first = rows.length > 0 ? Math.min(...byTime.keys()) : undefined;
679
+ const startMs = since ? since.getTime() : first;
680
+ if (startMs === undefined) return { buckets: [] };
681
+ const aligned = Math.floor(startMs / stepMs) * stepMs;
682
+ const buckets: UsageChartBucket[] = [];
683
+ for (let at = aligned; at <= Date.now(); at += stepMs) {
684
+ buckets.push(
685
+ byTime.get(at) ?? { t: new Date(at).toISOString(), requests: 0, input: 0, cached: 0, output: 0 },
686
+ );
687
+ }
688
+ return { buckets };
689
+ }
690
+
691
+ async usageBy(
692
+ tenantId: string,
693
+ dimension: UsageDimension,
694
+ period: string,
695
+ ): Promise<CachedResult<UsageByResponse>> {
696
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, `${dimension}:${period}`, "by"), () =>
697
+ this.computeBy(tenantId, dimension, period),
698
+ );
699
+ }
700
+
701
+ private async computeBy(
702
+ tenantId: string,
703
+ dimension: UsageDimension,
704
+ period: string,
705
+ ): Promise<UsageByResponse> {
706
+ // The breakdown is read from the per-name sums the day index maintains, not
707
+ // from the events: the console asks how much went to each model, and the
708
+ // index already carries exactly that. Parsing the window to recount it was
709
+ // the whole cost of this endpoint.
710
+ const buckets = await this.periodBuckets(tenantId, period);
711
+ const groups = new Map<string, BreakdownBucket>();
712
+ // Fall back to the events only when the index carries nothing for the
713
+ // period — an install that has not folded yet, or a day whose index is
714
+ // missing. Never alongside it: the same request is in both, and counting it
715
+ // twice would double every column.
716
+ if (buckets.length === 0) {
717
+ for (const event of await this.periodEvents(tenantId, period)) {
718
+ const raw = dimensionName(dimension, event);
719
+ if (raw === null || raw.length === 0) continue;
720
+ const name = dimension === "client" ? clientNameFromUserAgent(raw) : raw;
721
+ const bucket = groups.get(name) ?? emptyBreakdown(name);
722
+ bucket.requests += 1;
723
+ bucket.input += event.inputTokens ?? 0;
724
+ bucket.output += event.outputTokens ?? 0;
725
+ if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
726
+ bucket.cached += event.inputTokens ?? 0;
727
+ }
728
+ if (isGatewayError(event.status, event.httpStatus)) bucket.errors += 1;
729
+ if (event.estimatedCostUsd !== null) bucket.cost = (bucket.cost ?? 0) + event.estimatedCostUsd;
730
+ if (event.tokensPerSec !== null) {
731
+ bucket.rateSum += event.tokensPerSec;
732
+ bucket.rateCount += 1;
733
+ }
734
+ groups.set(name, bucket);
735
+ }
736
+ }
737
+ for (const indexed of buckets) {
738
+ const source =
739
+ dimension === "model"
740
+ ? indexed.dimModel
741
+ : dimension === "provider"
742
+ ? indexed.dimProvider
743
+ : dimension === "client"
744
+ ? indexed.dimClient
745
+ : indexed.dimClientIp;
746
+ for (const [raw, entry] of Object.entries(source)) {
747
+ if (raw.length === 0) continue;
748
+ const name = dimension === "client" ? clientNameFromUserAgent(raw) : raw;
749
+ const bucket = groups.get(name) ?? emptyBreakdown(name);
750
+ bucket.requests += entry.requests;
751
+ bucket.input += entry.inputTokens;
752
+ bucket.output += entry.outputTokens;
753
+ bucket.cached += entry.cachedTokens;
754
+ bucket.errors += entry.errors;
755
+ bucket.cost = (bucket.cost ?? 0) + entry.estimatedCostUsd;
756
+ bucket.rateSum += entry.tokensPerSecSum;
757
+ bucket.rateCount += entry.tokensPerSecCount;
758
+ groups.set(name, bucket);
759
+ }
760
+ }
761
+ const mapped = [...groups.values()]
762
+ .map((bucket) => ({
763
+ name: bucket.name,
764
+ requests: bucket.requests,
765
+ input: bucket.input,
766
+ output: bucket.output,
767
+ cached: bucket.cached,
768
+ total: bucket.input + bucket.output,
769
+ errors: bucket.errors,
770
+ costUsd: bucket.cost,
771
+ cacheHitRate: bucket.input > 0 ? (bucket.cached / bucket.input) * 100 : 0,
772
+ avgTokensPerSec: bucket.rateCount > 0 ? bucket.rateSum / bucket.rateCount : 0,
773
+ }))
774
+ .sort((a, b) => b.requests - a.requests)
775
+ .slice(0, 100);
776
+ if (dimension === "client_ip") {
777
+ // Same fail-closed gate as the request list: storage keeps the raw
778
+ // address, only presentation masks it.
779
+ const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
780
+ if (!hideClientIp) return { dimension, rows: mapped };
781
+ // Grouping happened on the raw address, so masking can collapse distinct
782
+ // hosts into one display name (`203.0.113.7` and `203.0.113.9` both become
783
+ // `203.0.113.xxx`). Re-aggregate by the masked name: two rows the operator
784
+ // cannot tell apart are one row, and the totals must not be understated.
785
+ const merged = new Map<string, (typeof mapped)[number]>();
786
+ for (const row of mapped) {
787
+ const masked = maskClientIp(row.name);
788
+ const existing = merged.get(masked);
789
+ if (existing === undefined) {
790
+ merged.set(masked, { ...row, name: masked });
791
+ continue;
792
+ }
793
+ const input = existing.input + row.input;
794
+ const output = existing.output + row.output;
795
+ merged.set(masked, {
796
+ ...existing,
797
+ requests: existing.requests + row.requests,
798
+ input,
799
+ output,
800
+ cached: existing.cached + row.cached,
801
+ total: input + output,
802
+ errors: existing.errors + row.errors,
803
+ costUsd:
804
+ existing.costUsd === null && row.costUsd === null
805
+ ? null
806
+ : (existing.costUsd ?? 0) + (row.costUsd ?? 0),
807
+ cacheHitRate: input > 0 ? (existing.cached + row.cached) / input * 100 : 0,
808
+ });
809
+ }
810
+ return {
811
+ dimension,
812
+ rows: [...merged.values()].sort((a, b) => b.requests - a.requests),
813
+ };
814
+ }
815
+ if (dimension !== "key") return { dimension, rows: mapped };
816
+ const ids = [...new Set(mapped.map((row) => row.name))];
817
+ const labels = ids.length > 0 ? await this.apiKeyLabels(tenantId, ids) : new Map<string, string>();
818
+ return {
819
+ dimension,
820
+ rows: mapped.map((row) => {
821
+ const label = labels.get(row.name);
822
+ return label === undefined ? row : { ...row, label };
823
+ }),
824
+ };
825
+ }
826
+
827
+ private async apiKeyLabels(tenantId: string, ids: readonly string[]): Promise<Map<string, string>> {
828
+ const unique = [...new Set(ids)];
829
+ if (unique.length === 0) return new Map<string, string>();
830
+ const keyRows = await this.db
831
+ .select({ id: apiKeys.id, label: apiKeys.label })
832
+ .from(apiKeys)
833
+ .where(and(inArray(apiKeys.id, [...unique]), eq(apiKeys.tenantId, tenantId)));
834
+ return new Map(keyRows.map((keyRow) => [keyRow.id, keyRow.label] as const));
835
+ }
836
+
837
+ async usageCache(tenantId: string, period: string): Promise<CachedResult<UsageCacheResponse>> {
838
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "cache"), () =>
839
+ this.computeCache(tenantId, period),
840
+ );
841
+ }
842
+
843
+ private async computeCache(tenantId: string, period: string): Promise<UsageCacheResponse> {
844
+ const events = await this.periodEvents(tenantId, period);
845
+ let inputTokens = 0;
846
+ let cachedTokens = 0;
847
+ let outputTokens = 0;
848
+ for (const event of events) {
849
+ inputTokens += event.inputTokens ?? 0;
850
+ outputTokens += event.outputTokens ?? 0;
851
+ if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
852
+ cachedTokens += event.inputTokens ?? 0;
853
+ }
854
+ }
855
+ const tokenTotal = inputTokens + outputTokens;
856
+ return {
857
+ period,
858
+ inputTokens,
859
+ cachedTokens,
860
+ cacheWriteTokens: 0,
861
+ hitRate: tokenTotal > 0 ? (cachedTokens / tokenTotal) * 100 : 0,
862
+ };
863
+ }
864
+
865
+ async usageRequests(
866
+ tenantId: string,
867
+ period: string,
868
+ limit: number,
869
+ httpStatus?: number,
870
+ ): Promise<CachedResult<UsageRequestsResponse>> {
871
+ // Ask the reader for `limit` rows instead of folding the whole window and
872
+ // slicing it. It stops at the newest file that fills the page, so the
873
+ // console's most-polled endpoint no longer parses the retention window just
874
+ // to show 50 rows.
875
+ const since = periodStartDate(period);
876
+ const events = await queryTelemetryEvents({
877
+ tenantId,
878
+ limit,
879
+ ...(httpStatus === undefined ? {} : { httpStatus }),
880
+ ...(since === undefined ? {} : { since }),
881
+ });
882
+ const rows = events.slice(0, limit);
883
+ const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
884
+ // Cached like its siblings, so a repeated poll costs a `304` instead of
885
+ // another read. The mask flag is part of the key: it is a privacy setting,
886
+ // so a page must never be served from an entry computed under the other
887
+ // value. The rows were already read above — this bounds the label lookup
888
+ // and the mapping, not the telemetry read.
889
+ return this.readThroughWithEtag(
890
+ this.usageCacheKey(
891
+ tenantId,
892
+ `${period}:${limit}:${httpStatus ?? "*"}:${hideClientIp}`,
893
+ "requests",
894
+ ),
895
+ async () => {
896
+ const keyIds = [...new Set(rows.map((row) => row.apiKeyId).filter((id): id is string => id !== null))];
897
+ const labels = await this.apiKeyLabels(tenantId, keyIds);
898
+ // One indexed read for the whole page, not one per row: presence lives
899
+ // on the payload row so the list can render it without inflating a
900
+ // frame for every row it shows.
901
+ const requestIds = [...new Set(rows.map((row) => row.requestId).filter((id): id is string => id !== null))];
902
+ const stages = await this.payloadStages(tenantId, requestIds);
903
+ return {
904
+ items: rows.map((row) => {
905
+ const item = mapUsageRequestItem(row, hideClientIp);
906
+ const label = row.apiKeyId ? labels.get(row.apiKeyId) : undefined;
907
+ const labeled = label === undefined ? item : { ...item, apiKeyLabel: label };
908
+ const stage = stages.get(row.requestId);
909
+ // Omitted rather than zero when the row is unknown: absence means
910
+ // "no payload was captured", and 0 would say the same thing while
911
+ // claiming it had been measured.
912
+ return stage === undefined ? labeled : { ...labeled, payloadStages: stage };
913
+ }),
914
+ };
915
+ },
916
+ );
917
+ }
918
+
919
+ /**
920
+ * Captured-stage bitmask per request, newest payload row winning.
921
+ * One indexed read for a whole page. Presence is on the payload row
922
+ * specifically so the list never has to inflate a frame to answer "does this
923
+ * panel have content".
924
+ */
925
+ private async payloadStages(tenantId: string, requestIds: readonly string[]): Promise<Map<string, number>> {
926
+ if (requestIds.length === 0) return new Map();
927
+ const rows = await this.db
928
+ .select({ requestId: telemetryPayloads.requestId, stages: telemetryPayloads.stages })
929
+ .from(telemetryPayloads)
930
+ .where(
931
+ and(
932
+ eq(telemetryPayloads.tenantId, tenantId),
933
+ inArray(telemetryPayloads.requestId, [...requestIds]),
934
+ ),
935
+ )
936
+ .orderBy(desc(telemetryPayloads.capturedAt));
937
+ const byRequest = new Map<string, number>();
938
+ for (const row of rows) {
939
+ if (row.requestId === null || row.stages === null) continue;
940
+ // Ordered newest-first, so the first hit is the one the drawer reads.
941
+ if (!byRequest.has(row.requestId)) byRequest.set(row.requestId, row.stages);
942
+ }
943
+ return byRequest;
944
+ }
945
+
946
+ async usageRequestDetail(
947
+ tenantId: string,
948
+ requestId: string,
949
+ ): Promise<UsageRequestDetail | undefined> {
950
+ const rows = await queryTelemetryEvents({ tenantId, requestId });
951
+ const event = rows[0];
952
+ if (!event) return undefined;
953
+ // The label, proxy, and mask lookups are independent: run them together
954
+ // instead of three sequential round trips.
955
+ const [hideClientIp, keyLabels, proxy] = await Promise.all([
956
+ shouldMaskClientIp(this.preferences, tenantId),
957
+ event.apiKeyId ? this.apiKeyLabels(tenantId, [event.apiKeyId]) : undefined,
958
+ event.networkPoolId ? resolveProxyLabel(this.db, tenantId, event.networkPoolId) : ("direct" as const),
959
+ ]);
960
+ const item = mapUsageRequestItem(event, hideClientIp);
961
+ const keyLabel = event.apiKeyId ? keyLabels?.get(event.apiKeyId) : undefined;
962
+ const labeled = keyLabel === undefined ? item : { ...item, apiKeyLabel: keyLabel };
963
+ const presented = { ...labeled, proxy };
964
+ // Filter the payload table directly instead of joining `telemetry_events`
965
+ // only to re-apply the tenant predicate: `telemetry_payloads` carries
966
+ // `tenant_id` itself, so the join added a second table's rows to filter for
967
+ // no additional constraint. This matches `getEvent` above and uses
968
+ // `telemetry_payloads_tenant_request_idx`.
969
+ const linkedPayloadRows = await this.db
970
+ .select({ payload: telemetryPayloads })
971
+ .from(telemetryPayloads)
972
+ .where(
973
+ and(
974
+ eq(telemetryPayloads.requestId, requestId),
975
+ eq(telemetryPayloads.tenantId, tenantId),
976
+ ),
977
+ )
978
+ .orderBy(desc(telemetryPayloads.capturedAt))
979
+ .limit(1);
980
+ const payloadRow = linkedPayloadRows[0]?.payload;
981
+ const captured = await readCapturedBodies(payloadRow);
982
+ if (!captured) {
983
+ return { ...presented, payloads: null };
984
+ }
985
+ return {
986
+ ...presented,
987
+ payloads: {
988
+ ...(captured.request !== undefined ? { request: captured.request } : {}),
989
+ ...(captured.response !== undefined ? { response: captured.response } : {}),
990
+ ...(captured.clientResponse !== undefined ? { clientResponse: captured.clientResponse } : {}),
991
+ ...(captured.providerRequest !== undefined
992
+ ? { providerRequest: captured.providerRequest }
993
+ : {}),
994
+ ...(captured.providerResponse !== undefined
995
+ ? { providerResponse: captured.providerResponse }
996
+ : {}),
997
+ },
998
+ ...(captured.signals === undefined ? {} : { payloadSignals: captured.signals }),
999
+ };
1000
+ }
1001
+
1002
+ async listEvents(
1003
+ tenantId: string,
1004
+ limit: number,
1005
+ cursor?: string,
1006
+ ): Promise<TelemetryEventListPage> {
1007
+ const before = decodeDatedCursor(cursor);
1008
+ // The cursor becomes an `until` bound inside the query, so `limit` applies
1009
+ // to every page and not only the first. Filtering after the read would force
1010
+ // a cursor page to fetch the whole window to know where it ended, dropping
1011
+ // `limit` the moment a cursor appears.
1012
+ //
1013
+ // `until` is inclusive while the cursor is exclusive, so rows at the exact
1014
+ // cursor timestamp are still compared by id here — the cheap part of the
1015
+ // predicate, now over one timestamp's worth of rows instead of the window.
1016
+ const events = await queryTelemetryEvents({
1017
+ tenantId,
1018
+ limit: limit + 1,
1019
+ ...(before === undefined ? {} : { until: new Date(before.createdAt) }),
1020
+ });
1021
+ const rows = (before
1022
+ ? events.filter((event) => event.createdAt < before.createdAt || (event.createdAt === before.createdAt && event.id < before.id))
1023
+ : events
1024
+ ).slice(0, limit + 1);
1025
+ const overflow = rows.length > limit;
1026
+ const trimmed = overflow ? rows.slice(0, limit) : rows;
1027
+ const entries = trimmed.map(mapTelemetryEventRow);
1028
+ const lastEntry = entries[entries.length - 1];
1029
+ const nextCursor =
1030
+ overflow && lastEntry !== undefined
1031
+ ? encodeCursor({ createdAt: lastEntry.createdAt, id: lastEntry.id })
1032
+ : undefined;
1033
+ return nextCursor ? { entries, nextCursor } : { entries };
1034
+ }
1035
+
1036
+ async getEvent(
1037
+ tenantId: string,
1038
+ eventId: string,
1039
+ includePayload = false,
1040
+ createdAt?: string,
1041
+ ): Promise<TelemetryEventDetail | undefined> {
1042
+ // Matched by `id`, which is the field the list cursor encodes — not by
1043
+ // `requestId`, which is a different column that merely defaults to the
1044
+ // same value. Predicating on it lets the reader stop at the first hit
1045
+ // instead of parsing the retention window to find one row.
1046
+ const events = await queryTelemetryEvents({ tenantId, id: eventId });
1047
+ const event = events.find(
1048
+ (candidate) =>
1049
+ candidate.id === eventId && (createdAt === undefined || candidate.createdAt === createdAt),
1050
+ );
1051
+ if (!event) return undefined;
1052
+ const base = mapTelemetryEventRow(event);
1053
+ if (!includePayload) return base;
1054
+ const payloadRows = await this.db
1055
+ .select()
1056
+ .from(telemetryPayloads)
1057
+ .where(
1058
+ and(
1059
+ eq(telemetryPayloads.requestId, event.requestId),
1060
+ eq(telemetryPayloads.tenantId, tenantId),
1061
+ ),
1062
+ )
1063
+ .orderBy(desc(telemetryPayloads.capturedAt))
1064
+ .limit(1);
1065
+ const payloadRow = payloadRows[0];
1066
+ if (!payloadRow) return base;
1067
+ const captured = await readCapturedBodies(payloadRow);
1068
+ if (!captured) return base;
1069
+ return {
1070
+ ...base,
1071
+ payload: {
1072
+ ...(captured.request !== undefined ? { requestBody: captured.request } : {}),
1073
+ ...(captured.response !== undefined ? { responseBody: captured.response } : {}),
1074
+ },
1075
+ };
1076
+ }
1077
+ }
1078
+
1079
+ /**
1080
+ * Reads the captured bodies behind one `telemetry_payloads` row.
1081
+ *
1082
+ * The row stores typed frame-reference columns only; every body lives in the
1083
+ * frame file those columns name. Returns `undefined` when there is no row, no
1084
+ * usable reference, or the frame cannot be read — all three mean "nothing to
1085
+ * show", and the caller renders the request without a payload section. The raw
1086
+ * reference is never returned to a caller, so it cannot reach the dashboard.
1087
+ */
1088
+ async function readCapturedBodies(
1089
+ row:
1090
+ | {
1091
+ readonly storage: string;
1092
+ readonly file: string;
1093
+ readonly offset: number;
1094
+ readonly length: number;
1095
+ readonly checksum: string;
1096
+ readonly version: number;
1097
+ }
1098
+ | null
1099
+ | undefined,
1100
+ ): Promise<
1101
+ | {
1102
+ request: unknown;
1103
+ response: unknown;
1104
+ clientResponse: unknown;
1105
+ providerRequest: unknown;
1106
+ providerResponse: unknown;
1107
+ signals?: { toolCalls: number; images: number; attachments: number };
1108
+ }
1109
+ | undefined
1110
+ > {
1111
+ if (!row) return undefined;
1112
+ const reference = payloadReferenceFromRow(row);
1113
+ if (!reference) return undefined;
1114
+ try {
1115
+ const stored = await readPayloadFrame(reference);
1116
+ if (!stored || typeof stored !== "object") return undefined;
1117
+ const record = stored as Record<string, unknown>;
1118
+ const bodies = {
1119
+ request: record["request_body"] ?? undefined,
1120
+ response: record["response_body"] ?? undefined,
1121
+ clientResponse: record["client_response_body"] ?? undefined,
1122
+ providerRequest: record["provider_request_body"] ?? undefined,
1123
+ providerResponse: record["provider_response_body"] ?? undefined,
1124
+ };
1125
+ const signals =
1126
+ typeof record["signals"] === "object" &&
1127
+ record["signals"] !== null &&
1128
+ !Array.isArray(record["signals"])
1129
+ ? (record["signals"] as { toolCalls: number; images: number; attachments: number })
1130
+ : undefined;
1131
+ return {
1132
+ ...bodies,
1133
+ ...(signals === undefined ? {} : { signals }),
1134
+ };
1135
+ } catch {
1136
+ return undefined;
1137
+ }
1138
+ }