cartethyia 2.7.2 → 2.7.4

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 (405) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-XaUmiBrC.js → BannedPage-ChG5svFb.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-ChG5svFb.js.gz +0 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js +1 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js +1 -0
  7. package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-CXGS_Ct-.js → CombosPage-DCgm5g6g.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-DCgm5g6g.js.gz +0 -0
  10. package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js +1 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js.gz +0 -0
  12. package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js +1 -0
  13. package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-BKtAPYVF.js → HealthEventsModal-DzSWzE_j.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-DzSWzE_j.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BdmRLnq2.js → LoginPage-U6AKaJcF.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-U6AKaJcF.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BMS2t8BA.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-BMS2t8BA.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js +5 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js.gz +0 -0
  24. package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js +1 -0
  25. package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js.gz +0 -0
  26. package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js +9 -0
  27. package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-L0skzML8.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-L0skzML8.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js.gz +0 -0
  32. package/dist/dashboard/assets/SetupPage-f0DE1h0-.js +1 -0
  33. package/dist/dashboard/assets/SetupPage-f0DE1h0-.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Dqf4zpS-.js → SortableList-ClzpwyET.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-ClzpwyET.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-Byj2lAEm.js → StudioPage-Cq5tW7hW.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-Cq5tW7hW.js.gz +0 -0
  38. package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js +56 -0
  39. package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js.gz +0 -0
  40. package/dist/dashboard/assets/api-keys-C38iXoO4.js +1 -0
  41. package/dist/dashboard/assets/api-keys-C38iXoO4.js.gz +0 -0
  42. package/dist/dashboard/assets/app-a159k0ub.css +2 -0
  43. package/dist/dashboard/assets/app-a159k0ub.css.gz +0 -0
  44. package/dist/dashboard/assets/arrow-up-59ms1iiT.js +1 -0
  45. package/dist/dashboard/assets/arrow-up-59ms1iiT.js.gz +0 -0
  46. package/dist/dashboard/assets/assert-DbRA8EG2.js +15 -0
  47. package/dist/dashboard/assets/assert-DbRA8EG2.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-D9CS6dZk.js} +1 -1
  49. package/dist/dashboard/assets/badge-D9CS6dZk.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DympBNq4.js} +1 -1
  51. package/dist/dashboard/assets/banner-DympBNq4.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-Cca1XC6G.js → card-ibRWU_uT.js} +1 -1
  53. package/dist/dashboard/assets/card-ibRWU_uT.js.gz +0 -0
  54. package/dist/dashboard/assets/cli-tools-63qBGaRw.js +1 -0
  55. package/dist/dashboard/assets/cli-tools-63qBGaRw.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DJm-9pz7.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DJm-9pz7.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-8fiE6tec.js} +1 -1
  59. package/dist/dashboard/assets/dialog-8fiE6tec.js.gz +0 -0
  60. package/dist/dashboard/assets/drawer-CqGYeOrd.js +1 -0
  61. package/dist/dashboard/assets/drawer-CqGYeOrd.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-BSAsYsmV.js} +1 -1
  63. package/dist/dashboard/assets/entry-BSAsYsmV.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CiIQULZD.js +4 -0
  65. package/dist/dashboard/assets/entry-CiIQULZD.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-Daz0lJ-W.js +1 -0
  67. package/dist/dashboard/assets/entry-Daz0lJ-W.js.gz +0 -0
  68. package/dist/dashboard/assets/{concurrency-BDF0ok7y.js → fingerprint-pattern-BWuHtbW4.js} +1 -1
  69. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  70. package/dist/dashboard/assets/{formatters-CWWZ-uCZ.js → formatters-D-MTJxMr.js} +1 -1
  71. package/dist/dashboard/assets/formatters-D-MTJxMr.js.gz +0 -0
  72. package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-CPK47xQx.js} +1 -1
  73. package/dist/dashboard/assets/github-badge-CPK47xQx.js.gz +0 -0
  74. package/dist/dashboard/assets/{index-DFVFrnWW.js → index-DBNPOfhV.js} +2 -2
  75. package/dist/dashboard/assets/index-DBNPOfhV.js.gz +0 -0
  76. package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-BT0zntEr.js} +1 -1
  77. package/dist/dashboard/assets/inline-BT0zntEr.js.gz +0 -0
  78. package/dist/dashboard/assets/{input-C_ILAvBb.js → input-Da2ZViWg.js} +1 -1
  79. package/dist/dashboard/assets/input-Da2ZViWg.js.gz +0 -0
  80. package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-BXNeLw6M.js} +1 -1
  81. package/dist/dashboard/assets/layout-BXNeLw6M.js.gz +0 -0
  82. package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-BycOrrIV.js} +1 -1
  83. package/dist/dashboard/assets/list-row-BycOrrIV.js.gz +0 -0
  84. package/dist/dashboard/assets/live-DCQNZpF7.js +1 -0
  85. package/dist/dashboard/assets/live-DCQNZpF7.js.gz +0 -0
  86. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js +1 -0
  87. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js.gz +0 -0
  88. package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-Rr0TIvcj.js} +1 -1
  89. package/dist/dashboard/assets/mutation-Rr0TIvcj.js.gz +0 -0
  90. package/dist/dashboard/assets/network-9Yj2ZyLo.js +1 -0
  91. package/dist/dashboard/assets/network-9Yj2ZyLo.js.gz +0 -0
  92. package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-CEagj6ZZ.js} +2 -2
  93. package/dist/dashboard/assets/numbers-CEagj6ZZ.js.gz +0 -0
  94. package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-BloKtaXJ.js} +1 -1
  95. package/dist/dashboard/assets/query-keys-BloKtaXJ.js.gz +0 -0
  96. package/dist/dashboard/assets/quota-Caw7wqci.js +1 -0
  97. package/dist/dashboard/assets/quota-Caw7wqci.js.gz +0 -0
  98. package/dist/dashboard/assets/routing-CO9zCUus.js +1 -0
  99. package/dist/dashboard/assets/routing-CO9zCUus.js.gz +0 -0
  100. package/dist/dashboard/assets/{select-DBNVLdUu.js → select-BIYIBkMU.js} +2 -2
  101. package/dist/dashboard/assets/select-BIYIBkMU.js.gz +0 -0
  102. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
  103. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
  104. package/dist/dashboard/assets/settings-jS3-PNSA.js +1 -0
  105. package/dist/dashboard/assets/settings-jS3-PNSA.js.gz +0 -0
  106. package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-DfgnFDWY.js} +1 -1
  107. package/dist/dashboard/assets/stack-DfgnFDWY.js.gz +0 -0
  108. package/dist/dashboard/assets/{state-CWoo7ty5.js → state-CkfdYWel.js} +1 -1
  109. package/dist/dashboard/assets/state-CkfdYWel.js.gz +0 -0
  110. package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-SYm237HG.js} +1 -1
  111. package/dist/dashboard/assets/switch-SYm237HG.js.gz +0 -0
  112. package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-BQqCRNIi.js} +1 -1
  113. package/dist/dashboard/assets/useMutation-BQqCRNIi.js.gz +0 -0
  114. package/dist/dashboard/index.html +3 -2
  115. package/dist/dashboard/index.html.gz +0 -0
  116. package/dist/dashboard/precompressed-assets.json +118 -115
  117. package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
  118. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +21 -0
  119. package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
  120. package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
  121. package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
  122. package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
  123. package/migrations/full-postgres/0032_add_provider_pool_accounts.sql +53 -0
  124. package/migrations/full-postgres/0033_add_api_key_ban.sql +9 -0
  125. package/migrations/full-postgres/0034_add_api_key_ban_until.sql +7 -0
  126. package/migrations/full-postgres/0035_align_pool_account_status_enum.sql +21 -0
  127. package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
  128. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +21 -0
  129. package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
  130. package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
  131. package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
  132. package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
  133. package/migrations/lite-sqlite3/0032_add_provider_pool_accounts.sql +44 -0
  134. package/migrations/lite-sqlite3/0033_add_api_key_ban.sql +8 -0
  135. package/migrations/lite-sqlite3/0034_add_api_key_ban_until.sql +5 -0
  136. package/migrations/lite-sqlite3/0035_align_pool_account_status_enum.sql +6 -0
  137. package/package.json +2 -1
  138. package/src/boot/app.ts +9 -8
  139. package/src/config/network.ts +7 -3
  140. package/src/console/api-keys/bucket-spend.ts +7 -7
  141. package/src/console/api-keys/contracts.ts +475 -410
  142. package/src/console/api-keys/routes.ts +211 -23
  143. package/src/console/audit/routes.ts +3 -3
  144. package/src/console/auth/account-reset.ts +4 -4
  145. package/src/console/auth/session.ts +3 -3
  146. package/src/console/backup/contracts.ts +2 -0
  147. package/src/console/backup/export-endpoint.ts +14 -17
  148. package/src/console/backup/routes.ts +43 -11
  149. package/src/console/backup/service.ts +11 -6
  150. package/src/console/backup/store.ts +384 -88
  151. package/src/console/cli-tools/routes.ts +3 -3
  152. package/src/console/console-router-guards.ts +2 -2
  153. package/src/console/console-router.ts +4 -4
  154. package/src/console/dashboard-assets.ts +16 -1
  155. package/src/console/data-management/routes.ts +3 -3
  156. package/src/console/domain-registration.ts +4 -2
  157. package/src/console/observability/contracts.ts +4 -0
  158. package/src/console/observability/live.ts +3 -3
  159. package/src/console/observability/logs.ts +3 -3
  160. package/src/console/observability/performance.ts +3 -3
  161. package/src/console/observability/routes.ts +3 -3
  162. package/src/console/observability/store.ts +16 -16
  163. package/src/console/providers/catalog/contracts.ts +43 -22
  164. package/src/console/providers/catalog/provider-operations.ts +17 -2
  165. package/src/console/providers/catalog/routes.ts +3 -3
  166. package/src/console/providers/catalog/store-helpers.ts +349 -326
  167. package/src/console/providers/catalog/store.ts +1523 -1292
  168. package/src/console/providers/detail/routes.ts +3 -3
  169. package/src/console/providers/oauth/routes.ts +3 -3
  170. package/src/console/quota/account-quota-tenant.ts +34 -2
  171. package/src/console/quota/account-quota.ts +4 -1
  172. package/src/console/quota/account-test-egress.ts +4 -4
  173. package/src/console/quota/native-identity.ts +189 -0
  174. package/src/console/remote-support/contracts.ts +12 -6
  175. package/src/console/remote-support/service.ts +2 -2
  176. package/src/console/routing/model/routes.ts +3 -3
  177. package/src/console/routing/pools/contracts.ts +11 -8
  178. package/src/console/routing/pools/operations.ts +14 -7
  179. package/src/console/routing/pools/probe-verdict.ts +8 -3
  180. package/src/console/routing/pools/relay-deploy.ts +9 -9
  181. package/src/console/routing/pools/routes.ts +3 -3
  182. package/src/console/routing/pools/store.ts +21 -1
  183. package/src/console/settings/routes.ts +3 -3
  184. package/src/console/share/share-router.ts +51 -18
  185. package/src/console/share/share-stats.ts +158 -49
  186. package/src/console/share/share-usage.ts +24 -7
  187. package/src/console/shared/read-through-cache.ts +38 -2
  188. package/src/console/system-prompt/contracts.ts +102 -0
  189. package/src/console/system-prompt/routes.ts +165 -0
  190. package/src/console/updates/routes.ts +7 -4
  191. package/src/main.ts +11 -0
  192. package/src/network/outbound-fetch.ts +75 -4
  193. package/src/network/pool/resolver.ts +39 -39
  194. package/src/network/pool-cooldown-store.ts +71 -16
  195. package/src/network/pool-health-machine.ts +21 -1
  196. package/src/network/rate-limit-verdict.ts +68 -0
  197. package/src/network/ssrf.ts +4 -4
  198. package/src/observability/event-query.ts +437 -22
  199. package/src/observability/telemetry-status.ts +1 -1
  200. package/src/persistence/db-pg.ts +42 -0
  201. package/src/persistence/db-sqlite.ts +7 -2
  202. package/src/persistence/readiness.ts +18 -6
  203. package/src/persistence/schema.ts +162 -1
  204. package/src/persistence/stores/api-key-store.ts +849 -581
  205. package/src/persistence/stores/share-store.ts +65 -13
  206. package/src/persistence/stores/telemetry-store.ts +58 -2
  207. package/src/persistence/system-prompt-store.ts +104 -0
  208. package/src/protocol/codecs.ts +6 -6
  209. package/src/protocol/gateway-error.ts +6 -3
  210. package/src/protocol/image-generation.ts +2 -2
  211. package/src/protocol/reasoning.ts +65 -1
  212. package/src/protocol/request/chat.ts +1 -0
  213. package/src/protocol/response/chat.ts +2 -2
  214. package/src/protocol/response/codex.ts +1 -1
  215. package/src/protocol/response/gemini.ts +1 -1
  216. package/src/protocol/response/messages.ts +5 -5
  217. package/src/protocol/response/responses.ts +2 -2
  218. package/src/protocol/sse-decoder.ts +14 -3
  219. package/src/protocol/translation/capabilities.ts +2 -2
  220. package/src/protocol/upstream/send-claude-messages.ts +3 -3
  221. package/src/providers/adapters/base-adapter.ts +1 -1
  222. package/src/providers/adapters/compatible-adapter.ts +2 -2
  223. package/src/providers/authentication/oauth-refresh-service.ts +2 -2
  224. package/src/providers/configured-provider.ts +1 -1
  225. package/src/providers/discovery/probe-phases.ts +2 -2
  226. package/src/providers/discovery/probing-service.ts +12 -12
  227. package/src/providers/integrations/agentrouter/agentrouter.ts +2 -3
  228. package/src/providers/integrations/alibabacp/alibabacp.ts +2 -2
  229. package/src/providers/integrations/anthropic.ts +4 -11
  230. package/src/providers/integrations/antigravity/antigravity.ts +33 -47
  231. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -1
  232. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  233. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +37 -6
  234. package/src/providers/integrations/buddy/buddy-identity.ts +22 -1
  235. package/src/providers/integrations/buddy/buddy-media.ts +21 -17
  236. package/src/providers/integrations/buddy/codebuddy/provider.ts +6 -2
  237. package/src/providers/integrations/buddy/workbuddy/provider.ts +6 -2
  238. package/src/providers/integrations/claude/claude-betas.ts +1 -1
  239. package/src/providers/integrations/claude/claude-credentials.ts +1 -1
  240. package/src/providers/integrations/claude/claude-slow-lane-retry.ts +1 -1
  241. package/src/providers/integrations/claude/claude.ts +3 -10
  242. package/src/providers/integrations/cline/cline.ts +8 -1
  243. package/src/providers/integrations/cloudflareai/cloudflareai.ts +4 -7
  244. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  245. package/src/providers/integrations/codex/codex.ts +14 -15
  246. package/src/providers/integrations/commandcode/commandcode.ts +4 -4
  247. package/src/providers/integrations/commandcode/quota.ts +5 -1
  248. package/src/providers/integrations/cursor/cursor-http2.ts +11 -11
  249. package/src/providers/integrations/cursor/cursor.ts +15 -15
  250. package/src/providers/integrations/factory/factory.ts +4 -4
  251. package/src/providers/integrations/gemini.ts +11 -11
  252. package/src/providers/integrations/github/github.ts +26 -7
  253. package/src/providers/integrations/grok/grok.ts +2 -6
  254. package/src/providers/integrations/hermes.ts +5 -1
  255. package/src/providers/integrations/inferhub.ts +4 -11
  256. package/src/providers/integrations/kilo/kilo.ts +4 -0
  257. package/src/providers/integrations/kimi/kimi.ts +4 -11
  258. package/src/providers/integrations/kiro/kiro.ts +10 -10
  259. package/src/providers/integrations/muse/muse.ts +6 -9
  260. package/src/providers/integrations/openrouter/provider.ts +4 -0
  261. package/src/providers/integrations/perplexity.ts +2 -2
  262. package/src/providers/integrations/qoder/qoder-catalog.ts +47 -9
  263. package/src/providers/integrations/qoder/qoder-checkin.ts +242 -51
  264. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +157 -0
  265. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -3
  266. package/src/providers/integrations/qoder/qoder-realm.ts +15 -0
  267. package/src/providers/integrations/qoder/qoder-umid.ts +287 -0
  268. package/src/providers/integrations/qoder/qoder-virtual.ts +2 -2
  269. package/src/providers/integrations/qoder/qoder.ts +192 -24
  270. package/src/providers/integrations/tokenharbor.ts +2 -2
  271. package/src/providers/integrations/trae/trae.ts +6 -9
  272. package/src/providers/integrations/zcode/oauth.ts +1 -1
  273. package/src/providers/operations/account-error-classification.ts +6 -8
  274. package/src/providers/operations/account-health-service.ts +14 -16
  275. package/src/providers/operations/client-versions.ts +37 -22
  276. package/src/providers/operations/provider-catalog-service.ts +4 -4
  277. package/src/providers/operations/provider-credential-service.ts +16 -4
  278. package/src/providers/operations/upstream-deadline.ts +3 -3
  279. package/src/providers/provider-contracts.ts +55 -3
  280. package/src/providers/provider-credential-resolver.ts +1 -1
  281. package/src/providers/provider-registry.ts +4 -4
  282. package/src/providers/quota/quota-contracts.ts +24 -10
  283. package/src/providers/quota/refresh.ts +2 -2
  284. package/src/providers/search/search-provider.ts +5 -5
  285. package/src/security/abuse.ts +4 -4
  286. package/src/security/access-control.ts +19 -0
  287. package/src/security/admission/reasons.ts +17 -17
  288. package/src/security/api-key-auth.ts +18 -5
  289. package/src/shared/coerce.ts +19 -0
  290. package/src/transport/dispatch/attempt-finalize.ts +4 -3
  291. package/src/transport/dispatch/attempt-loop.ts +10 -7
  292. package/src/transport/dispatch/empty-stream.ts +6 -6
  293. package/src/transport/dispatch/generation.ts +9 -9
  294. package/src/transport/dispatch/leases.ts +8 -8
  295. package/src/transport/dispatch/proxy-request.ts +38 -9
  296. package/src/transport/dispatch/responses-compact.ts +11 -11
  297. package/src/transport/dispatch/retry-policy.ts +4 -67
  298. package/src/transport/dispatch/stream-bridge.ts +1 -1
  299. package/src/transport/dispatch/streaming/finalize.ts +9 -8
  300. package/src/transport/dispatch/streaming/stream.ts +16 -1
  301. package/src/transport/dispatch/systemone.ts +8 -8
  302. package/src/transport/dispatch/token-count.ts +9 -9
  303. package/src/transport/dispatch/upstream.ts +10 -0
  304. package/src/transport/dispatch/websearch.ts +6 -6
  305. package/src/transport/drain-endpoint.ts +2 -2
  306. package/src/transport/failure-policy.ts +13 -6
  307. package/src/transport/guards/auth-guard.ts +6 -5
  308. package/src/transport/guards/body-policy.ts +4 -4
  309. package/src/transport/guards/error-lifecycle.ts +18 -3
  310. package/src/transport/guards/guard-errors.ts +7 -3
  311. package/src/transport/guards/headroom-guard.ts +1 -1
  312. package/src/transport/guards/pipeline.ts +6 -6
  313. package/src/transport/guards/readiness-guard.ts +16 -2
  314. package/src/transport/guards/request-context.ts +3 -3
  315. package/src/transport/request/preparer.ts +37 -21
  316. package/src/transport/request/state.ts +4 -7
  317. package/src/transport/request/system-prompt-injection.ts +89 -0
  318. package/src/transport/request/tenant-preferences.ts +43 -0
  319. package/src/transport/routing/admission.ts +13 -3
  320. package/src/transport/routing/route-catalog.ts +104 -2
  321. package/src/transport/routing/route-model.ts +60 -7
  322. package/src/transport/routing/router.ts +306 -256
  323. package/src/transport/shutdown-notice.ts +19 -7
  324. package/src/transport/surface/completion.ts +1 -1
  325. package/src/transport/surface/messages/encode.ts +1 -1
  326. package/src/workers/daily-checkin.ts +2 -0
  327. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  328. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js +0 -1
  329. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  330. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js +0 -1
  331. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  332. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  333. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +0 -1
  334. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  335. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js +0 -1
  336. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  337. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  338. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  339. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  340. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +0 -1
  341. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  342. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +0 -4
  343. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  344. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +0 -1
  345. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  346. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +0 -9
  347. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  348. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js +0 -1
  349. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  350. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js +0 -1
  351. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  352. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +0 -1
  353. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  354. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  355. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  356. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +0 -56
  357. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  358. package/dist/dashboard/assets/api-keys-CUi6UXsR.js +0 -1
  359. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  360. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  361. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  362. package/dist/dashboard/assets/assert-67xXH5rf.js +0 -1
  363. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  364. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  365. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  366. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  367. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js +0 -1
  368. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  369. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  370. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  371. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  372. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  373. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  374. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  375. package/dist/dashboard/assets/entry-ClCMCWWQ.js +0 -4
  376. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  377. package/dist/dashboard/assets/entry-_xHo5x6r.js +0 -1
  378. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  379. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  380. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  381. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  382. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  383. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  384. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  385. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  386. package/dist/dashboard/assets/live-D0mm--ME.js +0 -1
  387. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  388. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +0 -1
  389. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  390. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  391. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  392. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  393. package/dist/dashboard/assets/quota-DZGyp0N5.js +0 -1
  394. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  395. package/dist/dashboard/assets/routing-BnIpi1IJ.js +0 -1
  396. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  397. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  398. package/dist/dashboard/assets/settings-BAFhDgX8.js +0 -1
  399. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  400. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  401. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  402. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  403. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  404. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-BayTTy01.js} +0 -0
  405. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-BayTTy01.js.gz} +0 -0
@@ -17,9 +17,9 @@
17
17
  * latency arrays are never returned to JavaScript;
18
18
  * - `isGatewayError()` semantics live in one shared SQL predicate.
19
19
  */
20
- import { and, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
20
+ import { and, desc, eq, gte, inArray, isNotNull, or, sql, type SQL } from "drizzle-orm";
21
21
  import type { CartethyiaDatabase } from "../persistence/db-handle";
22
- import { telemetryEvents } from "../persistence/schema";
22
+ import { apiKeys, telemetryEvents, telemetryUsageTotals } from "../persistence/schema";
23
23
  import type { TelemetryEventRecord } from "./event-log";
24
24
  import { gatewayErrorSql } from "./telemetry-status";
25
25
 
@@ -31,7 +31,7 @@ import { gatewayErrorSql } from "./telemetry-status";
31
31
  * totals, and the durable usage rollup cannot drift apart — an unknown status
32
32
  * must be classified identically in all three.
33
33
  */
34
- const IS_GATEWAY_ERROR_SQL = gatewayErrorSql(telemetryEvents.status, telemetryEvents.httpStatus);
34
+ export const IS_GATEWAY_ERROR_SQL = gatewayErrorSql(telemetryEvents.status, telemetryEvents.httpStatus);
35
35
 
36
36
  /** One bounded page of events, newest first. */
37
37
  export interface EventPage {
@@ -52,6 +52,17 @@ export interface EventListQuery {
52
52
  readonly until?: Date;
53
53
  readonly apiKeyIds?: readonly string[];
54
54
  readonly accountIds?: readonly string[];
55
+ /**
56
+ * The share-template key whose family (the template itself plus every child
57
+ * key it issued) scopes the read. Rendered as an `EXISTS` subquery against
58
+ * `api_keys` rather than an id list: a family is minted by the tenant and
59
+ * can grow to whatever size the tenant drives it to, so enumerating its ids
60
+ * in JavaScript to feed a bind list would scale the query's wire size with
61
+ * the family instead of with the result. The subquery keeps the same
62
+ * membership semantics — template id or any child of it — for any family
63
+ * size.
64
+ */
65
+ readonly familyApiKeyId?: string;
55
66
  readonly requestId?: string;
56
67
  /** Several request ids at once — the backup restore's "already present" check. */
57
68
  readonly requestIds?: readonly string[];
@@ -231,6 +242,94 @@ function scoped(tenantId: string, since?: Date) {
231
242
  return since === undefined ? tenant : and(tenant, gte(telemetryEvents.createdAt, since));
232
243
  }
233
244
 
245
+ /**
246
+ * Thrown when a membership filter outruns the guard below. This is a handled
247
+ * query error, not a protocol fault: callers surface it as a request failure
248
+ * instead of crashing the process with a PostgreSQL wire error that systemd
249
+ * would only restart into.
250
+ */
251
+ export class TelemetryQueryInputError extends Error {
252
+ constructor(message: string) {
253
+ super(message);
254
+ this.name = "TelemetryQueryInputError";
255
+ }
256
+ }
257
+
258
+ /**
259
+ * The deduplicated id count a list membership filter accepts before refusing.
260
+ *
261
+ * `idMembershipFilter` binds the whole set as one array parameter, so even a
262
+ * six-figure set is wire-safe; this guard is not a protocol necessity but a
263
+ * runaway-collector tripwire — a bug elsewhere (an unbounded accumulator, a
264
+ * vanished scope) must fail as a query error instead of materializing an
265
+ * unbounded parameter. Families larger than this must use the `familyApiKeyId`
266
+ * filter, which never materializes ids.
267
+ */
268
+ const MAX_MEMBERSHIP_IDS = 200_000;
269
+
270
+ /**
271
+ * The chunk size for the SQLite rendering of a list membership filter.
272
+ *
273
+ * SQLite has no array parameters, so the set has to come back as individual
274
+ * placeholders; 500 per chunk sits far below every build's
275
+ * SQLITE_MAX_VARIABLE_NUMBER while keeping the OR of chunks — semantically
276
+ * the identical membership test — to a handful of clauses for realistic sets.
277
+ */
278
+ const SQLITE_IDS_PER_CHUNK = 500;
279
+
280
+ /**
281
+ * Membership of `column` in a caller-supplied id set, safe at any set size.
282
+ *
283
+ * Expanding the members into one bind placeholder each breaks the wire twice
284
+ * over: PostgreSQL caps a Bind message at 65,535 parameters, and node-postgres
285
+ * miscounts long placeholder lists anyway (`08P01 bind message has N parameter
286
+ * formats but 0 parameters` — the shape that crash-looped the dev deployment,
287
+ * whose share family carried ~90k minted child keys). Instead the set is
288
+ * deduplicated and handed to the server as a single array parameter
289
+ * (`= ANY(…::uuid[])`), or, on SQLite, to chunks of individual placeholders;
290
+ * membership semantics are unchanged in both renderings.
291
+ */
292
+ function idMembershipFilter(
293
+ db: CartethyiaDatabase,
294
+ column: typeof telemetryEvents.apiKeyId | typeof telemetryEvents.accountId | typeof telemetryEvents.requestId,
295
+ values: readonly string[],
296
+ cast: "uuid[]" | "text[]",
297
+ ): SQL {
298
+ const unique = [...new Set(values)];
299
+ if (unique.length > MAX_MEMBERSHIP_IDS) {
300
+ throw new TelemetryQueryInputError(
301
+ `telemetry filter exceeds ${MAX_MEMBERSHIP_IDS} distinct ids (got ${unique.length}); scope the read by family or tenant instead`,
302
+ );
303
+ }
304
+ if (unique.length === 0) return sql`false`;
305
+ if (isPostgres(db)) {
306
+ return sql`${column} = ANY(${unique}::${sql.raw(cast)})`;
307
+ }
308
+ if (unique.length <= SQLITE_IDS_PER_CHUNK) return inArray(column, unique);
309
+ const chunks: SQL[] = [];
310
+ for (let start = 0; start < unique.length; start += SQLITE_IDS_PER_CHUNK) {
311
+ chunks.push(inArray(column, unique.slice(start, start + SQLITE_IDS_PER_CHUNK)));
312
+ }
313
+ return or(...chunks) as SQL;
314
+ }
315
+
316
+ /**
317
+ * Membership of `api_key_id` in the family of one share template — the
318
+ * template itself plus every key it issued.
319
+ *
320
+ * The primary predicate is the denormalized `family_template_key_id` column:
321
+ * an event is in the family when it was stamped with the template at write
322
+ * time. That makes the read independent of the key row surviving — a revoked
323
+ * or removed key keeps its history. Events written before the column existed
324
+ * (or whose key was hard-deleted before the backfill) fall back to an
325
+ * `EXISTS` against `api_keys`, so the migration window is still counted when
326
+ * the key row is present. The tenant predicate is carried by every read
327
+ * already, so a child in another tenant still cannot match.
328
+ */
329
+ function familyKeyCondition(templateKeyId: string): SQL {
330
+ return sql`(${telemetryEvents.familyTemplateKeyId} = ${templateKeyId} or (${telemetryEvents.familyTemplateKeyId} is null and exists (select 1 from ${apiKeys} where ${apiKeys.id} = ${telemetryEvents.apiKeyId} and (${apiKeys.id} = ${templateKeyId} or ${apiKeys.parentKeyId} = ${templateKeyId}))))`;
331
+ }
332
+
234
333
  /**
235
334
  * Bounded detail/list read — the only place rows are materialized.
236
335
  *
@@ -249,16 +348,19 @@ export async function queryTelemetryEvents(
249
348
  conditions.push(sql`${telemetryEvents.createdAt} < ${query.until}`);
250
349
  }
251
350
  if (query.apiKeyIds !== undefined) {
252
- conditions.push(inArray(telemetryEvents.apiKeyId, [...query.apiKeyIds]));
351
+ conditions.push(idMembershipFilter(db, telemetryEvents.apiKeyId, query.apiKeyIds, "uuid[]"));
352
+ }
353
+ if (query.familyApiKeyId !== undefined) {
354
+ conditions.push(familyKeyCondition(query.familyApiKeyId));
253
355
  }
254
356
  if (query.accountIds !== undefined) {
255
- conditions.push(inArray(telemetryEvents.accountId, [...query.accountIds]));
357
+ conditions.push(idMembershipFilter(db, telemetryEvents.accountId, query.accountIds, "uuid[]"));
256
358
  }
257
359
  if (query.requestId !== undefined) {
258
360
  conditions.push(eq(telemetryEvents.requestId, query.requestId));
259
361
  }
260
362
  if (query.requestIds !== undefined) {
261
- conditions.push(inArray(telemetryEvents.requestId, [...query.requestIds]));
363
+ conditions.push(idMembershipFilter(db, telemetryEvents.requestId, query.requestIds, "text[]"));
262
364
  }
263
365
  if (query.id !== undefined) {
264
366
  conditions.push(eq(telemetryEvents.id, query.id));
@@ -414,23 +516,39 @@ async function latencyPercentiles(
414
516
  }
415
517
 
416
518
  // SQLite: nearest-rank percentile = the value at ceil(p * n) rows into the
417
- // ordered latency column, via ORDER BY + OFFSET/LIMIT in SQL.
418
- const rankAt = async (fraction: number): Promise<number | null> => {
419
- const offset = Math.max(0, Math.ceil(fraction * sampled) - 1);
420
- const [row] = await db
421
- .select({ value: telemetryEvents.latencyMs })
422
- .from(telemetryEvents)
423
- .where(and(...base))
424
- .orderBy(telemetryEvents.latencyMs)
425
- .limit(1)
426
- .offset(offset);
427
- return row?.value ?? null;
519
+ // ordered latency column. One query computes all three ranks via a window
520
+ // function, instead of three separate OFFSET/LIMIT passes over the same
521
+ // ordered column (one per percentile).
522
+ const n = Math.max(1, sampled);
523
+ const ranks = [
524
+ Math.ceil(0.5 * n),
525
+ Math.ceil(0.95 * n),
526
+ Math.ceil(0.99 * n),
527
+ ];
528
+ const [row] = await db
529
+ .select({
530
+ p50: sql<number | null>`max(case when rn = ${ranks[0]} then value end)`,
531
+ p95: sql<number | null>`max(case when rn = ${ranks[1]} then value end)`,
532
+ p99: sql<number | null>`max(case when rn = ${ranks[2]} then value end)`,
533
+ })
534
+ .from(
535
+ sql`(
536
+ select ${telemetryEvents.latencyMs} as value,
537
+ row_number() over (order by ${telemetryEvents.latencyMs}) as rn
538
+ from ${telemetryEvents}
539
+ where ${and(...base)}
540
+ )`,
541
+ );
542
+ return {
543
+ p50: numericNullable(row?.p50 ?? null),
544
+ p95: numericNullable(row?.p95 ?? null),
545
+ p99: numericNullable(row?.p99 ?? null),
428
546
  };
429
- const [p50, p95, p99] = await Promise.all([rankAt(0.5), rankAt(0.95), rankAt(0.99)]);
430
- return { p50, p95, p99 };
431
547
  }
432
548
 
433
- /** The SQL column one breakdown dimension groups by. */
549
+ /**
550
+ * The SQL column one breakdown dimension groups by.
551
+ */
434
552
  function dimensionColumn(dimension: string) {
435
553
  if (dimension === "model") return telemetryEvents.requestedModel;
436
554
  if (dimension === "provider") return telemetryEvents.providerId;
@@ -638,14 +756,23 @@ export async function healthTelemetry(
638
756
  export async function sumTelemetryTokens(
639
757
  db: CartethyiaDatabase,
640
758
  tenantId: string,
641
- filters: { readonly apiKeyIds?: readonly string[]; readonly since: Date; readonly until?: Date },
759
+ filters: {
760
+ readonly apiKeyIds?: readonly string[];
761
+ /** The share-template family filter; see {@link EventListQuery.familyApiKeyId}. */
762
+ readonly familyKeyId?: string;
763
+ readonly since: Date;
764
+ readonly until?: Date;
765
+ },
642
766
  ): Promise<number> {
643
767
  const conditions = [scoped(tenantId, filters.since)];
644
768
  if (filters.until !== undefined) {
645
769
  conditions.push(sql`${telemetryEvents.createdAt} < ${filters.until}`);
646
770
  }
647
771
  if (filters.apiKeyIds !== undefined && filters.apiKeyIds.length > 0) {
648
- conditions.push(inArray(telemetryEvents.apiKeyId, [...filters.apiKeyIds]));
772
+ conditions.push(idMembershipFilter(db, telemetryEvents.apiKeyId, filters.apiKeyIds, "uuid[]"));
773
+ }
774
+ if (filters.familyKeyId !== undefined) {
775
+ conditions.push(familyKeyCondition(filters.familyKeyId));
649
776
  }
650
777
  const [row] = await db
651
778
  .select({
@@ -656,6 +783,196 @@ export async function sumTelemetryTokens(
656
783
  return numeric(row?.total ?? 0);
657
784
  }
658
785
 
786
+ /** One hour, in milliseconds — the share page's hourly bucket width. */
787
+ const HOUR_MS = 3_600_000;
788
+
789
+ /**
790
+ * All-time family counts from the durable rollup, not from a scan of the
791
+ * retained telemetry window.
792
+ *
793
+ * The share KPI cards need lifetime numbers. `telemetry_usage_totals` is
794
+ * charged at write time and accumulates per identity, so a family's lifetime
795
+ * requests/errors/tokens are a SUM over its rows (keyed by
796
+ * `family_template_key_id`) — sub-millisecond on a small table, versus a
797
+ * full-window aggregate over `telemetry_events` that reads every retained
798
+ * event on every tick. The rollup is the same definition as the read-side
799
+ * counts, so the two cannot drift.
800
+ */
801
+ export interface FamilyKpiTotals {
802
+ readonly requests: number;
803
+ readonly errors: number;
804
+ readonly tokens: number;
805
+ }
806
+
807
+ /** Sums one share family's durable lifetime totals. */
808
+ export async function familyKpiTotals(
809
+ db: CartethyiaDatabase,
810
+ tenantId: string,
811
+ familyKeyId: string,
812
+ ): Promise<FamilyKpiTotals> {
813
+ const [row] = await db
814
+ .select({
815
+ requests: sql<number>`coalesce(sum(${telemetryUsageTotals.requests}), 0)`,
816
+ errors: sql<number>`coalesce(sum(${telemetryUsageTotals.errors}), 0)`,
817
+ tokens: sql<number>`coalesce(sum(coalesce(${telemetryUsageTotals.inputTokens}, 0) + coalesce(${telemetryUsageTotals.outputTokens}, 0)), 0)`,
818
+ })
819
+ .from(telemetryUsageTotals)
820
+ .where(
821
+ and(
822
+ eq(telemetryUsageTotals.tenantId, tenantId),
823
+ eq(telemetryUsageTotals.familyTemplateKeyId, familyKeyId),
824
+ ),
825
+ );
826
+ return {
827
+ requests: numeric(row?.requests ?? 0),
828
+ errors: numeric(row?.errors ?? 0),
829
+ tokens: numeric(row?.tokens ?? 0),
830
+ };
831
+ }
832
+
833
+ /**
834
+ * All-time family counts: the numbers a share page's KPI card shows.
835
+ *
836
+ * These must be computed in SQL, not derived from the bounded page read — the
837
+ * page caps at {@link MAX_LIMIT}, so a busy family would freeze at 500
838
+ * requests/errors/last-hour regardless of real traffic.
839
+ */
840
+ export interface FamilyUsageTotals {
841
+ readonly requests: number;
842
+ readonly errors: number;
843
+ readonly lastHourRequests: number;
844
+ }
845
+
846
+ /** Counts one share family's requests, errors, and last-hour traffic in SQL. */
847
+ export async function familyUsageTotals(
848
+ db: CartethyiaDatabase,
849
+ tenantId: string,
850
+ familyKeyId: string,
851
+ now: Date,
852
+ ): Promise<FamilyUsageTotals> {
853
+ const lastHourStart = new Date(now.getTime() - HOUR_MS);
854
+ const conditions = [eq(telemetryEvents.tenantId, tenantId), familyKeyCondition(familyKeyId)];
855
+ const [row] = await db
856
+ .select({
857
+ requests: sql<number>`count(*)`,
858
+ errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
859
+ lastHourRequests: sql<number>`sum(case when ${telemetryEvents.createdAt} >= ${lastHourStart} then 1 else 0 end)`,
860
+ })
861
+ .from(telemetryEvents)
862
+ .where(and(...conditions));
863
+ return {
864
+ requests: numeric(row?.requests ?? 0),
865
+ errors: numeric(row?.errors ?? 0),
866
+ lastHourRequests: numeric(row?.lastHourRequests ?? 0),
867
+ };
868
+ }
869
+
870
+ /** Counts one share family's requests in a bounded window — a range scan, not a full-window aggregate. */
871
+ export async function familyWindowRequests(
872
+ db: CartethyiaDatabase,
873
+ tenantId: string,
874
+ familyKeyId: string,
875
+ since: Date,
876
+ ): Promise<number> {
877
+ const [row] = await db
878
+ .select({
879
+ requests: sql<number>`count(*)`,
880
+ })
881
+ .from(telemetryEvents)
882
+ .where(
883
+ and(
884
+ eq(telemetryEvents.tenantId, tenantId),
885
+ familyKeyCondition(familyKeyId),
886
+ gte(telemetryEvents.createdAt, since),
887
+ ),
888
+ );
889
+ return numeric(row?.requests ?? 0);
890
+ }
891
+
892
+ /**
893
+ * One share family's client-IP breakdown, computed in SQL.
894
+ *
895
+ * Unlike the 24-hour page read, this covers a bounded history so an address
896
+ * that stopped before the last day still appears — marked inactive. The
897
+ * `active` flag is decided in the caller from `lastSeenAt` vs a cutoff; here
898
+ * we only aggregate. The window is bounded (`since`, default the whole
899
+ * retained window) so an unbounded scan is never the default for callers
900
+ * that only need recent addresses.
901
+ */
902
+ export async function familyIpBreakdown(
903
+ db: CartethyiaDatabase,
904
+ tenantId: string,
905
+ familyKeyId: string,
906
+ limit = 50,
907
+ since?: Date,
908
+ ): Promise<{ ip: string; requests: number; tokens: number; lastSeenAt: Date | null }[]> {
909
+ const conditions = [
910
+ eq(telemetryEvents.tenantId, tenantId),
911
+ familyKeyCondition(familyKeyId),
912
+ ...(since === undefined ? [] : [gte(telemetryEvents.createdAt, since)]),
913
+ ];
914
+ const rows = await db
915
+ .select({
916
+ ip: telemetryEvents.clientIp,
917
+ requests: sql<number>`count(*)`,
918
+ tokens: sql<number>`coalesce(sum(coalesce(${telemetryEvents.inputTokens}, 0) + coalesce(${telemetryEvents.outputTokens}, 0)), 0)`,
919
+ lastSeenAt: sql<Date>`max(${telemetryEvents.createdAt})`,
920
+ })
921
+ .from(telemetryEvents)
922
+ .where(and(...conditions))
923
+ .groupBy(telemetryEvents.clientIp)
924
+ // Highest volume first: the table answers "who has used the link most",
925
+ // not just who is active right now.
926
+ .orderBy(desc(sql`count(*)`))
927
+ .limit(limit);
928
+ return rows.flatMap((row) =>
929
+ row.ip === null ? [] : [{ ip: row.ip, requests: numeric(row.requests), tokens: numeric(row.tokens), lastSeenAt: row.lastSeenAt }],
930
+ );
931
+ }
932
+
933
+ /**
934
+ * One share family's time-series buckets, computed in SQL with the family
935
+ * membership filter.
936
+ *
937
+ * Mirrors {@link chartTelemetry} but scoped to a share family. The share page's
938
+ * 24-hour strip must not be truncated to the bounded page (500 events), or a
939
+ * busy family's chart undercounts every hour bucket.
940
+ */
941
+ export async function familyHourlyTelemetry(
942
+ db: CartethyiaDatabase,
943
+ tenantId: string,
944
+ familyKeyId: string,
945
+ since: Date,
946
+ bucketSeconds: number,
947
+ ): Promise<ChartBucket[]> {
948
+ const width = Math.max(1, Math.trunc(bucketSeconds));
949
+ // Inlined (not parameterised) so Postgres sees the SELECT and GROUP BY
950
+ // expressions as identical — see chartTelemetry for the failure this avoids.
951
+ const w = sql.raw(String(width));
952
+ const bucketExpression = isPostgres(db)
953
+ ? sql<number>`(cast(extract(epoch from ${telemetryEvents.createdAt}) as bigint) / ${w}) * ${w}`
954
+ : sql<number>`(cast(strftime('%s', ${telemetryEvents.createdAt}) as integer) / ${w}) * ${w}`;
955
+ const conditions = [scoped(tenantId, since), familyKeyCondition(familyKeyId)];
956
+ const rows = await db
957
+ .select({
958
+ at: bucketExpression,
959
+ requests: sql<number>`count(*)`,
960
+ errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
961
+ inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
962
+ outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
963
+ })
964
+ .from(telemetryEvents)
965
+ .where(and(...conditions))
966
+ .groupBy(bucketExpression);
967
+ return rows.map((row) => ({
968
+ at: numeric(row.at) * 1000,
969
+ requests: numeric(row.requests),
970
+ errors: numeric(row.errors),
971
+ inputTokens: numeric(row.inputTokens),
972
+ outputTokens: numeric(row.outputTokens),
973
+ }));
974
+ }
975
+
659
976
  /**
660
977
  * The health card's latency and throughput figures for one window.
661
978
  *
@@ -750,3 +1067,101 @@ export async function summarizeUsagePerformanceSql(
750
1067
  avgTokensPerSec: numeric(row?.avgTokensPerSec ?? 0),
751
1068
  };
752
1069
  }
1070
+
1071
+ /**
1072
+ * Combined usage totals + performance in ONE window scan.
1073
+ *
1074
+ * `summarizeTelemetry` and `summarizeUsagePerformanceSql` both scan the same
1075
+ * tenant window and each additionally run the percentile path, so the Usage
1076
+ * summary endpoint paid two scans plus two percentile passes for one response.
1077
+ * This runs the combined SELECT once and one percentile pass, returning both
1078
+ * shapes so the caller needs no second query.
1079
+ */
1080
+ export async function summarizeUsageWithPerformance(
1081
+ db: CartethyiaDatabase,
1082
+ tenantId: string,
1083
+ since: Date,
1084
+ ): Promise<{ summary: UsageSummary; performance: UsagePerformanceTotals }> {
1085
+ const condition = scoped(tenantId, since);
1086
+ const [row] = await db
1087
+ .select({
1088
+ requests: sql<number>`count(*)`,
1089
+ errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
1090
+ cancelled: sql<number>`sum(case when ${telemetryEvents.status} = 'cancelled' then 1 else 0 end)`,
1091
+ truncated: sql<number>`sum(case when ${telemetryEvents.status} = 'truncated' then 1 else 0 end)`,
1092
+ inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
1093
+ outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
1094
+ cachedInputTokens: sql<number>`coalesce(sum(${telemetryEvents.cachedInputTokens}), 0)`,
1095
+ reasoningTokens: sql<number>`coalesce(sum(${telemetryEvents.reasoningTokens}), 0)`,
1096
+ estimatedCostUsd: sql<number>`coalesce(sum(${telemetryEvents.estimatedCostUsd}), 0)`,
1097
+ sampled: sql<number>`count(${telemetryEvents.latencyMs})`,
1098
+ latencyAvg: sql<number>`avg(${telemetryEvents.latencyMs})`,
1099
+ unpriced: sql<number>`sum(case when ${telemetryEvents.estimatedCostUsd} is null then 1 else 0 end)`,
1100
+ resolveSum: sql<number>`coalesce(sum(${telemetryEvents.resolveMs}), 0)`,
1101
+ resolveCount: sql<number>`count(${telemetryEvents.resolveMs})`,
1102
+ avgTokensPerSec: sql<number>`coalesce(avg(${telemetryEvents.tokensPerSec}), 0)`,
1103
+ })
1104
+ .from(telemetryEvents)
1105
+ .where(condition);
1106
+
1107
+ if (!row) {
1108
+ const empty: UsageSummary = {
1109
+ requests: 0,
1110
+ errors: 0,
1111
+ cancelled: 0,
1112
+ truncated: 0,
1113
+ inputTokens: 0,
1114
+ outputTokens: 0,
1115
+ cachedInputTokens: 0,
1116
+ reasoningTokens: 0,
1117
+ estimatedCostUsd: 0,
1118
+ sampled: 0,
1119
+ latencyMs: { avg: null, p50: null, p95: null, p99: null },
1120
+ unpriced: 0,
1121
+ };
1122
+ const emptyPerformance: UsagePerformanceTotals = {
1123
+ avgResolveMs: 0,
1124
+ avgDurationMs: 0,
1125
+ latencyP50Ms: 0,
1126
+ latencyP95Ms: 0,
1127
+ latencyP99Ms: 0,
1128
+ latencySampleCount: 0,
1129
+ resolveSampleCount: 0,
1130
+ avgTokensPerSec: 0,
1131
+ };
1132
+ return { summary: empty, performance: emptyPerformance };
1133
+ }
1134
+
1135
+ const sampled = numeric(row.sampled);
1136
+ const percentiles = await latencyPercentiles(db, tenantId, since, sampled);
1137
+ const resolveCount = numeric(row.resolveCount);
1138
+ return {
1139
+ summary: {
1140
+ requests: numeric(row.requests),
1141
+ errors: numeric(row.errors),
1142
+ cancelled: numeric(row.cancelled),
1143
+ truncated: numeric(row.truncated),
1144
+ inputTokens: numeric(row.inputTokens),
1145
+ outputTokens: numeric(row.outputTokens),
1146
+ cachedInputTokens: numeric(row.cachedInputTokens),
1147
+ reasoningTokens: numeric(row.reasoningTokens),
1148
+ estimatedCostUsd: numeric(row.estimatedCostUsd),
1149
+ sampled,
1150
+ latencyMs: {
1151
+ avg: numericNullable(row.latencyAvg),
1152
+ ...percentiles,
1153
+ },
1154
+ unpriced: numeric(row.unpriced),
1155
+ },
1156
+ performance: {
1157
+ avgResolveMs: resolveCount > 0 ? numeric(row.resolveSum) / resolveCount : 0,
1158
+ avgDurationMs: numericNullable(row.latencyAvg) ?? 0,
1159
+ latencyP50Ms: percentiles.p50 ?? 0,
1160
+ latencyP95Ms: percentiles.p95 ?? 0,
1161
+ latencyP99Ms: percentiles.p99 ?? 0,
1162
+ latencySampleCount: sampled,
1163
+ resolveSampleCount: resolveCount,
1164
+ avgTokensPerSec: numeric(row.avgTokensPerSec),
1165
+ },
1166
+ };
1167
+ }
@@ -30,7 +30,7 @@
30
30
  * rule as well, so it needs no special case.
31
31
  * - **`503`** is capacity or availability: the gateway *correctly* refusing work
32
32
  * it cannot do right now (`admission_unavailable`, `accounts_unavailable`,
33
- * `shutting_down`, `restart_for_update`, `platform_unavailable`). Retryable,
33
+ * `shutting_down` (incl. update restart), `platform_unavailable`). Retryable,
34
34
  * self-inflicted, and an operator-capacity signal rather than a defect.
35
35
  * - **`504`** is a timeout, not a malfunction. It represents a deadline that
36
36
  * expired: the request deadline, the stream-stall
@@ -108,6 +108,44 @@ export function poolMaxFromEnv(): number {
108
108
  return value;
109
109
  }
110
110
 
111
+ /** Target number of idle connections the pool shrinks to (default 2). */
112
+ export function poolIdleFloorFromEnv(): number {
113
+ const raw = process.env.DATABASE_POOL_IDLE_FLOOR ?? "2";
114
+ const value = Number(raw);
115
+ if (!Number.isInteger(value) || value < 0) {
116
+ throw new Error(`DATABASE_POOL_IDLE_FLOOR must be a non-negative integer (got "${raw}")`);
117
+ }
118
+ return value;
119
+ }
120
+
121
+ /**
122
+ * Periodically closes idle clients above the floor so a low-traffic process
123
+ * does not hold `DATABASE_POOL_MAX` backends open for nothing. node-postgres
124
+ * only closes an idle client after `idleTimeoutMillis` (60s here); this gives
125
+ * the pool a faster, explicit shrink path while still keeping the floor warm.
126
+ */
127
+ export function startPoolShrinker(pool: Pool, floor: number): () => void {
128
+ const interval = setInterval(() => {
129
+ try {
130
+ const idle = pool.idleCount;
131
+ const excess = idle - floor;
132
+ if (excess <= 0) return;
133
+ // Close the excess idle clients in order; `release(true)` destroys the
134
+ // client instead of returning it to the pool.
135
+ for (let index = 0; index < excess; index += 1) {
136
+ pool
137
+ .connect()
138
+ .then((client) => client.release(true))
139
+ .catch(() => undefined);
140
+ }
141
+ } catch {
142
+ // The pool is best-effort; a failed shrink must never crash a request.
143
+ }
144
+ }, 30_000);
145
+ interval.unref();
146
+ return () => clearInterval(interval);
147
+ }
148
+
111
149
  declare global {
112
150
  // eslint-disable-next-line no-var -- globalThis augmentation requires `var`
113
151
  var __cartethyiaPool: Pool | undefined;
@@ -147,6 +185,10 @@ export function getPool(): Pool {
147
185
  pool.on("error", (err) => {
148
186
  log.error("[postgres] pool error (idle client)", err);
149
187
  });
188
+ // Idle-floor shrinker: keeps the pool warm at the floor instead of parking
189
+ // a full `max` worth of backends. Tied to the pool's lifetime, so a hot
190
+ // reload that replaces the pool re-registers it.
191
+ startPoolShrinker(pool, poolIdleFloorFromEnv());
150
192
  globalThis.__cartethyiaPool = pool;
151
193
  return pool;
152
194
  }
@@ -327,7 +327,8 @@ const AUTH_KEY_COLUMNS = [
327
327
  "issued_client_ip_key", "label", "scopes", "requests_per_minute", "daily_token_limit",
328
328
  "monthly_token_limit", "lifetime_token_budget", "lifetime_tokens_consumed",
329
329
  "max_concurrent_requests", "model_access_mode", "model_list", "client_router_denylist",
330
- "model_prefix", "created_at", "enabled", "revoked_at", "key_prefix", "key_encrypted",
330
+ "model_prefix", "created_at", "enabled", "revoked_at", "banned_at", "ban_reason", "banned_until",
331
+ "key_prefix", "key_encrypted",
331
332
  "notes_title", "notes_subtitle", "notes_body", "is_default", "sort_index",
332
333
  ].join(", ");
333
334
 
@@ -341,7 +342,11 @@ export interface SqliteAuthStatements {
341
342
  }
342
343
 
343
344
  export function prepareAuthStatements(sqlite: Database): SqliteAuthStatements {
344
- const active = `enabled = 1 AND revoked_at IS NULL`;
345
+ // A key is active while not revoked and not under an active ban: a ban is
346
+ // active when `banned_at` is set and `banned_until` is null (permanent) or
347
+ // still in the future. SQLite stores timestamps as ISO text, so the
348
+ // comparison is lexical (ISO sorts chronologically).
349
+ const active = `enabled = 1 AND revoked_at IS NULL AND (banned_at IS NULL OR (banned_until IS NOT NULL AND banned_until <= datetime('now')))`;
345
350
  // `query`, not `prepare`: bun's `prepare` compiles a statement per call and
346
351
  // never finalizes one, while `query` caches the compiled statement behind an
347
352
  // LRU of its own. All three are constant text run once per authenticated