cartethyia 2.5.10 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (369) hide show
  1. package/.env.example +8 -0
  2. package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-wr370U7a.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-wr370U7a.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-D1sCezWa.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-D1sCezWa.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-DVWbJPEQ.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-DVWbJPEQ.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-CZc7fyMe.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-CZc7fyMe.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-OPN3qRCa.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-OPN3qRCa.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-DGTSqmwm.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-DGTSqmwm.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-BujG2QP4.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-BujG2QP4.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-C_Jd_KgP.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-C_Jd_KgP.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-Bpbu-VeU.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-Bpbu-VeU.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-BEhXOO4L.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-BEhXOO4L.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-DbQnexQd.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-DbQnexQd.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-ByprzADo.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-ByprzADo.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-CvSfv6tP.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-CvSfv6tP.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-_NzLq3C-.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-_NzLq3C-.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-Bg9DcG-G.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-Bg9DcG-G.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C2PWPzJm.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-C2PWPzJm.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-Dky1zZMt.js} +1 -1
  37. package/dist/dashboard/assets/StudioPage-Dky1zZMt.js.gz +0 -0
  38. package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-C5Vdpd_c.js} +2 -2
  39. package/dist/dashboard/assets/UsagePage-C5Vdpd_c.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  42. package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
  43. package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
  44. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  47. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
  49. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
  51. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
  53. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
  61. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  62. package/dist/dashboard/assets/entry-BNFjUjQ0.js +4 -0
  63. package/dist/dashboard/assets/entry-BNFjUjQ0.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
  65. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-Bhey_rg_.js → index-DyllNVCY.js} +2 -2
  71. package/dist/dashboard/assets/index-DyllNVCY.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
  73. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
  75. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
  77. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  81. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  84. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  85. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  86. package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
  87. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  88. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  89. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  90. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  91. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CxVVB__N.js → select-DAhoTabv.js} +1 -1
  93. package/dist/dashboard/assets/select-DAhoTabv.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  95. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
  97. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
  99. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
  101. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  104. package/dist/dashboard/index.html +1 -1
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/README.md +52 -52
  108. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  109. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  110. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  111. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  112. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  113. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  114. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  115. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  116. package/package.json +96 -95
  117. package/scripts/cli/admin.ts +126 -128
  118. package/scripts/cli/autostart.ts +237 -237
  119. package/scripts/cli/background.ts +123 -110
  120. package/scripts/cli/bin/carte.cjs +8 -8
  121. package/scripts/cli/bin/cartethyia.cjs +177 -167
  122. package/scripts/cli/console-menu.ts +45 -30
  123. package/scripts/cli/controller.ts +145 -0
  124. package/scripts/cli/env.ts +99 -99
  125. package/scripts/cli/gateway.ts +264 -260
  126. package/scripts/cli/instance.ts +234 -224
  127. package/scripts/cli/main.ts +195 -167
  128. package/scripts/cli/platform.ts +64 -64
  129. package/scripts/cli/prompt.ts +217 -79
  130. package/scripts/cli/services.ts +88 -88
  131. package/scripts/cli/setup.ts +445 -445
  132. package/scripts/cli/start.ts +85 -85
  133. package/scripts/cli/startup-signal.ts +23 -23
  134. package/scripts/cli/telemetry.ts +70 -0
  135. package/scripts/cli/tray/hidden-window.vbs +16 -2
  136. package/scripts/cli/tray/index.ts +263 -263
  137. package/scripts/cli/tray/windows.ps1 +107 -107
  138. package/scripts/cli/tray.ts +29 -4
  139. package/scripts/cli/update.ts +39 -39
  140. package/scripts/cli/version.ts +67 -67
  141. package/scripts/cli/wordmark.ts +58 -58
  142. package/src/boot/app.ts +16 -1
  143. package/src/boot/dependencies.ts +128 -25
  144. package/src/config/resources.ts +137 -0
  145. package/src/config/runtime.ts +133 -76
  146. package/src/config/spec.ts +30 -29
  147. package/src/config/telemetry.ts +0 -25
  148. package/src/console/api-keys/bucket-spend.ts +14 -17
  149. package/src/console/api-keys/contracts.ts +410 -410
  150. package/src/console/api-keys/routes.ts +12 -0
  151. package/src/console/audit/store.ts +2 -3
  152. package/src/console/auth/setup-service.ts +8 -1
  153. package/src/console/backup/routes.ts +7 -0
  154. package/src/console/backup/service.ts +463 -463
  155. package/src/console/backup/store.ts +16 -11
  156. package/src/console/backup/validate.ts +1 -4
  157. package/src/console/cli-tools/fs-ops.ts +18 -2
  158. package/src/console/data-management/contracts.ts +1 -8
  159. package/src/console/data-management/operations.ts +61 -48
  160. package/src/console/data-management/routes.ts +4 -2
  161. package/src/console/domain-registration.ts +25 -3
  162. package/src/console/inferhub/client.ts +139 -0
  163. package/src/console/inferhub/key-store.ts +129 -0
  164. package/src/console/inferhub/normalize.ts +347 -0
  165. package/src/console/inferhub/routes.ts +411 -0
  166. package/src/console/inferhub/store.ts +109 -0
  167. package/src/console/inferhub/types.ts +183 -0
  168. package/src/console/observability/contracts.ts +2 -41
  169. package/src/console/observability/store.ts +176 -371
  170. package/src/console/providers/catalog/public-model-store.ts +0 -0
  171. package/src/console/providers/catalog/store.ts +23 -36
  172. package/src/console/quota/account-quota-shared.ts +52 -52
  173. package/src/console/quota/account-quota-tenant.ts +16 -24
  174. package/src/console/quota/refresh.ts +441 -441
  175. package/src/console/routing/pools/import-parse.ts +206 -206
  176. package/src/console/routing/pools/operations.ts +543 -543
  177. package/src/console/routing/pools/probe-verdict.ts +132 -132
  178. package/src/console/routing/pools/routes.ts +181 -181
  179. package/src/console/settings/contracts.ts +42 -40
  180. package/src/console/settings/store.ts +25 -25
  181. package/src/console/share/share-stats.ts +22 -4
  182. package/src/console/share/share-usage.ts +30 -4
  183. package/src/console/shared/cursor.ts +15 -0
  184. package/src/console/updates/routes.ts +12 -0
  185. package/src/main.ts +13 -5
  186. package/src/network/pool/resolver.ts +1104 -1104
  187. package/src/observability/client-name.ts +22 -0
  188. package/src/observability/event-log.ts +5 -29
  189. package/src/observability/event-query.ts +690 -0
  190. package/src/observability/log-ring.ts +26 -2
  191. package/src/observability/log-sanitize.ts +118 -0
  192. package/src/observability/logger.ts +17 -13
  193. package/src/observability/metrics.ts +35 -0
  194. package/src/observability/payload-capture.ts +114 -41
  195. package/src/observability/payload-store.ts +249 -24
  196. package/src/observability/raw-event-writer.ts +118 -0
  197. package/src/observability/runtime-metrics.ts +71 -1
  198. package/src/observability/telemetry-buffer.ts +100 -17
  199. package/src/observability/telemetry-reconciliation.ts +417 -0
  200. package/src/persistence/boot.ts +20 -9
  201. package/src/persistence/db-pg.ts +13 -0
  202. package/src/persistence/db-sqlite.ts +569 -497
  203. package/src/persistence/dialect.ts +3 -2
  204. package/src/persistence/migrate.ts +181 -181
  205. package/src/persistence/schema.ts +1496 -1406
  206. package/src/persistence/shared-cache.ts +5 -1
  207. package/src/persistence/stores/api-key-store.ts +581 -581
  208. package/src/persistence/stores/telemetry-store.ts +124 -63
  209. package/src/protocol/reasoning-text.ts +30 -0
  210. package/src/protocol/response/chat.ts +33 -7
  211. package/src/protocol/response/codex.ts +14 -2
  212. package/src/protocol/response/messages.ts +5 -2
  213. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  214. package/src/providers/bundled-discovery-providers.ts +122 -122
  215. package/src/providers/default-registry.ts +10 -0
  216. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  217. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  218. package/src/providers/discovery/probe-phases.ts +43 -7
  219. package/src/providers/discovery/probing-service.ts +70 -32
  220. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  221. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  222. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  223. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  224. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  225. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  226. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  227. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  228. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  229. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  230. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  231. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  232. package/src/providers/integrations/claude/claude.ts +3 -4
  233. package/src/providers/integrations/cline/cline.ts +94 -8
  234. package/src/providers/integrations/codex/codex.ts +61 -37
  235. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  236. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  237. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  238. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  239. package/src/providers/integrations/cursor/cursor.ts +29 -17
  240. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  241. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  242. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  243. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  244. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  245. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  246. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  247. package/src/providers/integrations/hermes.ts +7 -1
  248. package/src/providers/integrations/inferhub.ts +201 -73
  249. package/src/providers/integrations/kilo/kilo.ts +152 -152
  250. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  251. package/src/providers/integrations/openrouter/provider.ts +129 -129
  252. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  253. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  254. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  255. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  256. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  257. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  258. package/src/providers/integrations/zcode/oauth.ts +1 -1
  259. package/src/providers/operations/account-health-service.ts +605 -279
  260. package/src/providers/provider-metadata.ts +12 -1
  261. package/src/providers/provider-model-definition.ts +373 -373
  262. package/src/providers/quota/quota-contracts.ts +3 -7
  263. package/src/providers/search/search-provider.ts +2 -6
  264. package/src/security/admission/contracts.ts +10 -0
  265. package/src/security/admission/in-memory-store.ts +89 -14
  266. package/src/security/admission/lua.ts +191 -182
  267. package/src/security/admission/redis-store.ts +30 -0
  268. package/src/security/admission/service.ts +18 -0
  269. package/src/security/api-key-auth-cache.ts +96 -0
  270. package/src/security/api-key-auth.ts +11 -100
  271. package/src/security/client-router-fingerprint.ts +3 -7
  272. package/src/security/ip-boundary.ts +7 -3
  273. package/src/shared/coerce.ts +67 -0
  274. package/src/shared/hash.ts +6 -0
  275. package/src/shared/time.ts +7 -0
  276. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  277. package/src/transport/dispatch/attempt-loop.ts +103 -81
  278. package/src/transport/dispatch/capture-policy.ts +78 -0
  279. package/src/transport/dispatch/proxy-request.ts +4 -2
  280. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  281. package/src/transport/dispatch/streaming/stream.ts +19 -2
  282. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  283. package/src/transport/failure-policy.ts +27 -0
  284. package/src/transport/guards/abuse-guard.ts +2 -2
  285. package/src/transport/guards/auth-guard.ts +17 -0
  286. package/src/transport/guards/body-policy.ts +3 -2
  287. package/src/transport/guards/concurrency-guard.ts +89 -13
  288. package/src/transport/guards/early-rejection-capture.ts +43 -3
  289. package/src/transport/guards/error-lifecycle.ts +10 -2
  290. package/src/transport/guards/guard-errors.ts +23 -0
  291. package/src/transport/guards/headroom-guard.ts +41 -7
  292. package/src/transport/guards/pipeline.ts +24 -0
  293. package/src/transport/guards/request-context.ts +2 -1
  294. package/src/transport/routing/admission.ts +51 -9
  295. package/src/transport/routing/bounded-lru.ts +49 -0
  296. package/src/transport/routing/candidate-index.ts +88 -0
  297. package/src/transport/routing/reservations.ts +121 -7
  298. package/src/transport/routing/round-robin.ts +10 -22
  299. package/src/transport/routing/route-catalog.ts +26 -13
  300. package/src/transport/routing/route-model.ts +22 -2
  301. package/src/transport/routing/router.ts +53 -31
  302. package/src/transport/surface/chat/encode.ts +2 -3
  303. package/src/transport/surface/chat/parse.ts +2 -1
  304. package/src/transport/surface/dialects.ts +11 -3
  305. package/src/transport/surface/messages/encode.ts +2 -1
  306. package/src/transport/surface/messages/parse.ts +3 -1
  307. package/src/transport/surface/messages/stream.ts +2 -6
  308. package/src/workers/maintenance-lease.ts +200 -0
  309. package/src/workers/scheduler.ts +101 -1
  310. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  311. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  312. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  313. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  314. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  315. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  316. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
  317. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  318. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  319. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  320. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
  321. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  322. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
  323. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  324. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  325. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  326. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
  327. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  328. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
  329. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  330. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  331. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  332. package/dist/dashboard/assets/StudioPage-CM92fgJf.js.gz +0 -0
  333. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  334. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  335. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  336. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  337. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  338. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  339. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  340. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  341. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  342. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  343. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  344. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
  346. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  347. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  348. package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
  349. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  350. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  351. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  352. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  353. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  354. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  355. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  356. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  357. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  358. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  359. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  360. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  361. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  362. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  363. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  364. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  365. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  366. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  367. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  368. package/src/observability/event-compaction.ts +0 -317
  369. package/src/observability/event-day-index.ts +0 -439
@@ -0,0 +1,690 @@
1
+ /**
2
+ * The canonical telemetry reader.
3
+ *
4
+ * `telemetry_events` is already written by the production path (the
5
+ * transactional `insertEvents`), is indexed for keyset pagination and
6
+ * tenant-scoped breakdowns, and carries exactly the bounded metadata fields
7
+ * the console aggregates over — bodies live only in payload frame files. It
8
+ * is therefore the analytics source: every function here runs its aggregate
9
+ * IN SQL and returns only totals, groups, or buckets, so no read materializes
10
+ * the retained window in JavaScript.
11
+ *
12
+ * Rules enforced here:
13
+ * - every statement is tenant-scoped and `created_at >= since`;
14
+ * - list/detail use `(created_at, id)` keyset pagination, newest first;
15
+ * - latency percentiles are computed by the database (Postgres
16
+ * `percentile_cont`, SQLite nearest-rank via ordered OFFSET/LIMIT) —
17
+ * latency arrays are never returned to JavaScript;
18
+ * - `isGatewayError()` semantics live in one shared SQL predicate.
19
+ */
20
+ import { and, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
21
+ import type { CartethyiaDatabase } from "../persistence/db-handle";
22
+ import { telemetryEvents } from "../persistence/schema";
23
+ import type { TelemetryEventRecord } from "./event-log";
24
+ import { gatewayErrorSql } from "./telemetry-status";
25
+
26
+ /**
27
+ * The SQL predicate for "this request counts as a gateway error".
28
+ *
29
+ * `gatewayErrorSql` (telemetry-status.ts) is derived from the same constants
30
+ * `isGatewayError` reads, so the row-level predicate, the console's error
31
+ * totals, and the durable usage rollup cannot drift apart — an unknown status
32
+ * must be classified identically in all three.
33
+ */
34
+ const IS_GATEWAY_ERROR_SQL = gatewayErrorSql(telemetryEvents.status, telemetryEvents.httpStatus);
35
+
36
+ /** One bounded page of events, newest first. */
37
+ export interface EventPage {
38
+ readonly events: TelemetryEventRecord[];
39
+ /** Cursor for the next (older) page; undefined when the window is exhausted. */
40
+ readonly nextCursor?: string;
41
+ }
42
+
43
+ export interface EventListQuery {
44
+ readonly tenantId: string;
45
+ /**
46
+ * Lower bound. Omitting it means "the whole retained window" — the SQL table
47
+ * is pruned by the same retention rule as the raw files, so an unbounded
48
+ * query is bounded by retention rather than unbounded in effect. (Backup
49
+ * export is the one caller that needs the full window.)
50
+ */
51
+ readonly since?: Date;
52
+ readonly until?: Date;
53
+ readonly apiKeyIds?: readonly string[];
54
+ readonly accountIds?: readonly string[];
55
+ readonly requestId?: string;
56
+ /** Several request ids at once — the backup restore's "already present" check. */
57
+ readonly requestIds?: readonly string[];
58
+ readonly id?: string;
59
+ readonly httpStatus?: number;
60
+ readonly limit?: number;
61
+ /** Keyset cursor: `{createdAt}|{id}` of the last row of the previous page. */
62
+ readonly cursor?: string;
63
+ }
64
+
65
+ export interface UsageSummary {
66
+ readonly requests: number;
67
+ readonly errors: number;
68
+ /** Lifecycle breakdown: cancelled and truncated counts, for the summary card. */
69
+ readonly cancelled: number;
70
+ readonly truncated: number;
71
+ readonly inputTokens: number;
72
+ readonly outputTokens: number;
73
+ readonly cachedInputTokens: number;
74
+ readonly reasoningTokens: number;
75
+ readonly estimatedCostUsd: number;
76
+ /** Requests whose latency was recorded — the denominator for `latencyMs`. */
77
+ readonly sampled: number;
78
+ readonly latencyMs: {
79
+ readonly avg: number | null;
80
+ readonly p50: number | null;
81
+ readonly p95: number | null;
82
+ readonly p99: number | null;
83
+ };
84
+ /** Requests whose usage carried no price; the analytics `partial` flag. */
85
+ readonly unpriced: number;
86
+ }
87
+
88
+ export interface BreakdownGroup {
89
+ readonly key: string;
90
+ readonly requests: number;
91
+ readonly errors: number;
92
+ readonly inputTokens: number;
93
+ readonly outputTokens: number;
94
+ /** Summed estimated cost; null when no row in the group carried a price. */
95
+ readonly costUsd: number | null;
96
+ /** Mean tokens/sec across rows that reported one; 0 when none did. */
97
+ readonly avgTokensPerSec: number;
98
+ /** Cached-input share of the prompt, summed for the group. */
99
+ readonly cachedInputTokens: number;
100
+ }
101
+
102
+ export interface ChartBucket {
103
+ readonly at: number;
104
+ readonly requests: number;
105
+ readonly errors: number;
106
+ readonly inputTokens: number;
107
+ readonly outputTokens: number;
108
+ }
109
+
110
+ export interface HealthTotals {
111
+ readonly requests: number;
112
+ readonly errors: number;
113
+ readonly inputTokens: number;
114
+ readonly outputTokens: number;
115
+ readonly cachedInputTokens: number;
116
+ readonly latencyAvgMs: number | null;
117
+ }
118
+
119
+ const DEFAULT_LIMIT = 50;
120
+ const MAX_LIMIT = 500;
121
+
122
+ function numeric(value: unknown): number {
123
+ const parsed = Number(value ?? 0);
124
+ return Number.isFinite(parsed) ? parsed : 0;
125
+ }
126
+
127
+ function numericNullable(value: unknown): number | null {
128
+ const parsed = Number(value ?? 0);
129
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
130
+ }
131
+
132
+ /** Maps one `telemetry_events` row to the console's record shape. */
133
+ export function eventRecordFromRow(row: {
134
+ readonly id: string | null;
135
+ readonly createdAt: Date;
136
+ readonly tenantId: string;
137
+ readonly requestId: string;
138
+ readonly sourceSurface: string | null;
139
+ readonly requestedModel: string | null;
140
+ readonly reportedModel: string | null;
141
+ readonly requestedEffort: string | null;
142
+ readonly providerId: string | null;
143
+ readonly accountId: string | null;
144
+ readonly networkPoolId: string | null;
145
+ readonly endpoint: string | null;
146
+ readonly apiKeyId: string | null;
147
+ readonly userAgent: string | null;
148
+ readonly clientName: string | null;
149
+ readonly clientIp: string | null;
150
+ readonly latencyMs: number | null;
151
+ readonly ttfbMs: number | null;
152
+ readonly stream: boolean | null;
153
+ readonly status: string | null;
154
+ readonly httpStatus: number | null;
155
+ readonly errorOrigin: string | null;
156
+ readonly errorCategory: string | null;
157
+ readonly inputTokens: number | null;
158
+ readonly cachedInputTokens: number | null;
159
+ readonly outputTokens: number | null;
160
+ readonly reasoningTokens: number | null;
161
+ readonly estimatedCostUsd: string | number | null;
162
+ readonly creditUsed: string | number | null;
163
+ readonly resolveMs: number | null;
164
+ readonly tokensPerSec: string | number | null;
165
+ readonly firstContentDeltaAtMs: number | null;
166
+ readonly lastEventAtMs: number | null;
167
+ readonly payloadCaptureState: string | null;
168
+ }): TelemetryEventRecord {
169
+ return {
170
+ id: row.id ?? row.requestId,
171
+ createdAt: new Date(row.createdAt).toISOString(),
172
+ tenantId: row.tenantId,
173
+ requestId: row.requestId,
174
+ sourceSurface: row.sourceSurface,
175
+ requestedModel: row.requestedModel,
176
+ reportedModel: row.reportedModel,
177
+ requestedEffort: row.requestedEffort,
178
+ providerId: row.providerId,
179
+ accountId: row.accountId,
180
+ networkPoolId: row.networkPoolId,
181
+ endpoint: row.endpoint,
182
+ apiKeyId: row.apiKeyId,
183
+ userAgent: row.userAgent,
184
+ clientIp: row.clientIp,
185
+ latencyMs: row.latencyMs,
186
+ ttfbMs: row.ttfbMs,
187
+ stream: row.stream,
188
+ status: row.status,
189
+ httpStatus: row.httpStatus,
190
+ errorOrigin: row.errorOrigin,
191
+ errorCategory: row.errorCategory,
192
+ inputTokens: row.inputTokens,
193
+ cachedInputTokens: row.cachedInputTokens,
194
+ outputTokens: row.outputTokens,
195
+ reasoningTokens: row.reasoningTokens,
196
+ estimatedCostUsd: numericNullable(row.estimatedCostUsd),
197
+ creditUsed: numericNullable(row.creditUsed),
198
+ resolveMs: row.resolveMs,
199
+ tokensPerSec: numericNullable(row.tokensPerSec),
200
+ firstContentDeltaAtMs: row.firstContentDeltaAtMs,
201
+ lastEventAtMs: row.lastEventAtMs,
202
+ payloadCaptureState: row.payloadCaptureState,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * True when this handle speaks PostgreSQL.
208
+ *
209
+ * Read off the live dialect rather than `resolveDbMode()`/`handle.kind`:
210
+ * `CartethyiaDatabase` is the pg-typed builder on both backends (the SQLite
211
+ * adapter shares the type), and a test can build a SQLite handle while the
212
+ * process' mode is `full` — a mode-based guess then sends `percentile_cont`,
213
+ * which SQLite lacks, to SQLite. The dialect is the thing that actually
214
+ * renders the SQL, so it is the only answer that cannot disagree with it.
215
+ */
216
+ function isPostgres(db: CartethyiaDatabase): boolean {
217
+ const dialect = (db as unknown as { dialect?: { constructor?: { name?: string } } }).dialect;
218
+ return dialect?.constructor?.name === "PostgresDialect";
219
+ }
220
+
221
+ /**
222
+ * The tenant predicate — the scope every read shares — plus the window when
223
+ * one is given.
224
+ *
225
+ * Tenant is NOT optional: every statement here is tenant-scoped, and a query
226
+ * that forgot it would read another tenant's traffic.
227
+ */
228
+ function scoped(tenantId: string, since?: Date) {
229
+ const tenant = eq(telemetryEvents.tenantId, tenantId);
230
+ return since === undefined ? tenant : and(tenant, gte(telemetryEvents.createdAt, since));
231
+ }
232
+
233
+ /**
234
+ * Bounded detail/list read — the only place rows are materialized.
235
+ *
236
+ * Keyset pagination on `(created_at DESC, id DESC)`: `created_at` has
237
+ * second resolution, so a cursor on it alone could skip or repeat rows at a
238
+ * boundary. The cursor is the last row of the page, encoded as
239
+ * `{createdAtMs}|{id}`.
240
+ */
241
+ export async function queryTelemetryEvents(
242
+ db: CartethyiaDatabase,
243
+ query: EventListQuery,
244
+ ): Promise<EventPage> {
245
+ const limit = Math.max(1, Math.min(MAX_LIMIT, Math.trunc(query.limit ?? DEFAULT_LIMIT)));
246
+ const conditions = [scoped(query.tenantId, query.since)];
247
+ if (query.until !== undefined) {
248
+ conditions.push(sql`${telemetryEvents.createdAt} < ${query.until}`);
249
+ }
250
+ if (query.apiKeyIds !== undefined) {
251
+ conditions.push(inArray(telemetryEvents.apiKeyId, [...query.apiKeyIds]));
252
+ }
253
+ if (query.accountIds !== undefined) {
254
+ conditions.push(inArray(telemetryEvents.accountId, [...query.accountIds]));
255
+ }
256
+ if (query.requestId !== undefined) {
257
+ conditions.push(eq(telemetryEvents.requestId, query.requestId));
258
+ }
259
+ if (query.requestIds !== undefined) {
260
+ conditions.push(inArray(telemetryEvents.requestId, [...query.requestIds]));
261
+ }
262
+ if (query.id !== undefined) {
263
+ conditions.push(eq(telemetryEvents.id, query.id));
264
+ }
265
+ if (query.httpStatus !== undefined) {
266
+ conditions.push(eq(telemetryEvents.httpStatus, query.httpStatus));
267
+ }
268
+ if (query.cursor !== undefined) {
269
+ const separator = query.cursor.indexOf("|");
270
+ const at = Number(query.cursor.slice(0, separator));
271
+ const id = query.cursor.slice(separator + 1);
272
+ if (Number.isFinite(at) && id.length > 0) {
273
+ conditions.push(
274
+ sql`(${telemetryEvents.createdAt} < ${new Date(at)} OR (${telemetryEvents.createdAt} = ${new Date(at)} AND ${telemetryEvents.id} < ${id}))`,
275
+ );
276
+ }
277
+ }
278
+
279
+ const rows = await db
280
+ .select()
281
+ .from(telemetryEvents)
282
+ .where(and(...conditions))
283
+ .orderBy(desc(telemetryEvents.createdAt), desc(telemetryEvents.id))
284
+ .limit(limit + 1);
285
+
286
+ const page = rows.slice(0, limit);
287
+ const hasMore = rows.length > limit;
288
+ const last = page[page.length - 1];
289
+ const events = page.map((row) => eventRecordFromRow(row as Parameters<typeof eventRecordFromRow>[0]));
290
+ return {
291
+ events,
292
+ ...(hasMore && last
293
+ ? {
294
+ nextCursor: `${new Date(last.createdAt as unknown as Date).getTime()}|${last.id ?? last.requestId}`,
295
+ }
296
+ : {}),
297
+ };
298
+ }
299
+
300
+ /** One event by request id — a single indexed row, never a window scan. */
301
+ export async function eventByRequestId(
302
+ db: CartethyiaDatabase,
303
+ tenantId: string,
304
+ requestId: string,
305
+ ): Promise<TelemetryEventRecord | undefined> {
306
+ const [row] = await db
307
+ .select()
308
+ .from(telemetryEvents)
309
+ .where(and(eq(telemetryEvents.tenantId, tenantId), eq(telemetryEvents.requestId, requestId)))
310
+ .limit(1);
311
+ if (!row) return undefined;
312
+ return eventRecordFromRow(row as Parameters<typeof eventRecordFromRow>[0]);
313
+ }
314
+
315
+ /**
316
+ * Totals plus latency percentiles for one tenant window.
317
+ *
318
+ * Percentiles are computed by the database. Postgres uses `percentile_cont`;
319
+ * SQLite (no ordered-set aggregates) computes nearest-rank percentiles from
320
+ * the ordered latency column via OFFSET/LIMIT. Either way, latency values are
321
+ * never returned to JavaScript — the array that the old raw reader built to
322
+ * sort in JS was the memory cost this replaces.
323
+ */
324
+ export async function summarizeTelemetry(
325
+ db: CartethyiaDatabase,
326
+ tenantId: string,
327
+ since: Date,
328
+ ): Promise<UsageSummary> {
329
+ const condition = scoped(tenantId, since);
330
+ const [row] = await db
331
+ .select({
332
+ requests: sql<number>`count(*)`,
333
+ errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
334
+ cancelled: sql<number>`sum(case when ${telemetryEvents.status} = 'cancelled' then 1 else 0 end)`,
335
+ truncated: sql<number>`sum(case when ${telemetryEvents.status} = 'truncated' then 1 else 0 end)`,
336
+ inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
337
+ outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
338
+ cachedInputTokens: sql<number>`coalesce(sum(${telemetryEvents.cachedInputTokens}), 0)`,
339
+ reasoningTokens: sql<number>`coalesce(sum(${telemetryEvents.reasoningTokens}), 0)`,
340
+ estimatedCostUsd: sql<number>`coalesce(sum(${telemetryEvents.estimatedCostUsd}), 0)`,
341
+ sampled: sql<number>`count(${telemetryEvents.latencyMs})`,
342
+ latencyAvg: sql<number>`avg(${telemetryEvents.latencyMs})`,
343
+ unpriced: sql<number>`sum(case when ${telemetryEvents.estimatedCostUsd} is null then 1 else 0 end)`,
344
+ })
345
+ .from(telemetryEvents)
346
+ .where(condition);
347
+ if (!row) {
348
+ return {
349
+ requests: 0,
350
+ errors: 0,
351
+ cancelled: 0,
352
+ truncated: 0,
353
+ inputTokens: 0,
354
+ outputTokens: 0,
355
+ cachedInputTokens: 0,
356
+ reasoningTokens: 0,
357
+ estimatedCostUsd: 0,
358
+ sampled: 0,
359
+ latencyMs: { avg: null, p50: null, p95: null, p99: null },
360
+ unpriced: 0,
361
+ };
362
+ }
363
+
364
+ const sampled = numeric(row.sampled);
365
+ const percentiles = await latencyPercentiles(db, tenantId, since, sampled);
366
+ return {
367
+ requests: numeric(row.requests),
368
+ errors: numeric(row.errors),
369
+ cancelled: numeric(row.cancelled),
370
+ truncated: numeric(row.truncated),
371
+ inputTokens: numeric(row.inputTokens),
372
+ outputTokens: numeric(row.outputTokens),
373
+ cachedInputTokens: numeric(row.cachedInputTokens),
374
+ reasoningTokens: numeric(row.reasoningTokens),
375
+ estimatedCostUsd: numeric(row.estimatedCostUsd),
376
+ sampled,
377
+ latencyMs: {
378
+ avg: numericNullable(row.latencyAvg),
379
+ ...percentiles,
380
+ },
381
+ unpriced: numeric(row.unpriced),
382
+ };
383
+ }
384
+
385
+ type Percentiles = { p50: number | null; p95: number | null; p99: number | null };
386
+
387
+ async function latencyPercentiles(
388
+ db: CartethyiaDatabase,
389
+ tenantId: string,
390
+ since: Date,
391
+ sampled: number,
392
+ ): Promise<Percentiles> {
393
+ const empty: Percentiles = { p50: null, p95: null, p99: null };
394
+ if (sampled <= 0) return empty;
395
+ const base = [scoped(tenantId, since), isNotNull(telemetryEvents.latencyMs)];
396
+
397
+ // Postgres: one statement computing all three continuous percentiles.
398
+ if (isPostgres(db)) {
399
+ const [row] = await db
400
+ .select({
401
+ p50: sql<number>`percentile_cont(0.50) within group (order by ${telemetryEvents.latencyMs})`,
402
+ p95: sql<number>`percentile_cont(0.95) within group (order by ${telemetryEvents.latencyMs})`,
403
+ p99: sql<number>`percentile_cont(0.99) within group (order by ${telemetryEvents.latencyMs})`,
404
+ })
405
+ .from(telemetryEvents)
406
+ .where(and(...base));
407
+ if (!row) return empty;
408
+ return {
409
+ p50: numericNullable(row.p50),
410
+ p95: numericNullable(row.p95),
411
+ p99: numericNullable(row.p99),
412
+ };
413
+ }
414
+
415
+ // SQLite: nearest-rank percentile = the value at ceil(p * n) rows into the
416
+ // ordered latency column, via ORDER BY + OFFSET/LIMIT in SQL.
417
+ const rankAt = async (fraction: number): Promise<number | null> => {
418
+ const offset = Math.max(0, Math.ceil(fraction * sampled) - 1);
419
+ const [row] = await db
420
+ .select({ value: telemetryEvents.latencyMs })
421
+ .from(telemetryEvents)
422
+ .where(and(...base))
423
+ .orderBy(telemetryEvents.latencyMs)
424
+ .limit(1)
425
+ .offset(offset);
426
+ return row?.value ?? null;
427
+ };
428
+ const [p50, p95, p99] = await Promise.all([rankAt(0.5), rankAt(0.95), rankAt(0.99)]);
429
+ return { p50, p95, p99 };
430
+ }
431
+
432
+ /** The SQL column one breakdown dimension groups by. */
433
+ function dimensionColumn(dimension: string) {
434
+ if (dimension === "model") return telemetryEvents.requestedModel;
435
+ if (dimension === "provider") return telemetryEvents.providerId;
436
+ if (dimension === "account") return telemetryEvents.accountId;
437
+ if (dimension === "api_key" || dimension === "key") return telemetryEvents.apiKeyId;
438
+ if (dimension === "client") return telemetryEvents.clientName;
439
+ // `client_ip` groups on the raw address; masking stays a post-grouping
440
+ // presentation step in the console, unchanged.
441
+ if (dimension === "client_ip" || dimension === "ip") return telemetryEvents.clientIp;
442
+ if (dimension === "status") return telemetryEvents.httpStatus;
443
+ if (dimension === "endpoint") return telemetryEvents.endpoint;
444
+ return telemetryEvents.requestedModel;
445
+ }
446
+
447
+ /** The dimension's raw value as a string, for the console's grouping step. */
448
+ function dimensionValue(raw: unknown): string {
449
+ const value = raw === null || raw === undefined ? "" : String(raw);
450
+ return value.length === 0 ? "unknown" : value;
451
+ }
452
+
453
+ /**
454
+ * One dimension's groups, at most 100, ordered by request count.
455
+ *
456
+ * Grouping happens in SQL; only the groups come back. `client` groups on the
457
+ * ingestion-stamped `client_name` rather than re-deriving a name per row in
458
+ * JavaScript, and `client_ip` masking stays a post-grouping presentation step.
459
+ */
460
+ export async function breakdownTelemetry(
461
+ db: CartethyiaDatabase,
462
+ tenantId: string,
463
+ dimension: string,
464
+ since: Date,
465
+ ): Promise<BreakdownGroup[]> {
466
+ const column = dimensionColumn(dimension);
467
+ const rows = await db
468
+ .select({
469
+ key: column,
470
+ requests: sql<number>`count(*)`,
471
+ errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
472
+ inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
473
+ outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
474
+ cachedInputTokens: sql<number>`coalesce(sum(${telemetryEvents.cachedInputTokens}), 0)`,
475
+ costUsd: sql<number>`coalesce(sum(${telemetryEvents.estimatedCostUsd}), 0)`,
476
+ avgTokensPerSec: sql<number>`coalesce(avg(${telemetryEvents.tokensPerSec}), 0)`,
477
+ })
478
+ .from(telemetryEvents)
479
+ .where(scoped(tenantId, since))
480
+ .groupBy(column)
481
+ .orderBy(desc(sql`count(*)`))
482
+ .limit(100);
483
+ return rows.map((row) => ({
484
+ key: dimensionValue(row.key),
485
+ requests: numeric(row.requests),
486
+ errors: numeric(row.errors),
487
+ inputTokens: numeric(row.inputTokens),
488
+ outputTokens: numeric(row.outputTokens),
489
+ cachedInputTokens: numeric(row.cachedInputTokens),
490
+ // A group whose rows carried no price reports null, matching the console's
491
+ // "cost is unknown" reading rather than a false zero.
492
+ costUsd: numeric(row.costUsd) > 0 ? numeric(row.costUsd) : null,
493
+ avgTokensPerSec: numeric(row.avgTokensPerSec),
494
+ }));
495
+ }
496
+
497
+ /**
498
+ * Time-series buckets, computed in SQL.
499
+ *
500
+ * Bucket width comes from the caller (the console's existing period rule); the
501
+ * epoch bucketing is portable — `to_timestamp`/`unixepoch` differences are
502
+ * handled per backend.
503
+ */
504
+ export async function chartTelemetry(
505
+ db: CartethyiaDatabase,
506
+ tenantId: string,
507
+ since: Date,
508
+ bucketSeconds: number,
509
+ ): Promise<ChartBucket[]> {
510
+ const width = Math.max(1, Math.trunc(bucketSeconds));
511
+ const bucketExpression = isPostgres(db)
512
+ ? sql<number>`(cast(extract(epoch from ${telemetryEvents.createdAt}) as bigint) / ${width}) * ${width}`
513
+ : sql<number>`(cast(strftime('%s', ${telemetryEvents.createdAt}) as integer) / ${width}) * ${width}`;
514
+ const rows = await db
515
+ .select({
516
+ at: bucketExpression,
517
+ requests: sql<number>`count(*)`,
518
+ errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
519
+ inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
520
+ outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
521
+ })
522
+ .from(telemetryEvents)
523
+ .where(scoped(tenantId, since))
524
+ .groupBy(bucketExpression)
525
+ .orderBy(bucketExpression);
526
+ return rows.map((row) => ({
527
+ at: numeric(row.at) * 1000,
528
+ requests: numeric(row.requests),
529
+ errors: numeric(row.errors),
530
+ inputTokens: numeric(row.inputTokens),
531
+ outputTokens: numeric(row.outputTokens),
532
+ }));
533
+ }
534
+
535
+ /** Overview/health totals for one tenant window. */
536
+ export async function healthTelemetry(
537
+ db: CartethyiaDatabase,
538
+ tenantId: string,
539
+ since: Date,
540
+ ): Promise<HealthTotals> {
541
+ const [row] = await db
542
+ .select({
543
+ requests: sql<number>`count(*)`,
544
+ errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
545
+ inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
546
+ outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
547
+ cachedInputTokens: sql<number>`coalesce(sum(${telemetryEvents.cachedInputTokens}), 0)`,
548
+ latencyAvg: sql<number>`avg(${telemetryEvents.latencyMs})`,
549
+ })
550
+ .from(telemetryEvents)
551
+ .where(scoped(tenantId, since));
552
+ if (!row) {
553
+ return {
554
+ requests: 0,
555
+ errors: 0,
556
+ inputTokens: 0,
557
+ outputTokens: 0,
558
+ cachedInputTokens: 0,
559
+ latencyAvgMs: null,
560
+ };
561
+ }
562
+ return {
563
+ requests: numeric(row.requests),
564
+ errors: numeric(row.errors),
565
+ inputTokens: numeric(row.inputTokens),
566
+ outputTokens: numeric(row.outputTokens),
567
+ cachedInputTokens: numeric(row.cachedInputTokens),
568
+ latencyAvgMs: numericNullable(row.latencyAvg),
569
+ };
570
+ }
571
+
572
+ /**
573
+ * Summed input+output tokens for a set of API keys over one window — the
574
+ * family bucket spend query.
575
+ */
576
+ export async function sumTelemetryTokens(
577
+ db: CartethyiaDatabase,
578
+ tenantId: string,
579
+ filters: { readonly apiKeyIds?: readonly string[]; readonly since: Date; readonly until?: Date },
580
+ ): Promise<number> {
581
+ const conditions = [scoped(tenantId, filters.since)];
582
+ if (filters.until !== undefined) {
583
+ conditions.push(sql`${telemetryEvents.createdAt} < ${filters.until}`);
584
+ }
585
+ if (filters.apiKeyIds !== undefined && filters.apiKeyIds.length > 0) {
586
+ conditions.push(inArray(telemetryEvents.apiKeyId, [...filters.apiKeyIds]));
587
+ }
588
+ const [row] = await db
589
+ .select({
590
+ total: sql<number>`coalesce(sum(coalesce(${telemetryEvents.inputTokens}, 0) + coalesce(${telemetryEvents.outputTokens}, 0)), 0)`,
591
+ })
592
+ .from(telemetryEvents)
593
+ .where(and(...conditions));
594
+ return numeric(row?.total ?? 0);
595
+ }
596
+
597
+ /**
598
+ * The health card's latency and throughput figures for one window.
599
+ *
600
+ * Same principle as {@link summarizeTelemetry}: `avg(tokens_per_sec)` and the
601
+ * latency percentiles are computed by the database. The console's health card
602
+ * used to pull every retained event into an array to average `tokensPerSec`
603
+ * and sort latencies in JavaScript.
604
+ */
605
+ export interface HealthPerformance {
606
+ readonly latencyAvgMs: number | null;
607
+ readonly latencyP50Ms: number | null;
608
+ readonly latencyP95Ms: number | null;
609
+ readonly latencyP99Ms: number | null;
610
+ readonly avgTokensPerSec: number;
611
+ }
612
+
613
+ export async function summarizeHealthPerformance(
614
+ db: CartethyiaDatabase,
615
+ tenantId: string,
616
+ since: Date,
617
+ ): Promise<HealthPerformance> {
618
+ const [row] = await db
619
+ .select({
620
+ latencyAvg: sql<number>`avg(${telemetryEvents.latencyMs})`,
621
+ sampled: sql<number>`count(${telemetryEvents.latencyMs})`,
622
+ avgTokensPerSec: sql<number>`coalesce(avg(${telemetryEvents.tokensPerSec}), 0)`,
623
+ })
624
+ .from(telemetryEvents)
625
+ .where(scoped(tenantId, since));
626
+ const sampled = numeric(row?.sampled ?? 0);
627
+ const percentiles = await latencyPercentiles(db, tenantId, since, sampled);
628
+ return {
629
+ latencyAvgMs: numericNullable(row?.latencyAvg ?? null),
630
+ latencyP50Ms: percentiles.p50,
631
+ latencyP95Ms: percentiles.p95,
632
+ latencyP99Ms: percentiles.p99,
633
+ avgTokensPerSec: numeric(row?.avgTokensPerSec ?? 0),
634
+ };
635
+ }
636
+
637
+ /**
638
+ * The Usage summary's performance block, computed in SQL.
639
+ *
640
+ * Replaces `summarizeUsagePerformance` (which sorted a latency array in
641
+ * JavaScript): `avg(resolve_ms)` and `avg(tokens_per_sec)` are SQL averages,
642
+ * and the latency percentiles come from the same database-side percentile
643
+ * path {@link summarizeTelemetry} uses.
644
+ */
645
+ export interface UsagePerformanceTotals {
646
+ readonly avgResolveMs: number;
647
+ readonly avgDurationMs: number;
648
+ /**
649
+ * Percentiles as plain numbers: an empty window reports 0, which is what the
650
+ * JS-side `summarizeUsagePerformance` reported and what the summary card's
651
+ * contract expects. (The health reader keeps null so "no samples" stays
652
+ * distinguishable from a genuine near-zero latency.)
653
+ */
654
+ readonly latencyP50Ms: number;
655
+ readonly latencyP95Ms: number;
656
+ readonly latencyP99Ms: number;
657
+ readonly latencySampleCount: number;
658
+ readonly resolveSampleCount: number;
659
+ readonly avgTokensPerSec: number;
660
+ }
661
+
662
+ export async function summarizeUsagePerformanceSql(
663
+ db: CartethyiaDatabase,
664
+ tenantId: string,
665
+ since: Date,
666
+ ): Promise<UsagePerformanceTotals> {
667
+ const [row] = await db
668
+ .select({
669
+ sampled: sql<number>`count(${telemetryEvents.latencyMs})`,
670
+ latencyAvg: sql<number>`avg(${telemetryEvents.latencyMs})`,
671
+ resolveSum: sql<number>`coalesce(sum(${telemetryEvents.resolveMs}), 0)`,
672
+ resolveCount: sql<number>`count(${telemetryEvents.resolveMs})`,
673
+ avgTokensPerSec: sql<number>`coalesce(avg(${telemetryEvents.tokensPerSec}), 0)`,
674
+ })
675
+ .from(telemetryEvents)
676
+ .where(scoped(tenantId, since));
677
+ const sampled = numeric(row?.sampled ?? 0);
678
+ const resolveCount = numeric(row?.resolveCount ?? 0);
679
+ const percentiles = await latencyPercentiles(db, tenantId, since, sampled);
680
+ return {
681
+ avgResolveMs: resolveCount > 0 ? numeric(row?.resolveSum ?? 0) / resolveCount : 0,
682
+ avgDurationMs: numericNullable(row?.latencyAvg ?? null) ?? 0,
683
+ latencyP50Ms: percentiles.p50 ?? 0,
684
+ latencyP95Ms: percentiles.p95 ?? 0,
685
+ latencyP99Ms: percentiles.p99 ?? 0,
686
+ latencySampleCount: sampled,
687
+ resolveSampleCount: resolveCount,
688
+ avgTokensPerSec: numeric(row?.avgTokensPerSec ?? 0),
689
+ };
690
+ }