cartethyia 2.7.2 → 2.7.3

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 (249) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-XaUmiBrC.js → BannedPage-2TJD37Hb.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-2TJD37Hb.js.gz +0 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js +1 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js +1 -0
  7. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-CXGS_Ct-.js → CombosPage-JEbYJFxw.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-DGnGNYnM.js → ConsoleLogPage-C3xx_inM.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-C3xx_inM.js.gz +0 -0
  12. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js +1 -0
  13. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BdmRLnq2.js → LoginPage-C4w7vea-.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-C4w7vea-.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BtPcAfot.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-BtPcAfot.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js +4 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js +1 -0
  25. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js.gz +0 -0
  26. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js +9 -0
  27. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-7P2n--Fv.js → QuotaPage-Ci5O4BnH.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
  30. package/dist/dashboard/assets/{SettingsPage-CxZ1fTkN.js → SettingsPage-CDZjTt87.js} +1 -1
  31. package/dist/dashboard/assets/SettingsPage-CDZjTt87.js.gz +0 -0
  32. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js +1 -0
  33. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Dqf4zpS-.js → SortableList-BAL5npBC.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-Byj2lAEm.js → StudioPage-TP7voAQ0.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
  38. package/dist/dashboard/assets/{UsagePage-CTkLAuRc.js → UsagePage-Cj_fEzhg.js} +13 -13
  39. package/dist/dashboard/assets/UsagePage-Cj_fEzhg.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-CUi6UXsR.js → api-keys-MbI7e7rt.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-MbI7e7rt.js.gz +0 -0
  42. package/dist/dashboard/assets/app-B2vyPoII.css +2 -0
  43. package/dist/dashboard/assets/app-B2vyPoII.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-67xXH5rf.js → assert-C0YYuZJ4.js} +1 -1
  47. package/dist/dashboard/assets/assert-C0YYuZJ4.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-B_DMpMJN.js} +1 -1
  49. package/dist/dashboard/assets/badge-B_DMpMJN.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DMrMPmth.js} +1 -1
  51. package/dist/dashboard/assets/banner-DMrMPmth.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-Cca1XC6G.js → card-Be5Uzamx.js} +1 -1
  53. package/dist/dashboard/assets/card-Be5Uzamx.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-KvsWa1Jl.js → cli-tools-muFRphDu.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-muFRphDu.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DOfrVmsi.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOfrVmsi.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-C9omdjQt.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C9omdjQt.js.gz +0 -0
  60. package/dist/dashboard/assets/drawer-Bm8WKaGY.js +1 -0
  61. package/dist/dashboard/assets/drawer-Bm8WKaGY.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-_xHo5x6r.js → entry-DS90gUks.js} +1 -1
  63. package/dist/dashboard/assets/entry-DS90gUks.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-ClCMCWWQ.js → entry-j40LJotO.js} +3 -3
  65. package/dist/dashboard/assets/entry-j40LJotO.js.gz +0 -0
  66. package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-q5HwZ8nN.js} +1 -1
  67. package/dist/dashboard/assets/entry-q5HwZ8nN.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-BTScrQpe.js} +1 -1
  71. package/dist/dashboard/assets/formatters-BTScrQpe.js.gz +0 -0
  72. package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-Orjuzemr.js} +1 -1
  73. package/dist/dashboard/assets/github-badge-Orjuzemr.js.gz +0 -0
  74. package/dist/dashboard/assets/{index-DFVFrnWW.js → index-cPrX5vn6.js} +2 -2
  75. package/dist/dashboard/assets/index-cPrX5vn6.js.gz +0 -0
  76. package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-Dvux8w-0.js} +1 -1
  77. package/dist/dashboard/assets/inline-Dvux8w-0.js.gz +0 -0
  78. package/dist/dashboard/assets/{input-C_ILAvBb.js → input-CU1Rgf3p.js} +1 -1
  79. package/dist/dashboard/assets/input-CU1Rgf3p.js.gz +0 -0
  80. package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-C_7A6H55.js} +1 -1
  81. package/dist/dashboard/assets/layout-C_7A6H55.js.gz +0 -0
  82. package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-CzFvogbj.js} +1 -1
  83. package/dist/dashboard/assets/list-row-CzFvogbj.js.gz +0 -0
  84. package/dist/dashboard/assets/{live-D0mm--ME.js → live-KaG15HLQ.js} +1 -1
  85. package/dist/dashboard/assets/live-KaG15HLQ.js.gz +0 -0
  86. package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-JJGQm8TD.js} +1 -1
  87. package/dist/dashboard/assets/mutation-JJGQm8TD.js.gz +0 -0
  88. package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-BNDZnsUE.js} +1 -1
  89. package/dist/dashboard/assets/numbers-BNDZnsUE.js.gz +0 -0
  90. package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-CLaNz8Xz.js} +1 -1
  91. package/dist/dashboard/assets/query-keys-CLaNz8Xz.js.gz +0 -0
  92. package/dist/dashboard/assets/{routing-BnIpi1IJ.js → routing-BEH32QOC.js} +1 -1
  93. package/dist/dashboard/assets/routing-BEH32QOC.js.gz +0 -0
  94. package/dist/dashboard/assets/{select-DBNVLdUu.js → select-B3iTetHN.js} +1 -1
  95. package/dist/dashboard/assets/select-B3iTetHN.js.gz +0 -0
  96. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
  97. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
  98. package/dist/dashboard/assets/{settings-BAFhDgX8.js → settings-Dt3Eqbnq.js} +1 -1
  99. package/dist/dashboard/assets/settings-Dt3Eqbnq.js.gz +0 -0
  100. package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-KrOrm7JZ.js} +1 -1
  101. package/dist/dashboard/assets/stack-KrOrm7JZ.js.gz +0 -0
  102. package/dist/dashboard/assets/{state-CWoo7ty5.js → state-DLS7IDMy.js} +1 -1
  103. package/dist/dashboard/assets/state-DLS7IDMy.js.gz +0 -0
  104. package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-42ItvbAs.js} +1 -1
  105. package/dist/dashboard/assets/switch-42ItvbAs.js.gz +0 -0
  106. package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-Dmnfh1Ue.js} +1 -1
  107. package/dist/dashboard/assets/useMutation-Dmnfh1Ue.js.gz +0 -0
  108. package/dist/dashboard/index.html +3 -2
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +117 -115
  111. package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
  112. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +22 -0
  113. package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
  114. package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
  115. package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
  116. package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
  117. package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
  118. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +22 -0
  119. package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
  120. package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
  121. package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
  122. package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
  123. package/package.json +1 -1
  124. package/src/config/network.ts +1 -1
  125. package/src/console/api-keys/bucket-spend.ts +7 -7
  126. package/src/console/api-keys/contracts.ts +423 -410
  127. package/src/console/api-keys/routes.ts +118 -19
  128. package/src/console/backup/export-endpoint.ts +8 -11
  129. package/src/console/backup/routes.ts +40 -8
  130. package/src/console/backup/service.ts +11 -6
  131. package/src/console/backup/store.ts +384 -88
  132. package/src/console/dashboard-assets.ts +16 -1
  133. package/src/console/domain-registration.ts +2 -0
  134. package/src/console/quota/account-quota-tenant.ts +8 -1
  135. package/src/console/quota/account-quota.ts +4 -1
  136. package/src/console/quota/native-identity.ts +189 -0
  137. package/src/console/remote-support/contracts.ts +12 -6
  138. package/src/console/remote-support/service.ts +2 -2
  139. package/src/console/share/share-router.ts +39 -6
  140. package/src/console/share/share-stats.ts +64 -25
  141. package/src/console/share/share-usage.ts +24 -7
  142. package/src/console/system-prompt/contracts.ts +102 -0
  143. package/src/console/system-prompt/routes.ts +165 -0
  144. package/src/main.ts +11 -0
  145. package/src/observability/event-query.ts +118 -7
  146. package/src/persistence/db-pg.ts +42 -0
  147. package/src/persistence/schema.ts +102 -0
  148. package/src/persistence/stores/api-key-store.ts +753 -581
  149. package/src/persistence/stores/share-store.ts +65 -13
  150. package/src/persistence/stores/telemetry-store.ts +57 -1
  151. package/src/persistence/system-prompt-store.ts +104 -0
  152. package/src/protocol/reasoning.ts +65 -1
  153. package/src/protocol/request/chat.ts +1 -0
  154. package/src/providers/integrations/antigravity/antigravity.ts +20 -23
  155. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  156. package/src/providers/integrations/buddy/buddy-media.ts +5 -1
  157. package/src/providers/integrations/buddy/codebuddy/provider.ts +5 -1
  158. package/src/providers/integrations/buddy/workbuddy/provider.ts +5 -1
  159. package/src/providers/integrations/cline/cline.ts +7 -0
  160. package/src/providers/integrations/github/github.ts +20 -1
  161. package/src/providers/integrations/hermes.ts +5 -1
  162. package/src/providers/integrations/kilo/kilo.ts +4 -0
  163. package/src/providers/integrations/openrouter/provider.ts +4 -0
  164. package/src/providers/integrations/qoder/qoder-checkin.ts +224 -51
  165. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +156 -0
  166. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -3
  167. package/src/providers/integrations/qoder/qoder-umid.ts +288 -0
  168. package/src/providers/integrations/qoder/qoder.ts +5 -3
  169. package/src/providers/operations/account-error-classification.ts +6 -8
  170. package/src/providers/operations/account-health-service.ts +14 -16
  171. package/src/providers/operations/client-versions.ts +37 -22
  172. package/src/providers/provider-contracts.ts +8 -0
  173. package/src/security/access-control.ts +19 -0
  174. package/src/security/api-key-auth.ts +14 -1
  175. package/src/transport/dispatch/proxy-request.ts +31 -2
  176. package/src/transport/dispatch/streaming/stream.ts +16 -1
  177. package/src/transport/dispatch/upstream.ts +10 -0
  178. package/src/transport/request/preparer.ts +17 -1
  179. package/src/transport/request/system-prompt-injection.ts +89 -0
  180. package/src/transport/request/tenant-preferences.ts +43 -0
  181. package/src/transport/routing/admission.ts +13 -3
  182. package/src/transport/routing/route-catalog.ts +7 -0
  183. package/src/transport/routing/route-model.ts +53 -0
  184. package/src/transport/routing/router.ts +306 -256
  185. package/src/workers/daily-checkin.ts +2 -0
  186. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  187. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js +0 -1
  188. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  189. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js +0 -1
  190. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  191. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  192. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  193. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js +0 -1
  194. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  195. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js +0 -1
  196. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  197. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  198. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  199. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +0 -1
  200. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  201. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +0 -4
  202. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  203. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +0 -1
  204. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  205. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +0 -9
  206. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  207. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  208. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  209. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +0 -1
  210. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  211. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  212. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  213. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  214. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  215. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  216. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  217. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  218. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  219. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  220. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  221. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  222. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  223. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  224. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  225. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  226. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  227. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  228. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  229. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  230. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  231. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  232. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  233. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  234. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  235. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  236. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  237. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  238. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  239. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  240. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  241. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  242. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  243. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  244. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  245. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  246. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  247. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  248. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-wvwCi4Eh.js} +0 -0
  249. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-wvwCi4Eh.js.gz} +0 -0
@@ -0,0 +1,165 @@
1
+ // Console API for tenant system-prompt injection.
2
+ //
3
+ // GET /system-prompt → the stored configuration
4
+ // PUT /system-prompt → replace the configuration wholesale
5
+ // DELETE /system-prompt → clear it entirely
6
+ //
7
+ // The PUT replaces the row: the modal edits the whole three-scope shape at
8
+ // once, so a PATCH surface would add states the UI never produces. Text is
9
+ // capped at 5 MB per scope and the provider/model maps are bounded, matching
10
+ // the store's own defensive limits.
11
+ import { Elysia, t } from "elysia";
12
+ import type { ConsoleAccessResolver } from "../auth/access";
13
+ import { ConsoleDomainError, consoleErrorHandler, requireTenantScope } from "../shared/errors";
14
+ import type { AuditSink } from "../audit/contracts";
15
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
16
+ import {
17
+ clearTenantSystemPrompts,
18
+ readTenantSystemPrompts,
19
+ writeTenantSystemPrompts,
20
+ } from "../../persistence/system-prompt-store";
21
+ import {
22
+ EMPTY_TENANT_SYSTEM_PROMPTS,
23
+ PROMPT_MODES,
24
+ SYSTEM_PROMPT_MODEL_LIMIT,
25
+ SYSTEM_PROMPT_PROVIDER_LIMIT,
26
+ SYSTEM_PROMPT_TEXT_CAP_BYTES,
27
+ isPromptSetting,
28
+ parseSystemPromptModelKey,
29
+ type PromptSetting,
30
+ type ProviderPromptEntry,
31
+ type TenantSystemPrompts,
32
+ } from "./contracts";
33
+
34
+ /** Route-group context injected by the composition root. */
35
+ export interface SystemPromptRoutesConfig {
36
+ readonly db: CartethyiaDatabase;
37
+ readonly accessResolver: ConsoleAccessResolver;
38
+ readonly auditSink?: AuditSink;
39
+ }
40
+
41
+ /** Validates one scope setting, including the 5 MB text cap. */
42
+ function validateSetting(value: unknown, scopeLabel: string): PromptSetting {
43
+ if (!isPromptSetting(value)) {
44
+ throw new ConsoleDomainError("invalid_request", 400, `${scopeLabel} is not a valid prompt setting`);
45
+ }
46
+ const bytes = Buffer.byteLength(value.text, "utf8");
47
+ if (bytes > SYSTEM_PROMPT_TEXT_CAP_BYTES) {
48
+ throw new ConsoleDomainError("payload_too_large", 413, `${scopeLabel} exceeds the 5 MB limit`);
49
+ }
50
+ if (!(PROMPT_MODES as readonly string[]).includes(value.mode)) {
51
+ throw new ConsoleDomainError("invalid_request", 400, `${scopeLabel} has an invalid mode`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ /** Validates the full PUT body into the stored shape. */
57
+ function validateBody(value: unknown): TenantSystemPrompts {
58
+ if (typeof value !== "object" || value === null) {
59
+ throw new ConsoleDomainError("invalid_request", 400, "Body must be an object");
60
+ }
61
+ const body = value as {
62
+ global?: unknown;
63
+ providers?: unknown;
64
+ models?: unknown;
65
+ };
66
+
67
+ const global =
68
+ body.global === null || body.global === undefined
69
+ ? null
70
+ : validateSetting(body.global, "global");
71
+
72
+ if (body.providers !== undefined && typeof body.providers !== "object") {
73
+ throw new ConsoleDomainError("invalid_request", 400, "providers must be an object");
74
+ }
75
+ const rawProviders = (body.providers ?? {}) as Record<string, unknown>;
76
+ const providerIds = Object.keys(rawProviders);
77
+ if (providerIds.length > SYSTEM_PROMPT_PROVIDER_LIMIT) {
78
+ throw new ConsoleDomainError(
79
+ "invalid_request",
80
+ 400,
81
+ `providers exceeds the ${SYSTEM_PROMPT_PROVIDER_LIMIT} entry limit`,
82
+ );
83
+ }
84
+ const providers: Record<string, ProviderPromptEntry> = {};
85
+ for (const providerId of providerIds) {
86
+ const entry = rawProviders[providerId];
87
+ if (typeof entry !== "object" || entry === null) {
88
+ throw new ConsoleDomainError("invalid_request", 400, `provider ${providerId} entry must be an object`);
89
+ }
90
+ const candidate = entry as Partial<ProviderPromptEntry>;
91
+ if (typeof candidate.followGlobal !== "boolean") {
92
+ throw new ConsoleDomainError("invalid_request", 400, `provider ${providerId} needs followGlobal`);
93
+ }
94
+ if (candidate.followGlobal) {
95
+ providers[providerId] = { followGlobal: true };
96
+ } else {
97
+ providers[providerId] = {
98
+ followGlobal: false,
99
+ setting: validateSetting(candidate.setting, `provider ${providerId}`),
100
+ };
101
+ }
102
+ }
103
+
104
+ if (body.models !== undefined && typeof body.models !== "object") {
105
+ throw new ConsoleDomainError("invalid_request", 400, "models must be an object");
106
+ }
107
+ const rawModels = (body.models ?? {}) as Record<string, unknown>;
108
+ const modelKeys = Object.keys(rawModels);
109
+ if (modelKeys.length > SYSTEM_PROMPT_MODEL_LIMIT) {
110
+ throw new ConsoleDomainError(
111
+ "invalid_request",
112
+ 400,
113
+ `models exceeds the ${SYSTEM_PROMPT_MODEL_LIMIT} entry limit`,
114
+ );
115
+ }
116
+ const models: Record<string, PromptSetting> = {};
117
+ for (const key of modelKeys) {
118
+ if (parseSystemPromptModelKey(key) === null) {
119
+ throw new ConsoleDomainError("invalid_request", 400, `model key ${key} is malformed`);
120
+ }
121
+ models[key] = validateSetting(rawModels[key], `model ${key}`);
122
+ }
123
+
124
+ return { global, providers, models };
125
+ }
126
+
127
+ /**
128
+ * The return type is inferred rather than annotated as `Elysia`: `.error()`
129
+ * widens the instance type with its handler's response envelope, so the
130
+ * annotated base type no longer matches the chained result (same reason the
131
+ * remote-support routes leave it inferred).
132
+ */
133
+ export function createSystemPromptRoutes(config: SystemPromptRoutesConfig) {
134
+ return new Elysia()
135
+ .error(consoleErrorHandler("System prompt settings failed"))
136
+ .get("/system-prompt", async ({ request }) => {
137
+ const access = requireTenantScope(config.accessResolver(request), "dashboard:read");
138
+ return await readTenantSystemPrompts(config.db, access.tenantId);
139
+ })
140
+ .put(
141
+ "/system-prompt",
142
+ { body: t.Any() },
143
+ async ({ request, body }) => {
144
+ const access = requireTenantScope(config.accessResolver(request), "dashboard:write");
145
+ const next = validateBody(body);
146
+ await writeTenantSystemPrompts(config.db, access.tenantId, next);
147
+ await config.auditSink?.record({
148
+ access,
149
+ action: "system_prompt.update",
150
+ target: "tenant_system_prompts",
151
+ detail: {
152
+ global: next.global !== null,
153
+ providers: Object.keys(next.providers).length,
154
+ models: Object.keys(next.models).length,
155
+ },
156
+ });
157
+ return next;
158
+ },
159
+ )
160
+ .delete("/system-prompt", async ({ request }) => {
161
+ const access = requireTenantScope(config.accessResolver(request), "dashboard:write");
162
+ await clearTenantSystemPrompts(config.db, access.tenantId);
163
+ return EMPTY_TENANT_SYSTEM_PROMPTS;
164
+ });
165
+ }
package/src/main.ts CHANGED
@@ -169,6 +169,17 @@ if (boot) {
169
169
  globalThis.__cartethyiaSignalsRegistered = true;
170
170
  process.on("SIGINT", () => shutdown("SIGINT"));
171
171
  process.on("SIGTERM", () => shutdown("SIGTERM"));
172
+ // A rejected promise nothing awaited (a sweep, a cache warm, a read whose
173
+ // parameters a bug elsewhere ballooned) must not kill the listener: the
174
+ // default is fatal exit, which turned one query fault into a systemd
175
+ // restart loop. Logged and absorbed — the failure stays visible, the
176
+ // process stays up.
177
+ process.on("unhandledRejection", (reason) => {
178
+ log.error(
179
+ "[process] unhandled rejection (kept alive)",
180
+ reason instanceof Error ? reason : new Error(String(reason)),
181
+ );
182
+ });
172
183
  // An in-place update signals SIGUSR2 before swapping the image so the old
173
184
  // process drains with the `update` reason and its callers are told the
174
185
  // replacement is seconds away instead of seeing a generic shutdown. Not
@@ -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 } from "../persistence/schema";
23
23
  import type { TelemetryEventRecord } from "./event-log";
24
24
  import { gatewayErrorSql } from "./telemetry-status";
25
25
 
@@ -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));
@@ -638,14 +740,23 @@ export async function healthTelemetry(
638
740
  export async function sumTelemetryTokens(
639
741
  db: CartethyiaDatabase,
640
742
  tenantId: string,
641
- filters: { readonly apiKeyIds?: readonly string[]; readonly since: Date; readonly until?: Date },
743
+ filters: {
744
+ readonly apiKeyIds?: readonly string[];
745
+ /** The share-template family filter; see {@link EventListQuery.familyApiKeyId}. */
746
+ readonly familyKeyId?: string;
747
+ readonly since: Date;
748
+ readonly until?: Date;
749
+ },
642
750
  ): Promise<number> {
643
751
  const conditions = [scoped(tenantId, filters.since)];
644
752
  if (filters.until !== undefined) {
645
753
  conditions.push(sql`${telemetryEvents.createdAt} < ${filters.until}`);
646
754
  }
647
755
  if (filters.apiKeyIds !== undefined && filters.apiKeyIds.length > 0) {
648
- conditions.push(inArray(telemetryEvents.apiKeyId, [...filters.apiKeyIds]));
756
+ conditions.push(idMembershipFilter(db, telemetryEvents.apiKeyId, filters.apiKeyIds, "uuid[]"));
757
+ }
758
+ if (filters.familyKeyId !== undefined) {
759
+ conditions.push(familyKeyCondition(filters.familyKeyId));
649
760
  }
650
761
  const [row] = await db
651
762
  .select({
@@ -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
  }
@@ -183,6 +183,31 @@ export const providers = pgTable(
183
183
  // tenant-owned/BYOK. The concurrency ceiling lives in provider routing
184
184
  // (`provider_routing_settings.max_inflight`) and network pools
185
185
  // (`network_pools.max_inflight`); there is no per-account override.
186
+ /**
187
+ * A Qoder native machine identity (UMID) bound to one account.
188
+ *
189
+ * The device identity is a deterministic function of a seed file
190
+ * (`~/.config/.locale_cfg`): the `runtime-info` bridge reads that seed and
191
+ * derives `machineToken`/`machineType`/`machineCode`. The server gates the
192
+ * daily 100-credit campaign on a *genuine, non-VM* device identity, so each
193
+ * account gets its own minted seed (a distinct "device").
194
+ *
195
+ * `seed` is the exact seed-file content the identity was minted from, stored
196
+ * so the same device identity can be replayed (restore seed → bridge → same
197
+ * token). It is not a credential, but it is the per-account device material
198
+ * and must never be shared between accounts. `machineToken` is the unique
199
+ * device key: two accounts sharing a token are the same device to the server.
200
+ */
201
+ export interface NativeIdentityRecord {
202
+ readonly machineToken: string;
203
+ readonly machineType: string;
204
+ readonly machineCode: string;
205
+ /** The seed-file content this identity was minted from, if captured. */
206
+ readonly seed?: string;
207
+ /** How this identity was produced: the genuine SG bridge or the fallback. */
208
+ readonly source?: "runtime-info" | "generated";
209
+ }
210
+
186
211
  export const providerAccounts = pgTable("provider_accounts", {
187
212
  id: uuid("id").primaryKey().defaultRandom(),
188
213
  providerId: text("provider_id")
@@ -270,6 +295,18 @@ export const providerAccounts = pgTable("provider_accounts", {
270
295
  */
271
296
  quotaPlan: text("quota_plan"),
272
297
 
298
+ /**
299
+ * Qoder native machine identity bound to this account, as one JSON object
300
+ * (see {@link NativeIdentityRecord}). `null` means the account has no bound
301
+ * identity: the check-in mints one on first use.
302
+ *
303
+ * Deliberately a dedicated column, not a key inside `auth_state`: the
304
+ * device token is queryable ("which device is taken by whom") and enforced
305
+ * unique by the partial index below, and it must survive backup/restore
306
+ * like any other non-secret column.
307
+ */
308
+ nativeIdentity: jsonb("native_identity").$type<NativeIdentityRecord | null>(),
309
+
273
310
  },
274
311
  (table) => [
275
312
  uniqueIndex("provider_accounts_identity_uidx").on(
@@ -290,6 +327,17 @@ export const providerAccounts = pgTable("provider_accounts", {
290
327
  // predicate that does not constrain `provider_id`. This one matches the
291
328
  // hot listing path.
292
329
  index("provider_accounts_tenant_id_idx").on(table.tenantId),
330
+ // One Qoder device identity per account: the partial unique index on the
331
+ // extracted `machineToken` guarantees two accounts never share a device,
332
+ // which is what makes the daily 100-credit grant reusable per account
333
+ // instead of colliding on a device identity. `NULL` rows (unbound
334
+ // accounts) are excluded so the uniqueness only applies to bound
335
+ // identities. The Postgres migration spells the expression
336
+ // `(native_identity ->> 'machineToken')`; the SQLite migration spells it
337
+ // `json_extract(native_identity, '$.machineToken')`.
338
+ uniqueIndex("provider_accounts_native_identity_token_uidx")
339
+ .on(sql`(${table.nativeIdentity} ->> 'machineToken')`)
340
+ .where(sql`${table.nativeIdentity} IS NOT NULL`),
293
341
  ],
294
342
  );
295
343
 
@@ -766,6 +814,13 @@ export const apiKeys = pgTable(
766
814
  .notNull()
767
815
  .default(0),
768
816
  maxConcurrentRequests: integer("max_concurrent_requests"),
817
+ /**
818
+ * Slot cap for a share template: the maximum number of concurrently
819
+ * active child keys it may mint. `null` = unlimited (legacy behavior).
820
+ * Enforced at mint time in `issueSharedApiKey`; the console exposes it as
821
+ * the template's "slots".
822
+ */
823
+ maxChildren: integer("max_children"),
769
824
  /**
770
825
  * `whitelist` = only the names in `modelList` may be used (empty = all);
771
826
  * `blacklist` = the names in `modelList` are refused (empty = none). One
@@ -1126,6 +1181,26 @@ export const consoleSettings = pgTable("console_settings", {
1126
1181
  updatedAt: updatedAtColumn(),
1127
1182
  });
1128
1183
 
1184
+ // Tenant-scoped system prompt injection (append/override). One row per
1185
+ // tenant; `global` NULL means never configured, distinct from an explicit
1186
+ // disabled setting. Providers/models are JSON object maps keyed by
1187
+ // providerId and `providerId::modelId` respectively — see
1188
+ // `src/console/system-prompt/contracts.ts` for the value shapes and the
1189
+ // resolution rules implemented by the dispatch-path transform.
1190
+ export const tenantSystemPrompts = pgTable("tenant_system_prompts", {
1191
+ tenantId: tenantRefPrimaryKey(),
1192
+ global: jsonb("global").$type<import("../console/system-prompt/contracts").PromptSetting | null>(),
1193
+ providers: jsonb("providers")
1194
+ .notNull()
1195
+ .default({})
1196
+ .$type<Record<string, import("../console/system-prompt/contracts").ProviderPromptEntry>>(),
1197
+ models: jsonb("models")
1198
+ .notNull()
1199
+ .default({})
1200
+ .$type<Record<string, import("../console/system-prompt/contracts").PromptSetting>>(),
1201
+ updatedAt: updatedAtColumn(),
1202
+ });
1203
+
1129
1204
  // InferHub `config_read` key. One row for the whole deployment.
1130
1205
  //
1131
1206
  // Deliberately NOT `provider_accounts.auth_state`: that field is documented as
@@ -1229,6 +1304,14 @@ export const telemetryEvents = pgTable(
1229
1304
  networkPoolId: uuid("network_pool_id"),
1230
1305
  endpoint: text("endpoint"),
1231
1306
  apiKeyId: uuid("api_key_id"),
1307
+ /**
1308
+ * The share-template key this event belongs to (the template itself, or
1309
+ * the parent of the child key that carried the request). Denormalized at
1310
+ * write time so family rollups survive the key row being deleted — an
1311
+ * `EXISTS` join against `api_keys` tied history to key lifetime. NULL for
1312
+ * requests that ran under no share template.
1313
+ */
1314
+ familyTemplateKeyId: uuid("family_template_key_id"),
1232
1315
  userAgent: text("user_agent"),
1233
1316
  clientIp: text("client_ip"),
1234
1317
  latencyMs: integer("latency_ms"),
@@ -1317,6 +1400,14 @@ export const telemetryEvents = pgTable(
1317
1400
  desc(table.createdAt),
1318
1401
  desc(table.id),
1319
1402
  ),
1403
+ // Share-family rollup: equality on the denormalized template, keyset
1404
+ // range+order. Backs both the family totals and the hourly chart reads.
1405
+ index("telemetry_events_tenant_family_created_id_idx").on(
1406
+ table.tenantId,
1407
+ table.familyTemplateKeyId,
1408
+ desc(table.createdAt),
1409
+ desc(table.id),
1410
+ ),
1320
1411
  index("telemetry_events_request_id_idx").on(table.requestId),
1321
1412
  index("telemetry_events_tenant_account_created_idx").on(
1322
1413
  table.tenantId,
@@ -1373,6 +1464,13 @@ export const telemetryUsageTotals = pgTable(
1373
1464
  tenantId: tenantRefRequired(),
1374
1465
  identityType: text("identity_type").$type<TelemetryUsageIdentityType>().notNull(),
1375
1466
  entityId: uuid("entity_id").notNull(),
1467
+ /**
1468
+ * For `api_key` identities, the share-template key this key belongs to
1469
+ * (the template itself, or the parent of a minted child). Denormalized so
1470
+ * family reads do not depend on the key row surviving; NULL for `account`
1471
+ * identities and for keys outside a share family.
1472
+ */
1473
+ familyTemplateKeyId: uuid("family_template_key_id"),
1376
1474
  requests: bigint("requests", { mode: "number" }).notNull().default(0),
1377
1475
  errors: bigint("errors", { mode: "number" }).notNull().default(0),
1378
1476
  inputTokens: bigint("input_tokens", { mode: "number" }).notNull().default(0),
@@ -1389,6 +1487,10 @@ export const telemetryUsageTotals = pgTable(
1389
1487
  "telemetry_usage_totals_identity_type_check",
1390
1488
  sql`${table.identityType} IN ('account', 'api_key')`,
1391
1489
  ),
1490
+ index("telemetry_usage_totals_tenant_family_idx").on(
1491
+ table.tenantId,
1492
+ table.familyTemplateKeyId,
1493
+ ),
1392
1494
  ],
1393
1495
  );
1394
1496