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
@@ -151,7 +151,7 @@ async function deployCloudflare(
151
151
  request: RelayDeployRequest,
152
152
  ): Promise<RelayDeployResult> {
153
153
  const accountId = request.accountId?.trim();
154
- if (!accountId) throw new GatewayError("invalid_request", 400, "Cloudflare account id is required");
154
+ if (!accountId) throw new GatewayError("invalid_request", 400, "Cloudflare account id is required (request {requestId}). Check the shape of what you sent — then resend 🪞");
155
155
  const projectName = request.projectName?.trim() || generateName("relay");
156
156
  const scriptUrl = `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(projectName)}`;
157
157
  const form = new FormData();
@@ -174,7 +174,7 @@ async function deployCloudflare(
174
174
  body: form,
175
175
  });
176
176
  if (!upload.ok)
177
- throw new GatewayError("platform_unavailable", upload.status, `Cloudflare deploy failed: ${await readError(upload)}`);
177
+ throw new GatewayError("platform_unavailable", upload.status, `Cloudflare deploy failed (request {requestId}): ${await readError(upload)}. Flip the switch back on when ready 🔌`);
178
178
  // Enable the workers.dev route; a failure here is non-fatal because the
179
179
  // subdomain lookup below decides the final URL and reports its own error.
180
180
  await fetchImpl(`${scriptUrl}/subdomain`, {
@@ -190,7 +190,7 @@ async function deployCloudflare(
190
190
  throw new GatewayError(
191
191
  "platform_unavailable",
192
192
  subdomainRes.status,
193
- `Worker uploaded but its workers.dev subdomain could not be read: ${await readError(subdomainRes)}`,
193
+ `Worker uploaded but its workers.dev subdomain could not be read (request {requestId}): ${await readError(subdomainRes)}. Flip the switch back on when ready 🔌`,
194
194
  );
195
195
  const subdomainData = (await subdomainRes.json().catch(() => ({}))) as {
196
196
  result?: { subdomain?: string };
@@ -200,7 +200,7 @@ async function deployCloudflare(
200
200
  throw new GatewayError(
201
201
  "platform_unavailable",
202
202
  502,
203
- "Worker uploaded but no workers.dev subdomain is configured for this Cloudflare account.",
203
+ "Worker uploaded but no workers.dev subdomain is configured for this Cloudflare account (request {requestId}). Flip the switch back on when ready 🔌",
204
204
  );
205
205
  return { target: "cloudflare", projectName, relayUrl: `https://${projectName}.${subdomain}.workers.dev` };
206
206
  }
@@ -251,7 +251,7 @@ async function deployVercel(
251
251
  }),
252
252
  });
253
253
  if (!deployRes.ok)
254
- throw new GatewayError("platform_unavailable", deployRes.status, `Vercel deploy failed: ${await readError(deployRes)}`);
254
+ throw new GatewayError("platform_unavailable", deployRes.status, `Vercel deploy failed (request {requestId}): ${await readError(deployRes)}. Flip the switch back on when ready 🔌`);
255
255
  // Pin the region again after the deploy created the project: the PATCH above
256
256
  // is a no-op on first deploy (the project does not exist yet), and a redeploy
257
257
  // is what Vercel requires for a region change to take effect anyway. The SSO
@@ -280,7 +280,7 @@ async function deployVercel(
280
280
  }).catch(() => undefined);
281
281
  const deployment = (await deployRes.json().catch(() => ({}))) as { url?: string };
282
282
  if (!deployment.url)
283
- throw new GatewayError("platform_unavailable", 502, "Vercel deploy returned no URL");
283
+ throw new GatewayError("platform_unavailable", 502, "Vercel deploy returned no URL (request {requestId}). Flip the switch back on when ready 🔌");
284
284
  return {
285
285
  target: "vercel",
286
286
  projectName,
@@ -300,11 +300,11 @@ async function deployDeno(
300
300
  body: JSON.stringify({ project: projectName, entryPoint: "main.ts", files: { "main.ts": RELAY_DENO_SOURCE } }),
301
301
  });
302
302
  if (!deployRes.ok)
303
- throw new GatewayError("platform_unavailable", deployRes.status, `Deno deploy failed: ${await readError(deployRes)}`);
303
+ throw new GatewayError("platform_unavailable", deployRes.status, `Deno deploy failed (request {requestId}): ${await readError(deployRes)}. Flip the switch back on when ready 🔌`);
304
304
  const deployment = (await deployRes.json().catch(() => ({}))) as { url?: string; domains?: string[] };
305
305
  const url = deployment.url ?? deployment.domains?.[0];
306
306
  if (!url)
307
- throw new GatewayError("platform_unavailable", 502, "Deno deploy returned no URL");
307
+ throw new GatewayError("platform_unavailable", 502, "Deno deploy returned no URL (request {requestId}). Flip the switch back on when ready 🔌");
308
308
  return {
309
309
  target: "deno",
310
310
  projectName,
@@ -333,6 +333,6 @@ export async function deployRelay(
333
333
  request: RelayDeployRequest,
334
334
  ): Promise<RelayDeployResult> {
335
335
  if (!request.token.trim())
336
- throw new GatewayError("invalid_request", 400, `${request.target} API token is required`);
336
+ throw new GatewayError("invalid_request", 400, `${request.target} API token is required (request {requestId}). Wrong key, right door — check the credential 🔑`);
337
337
  return DEPLOYERS[request.target](fetchImpl, request);
338
338
  }
@@ -1,5 +1,5 @@
1
1
  // Network-pool control-plane HTTP routes. The only module that touches Elysia.
2
- import { Elysia, t } from "elysia";
2
+ import { Elysia, t, type AnyElysia } from "elysia";
3
3
  import { consoleErrorHandler } from "../../shared/errors";
4
4
  import { literalUnion } from "../../shared/elysia-schema";
5
5
  import { RELAY_TARGETS, type RelayDeployRequest } from "./relay-deploy";
@@ -56,7 +56,7 @@ const importPoolsBody = t.Object({
56
56
  family: t.Optional(t.Union([t.String(), t.Null()])),
57
57
  labelPrefix: t.Optional(t.String()),
58
58
  });
59
- export function createNetworkPoolRoutes(config: NetworkPoolConfig): Elysia {
59
+ export function createNetworkPoolRoutes(config: NetworkPoolConfig): AnyElysia {
60
60
  const factory = createNetworkPoolOperations(config);
61
61
  return new Elysia({ prefix: "/network/pools" })
62
62
  .error(consoleErrorHandler("Network pool operation failed"))
@@ -207,5 +207,5 @@ export function createNetworkPoolRoutes(config: NetworkPoolConfig): Elysia {
207
207
  body as Partial<PoolStrategySetting>,
208
208
  );
209
209
  },
210
- ) as unknown as Elysia;
210
+ );
211
211
  }
@@ -1,7 +1,9 @@
1
1
  // Drizzle-backed console persistence for network pools.
2
+ import { randomUUID } from "node:crypto";
2
3
  import { and, asc, eq, inArray, sql } from "drizzle-orm";
3
4
  import type { CartethyiaDatabase } from "../../../persistence/db-handle";
4
- import { networkPools, poolRoutingSettings } from "../../../persistence/schema";
5
+ import { networkPools, poolRoutingSettings, providerPoolAccounts } from "../../../persistence/schema";
6
+ import { providerRateLimitIsIpScoped } from "../../../providers/provider-metadata";
5
7
  import {
6
8
  DEFAULT_POOL_STRATEGY,
7
9
  type CreateNetworkPoolRequest,
@@ -444,6 +446,24 @@ export class DrizzleNetworkPoolStore implements NetworkPoolStore {
444
446
  ...(record.family !== undefined ? { family: record.family } : {}),
445
447
  }));
446
448
  await this.db.insert(networkPools).values(values);
449
+
450
+ // Materialize pool-as-account rows for ip-scoped, credential-less
451
+ // providers (OpenCode Free today): the operator sees the egress in the
452
+ // provider's Accounts card, and routing excludes a spent egress like a
453
+ // cooling account. Every other provider gets no row.
454
+ if (providerRateLimitIsIpScoped("opencode")) {
455
+ const now = new Date();
456
+ const poolAccounts = values.map((value) => ({
457
+ id: randomUUID(),
458
+ tenantId: value.tenantId,
459
+ networkPoolId: value.id,
460
+ providerId: "opencode",
461
+ label: `Pool ${value.id.slice(0, 8)}`,
462
+ createdAt: now,
463
+ updatedAt: now,
464
+ }));
465
+ await this.db.insert(providerPoolAccounts).values(poolAccounts);
466
+ }
447
467
  return values.length;
448
468
  }
449
469
 
@@ -1,4 +1,4 @@
1
- import { Elysia } from "elysia";
1
+ import { Elysia, type AnyElysia } from "elysia";
2
2
  import {
3
3
  createRuntimeSettingsOperations,
4
4
  runtimeUpdateBody,
@@ -7,7 +7,7 @@ import {
7
7
  type UpdateRuntimeSettingsRequest,
8
8
  } from "./contracts";
9
9
 
10
- export function createRuntimeSettingsRoutes(config: RuntimeSettingsConfig): Elysia {
10
+ export function createRuntimeSettingsRoutes(config: RuntimeSettingsConfig): AnyElysia {
11
11
  const factory = createRuntimeSettingsOperations(config);
12
12
  return new Elysia({ prefix: "/settings/runtime" })
13
13
  .get("/", async ({ request, set }) => {
@@ -26,5 +26,5 @@ export function createRuntimeSettingsRoutes(config: RuntimeSettingsConfig): Elys
26
26
  } catch (e) {
27
27
  return runtimeSettingsErrorResponse(e, set);
28
28
  }
29
- }) as unknown as Elysia;
29
+ });
30
30
  }
@@ -4,8 +4,8 @@ import { globalOrOwnedBy } from "../../persistence/tenant-scope";
4
4
  // Share URLs reveal policy and allow one child key to be created per canonical
5
5
  // client IP. The child bearer is returned only by the successful issue call.
6
6
 
7
- import { Elysia } from "elysia";
8
- import { and, eq } from "drizzle-orm";
7
+ import { Elysia, type AnyElysia } from "elysia";
8
+ import { and, eq, sql } from "drizzle-orm";
9
9
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
10
10
  import { apiKeys, models, providers } from "../../persistence/schema";
11
11
  import { PublicModelCatalogStore } from "../providers/catalog/public-model-store";
@@ -34,12 +34,12 @@ const MIN_TOKEN_LENGTH = 20;
34
34
  /**
35
35
  * Cadence of the share stats SSE snapshot.
36
36
  *
37
- * Raised with `SHARE_STATS_CACHE_TTL_SECONDS` (10 s). At 2 s the tick was
38
- * shorter than the rollup's own cache TTL, so every push re-parsed the whole
39
- * retained telemetry window — the interval was the thing making this read
40
- * expensive, not the number of viewers. Short enough to still read as live.
37
+ * Roughly one tenth of the cache TTL (`SHARE_STATS_CACHE_TTL_SECONDS`, 300
38
+ * s), so nearly every tick is served from the cache and the cold-compute
39
+ * path runs at most once per five minutes per family. Short enough to still
40
+ * read as live.
41
41
  */
42
- const SHARE_STATS_STREAM_INTERVAL_MS = 10_000;
42
+ const SHARE_STATS_STREAM_INTERVAL_MS = 30_000;
43
43
 
44
44
  /**
45
45
  * Context and capabilities for one allowed model, sourced from the public
@@ -89,12 +89,12 @@ function json(body: unknown, status = 200): Response {
89
89
  function notFound(refusal: ShareLinkRefusal = "not_found"): Response {
90
90
  const message =
91
91
  refusal === "expired"
92
- ? "This share link has expired"
92
+ ? "This share link has expired (request {requestId}). Time for a fresh one 🌷"
93
93
  : refusal === "revoked"
94
- ? "This share link's key has been revoked"
94
+ ? "This share link's key has been revoked (request {requestId}). Ask for a new one 📩"
95
95
  : refusal === "disabled"
96
- ? "This share link's key is paused"
97
- : "Share link is unavailable";
96
+ ? "This share link's key is paused (request {requestId}). Re-enable it when ready 🌱"
97
+ : "Share link is unavailable (request {requestId}). This link leads nowhere — check it again 🔗";
98
98
  const error = new GatewayError(`link_${refusal}`, 404, message);
99
99
  return json(publicGatewayErrorBody(error), error.status);
100
100
  }
@@ -227,12 +227,20 @@ async function resolveFamilyStats(
227
227
  if (resolved === null) return null;
228
228
  const templateId = resolved.key.id;
229
229
  const tenantId = resolved.key.tenantId;
230
- const children = await db
231
- .select({ id: apiKeys.id, revokedAt: apiKeys.revokedAt })
230
+ // Recipient counts come back as one aggregate row: a family minted by the
231
+ // tenant can carry tens of thousands of children, and materializing their
232
+ // ids only to count them made every share-page view pay for the family's
233
+ // size. The fingerprint/quote keys the rollup needs are addressed by the
234
+ // template id (`familyApiKeyId`), which scopes in SQL without any id list.
235
+ const [counts] = await db
236
+ .select({
237
+ total: sql<number>`count(*)`,
238
+ active: sql<number>`sum(case when ${apiKeys.revokedAt} is null then 1 else 0 end)`,
239
+ })
232
240
  .from(apiKeys)
233
241
  .where(eq(apiKeys.parentKeyId, templateId));
234
- const active = children.filter((child) => child.revokedAt === null).length;
235
- const keyIds = [templateId, ...children.map((child) => child.id)];
242
+ const total = Number(counts?.total ?? 0);
243
+ const active = Number(counts?.active ?? 0);
236
244
  // Rank only the models this link grants. A refused request still writes a
237
245
  // telemetry row carrying the name the client asked for, so an unfiltered
238
246
  // top-models table would list the very names the grant excludes. An
@@ -244,11 +252,16 @@ async function resolveFamilyStats(
244
252
  configured !== null && configured.length > 0
245
253
  ? await modelsForShare(db, resolved.key)
246
254
  : undefined;
247
- return stats.getFamilyStats(tenantId, keyIds, { total: children.length, active }, allowedModels);
255
+ return stats.getFamilyStats(
256
+ tenantId,
257
+ { templateKeyId: templateId, memberCount: total + 1 },
258
+ { total, active },
259
+ allowedModels,
260
+ );
248
261
  }
249
262
 
250
263
  /** Creates the public enrollment page and one-time shared-key issuance route. */
251
- export function createShareRouter(options: ShareRouterOptions): Elysia {
264
+ export function createShareRouter(options: ShareRouterOptions): AnyElysia {
252
265
  const { db, shareStore } = options;
253
266
 
254
267
  return new Elysia()
@@ -379,6 +392,26 @@ export function createShareRouter(options: ShareRouterOptions): Elysia {
379
392
  nameHint,
380
393
  });
381
394
  if (issued.kind === "link_unavailable") return notFound();
395
+ if (issued.kind === "slot_full") {
396
+ return json({
397
+ error: {
398
+ code: "template_slot_full",
399
+ message: "This template has reached its maximum number of recipients.",
400
+ },
401
+ }, 429);
402
+ }
403
+ if (issued.kind === "already_issued") {
404
+ // The recipient's IP already enrolled from this link. The original
405
+ // bearer secret is not stored server-side, so it cannot be returned;
406
+ // refuse the duplicate mint instead. The share page surfaces this as
407
+ // a clear message rather than minting an unbounded key set.
408
+ return json({
409
+ error: {
410
+ code: "already_issued",
411
+ message: "This address already has a key from this share link.",
412
+ },
413
+ }, 409);
414
+ }
382
415
  set.status = 201;
383
416
  return json({
384
417
  key: generated.secret,
@@ -453,5 +486,5 @@ export function createShareRouter(options: ShareRouterOptions): Elysia {
453
486
  };
454
487
  }),
455
488
  );
456
- }) as unknown as Elysia;
489
+ })
457
490
  }
@@ -11,10 +11,19 @@
11
11
  // masked addresses. No request or response bodies cross this boundary.
12
12
 
13
13
  import { createHash } from "node:crypto";
14
+ import { eq } from "drizzle-orm";
14
15
  import { maskClientIp } from "../../observability/redaction";
15
16
  import { type TelemetryEventRecord } from "../../observability/event-log";
16
- import { queryTelemetryEvents as querySqlEvents } from "../../observability/event-query";
17
+ import {
18
+ queryTelemetryEvents as querySqlEvents,
19
+ sumTelemetryTokens,
20
+ familyKpiTotals,
21
+ familyWindowRequests,
22
+ familyHourlyTelemetry,
23
+ familyIpBreakdown,
24
+ } from "../../observability/event-query";
17
25
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
26
+ import { apiKeys } from "../../persistence/schema";
18
27
  import { isGatewayError } from "../../observability/telemetry-status";
19
28
  import type { RedisClient } from "../../persistence/redis";
20
29
  import { cachedRead } from "../shared/read-through-cache";
@@ -67,6 +76,12 @@ export interface ShareTopClientIp {
67
76
  * the tool rather than the machine.
68
77
  */
69
78
  readonly clientType: string | null;
79
+ /**
80
+ * True when this address made a request within {@link ACTIVE_IP_WINDOW_MS}.
81
+ * A recipient watching the link can tell which addresses are still using it
82
+ * versus ones that stopped days ago.
83
+ */
84
+ readonly active: boolean;
70
85
  }
71
86
 
72
87
  export interface ShareFamilyStats {
@@ -81,6 +96,14 @@ export interface ShareFamilyStats {
81
96
  /** Read-only family rollup for one share link. */
82
97
  export interface ShareStatsPort {
83
98
  /**
99
+ * The family is addressed by its template key (`templateKeyId`) plus the
100
+ * number of keys it covers at call time (`memberCount`), not by an id list:
101
+ * a family grows with every key the link mints, and enumerating its ids to
102
+ * build a query — or a cache key — scaled both with the family instead of
103
+ * with the request. `memberCount` keeps the cache key sensitive to minting:
104
+ * a newly issued child starts a fresh entry rather than being folded into
105
+ * the previous one for a full TTL.
106
+ *
84
107
  * `allowedModels`, when supplied, restricts the top-models table to the names
85
108
  * the link actually grants. A rejected request still writes a telemetry row
86
109
  * (with the requested name), so without this the table would rank models the
@@ -90,7 +113,7 @@ export interface ShareStatsPort {
90
113
  */
91
114
  getFamilyStats(
92
115
  tenantId: string,
93
- keyIds: readonly string[],
116
+ family: { readonly templateKeyId: string; readonly memberCount: number },
94
117
  recipients: { readonly total: number; readonly active: number },
95
118
  allowedModels?: readonly string[],
96
119
  ): Promise<ShareFamilyStats>;
@@ -98,6 +121,24 @@ export interface ShareStatsPort {
98
121
 
99
122
  const HOUR_MS = 3_600_000;
100
123
 
124
+ /**
125
+ * How old an address's last request may be and still count as active on the
126
+ * share page. 24 hours matches the page's own "last 24 hours" framing: an IP
127
+ * that made a request since yesterday is "active", one that stopped before
128
+ * that window is "inactive".
129
+ */
130
+ const ACTIVE_IP_WINDOW_MS = 24 * HOUR_MS;
131
+
132
+ /**
133
+ * Recency window for the client-IP ranking's *work set*: the GROUP BY only
134
+ * scans this slice of telemetry instead of the whole retained window every
135
+ * tick. Within the window, addresses are ranked by request volume (highest
136
+ * first), and older addresses fall out of the ranking entirely. 7 days keeps
137
+ * the ranking useful while staying orders of magnitude smaller than the
138
+ * retained window.
139
+ */
140
+ const IP_WINDOW_MS = 7 * 24 * HOUR_MS;
141
+
101
142
  /**
102
143
  * Names the client behind a request from its User-Agent.
103
144
  *
@@ -141,7 +182,7 @@ interface FamilyRollup {
141
182
  totals: ShareFamilyTotals;
142
183
  byHour: Map<string, { requests: number; tokens: number }>;
143
184
  models: Map<string, { modelId: string; requests: number; tokens: number; rateSum: number; rateCount: number; ttfbSum: number; ttfbCount: number }>;
144
- ips: Map<string, { ip: string; requests: number; tokens: number; lastSeenAt: string | null; userAgent: string | null }>;
185
+ ips: Map<string, { ip: string; requests: number; tokens: number; lastSeenAt: string | null; userAgent: string | null; active: boolean }>;
145
186
  }
146
187
 
147
188
  /**
@@ -150,7 +191,7 @@ interface FamilyRollup {
150
191
  */
151
192
  function rollupFamily(
152
193
  events: readonly TelemetryEventRecord[],
153
- bounds: { dayStart: number; monthStart: number; lastHourStart: number; windowStart: number; allowed: ReadonlySet<string> | null },
194
+ bounds: { dayStart: number; monthStart: number; lastHourStart: number; windowStart: number; activeIpCutoff: number; allowed: ReadonlySet<string> | null },
154
195
  ): FamilyRollup {
155
196
  const totals = { requests: 0, errors: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0, lastHourRequests: 0, todayTokens: 0, monthTokens: 0 };
156
197
  const byHour = new Map<string, { requests: number; tokens: number }>();
@@ -183,13 +224,14 @@ function rollupFamily(
183
224
  models.set(event.requestedModel, model);
184
225
  }
185
226
  if (event.clientIp !== null) {
186
- const ip = ips.get(event.clientIp) ?? { ip: event.clientIp, requests: 0, tokens: 0, lastSeenAt: null, userAgent: null };
227
+ const ip = ips.get(event.clientIp) ?? { ip: event.clientIp, requests: 0, tokens: 0, lastSeenAt: null, userAgent: null, active: false };
187
228
  ip.requests += 1;
188
229
  ip.tokens += tokens;
189
230
  // Events arrive newest first, so the first one seen is the most recent.
190
231
  if (ip.lastSeenAt === null) {
191
232
  ip.lastSeenAt = event.createdAt;
192
233
  ip.userAgent = event.userAgent;
234
+ ip.active = at >= bounds.activeIpCutoff;
193
235
  }
194
236
  ips.set(event.clientIp, ip);
195
237
  }
@@ -231,32 +273,35 @@ const HOURS_WINDOW = 24;
231
273
  /**
232
274
  * Cache lifetime for one family's rollup.
233
275
  *
234
- * Matched to the share page's SSE tick, which was the point of the old 2 s
235
- * value — but at 2 s the cache expired on every tick, so every single push was
236
- * a full parse of the retained telemetry window. A share page's numbers move
237
- * when a request does, not every 2 s; 10 s still reads as live, and cuts the
238
- * parse rate by five.
276
+ * Longer than the SSE tick (30s) so most ticks are served from cache. The
277
+ * cold-compute path reads the family's telemetry windows (24h chart, 7d IP
278
+ * ranking), which on a busy family is the single most expensive thing the
279
+ * share page does; a 5-minute TTL caps that to once per family per 5
280
+ * minutes while the page still reads as live — KPI numbers move with
281
+ * traffic, not on a per-minute cadence.
239
282
  *
240
283
  * Declared here rather than imported from the router so this port does not
241
- * depend on it. `SHARE_STATS_STREAM_INTERVAL_MS` must be raised with it, or
242
- * the two drift apart and the cache expires between ticks for no reason.
284
+ * depend on it. `SHARE_STATS_STREAM_INTERVAL_MS` must stay below it, or the
285
+ * tick and cache drift apart and every tick bypasses the cache.
243
286
  */
244
- export const SHARE_STATS_CACHE_TTL_SECONDS = 10;
287
+ export const SHARE_STATS_CACHE_TTL_SECONDS = 300;
245
288
 
246
289
  /**
247
- * Cache key for one family's rollup. Both inputs change the payload — `keyIds`
248
- * selects the family and `allowedModels` filters the model table — so both are
249
- * hashed in; the key ids are sorted so key order cannot split one family across
250
- * two entries.
290
+ * Cache key for one family's rollup. The family is named by its template key
291
+ * and the number of keys it covers — both change the payload — and
292
+ * `allowedModels` filters the model table, so all three are hashed in.
293
+ * (The key used to hash the sorted list of every family id; on a family with
294
+ * tens of thousands of minted children that built and sorted a multi-megabyte
295
+ * string on every page view, before the cache could even answer.)
251
296
  */
252
297
  function shareFamilyStatsKey(
253
298
  tenantId: string,
254
- keyIds: readonly string[],
299
+ family: { readonly templateKeyId: string; readonly memberCount: number },
255
300
  allowedModels: readonly string[] | undefined,
256
301
  ): string {
257
302
  const models = allowedModels === undefined ? "*" : [...allowedModels].sort().join(",");
258
303
  const hash = createHash("sha256")
259
- .update([...keyIds].sort().join(",") + "|" + models)
304
+ .update(`${family.templateKeyId}#${family.memberCount}|${models}`)
260
305
  .digest("hex");
261
306
  return `share:v1:stats:${tenantId}:${hash}`;
262
307
  }
@@ -271,12 +316,12 @@ interface ShareStatsPortDeps {
271
316
 
272
317
  export function createShareStatsPort(deps: ShareStatsPortDeps): ShareStatsPort {
273
318
  return {
274
- async getFamilyStats(tenantId, keyIds, recipients, allowedModels) {
275
- if (keyIds.length === 0) return emptyStats(recipients);
319
+ async getFamilyStats(tenantId, family, recipients, allowedModels) {
320
+ if (family.templateKeyId.length === 0) return emptyStats(recipients);
276
321
  const cached = await cachedRead<Omit<ShareFamilyStats, "recipients">>(
277
322
  deps.redis,
278
- shareFamilyStatsKey(tenantId, keyIds, allowedModels),
279
- () => computeFamilyStatsBody(deps.db, tenantId, keyIds, allowedModels),
323
+ shareFamilyStatsKey(tenantId, family, allowedModels),
324
+ () => computeFamilyStatsBody(deps.db, tenantId, family.templateKeyId, allowedModels),
280
325
  SHARE_STATS_CACHE_TTL_SECONDS,
281
326
  );
282
327
  return { ...cached, recipients };
@@ -288,7 +333,7 @@ export function createShareStatsPort(deps: ShareStatsPortDeps): ShareStatsPort {
288
333
  async function computeFamilyStatsBody(
289
334
  db: CartethyiaDatabase,
290
335
  tenantId: string,
291
- keyIds: readonly string[],
336
+ templateKeyId: string,
292
337
  allowedModels: readonly string[] | undefined,
293
338
  ): Promise<Omit<ShareFamilyStats, "recipients">> {
294
339
  // A share that grants a fixed set ranks only that set. Telemetry keeps the
@@ -303,21 +348,43 @@ async function computeFamilyStatsBody(
303
348
  const hourStart = utcHourStart(now);
304
349
  const lastHourStart = new Date(now.getTime() - HOUR_MS);
305
350
  const windowStart = new Date(hourStart.getTime() - (HOURS_WINDOW - 1) * HOUR_MS);
351
+ // The IP breakdown is bounded to this recency window: a family's "who is
352
+ // using the link" answer only needs addresses that were active recently,
353
+ // and an unbounded GROUP BY over every retained event is the one remaining
354
+ // full-window scan on the share page.
355
+ const ipWindowStart = new Date(now.getTime() - IP_WINDOW_MS);
306
356
 
307
- // Rank only names the grant actually authorizes. Telemetry keeps the
308
- // requested name even when the request was refused, so an unfiltered table
309
- // would list exactly the invalid names an abuser probed. Filtering before
310
- // the top-50 cut means refused names cannot hide a model the recipient may
311
- // use.
312
- //
313
- // Bounded by the retention floor, not by the oldest counter: `totalTokens`
314
- // is all-time, so cutting at `monthStart` would silently drop the rows that
315
- // make it differ from `monthTokens`. Retention already caps the window, and
316
- // the rows are read newest-first under a page cap, so a very large family
317
- // cannot pull the whole table.
357
+ // All-time family KPI comes from the durable rollup
358
+ // (`telemetry_usage_totals`, charged at write time), not from re-aggregating
359
+ // the entire retained telemetry window on every tick: the share page's tick
360
+ // would otherwise read every event a busy family ever produced just to draw
361
+ // four numbers. Today's/month's spend and the last-hour count are bounded
362
+ // window reads over the time index, which stay small.
363
+ const [kpi, todayTokens, monthTokens, lastHourRequests, template] = await Promise.all([
364
+ familyKpiTotals(db, tenantId, templateKeyId),
365
+ sumTelemetryTokens(db, tenantId, { familyKeyId: templateKeyId, since: dayStart }),
366
+ sumTelemetryTokens(db, tenantId, { familyKeyId: templateKeyId, since: monthStart }),
367
+ familyWindowRequests(db, tenantId, templateKeyId, lastHourStart),
368
+ db
369
+ .select({ lifetime: apiKeys.lifetimeTokensConsumed })
370
+ .from(apiKeys)
371
+ .where(eq(apiKeys.id, templateKeyId))
372
+ .limit(1),
373
+ ]);
374
+ // Cumulative family spend: the template's own counter plus the telemetry
375
+ // total. The counter is a manually-configured seed (an operator limit), not
376
+ // traffic, and it is never reset; azmetry rows for revoked/deleted children
377
+ // likewise stay counted, so the running total is monotonic and consistent
378
+ // across the overview, the share dialog, and this page.
379
+ const templateLifetime = Number(template[0]?.lifetime ?? 0);
380
+
381
+ // The ranked tables need real events, bounded to the 24-hour window. The
382
+ // totals and hourly chart are computed in SQL, so the page cap here only
383
+ // shapes the model/IP rankings — never the KPI numbers.
318
384
  const page = await querySqlEvents(db, {
319
385
  tenantId,
320
- apiKeyIds: [...keyIds],
386
+ familyApiKeyId: templateKeyId,
387
+ since: windowStart,
321
388
  limit: MAX_FAMILY_EVENTS,
322
389
  });
323
390
  const events = page.events;
@@ -326,12 +393,36 @@ async function computeFamilyStatsBody(
326
393
  monthStart: monthStart.getTime(),
327
394
  lastHourStart: lastHourStart.getTime(),
328
395
  windowStart: windowStart.getTime(),
396
+ activeIpCutoff: now.getTime() - ACTIVE_IP_WINDOW_MS,
329
397
  allowed: allowedSet,
330
398
  });
399
+ rollup.totals = {
400
+ ...rollup.totals,
401
+ // The template counter is the seed on the running total, not a distinct
402
+ // bucket: daily/monthly windows are telemetry-only (the counter has no
403
+ // time axis), so those keep their own sums.
404
+ totalTokens: templateLifetime + kpi.tokens,
405
+ todayTokens,
406
+ monthTokens,
407
+ // Lifetime counts come from the durable rollup, not the page: a family
408
+ // busier than the page cap must not freeze at 500 requests/errors.
409
+ requests: kpi.requests,
410
+ errors: kpi.errors,
411
+ lastHourRequests,
412
+ };
331
413
 
332
- // Fill the 24 buckets in JS: only hours that saw traffic are present, and
333
- // a chart drawn from sparse rows would silently compress the axis.
334
- const byHour = rollup.byHour;
414
+ // The hourly chart is a SQL aggregate over the whole family window, so a
415
+ // busy family's bars stay true. Buckets are filled in JS because only hours
416
+ // that saw traffic are present; a chart drawn from sparse rows would
417
+ // silently compress the axis.
418
+ const bucketSeconds = HOUR_MS / 1000;
419
+ const sqlHourly = await familyHourlyTelemetry(db, tenantId, templateKeyId, windowStart, bucketSeconds);
420
+ const byHour = new Map(
421
+ sqlHourly.map((bucket) => [
422
+ new Date(bucket.at).toISOString(),
423
+ { requests: bucket.requests, tokens: bucket.inputTokens + bucket.outputTokens },
424
+ ]),
425
+ );
335
426
  const hourly: ShareHourlyBucket[] = [];
336
427
  for (let index = 0; index < HOURS_WINDOW; index += 1) {
337
428
  const hour = new Date(windowStart.getTime() + index * HOUR_MS).toISOString();
@@ -339,6 +430,33 @@ async function computeFamilyStatsBody(
339
430
  hourly.push({ hour, requests: bucket?.requests ?? 0, tokens: bucket?.tokens ?? 0 });
340
431
  }
341
432
 
433
+ // Client IPs come from a SQL aggregate over a bounded recent window, ranked
434
+ // by request volume — so a family's "who is using the link most" answer
435
+ // stays cheap instead of scanning the whole retained telemetry on every
436
+ // tick. `clientType` is best-effort from the page's events (the most recent
437
+ // 24 hours), which carry the User-Agent.
438
+ const ipRows = await familyIpBreakdown(db, tenantId, templateKeyId, TOP_IPS_LIMIT, ipWindowStart);
439
+ const clientTypeByIp = new Map<string, string | null>();
440
+ for (const event of events) {
441
+ if (event.clientIp !== null && !clientTypeByIp.has(event.clientIp)) {
442
+ clientTypeByIp.set(event.clientIp, clientTypeFromUserAgent(event.userAgent));
443
+ }
444
+ }
445
+ const activeIpCutoff = now.getTime() - ACTIVE_IP_WINDOW_MS;
446
+ const clientIps = ipRows.map((row) => {
447
+ const lastSeenAt =
448
+ row.lastSeenAt instanceof Date ? row.lastSeenAt : new Date(String(row.lastSeenAt ?? ""));
449
+ const lastSeenMs = Number.isFinite(lastSeenAt.getTime()) ? lastSeenAt.getTime() : 0;
450
+ return {
451
+ ip: maskClientIp(row.ip),
452
+ requests: row.requests,
453
+ tokens: row.tokens,
454
+ lastSeenAt: lastSeenMs > 0 ? lastSeenAt.toISOString() : null,
455
+ clientType: clientTypeByIp.get(row.ip) ?? null,
456
+ active: lastSeenMs >= activeIpCutoff,
457
+ };
458
+ });
459
+
342
460
  return {
343
461
  totals: rollup.totals,
344
462
  hourly,
@@ -352,15 +470,6 @@ async function computeFamilyStatsBody(
352
470
  avgTokensPerSec: row.rateCount > 0 ? row.rateSum / row.rateCount : null,
353
471
  avgTtfbMs: row.ttfbCount > 0 ? Math.round(row.ttfbSum / row.ttfbCount) : null,
354
472
  })),
355
- clientIps: [...rollup.ips.values()]
356
- .sort((a, b) => b.requests - a.requests)
357
- .slice(0, TOP_IPS_LIMIT)
358
- .map((row) => ({
359
- ip: maskClientIp(row.ip),
360
- requests: row.requests,
361
- tokens: row.tokens,
362
- lastSeenAt: row.lastSeenAt,
363
- clientType: clientTypeFromUserAgent(row.userAgent),
364
- })),
473
+ clientIps,
365
474
  };
366
475
  }