cartethyia 2.5.10 → 2.6.0

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 (367) hide show
  1. package/.env.example +8 -0
  2. package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-BaCIdAUt.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
  37. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  38. package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-BFME-eba.js} +2 -2
  39. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  42. package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
  43. package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
  44. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  47. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
  49. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
  51. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
  53. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
  61. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
  63. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
  71. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
  73. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
  75. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
  77. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  81. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  84. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  85. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  86. package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
  87. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  88. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  89. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  90. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  91. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CxVVB__N.js → select-C980c4cz.js} +1 -1
  93. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  95. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
  97. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
  99. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
  101. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  104. package/dist/dashboard/index.html +1 -1
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/README.md +52 -52
  108. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  109. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  110. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  111. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  112. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  113. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  114. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  115. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  116. package/package.json +96 -95
  117. package/scripts/cli/admin.ts +126 -128
  118. package/scripts/cli/autostart.ts +237 -237
  119. package/scripts/cli/background.ts +120 -110
  120. package/scripts/cli/bin/carte.cjs +8 -8
  121. package/scripts/cli/bin/cartethyia.cjs +167 -167
  122. package/scripts/cli/console-menu.ts +39 -30
  123. package/scripts/cli/controller.ts +145 -0
  124. package/scripts/cli/env.ts +99 -99
  125. package/scripts/cli/gateway.ts +264 -260
  126. package/scripts/cli/instance.ts +234 -224
  127. package/scripts/cli/main.ts +188 -167
  128. package/scripts/cli/platform.ts +64 -64
  129. package/scripts/cli/prompt.ts +217 -79
  130. package/scripts/cli/services.ts +88 -88
  131. package/scripts/cli/setup.ts +445 -445
  132. package/scripts/cli/start.ts +85 -85
  133. package/scripts/cli/startup-signal.ts +23 -23
  134. package/scripts/cli/telemetry.ts +70 -0
  135. package/scripts/cli/tray/index.ts +263 -263
  136. package/scripts/cli/tray/windows.ps1 +107 -107
  137. package/scripts/cli/tray.ts +29 -4
  138. package/scripts/cli/update.ts +39 -39
  139. package/scripts/cli/version.ts +67 -67
  140. package/scripts/cli/wordmark.ts +58 -58
  141. package/src/boot/app.ts +16 -1
  142. package/src/boot/dependencies.ts +128 -25
  143. package/src/config/resources.ts +137 -0
  144. package/src/config/runtime.ts +133 -76
  145. package/src/config/spec.ts +30 -29
  146. package/src/config/telemetry.ts +0 -25
  147. package/src/console/api-keys/bucket-spend.ts +14 -17
  148. package/src/console/api-keys/contracts.ts +410 -410
  149. package/src/console/api-keys/routes.ts +12 -0
  150. package/src/console/audit/store.ts +2 -3
  151. package/src/console/auth/setup-service.ts +8 -1
  152. package/src/console/backup/routes.ts +7 -0
  153. package/src/console/backup/service.ts +463 -463
  154. package/src/console/backup/store.ts +16 -11
  155. package/src/console/backup/validate.ts +1 -4
  156. package/src/console/cli-tools/fs-ops.ts +18 -2
  157. package/src/console/data-management/contracts.ts +1 -8
  158. package/src/console/data-management/operations.ts +61 -48
  159. package/src/console/data-management/routes.ts +4 -2
  160. package/src/console/domain-registration.ts +25 -3
  161. package/src/console/inferhub/client.ts +139 -0
  162. package/src/console/inferhub/key-store.ts +129 -0
  163. package/src/console/inferhub/normalize.ts +347 -0
  164. package/src/console/inferhub/routes.ts +411 -0
  165. package/src/console/inferhub/store.ts +109 -0
  166. package/src/console/inferhub/types.ts +183 -0
  167. package/src/console/observability/contracts.ts +2 -41
  168. package/src/console/observability/store.ts +176 -371
  169. package/src/console/providers/catalog/public-model-store.ts +0 -0
  170. package/src/console/providers/catalog/store.ts +23 -36
  171. package/src/console/quota/account-quota-shared.ts +52 -52
  172. package/src/console/quota/account-quota-tenant.ts +16 -24
  173. package/src/console/quota/refresh.ts +441 -441
  174. package/src/console/routing/pools/import-parse.ts +206 -206
  175. package/src/console/routing/pools/operations.ts +543 -543
  176. package/src/console/routing/pools/probe-verdict.ts +132 -132
  177. package/src/console/routing/pools/routes.ts +181 -181
  178. package/src/console/settings/contracts.ts +42 -40
  179. package/src/console/settings/store.ts +25 -25
  180. package/src/console/share/share-stats.ts +22 -4
  181. package/src/console/share/share-usage.ts +30 -4
  182. package/src/console/shared/cursor.ts +15 -0
  183. package/src/console/updates/routes.ts +12 -0
  184. package/src/main.ts +13 -5
  185. package/src/network/pool/resolver.ts +1104 -1104
  186. package/src/observability/client-name.ts +22 -0
  187. package/src/observability/event-log.ts +5 -29
  188. package/src/observability/event-query.ts +690 -0
  189. package/src/observability/log-ring.ts +26 -2
  190. package/src/observability/log-sanitize.ts +118 -0
  191. package/src/observability/logger.ts +17 -13
  192. package/src/observability/metrics.ts +35 -0
  193. package/src/observability/payload-capture.ts +114 -41
  194. package/src/observability/payload-store.ts +249 -24
  195. package/src/observability/raw-event-writer.ts +118 -0
  196. package/src/observability/runtime-metrics.ts +71 -1
  197. package/src/observability/telemetry-buffer.ts +100 -17
  198. package/src/observability/telemetry-reconciliation.ts +417 -0
  199. package/src/persistence/boot.ts +20 -9
  200. package/src/persistence/db-pg.ts +13 -0
  201. package/src/persistence/db-sqlite.ts +569 -497
  202. package/src/persistence/dialect.ts +3 -2
  203. package/src/persistence/migrate.ts +181 -181
  204. package/src/persistence/schema.ts +1496 -1406
  205. package/src/persistence/shared-cache.ts +5 -1
  206. package/src/persistence/stores/api-key-store.ts +581 -581
  207. package/src/persistence/stores/telemetry-store.ts +124 -63
  208. package/src/protocol/reasoning-text.ts +30 -0
  209. package/src/protocol/response/chat.ts +33 -7
  210. package/src/protocol/response/codex.ts +14 -2
  211. package/src/protocol/response/messages.ts +5 -2
  212. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  213. package/src/providers/bundled-discovery-providers.ts +122 -122
  214. package/src/providers/default-registry.ts +10 -0
  215. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  216. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  217. package/src/providers/discovery/probe-phases.ts +43 -7
  218. package/src/providers/discovery/probing-service.ts +70 -32
  219. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  220. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  221. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  222. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  223. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  224. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  225. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  226. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  227. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  228. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  229. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  230. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  231. package/src/providers/integrations/claude/claude.ts +3 -4
  232. package/src/providers/integrations/cline/cline.ts +94 -8
  233. package/src/providers/integrations/codex/codex.ts +61 -37
  234. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  235. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  236. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  237. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  238. package/src/providers/integrations/cursor/cursor.ts +29 -17
  239. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  240. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  241. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  242. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  243. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  244. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  245. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  246. package/src/providers/integrations/hermes.ts +7 -1
  247. package/src/providers/integrations/inferhub.ts +201 -73
  248. package/src/providers/integrations/kilo/kilo.ts +152 -152
  249. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  250. package/src/providers/integrations/openrouter/provider.ts +129 -129
  251. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  252. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  253. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  254. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  255. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  256. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  257. package/src/providers/integrations/zcode/oauth.ts +1 -1
  258. package/src/providers/operations/account-health-service.ts +605 -279
  259. package/src/providers/provider-metadata.ts +12 -1
  260. package/src/providers/provider-model-definition.ts +373 -373
  261. package/src/providers/quota/quota-contracts.ts +3 -7
  262. package/src/providers/search/search-provider.ts +2 -6
  263. package/src/security/admission/contracts.ts +10 -0
  264. package/src/security/admission/in-memory-store.ts +89 -14
  265. package/src/security/admission/lua.ts +191 -182
  266. package/src/security/admission/redis-store.ts +30 -0
  267. package/src/security/admission/service.ts +18 -0
  268. package/src/security/api-key-auth-cache.ts +96 -0
  269. package/src/security/api-key-auth.ts +11 -100
  270. package/src/security/client-router-fingerprint.ts +3 -7
  271. package/src/security/ip-boundary.ts +7 -3
  272. package/src/shared/coerce.ts +67 -0
  273. package/src/shared/hash.ts +6 -0
  274. package/src/shared/time.ts +7 -0
  275. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  276. package/src/transport/dispatch/attempt-loop.ts +103 -81
  277. package/src/transport/dispatch/capture-policy.ts +78 -0
  278. package/src/transport/dispatch/proxy-request.ts +4 -2
  279. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  280. package/src/transport/dispatch/streaming/stream.ts +19 -2
  281. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  282. package/src/transport/failure-policy.ts +27 -0
  283. package/src/transport/guards/abuse-guard.ts +2 -2
  284. package/src/transport/guards/auth-guard.ts +17 -0
  285. package/src/transport/guards/body-policy.ts +3 -2
  286. package/src/transport/guards/concurrency-guard.ts +89 -13
  287. package/src/transport/guards/early-rejection-capture.ts +43 -3
  288. package/src/transport/guards/error-lifecycle.ts +10 -2
  289. package/src/transport/guards/guard-errors.ts +23 -0
  290. package/src/transport/guards/headroom-guard.ts +41 -7
  291. package/src/transport/guards/pipeline.ts +24 -0
  292. package/src/transport/guards/request-context.ts +2 -1
  293. package/src/transport/routing/admission.ts +51 -9
  294. package/src/transport/routing/bounded-lru.ts +49 -0
  295. package/src/transport/routing/candidate-index.ts +88 -0
  296. package/src/transport/routing/reservations.ts +121 -7
  297. package/src/transport/routing/round-robin.ts +10 -22
  298. package/src/transport/routing/route-catalog.ts +26 -13
  299. package/src/transport/routing/route-model.ts +22 -2
  300. package/src/transport/routing/router.ts +53 -31
  301. package/src/transport/surface/chat/encode.ts +2 -3
  302. package/src/transport/surface/chat/parse.ts +2 -1
  303. package/src/transport/surface/dialects.ts +11 -3
  304. package/src/transport/surface/messages/encode.ts +2 -1
  305. package/src/transport/surface/messages/parse.ts +3 -1
  306. package/src/transport/surface/messages/stream.ts +2 -6
  307. package/src/workers/maintenance-lease.ts +200 -0
  308. package/src/workers/scheduler.ts +101 -1
  309. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  310. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  311. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  312. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  313. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  314. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  315. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
  316. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  317. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  318. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  319. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
  320. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  321. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
  322. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  323. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  324. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  325. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
  326. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  327. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
  328. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  329. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  330. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  331. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  332. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  333. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  334. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  335. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  336. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  337. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  338. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  339. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  340. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  341. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  342. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  343. package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
  344. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  346. package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
  347. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  348. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  349. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  350. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  351. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  352. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  353. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  354. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  355. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  356. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  357. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  358. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  359. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  360. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  361. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  362. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  363. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  364. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  365. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  366. package/src/observability/event-compaction.ts +0 -317
  367. package/src/observability/event-day-index.ts +0 -439
@@ -1,1406 +1,1496 @@
1
- /** Canonical Drizzle schema: every persistence table, enum, index, and shared type. */
2
- import { desc, sql } from "drizzle-orm";
3
- import {
4
- bigint, boolean, check, customType, index, integer, jsonb, numeric, pgEnum, pgTable,
5
- primaryKey, text, timestamp, uniqueIndex, uuid, type AnyPgColumn,
6
- } from "drizzle-orm/pg-core";
7
-
8
- export const TENANT_STATUSES = ["active", "suspended", "deleted"] as const;
9
- export type TenantStatus = (typeof TENANT_STATUSES)[number];
10
-
11
- export const tenants = pgTable(
12
- "tenants",
13
- {
14
- id: uuid("id").primaryKey().defaultRandom(),
15
- name: text("name").notNull(),
16
- status: text("status").$type<TenantStatus>().notNull(),
17
- // Intentionally inline (not createdAtColumn()): this root table is imported
18
- // by the helpers below; sharing the helper here would make a use-before-define.
19
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
20
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
21
- },
22
- (table) => [
23
- check("tenants_status_check", sql`${table.status} IN ('active', 'suspended', 'deleted')`),
24
- ],
25
- );
26
-
27
- // Canonical tenant/timestamp column definitions shared by every table.
28
-
29
- export function tenantRefNullable() {
30
- return uuid("tenant_id").references(() => tenants.id, { onDelete: "cascade" });
31
- }
32
-
33
- export function tenantRefRequired() {
34
- return uuid("tenant_id")
35
- .notNull()
36
- .references(() => tenants.id, { onDelete: "cascade" });
37
- }
38
-
39
- export function tenantRefPrimaryKey() {
40
- return uuid("tenant_id")
41
- .primaryKey()
42
- .references(() => tenants.id, { onDelete: "cascade" });
43
- }
44
-
45
- export function tenantCascadeSetNull() {
46
- return uuid("tenant_id").references(() => tenants.id, { onDelete: "set null" });
47
- }
48
-
49
- export function createdAtColumn() {
50
- return timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
51
- }
52
-
53
- export function updatedAtColumn() {
54
- return timestamp("updated_at", { withTimezone: true }).notNull().defaultNow();
55
- }
56
-
57
- export function timestampColumns() {
58
- return { createdAt: createdAtColumn(), updatedAt: updatedAtColumn() };
59
- }
60
-
61
- /**
62
- * `bytea` column for encrypted credential ciphertext (application-layer
63
- * envelope encryption: the ciphertext stored here is opaque to the
64
- * database). Drizzle's pg-core does not ship a first-class `bytea`
65
- * builder, so this is the documented `customType` escape hatch (the same
66
- * DDL-shape escape-hatch principle applied to column-type gaps in the
67
- * ORM's builder).
68
- */
69
- export const bytea = customType<{ data: Buffer }>({
70
- dataType() {
71
- return "bytea";
72
- },
73
- });
74
-
75
- // Shared three-state health machine for provider_accounts and network_pools —
76
- // the account state machine is reused for network pools rather than
77
- // inventing a second one.
78
- //
79
- // `degraded` was retired. It sat between "working" and "parked" and every
80
- // consumer had to decide what it meant: routing treated it as a hard exclusion,
81
- // the sweep treated it as recoverable, and the console showed it as a third
82
- // badge — three readings of one value, and the operator reported the result as
83
- // ambiguous. The two facts it mixed are now each stated by a value that means
84
- // exactly one thing: a fault that clears on its own is `cooldown` (with a
85
- // deadline the sweep honors), and a fault that needs an operator is `disabled`.
86
- export const healthStatus = pgEnum("health_status", ["active", "cooldown", "disabled"]);
87
-
88
- /**
89
- * The health state machine shared by `provider_accounts` and `network_pools`.
90
- * One definition, because the two tables are driven by the same transitions and
91
- * a column added to one of them by hand is how they drift.
92
- */
93
- export function healthColumns() {
94
- return {
95
- status: healthStatus("status").notNull().default("active"),
96
- consecutiveFailures: integer("consecutive_failures").notNull().default(0),
97
- lastSuccessAt: timestamp("last_success_at", { withTimezone: true }),
98
- lastError: text("last_error"),
99
- lastErrorCategory: text("last_error_category"),
100
- lastErrorAt: timestamp("last_error_at", { withTimezone: true }),
101
- cooldownUntil: timestamp("cooldown_until", { withTimezone: true }),
102
- lastRecoveredAt: timestamp("last_recovered_at", { withTimezone: true }),
103
- };
104
- }
105
-
106
- export const credentialKind = pgEnum("credential_kind", ["api_key", "oauth", "none"]);
107
-
108
- // Shared wire-family domain used by providers.wire_family_default and
109
- // models.wire_family.
110
- //
111
- // `gemini` is Factory's native generateContent wire. Added by forward
112
- // migration `0002_add_gemini_wire_family.sql`; the enum here must stay in
113
- // step with what that migration applies, or a fresh database (built from the
114
- // baseline) and a migrated one would disagree.
115
- export const wireFamily = pgEnum("wire_family", ["chat", "responses", "messages", "gemini"]);
116
-
117
- // `https` is a hosted relay front door (Vercel, Cloudflare, Deno, Netlify):
118
- // plain HTTP CONNECT to an origin that terminates TLS itself, so the transport
119
- // is recorded as what it speaks rather than as a generic proxy.
120
- export const networkPoolKind = pgEnum("network_pool_kind", ["http", "https", "socks5"]);
121
-
122
- // health_events.entity_kind — distinguishes which owning
123
- // table entity_id/account_id/network_pool_id references.
124
- export const healthEntityKind = pgEnum("health_entity_kind", ["account", "pool"]);
125
-
126
- export const telemetrySourceSurface = pgEnum("telemetry_source_surface", [
127
- "chat",
128
- "responses",
129
- "messages",
130
- "completion",
131
- ]);
132
-
133
- export const telemetryStatus = pgEnum("telemetry_status", [
134
- "completed",
135
- "failed",
136
- "cancelled",
137
- "truncated",
138
- ]);
139
-
140
- // The id matches the seven canonical builtin IDs (openai, anthropic, claude,
141
- // codex, opencode-zen, ollamacloud, cerebras) or a custom/BYOK slug
142
- // rejected at the identity boundary when it collides case-insensitively with
143
- // a reserved ID — that rejection is application-layer, not a DB constraint
144
- // here. `capability_profile` is a JSONB routing-capability map with no index:
145
- // it is read with the provider row the snapshot builder already loads, so a
146
- // separate index would only add write cost. `tenant_id`
147
- // null means the provider is global/built-in, matching the same convention
148
- // as `provider_accounts.tenant_id`; populated means tenant-owned/BYOK.
149
- // `base_url`/`compatibility_profile` are BYOK-only: `compatibility_profile`
150
- // carries HTTP dispatch config (extra headers/query params/streaming mode)
151
- // and is intentionally a separate column from `capability_profile`, which
152
- // drives routing capability flags — conflating the two would corrupt routing
153
- // data with unrelated HTTP config.
154
- export const providers = pgTable(
155
- "providers",
156
- {
157
- id: text("id").primaryKey(),
158
- tenantId: tenantRefNullable(),
159
- wireFamilyDefault: wireFamily("wire_family_default"),
160
- capabilityProfile: jsonb("capability_profile"),
161
- baseUrl: text("base_url"),
162
- compatibilityProfile: jsonb("compatibility_profile"),
163
- enabled: boolean("enabled").notNull().default(true),
164
- // Builtin default `true` (most providers need a real credential). Only a
165
- // genuinely public/unauthenticated builtin (OpenCode Free) is seeded
166
- // `false`. When `false`, the snapshot builder emits a routable candidate
167
- // with zero `provider_accounts` rows instead of marking it `disabled` —
168
- // see `src/transport/routing/route-catalog.ts`.
169
- requiresAccount: boolean("requires_account").notNull().default(true),
170
- },
171
- (table) => [
172
- // `globalOrOwnedBy(providers.tenantId, …)` is the predicate behind
173
- // `listModelsForTenant` and `/quota/overview`, and it is a bare
174
- // `tenant_id IS NULL OR tenant_id = ?` — which no other index here can
175
- // serve, so both reads scanned the whole provider list. Cheap to keep:
176
- // providers are few and written rarely.
177
- index("providers_tenant_id_idx").on(table.tenantId),
178
- ],
179
- );
180
-
181
- // Upstream account credentials plus the full health state machine.
182
- // `tenant_id` null means the account is shared pool-wide; populated means
183
- // tenant-owned/BYOK. The concurrency ceiling lives in provider routing
184
- // (`provider_routing_settings.max_inflight`) and network pools
185
- // (`network_pools.max_inflight`); there is no per-account override.
186
- export const providerAccounts = pgTable("provider_accounts", {
187
- id: uuid("id").primaryKey().defaultRandom(),
188
- providerId: text("provider_id")
189
- .notNull()
190
- .references(() => providers.id, { onDelete: "cascade" }),
191
- tenantId: tenantRefNullable(),
192
- label: text("label").notNull(),
193
- credentialCiphertext: bytea("credential_ciphertext"),
194
- /**
195
- * HMAC fingerprint of the credential, never the plaintext secret. Nullable
196
- * for credential-less accounts and legacy rows without an identity.
197
- */
198
- credentialFingerprint: text("credential_fingerprint"),
199
- credentialKind: credentialKind("credential_kind").notNull(),
200
- /**
201
- * Per-account upstream auth configuration that is not a secret: auth method,
202
- * region, profile ARN, OAuth client id, token endpoint, scopes. The adapter
203
- * reads it where the credential is resolved (it selects the regional endpoint
204
- * and the profile the upstream binds the request to) and token refresh reads
205
- * it again to pick the right endpoint and client. Secrets never live here —
206
- * see `credential_ciphertext` and `provider_oauth_states`.
207
- */
208
- authState: jsonb("auth_state"),
209
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
210
-
211
- ...healthColumns(),
212
- modelCooldowns: jsonb("model_cooldowns").notNull().default({}),
213
- /**
214
- * How many times each model has been reported unsupported for this account:
215
- * `{ [modelId]: hits }`. Written only by the unsupported-model path, and it
216
- * is the escalation level for that pair's cooldown.
217
- *
218
- * Deliberately a separate column from `model_cooldowns`: that value is
219
- * `{ [modelId]: deadline }`, read by routing (`route-catalog.ts`) and by the
220
- * console, and widening it to `{until, hits}` would have changed a shape two
221
- * independent readers parse. Nullable — an account that never hit an
222
- * unsupported model has nothing to count.
223
- */
224
- modelBlockHits: jsonb("model_block_hits").$type<Readonly<Record<string, number>>>(),
225
- /**
226
- * Stable list position within (tenant, provider). Accounts were ordered by
227
- * `created_at`, which let two same-millisecond rows swap between loads; this
228
- * is explicit and can be rewritten when the operator reorders or appends.
229
- */
230
- sortIndex: integer("sort_index").notNull().default(0),
231
- /**
232
- * The credential is a static bearer token (a JWT/access token pasted on its
233
- * own, or an account an operator has pinned): it is used exactly as issued and
234
- * must never be sent to a refresh endpoint. Distinct from `credentialKind`,
235
- * which says what family the credential belongs to, not whether it can be
236
- * re-minted — an `oauth` account can carry a static token when its refresh
237
- * grant is absent or dead while the token itself is still valid. A static
238
- * account is skipped by the refresh sweep and is never disabled by a refresh
239
- * failure, because there is no refresh to run.
240
- */
241
- staticToken: boolean("static_token").notNull().default(false),
242
- /**
243
- * When this account's credential was last read for its own identity (email,
244
- * display name). Null means never read.
245
- *
246
- * This is the convergence stamp for the enrichment sweep, and the reason an
247
- * import can be instant: the batch path persists the credential without
248
- * parsing it, and the sweep fills the identity in afterwards. An account is
249
- * only listed while this is null, so a completed pass never re-decrypts a
250
- * credential it already read.
251
- */
252
- identityCheckedAt: timestamp("identity_checked_at", { withTimezone: true }),
253
- /**
254
- * Email read out of the account's own JWT claims. Cosmetic: it names the
255
- * account in the console and is never used to authenticate or route.
256
- */
257
- accountEmail: text("account_email"),
258
-
259
- /**
260
- * The plan the provider reports for this account, last seen by a quota read.
261
- *
262
- * Persisted rather than read from the quota cache: that cache is a short
263
- * in-process TTL and empty on a cold start, so the plan would be unknown
264
- * exactly when a freshly booted process makes its first routing decisions.
265
- *
266
- * Nullable: most providers report no plan at all, and an account never read
267
- * must say so rather than have a plan invented for it. Routing reads absence
268
- * as "unknown", never as "free" — guessing free would send pro-only traffic
269
- * to an account that may well be refused.
270
- */
271
- quotaPlan: text("quota_plan"),
272
-
273
- },
274
- (table) => [
275
- uniqueIndex("provider_accounts_identity_uidx").on(
276
- table.providerId,
277
- // No `::uuid` cast: the migrations create this index without one on
278
- // SQLite (baseline l.440) and with one on Postgres (l.423), and the
279
- // Drizzle definition is what a test's in-memory database is built
280
- // from — a Postgres-only cast there made every account insert fail
281
- // with `unrecognized token: ":"`.
282
- sql`coalesce(${table.tenantId}, '00000000-0000-0000-0000-000000000000')`,
283
- table.credentialFingerprint,
284
- ),
285
- index("provider_accounts_status_cooldown_idx").on(table.status, table.cooldownUntil),
286
- index("provider_accounts_provider_tenant_idx").on(table.providerId, table.tenantId),
287
- // The bare `tenant_id IS NULL OR tenant_id = ?` that `listAllAccounts`
288
- // uses is served by neither index above: `(status, cooldown_until)` leads
289
- // with the wrong column, and `(provider_id, tenant_id)` cannot serve a
290
- // predicate that does not constrain `provider_id`. This one matches the
291
- // hot listing path.
292
- index("provider_accounts_tenant_id_idx").on(table.tenantId),
293
- ],
294
- );
295
-
296
-
297
- // OAuth refresh token + cross-process lease, normalized out of
298
- // `provider_accounts` into its own 1:0/1 table. The vast majority of
299
- // provider accounts are `credentialKind: "api_key"`, for which these four
300
- // columns stayed null forever on the shared row; splitting them here keeps
301
- // the hot health-machine row narrow and isolates the fenced compare-and-swap
302
- // lease traffic (`providers/auth/refresh-service.ts`) from unrelated
303
- // account reads/writes.
304
- export const providerOauthStates = pgTable("provider_oauth_states", {
305
- providerAccountId: uuid("provider_account_id")
306
- .primaryKey()
307
- .references(() => providerAccounts.id, { onDelete: "cascade" }),
308
- /**
309
- * Encrypted refresh token. Nullable: an account can be pasted with only an
310
- * access token (or a credential whose refresh token never arrived), and the
311
- * account must still be tracked so the refresh sweep can flag it for re-auth
312
- * rather than leaving it invisible. A null value means "cannot refresh".
313
- */
314
- refreshCiphertext: bytea("refresh_ciphertext"),
315
- /**
316
- * Encrypted companion secret for the minority of flows whose refresh is not
317
- * authorized by the refresh token alone: a device-flow client registration
318
- * mints a client secret that must be replayed beside the token. Nullable —
319
- * most providers refresh with the refresh token by itself.
320
- */
321
- clientSecretCiphertext: bytea("client_secret_ciphertext"),
322
- /**
323
- * Access-token expiry. Nullable: a credential pasted without an expiry (an
324
- * opaque token, or a bare access token) has no known deadline, and reads as
325
- * "always due" so the sweep refreshes it as soon as it can.
326
- */
327
- expiresAt: timestamp("expires_at", { withTimezone: true }),
328
- // OAuth refresh lease: fenced compare-and-swap coordination so only one
329
- // process refreshes a given account's OAuth token at a time, and a losing
330
- // process reloads instead of clobbering a peer's fresher token.
331
- leaseOwner: text("lease_owner"),
332
- leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }),
333
- });
334
-
335
-
336
- // One row per (provider_id, model_id, route), with `endpoint_path`
337
- // identifying the route — the same model through two providers, or through
338
- // two routes of one provider, is two rows. `enabled` is a hard routing
339
- // invariant read by ProviderCatalogService, not a display-only flag.
340
- export const models = pgTable(
341
- "models",
342
- {
343
- id: uuid("id").primaryKey().defaultRandom(),
344
- providerId: text("provider_id")
345
- .notNull()
346
- .references(() => providers.id, { onDelete: "cascade" }),
347
- modelId: text("model_id").notNull(),
348
- wireFamily: wireFamily("wire_family").notNull(),
349
- // Which protocol shape at all (`llm` = the canonical chat pipeline). A
350
- // non-`llm` row (System One) is served by its native passthrough route, not
351
- // by the surface codecs. Kept a plain text column with a default rather than
352
- // a pgEnum: unlike `wire_family`, the set is expected to grow (embeddings,
353
- // tts, …) and a widened enum needs a migration per addition.
354
- serviceKind: text("service_kind").notNull().default("llm"),
355
- endpointPath: text("endpoint_path").notNull(),
356
- contextLimit: integer("context_limit"),
357
- outputLimit: integer("output_limit"),
358
- modalities: jsonb("modalities"),
359
- reasoning: boolean("reasoning").notNull().default(false),
360
- toolCall: boolean("tool_call").notNull().default(false),
361
- cost: jsonb("cost"),
362
- source: text("source"),
363
- sourceUpdatedAt: timestamp("source_updated_at", { withTimezone: true }),
364
- enabled: boolean("enabled").notNull().default(true),
365
- /**
366
- * Whether the upstream refuses this model on a free plan.
367
- *
368
- * Nullable three-valued: `true` refused on free, `false` served on free,
369
- * `NULL` nobody established it. `NULL` is the state of every model on every
370
- * provider that publishes nothing about plan access, and collapsing it to
371
- * `false` would assert a measured fact for rows nobody measured.
372
- *
373
- * A column rather than a derived rule because CodeBuddy publishes nothing
374
- * that predicts access — `credits` and `vendor` were both measured against
375
- * real dispatches and neither correlates. Only the refusal itself is a
376
- * signal, so the answer has to be recorded. See
377
- * `.skills/cartethyia-reverse-engineering/references/codebuddy-dispatch-headers.md`.
378
- */
379
- proOnly: boolean("pro_only"),
380
- /**
381
- * Whether the provider publishes this model as free.
382
- *
383
- * Mirrors `proOnly` but sourced from the provider's own directory rather
384
- * than inferred: Qoder states `is_free` per row, so the tier tracks a plan
385
- * change without a code change. Nullable three-valued — absence means the
386
- * provider did not state it, which is not the same as billed.
387
- */
388
- freeTier: boolean("free_tier"),
389
- },
390
- (table) => [
391
- uniqueIndex("models_provider_model_route_uidx").on(
392
- table.providerId,
393
- table.modelId,
394
- table.endpointPath,
395
- ),
396
- index("models_provider_enabled_idx").on(table.providerId, table.enabled),
397
- // Bare-id lookups, with no provider named: see migration
398
- // `0003_add_models_model_id_index`. Without it those seq-scan this table,
399
- // which carries one row per provider x model x route.
400
- index("models_model_id_idx").on(table.modelId),
401
- ],
402
- );
403
-
404
- // Tenant-scoped suppression of a GLOBAL/built-in model. Tenants cannot mutate
405
- // the shared `models` row (see `DrizzleProviderCatalogStore.setModelEnabled`),
406
- // so a disable is recorded here and filtered out of that tenant's routing
407
- // snapshot. Tenant-owned/BYOK models are still toggled directly on `models`.
408
- export const tenantDisabledModels = pgTable(
409
- "tenant_disabled_models",
410
- {
411
- id: uuid("id").primaryKey().defaultRandom(),
412
- tenantId: tenantRefRequired(),
413
- providerId: text("provider_id")
414
- .notNull()
415
- .references(() => providers.id, { onDelete: "cascade" }),
416
- modelId: text("model_id").notNull(),
417
- endpointPath: text("endpoint_path").notNull(),
418
- createdAt: createdAtColumn(),
419
- },
420
- (t) => [
421
- uniqueIndex("tenant_disabled_models_tenant_provider_model_uidx").on(
422
- t.tenantId,
423
- t.providerId,
424
- t.modelId,
425
- t.endpointPath,
426
- ),
427
- ],
428
- );
429
-
430
-
431
- // Tenant-owned HTTP/HTTPS/SOCKS5 pools share the provider-account health state machine.
432
- export const networkPools = pgTable("network_pools", {
433
- id: uuid("id").primaryKey().defaultRandom(),
434
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
435
- kind: networkPoolKind("kind").notNull(),
436
- /**
437
- * Operator-set family/tag for this pool, e.g. `residential-id` or
438
- * `datacenter-sg`. Purely organisational: it names which family the node
439
- * belongs to and nothing in routing or dispatch reads it. Null when the
440
- * operator has not tagged the pool.
441
- */
442
- family: text("family"),
443
- endpointConfig: jsonb("endpoint_config").notNull(),
444
- credentialCiphertext: bytea("credential_ciphertext"),
445
- maxInflight: integer("max_inflight"),
446
- /** Routing weight for weighted selection across the tenant pool group. */
447
- weight: integer("weight"),
448
- tenantId: tenantRefRequired(),
449
-
450
- ...healthColumns(),
451
- lastLatencyMs: integer("last_latency_ms"),
452
- lastHealthCheckAt: timestamp("last_health_check_at", { withTimezone: true }),
453
- /** Public egress address reported by the last successful probe; null until probed. */
454
- egressIp: text("egress_ip"),
455
- /** Operator-set egress allowance in bytes; null means unmetered. */
456
- quotaBytes: bigint("quota_bytes", { mode: "number" }),
457
- /** Last speed-test payload actually transferred, in bytes; null until measured. */
458
- lastSpeedtestBytes: integer("last_speedtest_bytes"),
459
- /** Wall-clock duration of the last speed-test transfer, in milliseconds. */
460
- lastSpeedtestDurationMs: integer("last_speedtest_duration_ms"),
461
- /** "ok" | "failed" for the last speed-test; null until measured. */
462
- lastSpeedtestStatus: text("last_speedtest_status"),
463
- /** Failure message of the last speed-test; null on success or unmeasured. */
464
- lastSpeedtestError: text("last_speedtest_error"),
465
- /** When the last speed-test ran. */
466
- lastSpeedtestAt: timestamp("last_speedtest_at", { withTimezone: true }),
467
- /**
468
- * Provider-scoped probe verdict, stored beside the other last-known facts
469
- * (`egress_ip`, `last_latency_ms`, the speed-test columns) rather than in the
470
- * browser.
471
- *
472
- * Browser storage is not enough: a verdict held only there is visible to the
473
- * operator who ran it and to nobody else — not to another machine, not to the
474
- * gateway, and not to admission. A pool would read "OpenCode blocked" on
475
- * screen while dispatch kept sending traffic through it, because nothing
476
- * outside that one browser had heard the verdict.
477
- *
478
- * `null` means never probed. These describe the *address* recorded in
479
- * `egress_ip` at the time, so a changed endpoint clears them.
480
- */
481
- providerProbeProviderId: text("provider_probe_provider_id"),
482
- /** `allowed` | `blocked` | `throttled` | `unreachable`; null until probed. */
483
- providerProbeStatus: text("provider_probe_status"),
484
- /** Upstream HTTP status when a response arrived. */
485
- providerProbeHttpStatus: integer("provider_probe_http_status"),
486
- /** Operator-facing reason: the upstream's own words, or why the probe failed. */
487
- providerProbeReason: text("provider_probe_reason"),
488
- providerProbeLatencyMs: integer("provider_probe_latency_ms"),
489
- providerProbeAt: timestamp("provider_probe_at", { withTimezone: true }),
490
- /** The endpoint the verdict was measured against. Compared on read, so an
491
- * edited endpoint reports "never probed" instead of a stale verdict. */
492
- providerProbeEndpoint: text("provider_probe_endpoint"),
493
- /** Metered egress totals, flushed from the in-memory socket counters on
494
- * every pool-touching write so the quota bar survives a restart. */
495
- bytesSentTotal: bigint("bytes_sent_total", { mode: "number" }).notNull().default(0),
496
- /** Metered ingress totals; see `bytesSentTotal`. */
497
- bytesReceivedTotal: bigint("bytes_received_total", { mode: "number" }).notNull().default(0),
498
- },
499
- (table) => [
500
- index("network_pools_tenant_id_idx").on(table.tenantId),
501
- index("network_pools_status_cooldown_idx").on(table.status, table.cooldownUntil),
502
- index("network_pools_family_idx").on(table.family),
503
- ],
504
- );
505
-
506
-
507
- // Account and network-pool health events share one table. The entity kind
508
- // selects exactly one owner FK, and the CHECK constraint below preserves
509
- // referential integrity for both variants.
510
- export const healthEvents = pgTable(
511
- "health_events",
512
- {
513
- id: uuid("id").primaryKey().defaultRandom(),
514
- entityKind: healthEntityKind("entity_kind").notNull(),
515
- accountId: uuid("account_id").references(() => providerAccounts.id, { onDelete: "cascade" }),
516
- networkPoolId: uuid("network_pool_id").references(() => networkPools.id, {
517
- onDelete: "cascade",
518
- }),
519
- fromStatus: healthStatus("from_status"),
520
- toStatus: healthStatus("to_status").notNull(),
521
- reason: text("reason"),
522
- errorCategory: text("error_category"),
523
- modelId: text("model_id"),
524
- createdAt: createdAtColumn(),
525
- },
526
- (table) => [
527
- index("health_events_account_id_created_at_idx").on(table.accountId, table.createdAt),
528
- // Descending `created_at` is deliberate: health history is always read
529
- // newest-first. The SQLite baseline drops the direction (see
530
- // `scripts/tools/generate-sqlite.ts` — SQLite scans an index either way),
531
- // so this is a Postgres-only access-path choice, not a cross-backend
532
- // disagreement.
533
- index("health_events_account_model_created_idx").on(
534
- table.accountId,
535
- table.modelId,
536
- desc(table.createdAt),
537
- ),
538
- index("health_events_network_pool_id_created_at_idx").on(table.networkPoolId, table.createdAt),
539
- check(
540
- "health_events_entity_reference_check",
541
- sql`(${table.entityKind} = 'account' AND ${table.accountId} IS NOT NULL AND ${table.networkPoolId} IS NULL) OR (${table.entityKind} = 'pool' AND ${table.networkPoolId} IS NOT NULL AND ${table.accountId} IS NULL)`,
542
- ),
543
- ],
544
- );
545
-
546
-
547
- // Model aliasing & combos. Aliases resolve a client-facing name to a
548
- // real model; combos back a client-facing name with >=1 real models,
549
- // auto-selected via `fallback` or `round_robin`.
550
- export const modelComboStrategy = pgEnum("model_combo_strategy", [
551
- "fallback",
552
- "round_robin",
553
- ]);
554
-
555
- /** Canonical `ComboStrategy` union, derived from the enum above. Console
556
- * domain and routing layers import this instead of hand-typing their own mirror.
557
- * `ProviderRoutingStrategy` covers the same two values but persists on
558
- * `provider_routing_settings` beside `rotateCount`. */
559
- export type ComboStrategy = (typeof modelComboStrategy.enumValues)[number];
560
-
561
- export const modelAliases = pgTable(
562
- "model_aliases",
563
- {
564
- id: uuid("id").primaryKey().defaultRandom(),
565
- tenantId: tenantRefRequired(),
566
- alias: text("alias").notNull(),
567
- targetModel: text("target_model").notNull(),
568
- /** Explicit list position. */
569
- sortIndex: integer("sort_index").notNull().default(0),
570
- ...timestampColumns(),
571
- },
572
- (t) => [uniqueIndex("model_aliases_tenant_alias_uidx").on(t.tenantId, t.alias)],
573
- );
574
- export type ModelAlias = typeof modelAliases.$inferSelect;
575
-
576
- export const modelCombos = pgTable(
577
- "model_combos",
578
- {
579
- id: uuid("id").primaryKey().defaultRandom(),
580
- tenantId: tenantRefRequired(),
581
- name: text("name").notNull(),
582
- members: jsonb("members").notNull().$type<string[]>(),
583
- strategy: modelComboStrategy("strategy").notNull().default("fallback"),
584
- /** Explicit list position. */
585
- sortIndex: integer("sort_index").notNull().default(0),
586
- ...timestampColumns(),
587
- },
588
- (t) => [uniqueIndex("model_combos_tenant_name_uidx").on(t.tenantId, t.name)],
589
- );
590
- export type ModelCombo = typeof modelCombos.$inferSelect;
591
-
592
- export const providerRoutingStrategy = pgEnum("provider_routing_strategy", [
593
- "fallback",
594
- "round_robin",
595
- ]);
596
-
597
- /**
598
- * Canonical provider-routing strategy union, derived from the enum above so
599
- * the persisted values and the type cannot drift. The console catalog and the
600
- * routing engine both import this instead of restating the two literals.
601
- */
602
- export type ProviderRoutingStrategy = (typeof providerRoutingStrategy.enumValues)[number];
603
-
604
- /**
605
- * Per-account inflight ceiling applied when a provider has no routing row yet.
606
- * One literal: the console default and the route catalog's fallback both read
607
- * it. `null` is the "unlimited" choice, and it is also this default — an
608
- * operator who has never opened the routing card asked for no ceiling, and
609
- * inventing one silently capped their concurrency. A ceiling they *did* set is
610
- * still honoured, so this only decides the untouched case.
611
- */
612
- export const DEFAULT_PROVIDER_MAX_INFLIGHT: null = null;
613
-
614
- export const providerRoutingSettings = pgTable(
615
- "provider_routing_settings",
616
- {
617
- providerId: text("provider_id")
618
- .notNull()
619
- .references(() => providers.id, { onDelete: "cascade" }),
620
- tenantId: tenantRefNullable(),
621
- strategy: providerRoutingStrategy("strategy").notNull().default("fallback"),
622
- /** Requests served by one account before round robin advances. */
623
- rotateCount: integer("rotate_count").notNull().default(1),
624
- /** Per-account inflight ceiling applied to every account of this provider
625
- * (the field sits next to the failover/round-robin strategy in the UI).
626
- * `null` = UNLIMITED concurrency per account. */
627
- maxInflight: integer("max_inflight"),
628
- /**
629
- * Per-pool inflight ceiling for this provider: how many concurrent
630
- * requests one proxy pool may carry *for this provider*.
631
- *
632
- * A second, independent axis from `maxInflight`. That one bounds a single
633
- * account; `network_pools.max_inflight` bounds a pool across every
634
- * provider; this one bounds one provider's share of one pool. All three
635
- * are enforced together — none replaces another.
636
- *
637
- * `null` = unlimited, and that is the default: an unconfigured provider
638
- * costs no extra Redis key and no extra round trip.
639
- */
640
- maxInflightPerProxy: integer("max_inflight_per_proxy"),
641
- /**
642
- * Minimum remaining allowance an account must hold to stay routable, for
643
- * this provider. `null` means no floor. Compared against the account's own
644
- * unit: absolute remaining credit when the provider reports credits, the
645
- * aggregate credit percentage and lowest independent percentage window
646
- * otherwise. An account below it is skipped and the request fails over to
647
- * the next account instead of erroring.
648
- */
649
- usageFloor: integer("usage_floor"),
650
- /** `credit` compares the summed remaining credit; `percent` compares the
651
- * aggregate credit percentage and lowest independent percentage-only
652
- * window. Ignored while `usageFloor` is null. */
653
- usageFloorUnit: text("usage_floor_unit").$type<"credit" | "percent">().notNull().default("percent"),
654
- enabled: boolean("enabled").notNull().default(false),
655
- // Route-selected User-Agent for built-in API-key providers; OAuth and BYOK identities stay native.
656
- // The column default is the Claude Code CLI identity; the live default for a
657
- // row that never set one is `buildClaudeCliUserAgent()` in
658
- // providers/operations/custom-cli-headers.ts, which this mirrors.
659
- userAgent: text("user_agent").notNull().default("claude-cli/2.1.293 (external, cli)"),
660
- // When true, this provider's requests always dial direct. When false,
661
- // dispatch automatically selects among every active network pool the
662
- // tenant owns (least-loaded/non-cooldown) — never an admin-pinned pool.
663
- skipProxy: boolean("skip_proxy").notNull().default(false),
664
- },
665
- (table) => ({
666
- tenantProviderUnique: uniqueIndex("provider_routing_settings_tenant_provider_idx").on(
667
- table.tenantId,
668
- table.providerId,
669
- ),
670
- globalProviderUnique: uniqueIndex("provider_routing_settings_global_provider_idx")
671
- .on(table.providerId)
672
- .where(sql`tenant_id IS NULL`),
673
- }),
674
- );
675
-
676
- export const poolRoutingStrategy = pgEnum("pool_routing_strategy", [
677
- "least_loaded",
678
- "round_robin",
679
- ]);
680
- export type PoolRoutingStrategy = (typeof poolRoutingStrategy.enumValues)[number];
681
-
682
- /**
683
- * Per-tenant selection strategy across the tenant's active network pools —
684
- * the pool-group counterpart to `provider_routing_settings`. `least_loaded`
685
- * (default) keeps the weighted least-loaded scan with its rotating tie-break;
686
- * `round_robin` serves `rotate_count` requests per pool before advancing,
687
- * mirroring the account strategy, and skips capacity/cooldown pools the way
688
- * account failover does. An absent row reads as the default, so tenants that
689
- * never touch the toggle own no rows.
690
- */
691
- export const poolRoutingSettings = pgTable("pool_routing_settings", {
692
- tenantId: tenantRefPrimaryKey(),
693
- strategy: poolRoutingStrategy("strategy").notNull().default("least_loaded"),
694
- /** Requests served by one pool before round robin advances. */
695
- rotateCount: integer("rotate_count").notNull().default(1),
696
- });
697
-
698
-
699
- export const API_KEY_MODES = ["personal", "share"] as const;
700
- export type ApiKeyMode = (typeof API_KEY_MODES)[number];
701
-
702
- /**
703
- * How a key's `modelList` is interpreted. Exactly two modes, never a
704
- * combination: `whitelist` allows only the listed names (an empty list allows
705
- * everything), `blacklist` refuses the listed names (an empty list refuses
706
- * nothing). Every model-authorization surface — the request path, admission,
707
- * `/v1/models`, and the share page — reads this one pair.
708
- */
709
- export const API_KEY_MODEL_ACCESS_MODES = ["whitelist", "blacklist"] as const;
710
- export type ApiKeyModelAccessMode = (typeof API_KEY_MODEL_ACCESS_MODES)[number];
711
-
712
- /**
713
- * Inbound `/v1/*` keys never store plaintext secrets. Personal keys carry an
714
- * authentication hash; share templates carry policy and child keys point back
715
- * to the template that issued them.
716
- */
717
- export const apiKeys = pgTable(
718
- "api_keys",
719
- {
720
- id: uuid("id").primaryKey().defaultRandom(),
721
- tenantId: tenantRefRequired(),
722
- keyHash: text("key_hash"),
723
- keyMode: text("key_mode").$type<ApiKeyMode>().notNull().default("personal"),
724
- parentKeyId: uuid("parent_key_id").references(
725
- (): AnyPgColumn => apiKeys.id,
726
- { onDelete: "cascade" },
727
- ),
728
- issuedClientIp: text("issued_client_ip"),
729
- issuedClientIpKey: text("issued_client_ip_key"),
730
- label: text("label").notNull(),
731
- scopes: jsonb("scopes").notNull().$type<readonly string[]>(),
732
- // Only the non-secret public key prefix is returned to the owner.
733
- keyPrefix: text("key_prefix"),
734
- // Personal keys retain an encrypted copy for the CLI handoff. Shared
735
- // children are revealed once and persist only their authentication hash.
736
- keyEncrypted: bytea("key_encrypted"),
737
- notesTitle: text("notes_title"),
738
- notesSubtitle: text("notes_subtitle"),
739
- notesBody: text("notes_body"),
740
- /** Explicit list position. */
741
- sortIndex: integer("sort_index").notNull().default(0),
742
- requestsPerMinute: integer("requests_per_minute"),
743
- dailyTokenLimit: bigint("daily_token_limit", { mode: "number" }),
744
- monthlyTokenLimit: bigint("monthly_token_limit", { mode: "number" }),
745
- lifetimeTokenBudget: bigint("lifetime_token_budget", { mode: "number" }),
746
- lifetimeTokensConsumed: bigint("lifetime_tokens_consumed", { mode: "number" })
747
- .notNull()
748
- .default(0),
749
- maxConcurrentRequests: integer("max_concurrent_requests"),
750
- /**
751
- * `whitelist` = only the names in `modelList` may be used (empty = all);
752
- * `blacklist` = the names in `modelList` are refused (empty = none). One
753
- * list, one mode.
754
- */
755
- modelAccessMode: text("model_access_mode")
756
- .$type<ApiKeyModelAccessMode>()
757
- .notNull()
758
- .default("whitelist"),
759
- modelList: jsonb("model_list").$type<readonly string[]>(),
760
- /**
761
- * Client-router ids this key refuses. A request whose fingerprint names one
762
- * of these is rejected before routing, so an operator can stop a downstream
763
- * router from spending the key. Empty/NULL means "no router is refused".
764
- *
765
- * A label, not a security control: a client that sends no fingerprint is
766
- * simply not matched, which is why this is a per-key policy rather than a
767
- * boundary anything else in the system trusts.
768
- */
769
- clientRouterDenylist: jsonb("client_router_denylist").$type<readonly string[]>(),
770
- modelPrefix: text("model_prefix"),
771
- createdAt: createdAtColumn(),
772
- enabled: boolean("enabled").notNull().default(true),
773
- revokedAt: timestamp("revoked_at", { withTimezone: true }),
774
- /**
775
- * The tenant's mandatory gateway key. Seeded at first boot whether or not
776
- * `CARTETHYIA_API_KEY` was configured, and the one key the console refuses
777
- * to revoke — it may only be rotated. A column rather than a label match,
778
- * so renaming the key cannot break the CLI lookup or let a second
779
- * default be created.
780
- */
781
- isDefault: boolean("is_default").notNull().default(false),
782
- },
783
- (table) => [
784
- uniqueIndex("api_keys_key_hash_idx").on(table.keyHash),
785
- /**
786
- * One *live* default key per tenant, enforced by the database.
787
- *
788
- * This is the only database-level guarantee behind `isDefault`, and
789
- * `test/persistence/sqlite-baseline.test.ts` pins it by name — so the
790
- * schema must keep declaring it to match the baseline.
791
- *
792
- * Partial on `revoked_at IS NULL` so a tenant can rotate its default key
793
- * without the old, revoked row blocking the new one.
794
- */
795
- uniqueIndex("api_keys_one_default_per_tenant_uidx")
796
- .on(table.tenantId)
797
- .where(sql`${table.isDefault} = true AND ${table.revokedAt} IS NULL`),
798
- index("api_keys_tenant_id_idx").on(table.tenantId),
799
- index("api_keys_enabled_tenant_idx").on(table.tenantId).where(sql`${table.enabled} = true`),
800
- index("api_keys_parent_key_id_idx").on(table.parentKeyId),
801
- check(
802
- "api_keys_mode_shape_check",
803
- sql`(
804
- (${table.keyMode} = 'personal' AND ${table.keyHash} IS NOT NULL
805
- AND ${table.parentKeyId} IS NULL AND ${table.issuedClientIp} IS NULL
806
- AND ${table.issuedClientIpKey} IS NULL)
807
- OR
808
- (${table.keyMode} = 'share' AND (
809
- (${table.parentKeyId} IS NULL AND ${table.keyHash} IS NULL
810
- AND ${table.keyEncrypted} IS NULL
811
- AND ${table.issuedClientIp} IS NULL AND ${table.issuedClientIpKey} IS NULL)
812
- OR
813
- (${table.parentKeyId} IS NOT NULL AND ${table.keyHash} IS NOT NULL
814
- AND ${table.keyEncrypted} IS NULL
815
- AND ${table.issuedClientIp} IS NOT NULL AND ${table.issuedClientIpKey} IS NOT NULL)
816
- ))
817
- )`,
818
- ),
819
- ],
820
- );
821
-
822
- /**
823
- * The optional popup a share link shows its recipient. Kept off `api_keys`
824
- * because the auth path reads that row on every request and the popup is
825
- * absent for nearly all of them. One row per key, present only when the owner
826
- * configured one.
827
- */
828
- export const sharePopups = pgTable(
829
- "share_popups",
830
- {
831
- apiKeyId: uuid("api_key_id")
832
- .primaryKey()
833
- .references(() => apiKeys.id, { onDelete: "cascade" }),
834
- enabled: boolean("enabled").notNull().default(false),
835
- image: bytea("image"),
836
- imageMime: text("image_mime"),
837
- title: text("title"),
838
- body: text("body"),
839
- },
840
- (table) => [
841
- check(
842
- "share_popups_image_mime_check",
843
- sql`${table.imageMime} IS NULL OR ${table.imageMime} IN ('image/png', 'image/jpeg', 'image/webp', 'image/gif')`,
844
- ),
845
- check(
846
- "share_popups_image_size_check",
847
- sql`${table.image} IS NULL OR octet_length(${table.image}) <= 2097152`,
848
- // SQLite has no octet_length. The generator rewrites this check to
849
- // `length(`; both count bytes for a blob column.
850
- ),
851
- ],
852
- );
853
-
854
-
855
- // Public bearer links for enrolling one shared key per resolved client IP, and
856
- // for handing a personal key to its owner. The hash is the lookup key; the
857
- // token is additionally retained encrypted so the owner's console can show the
858
- // link again instead of losing it after the one response that minted it.
859
- /**
860
- * Share-link kinds, as a runtime tuple. Route schemas and DTOs project this
861
- * list rather than restating it.
862
- */
863
- export const SHARE_LINK_KINDS = ["enroll", "handoff"] as const;
864
-
865
- /** One share-link kind. */
866
- export type ShareLinkKind = (typeof SHARE_LINK_KINDS)[number];
867
-
868
-
869
- export const shareLinks = pgTable(
870
- "share_links",
871
- {
872
- id: uuid("id").primaryKey().defaultRandom(),
873
- apiKeyId: uuid("api_key_id")
874
- .notNull()
875
- .references(() => apiKeys.id, { onDelete: "cascade" }),
876
- tokenHash: text("token_hash").notNull(),
877
- // Encrypted bearer token. Present so the console can re-display a stable
878
- // link; nullable, and a NULL row simply cannot be shown again.
879
- tokenEncrypted: bytea("token_encrypted"),
880
- /**
881
- * The public slug this page is reachable at, in the clear.
882
- *
883
- * `tokenEncrypted` is the bearer token under the deployment's encryption
884
- * key, so a link cannot be re-displayed once that key changes — which is
885
- * exactly what a restore onto a fresh deployment does. Holding the slug
886
- * unencrypted keeps every restored share page reachable under the address
887
- * it was created with.
888
- *
889
- * Nullable: a row without a slug has no address, and inventing one
890
- * would publish an address the page never had.
891
- */
892
- slug: text("slug"),
893
- kind: text("kind").$type<ShareLinkKind>().notNull().default("enroll"),
894
- active: boolean("active").notNull().default(true),
895
- createdAt: createdAtColumn(),
896
- expiresAt: timestamp("expires_at", { withTimezone: true }),
897
- lastViewedAt: timestamp("last_viewed_at", { withTimezone: true }),
898
- },
899
- (table) => [
900
- uniqueIndex("share_links_token_hash_idx").on(table.tokenHash),
901
- // Partial: rows without a slug are not constrained, so backfilling is never
902
- // blocked by a uniqueness collision.
903
- uniqueIndex("share_links_slug_uidx").on(table.slug).where(sql`${table.slug} IS NOT NULL`),
904
- index("share_links_api_key_idx").on(table.apiKeyId),
905
- index("share_links_active_kind_expires_idx").on(table.active, table.kind, table.expiresAt),
906
- check("share_links_kind_check", sql`${table.kind} IN ('enroll', 'handoff')`),
907
- ],
908
- );
909
-
910
-
911
- /**
912
- * Console dashboard users — distinct from API-key tenants.
913
- */
914
- export const consoleUsers = pgTable(
915
- "console_users",
916
- {
917
- id: uuid("id").primaryKey().defaultRandom(),
918
- tenantId: tenantRefRequired(),
919
- username: text("username").notNull(),
920
- email: text("email"),
921
- passwordHash: text("password_hash").notNull(),
922
- displayName: text("display_name"),
923
- isActive: boolean("is_active").notNull().default(true),
924
- isPlatformAdmin: boolean("is_platform_admin").notNull().default(false),
925
- isFirstBoot: boolean("is_first_boot").notNull().default(true),
926
- ...timestampColumns(),
927
- },
928
- (table) => [
929
- index("console_users_tenant_id_idx").on(table.tenantId),
930
- uniqueIndex("console_users_tenant_username_uidx").on(table.tenantId, table.username),
931
- ],
932
- );
933
-
934
- export type ConsoleUser = typeof consoleUsers.$inferSelect;
935
-
936
- /**
937
- * Console sessions — HTTP-only, signed cookies.
938
- * Session metadata only; no secrets in JSON.
939
- */
940
- export const consoleSessions = pgTable(
941
- "console_sessions",
942
- {
943
- id: uuid("id").primaryKey().defaultRandom(),
944
- userId: uuid("user_id")
945
- .notNull()
946
- .references(() => consoleUsers.id, { onDelete: "cascade" }),
947
- sessionToken: text("session_token").notNull().unique(),
948
- ipAddress: text("ip_address"),
949
- userAgent: text("user_agent"),
950
- expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
951
- createdAt: createdAtColumn(),
952
- },
953
- (table) => [
954
- index("console_sessions_user_id_idx").on(table.userId),
955
- ],
956
- );
957
-
958
- export type ConsoleSession = typeof consoleSessions.$inferSelect;
959
-
960
- /**
961
- * Console login lockout tracking — fail-closed rate limiting keyed by
962
- * client IP (login attempts happen before any user identity is known, so
963
- * this cannot be keyed by `console_users.id`). Persisted so a ban survives
964
- * a process restart and is shared across every app instance, instead of a
965
- * static in-process map that a second instance or a redeploy would forget.
966
- */
967
- export const consoleLockouts = pgTable(
968
- "console_lockouts",
969
- {
970
- id: uuid("id").primaryKey().defaultRandom(),
971
- ip: text("ip").notNull(),
972
- failureCount: integer("failure_count").notNull().default(0),
973
- windowUntil: timestamp("window_until", { withTimezone: true }),
974
- lockedUntil: timestamp("locked_until", { withTimezone: true }),
975
- reason: text("reason"),
976
- ...timestampColumns(),
977
- },
978
- (table) => [
979
- uniqueIndex("idx_console_lockouts_ip").on(table.ip),
980
- ],
981
- );
982
-
983
-
984
- // Configuration-change audit has its own retention lifecycle; it is separate
985
- // from metadata telemetry and short-lived optional payload capture.
986
- export const adminAuditLog = pgTable(
987
- "admin_audit_log",
988
- {
989
- id: uuid("id").primaryKey().defaultRandom(),
990
- createdAt: createdAtColumn(),
991
- actor: text("actor").notNull(),
992
- tenantId: tenantCascadeSetNull(),
993
- action: text("action").notNull(),
994
- target: text("target").notNull(),
995
- detail: jsonb("detail"),
996
- },
997
- (table) => [
998
- index("admin_audit_log_tenant_id_created_at_id_idx").on(
999
- table.tenantId,
1000
- table.createdAt,
1001
- table.id,
1002
- ),
1003
- ],
1004
- );
1005
-
1006
-
1007
- // Runtime preferences are stored in one validated JSONB bag. The filter
1008
- // master toggle remains a typed column because it is read on the proxy path.
1009
- export interface ConsoleSettingsPreferences {
1010
- tenantConcurrencyLimit?: number | null;
1011
- /** Normalizes provider-native thinking config for non-user final turns. */
1012
- thinkingNormalizationEnabled?: boolean;
1013
- responsesReasoningSummary?: "auto" | "concise" | "detailed";
1014
- /**
1015
- * Request compression: compact bulky tool-result text in the message history
1016
- * before dispatch (RTK prune). Default off.
1017
- */
1018
- rtkPruneEnabled?: boolean;
1019
- /** RTK prune strength when enabled (`lite`/`full`/`ultra`); `full` when unset. */
1020
- rtkPruneLevel?: "lite" | "full" | "ultra";
1021
- /**
1022
- * Request compression: append a minimal-code directive to the system content.
1023
- * Default off; `ponyTailLevel` selects the intensity when on.
1024
- */
1025
- ponyTailEnabled?: boolean;
1026
- /** PonyTail directive intensity; `full` when unset. Only applied when enabled. */
1027
- ponyTailLevel?: "lite" | "full" | "ultra" | null;
1028
- /**
1029
- * Payload capture mode (default `none` when unset):
1030
- * - `metadata` — Proxy→Provider method + allowlisted headers only
1031
- * - `full` — full redacted bodies up to the configured capture limit
1032
- * - `none` — no drawer capture (request metadata events still retained)
1033
- */
1034
- telemetryPayloads?: "full" | "metadata" | "none";
1035
- /**
1036
- * Capture depth for `full` mode (default `minimum` when unset). Fixed size
1037
- * tiers so every debug sees all four drawer panels — only the cap changes:
1038
- * - `minimum` — 1 MiB combined cap, light enough to leave on
1039
- * - `moderate` — 8 MiB combined cap, expect CPU/memory spikes while active
1040
- * - `maximum` — 16 MiB combined cap, expect CPU/memory spikes while active
1041
- */
1042
- telemetryPayloadDepth?: "minimum" | "moderate" | "maximum";
1043
- /**
1044
- * How long a captured payload frame is kept, in whole minutes.
1045
- *
1046
- * `null` or absent means the deployment default
1047
- * (`CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS`, 15 minutes). Raising it is a
1048
- * real trade: a frame is a gzipped copy of up to four request bodies, so
1049
- * keeping them for a week is keeping a week of bodies on disk. The upper
1050
- * bound is seven days, enforced by the capture path rather than trusted here.
1051
- */
1052
- telemetryPayloadRetentionMinutes?: number | null;
1053
- privacyMode?: "masked" | "full";
1054
- /**
1055
- * Ignores an account-wide cooldown when building route candidates.
1056
- *
1057
- * Off by default, and deliberately dangerous: an account in cooldown was
1058
- * parked because the upstream refused it, and dialing it anyway reproduces
1059
- * that refusal on every attempt. Turning this on can exhaust the candidate
1060
- * list and fail a request that would otherwise have been routed.
1061
- *
1062
- * The override is read-only — the cooldown is still recorded, still shown in
1063
- * Health, and still recovered by the sweep. Nothing is written back, so the
1064
- * toggle can be turned off again without cleaning anything up.
1065
- *
1066
- * Per-(account, model) cooldowns are NOT overridden. Those are usually a
1067
- * named quota ("daily free limit reached, try again in 11h") where the
1068
- * upstream stated a real reset; ignoring it only buys repeated refusals.
1069
- */
1070
- forceDisableCooldown?: boolean;
1071
- /**
1072
- * Per-tenant upstream request timeout in seconds, overriding
1073
- * `CARTETHYIA_UPSTREAM_TIMEOUT_MS`. `null` or absent means use the
1074
- * deployment default.
1075
- */
1076
- upstreamTimeoutSeconds?: number | null;
1077
- /**
1078
- * Per-tenant telemetry compaction age in days, overriding
1079
- * `CARTETHYIA_TELEMETRY_COMPACT_AFTER_DAYS`. `null` or absent means use the
1080
- * deployment default. Lower keeps less history but bounds the usage-list
1081
- * scan; see the compaction notes in `event-compaction.ts`.
1082
- */
1083
- telemetryWindowMinutes?: number | null;
1084
- /**
1085
- * Per-tenant ceiling for one inline attachment, in bytes, overriding the
1086
- * deployment default of 10 MB.
1087
- *
1088
- * Raising this asserts that this tenant's upstreams accept larger
1089
- * attachments. The clamp is a hard send gate precisely because an oversized
1090
- * block poisons every retry and every replayed turn — the upstream rejects
1091
- * the whole request rather than the one block — so an override larger than
1092
- * the provider actually accepts trades that protection away. Lowering it is
1093
- * the safer direction and the more common need.
1094
- */
1095
- imageClampMaxBytes?: number | null;
1096
- }
1097
-
1098
- export const consoleSettings = pgTable("console_settings", {
1099
- tenantId: tenantRefPrimaryKey(),
1100
- preferences: jsonb("preferences").notNull().default({}).$type<ConsoleSettingsPreferences>(),
1101
- updatedAt: updatedAtColumn(),
1102
- });
1103
-
1104
-
1105
- // CLI Tools (CLI tool configuration): per-tenant mapping table that lets a coding-agent
1106
- // slot (e.g. Claude Code's "opus" slot) target a specific Cartethyia model. Rows
1107
- // are opaque strings — validation happens at the API boundary against the
1108
- // tool registry, not in Postgres.
1109
- export const cliToolMappings = pgTable(
1110
- "cli_tool_mappings",
1111
- {
1112
- id: uuid("id").primaryKey().defaultRandom(),
1113
- tenantId: tenantRefRequired(),
1114
- apiKeyId: uuid("api_key_id")
1115
- .notNull()
1116
- .references((): AnyPgColumn => apiKeys.id, { onDelete: "cascade" }),
1117
- toolId: text("tool_id").notNull(),
1118
- slotKey: text("slot_key").notNull(),
1119
- sourceModel: text("source_model").notNull(),
1120
- targetModel: text("target_model").notNull(),
1121
- enabled: boolean("enabled").notNull().default(true),
1122
- updatedAt: updatedAtColumn(),
1123
- },
1124
- (table) => [
1125
- uniqueIndex("cli_tool_mappings_key").on(
1126
- table.tenantId,
1127
- table.toolId,
1128
- table.apiKeyId,
1129
- table.slotKey,
1130
- ),
1131
- ],
1132
- );
1133
-
1134
-
1135
- // Per-(tenant, tool, api_key) settings: whether mappings are active and which
1136
- // mode the tool is configured for. Primary key is the composite of all three.
1137
- export const cliToolSettings = pgTable(
1138
- "cli_tool_settings",
1139
- {
1140
- tenantId: tenantRefRequired(),
1141
- apiKeyId: uuid("api_key_id")
1142
- .notNull()
1143
- .references((): AnyPgColumn => apiKeys.id, { onDelete: "cascade" }),
1144
- toolId: text("tool_id").notNull(),
1145
- mappingsEnabled: boolean("mappings_enabled").notNull().default(false),
1146
- mode: text("mode").notNull().default("remote"),
1147
- updatedAt: updatedAtColumn(),
1148
- },
1149
- (table) => [primaryKey({ columns: [table.tenantId, table.toolId, table.apiKeyId] })],
1150
- );
1151
-
1152
-
1153
- // Metadata-only request telemetry. Retention is configured independently of
1154
- // payload capture (`CARTETHYIA_TELEMETRY_RETENTION_DAYS`, default 30 days).
1155
- // No prompt text, raw body, API key, encrypted-reasoning payload, or generic
1156
- // `payload` column — the schema structurally prevents those fields.
1157
- // Correlation columns intentionally have no catalog foreign keys, so a
1158
- // telemetry write never depends on provider-row lifetime.
1159
- export const telemetryEvents = pgTable(
1160
- "telemetry_events",
1161
- {
1162
- id: uuid("id").primaryKey().defaultRandom(),
1163
- createdAt: createdAtColumn(),
1164
- tenantId: tenantRefRequired(),
1165
- requestId: uuid("request_id").notNull(),
1166
- sourceSurface: telemetrySourceSurface("source_surface"),
1167
- requestedModel: text("requested_model"),
1168
- /**
1169
- * The `model` the upstream put in its own response, verbatim.
1170
- *
1171
- * Separate from `requested_model` because they answer different questions:
1172
- * one is what the client asked for, the other is what the provider said it
1173
- * ran. A provider that answers `muse-spark` as `deepseek-v4.1-flash` is
1174
- * telling the operator something, and overwriting one with the other would
1175
- * erase it. NULL when the response carried no model.
1176
- */
1177
- reportedModel: text("reported_model"),
1178
- /** Canonical reasoning effort the client asked for; NULL when it asked for none. */
1179
- requestedEffort: text("requested_effort"),
1180
- providerId: text("provider_id"),
1181
- accountId: uuid("account_id"),
1182
- networkPoolId: uuid("network_pool_id"),
1183
- endpoint: text("endpoint"),
1184
- apiKeyId: uuid("api_key_id"),
1185
- userAgent: text("user_agent"),
1186
- clientIp: text("client_ip"),
1187
- latencyMs: integer("latency_ms"),
1188
- ttfbMs: integer("ttfb_ms"),
1189
- stream: boolean("stream"),
1190
- status: telemetryStatus("status"),
1191
- /**
1192
- * HTTP status the client actually received. The `status` enum is the
1193
- * internal terminal state (`completed`/`failed`/…), which collapses every
1194
- * failure to one bucket; this column keeps the wire code, so a 503
1195
- * admission rejection stays distinguishable from a 500 upstream failure
1196
- * in the Usage status breakdown. NULL on rows written before the column.
1197
- */
1198
- httpStatus: integer("http_status"),
1199
- /**
1200
- * Which layer produced the failure: the gateway itself, the upstream
1201
- * provider, or the network in between. Recorded beside `error_category`
1202
- * because the category alone cannot separate them — `invalid_request` is
1203
- * both "the caller sent a bad body" (ours) and "the upstream rejected the
1204
- * body" (theirs), and an operator triaging a failure needs to know which.
1205
- */
1206
- errorOrigin: text("error_origin"),
1207
- errorCategory: text("error_category"),
1208
- inputTokens: integer("input_tokens"),
1209
- cachedInputTokens: integer("cached_input_tokens"),
1210
- outputTokens: integer("output_tokens"),
1211
- reasoningTokens: integer("reasoning_tokens"),
1212
- estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }),
1213
- /** Provider-billed credits (Tencent buddy-meter `credit`); NULL when unreported. */
1214
- creditUsed: numeric("credit_used", { precision: 12, scale: 4 }),
1215
- /**
1216
- * Gateway-side time before the upstream dispatch began: routing,
1217
- * credential lease, and request preparation. `ttfbMs` is measured from
1218
- * the dispatch start, so this is the overhead the client waited on before
1219
- * the provider was even called.
1220
- */
1221
- resolveMs: integer("resolve_ms"),
1222
- tokensPerSec: numeric("tokens_per_sec", { precision: 10, scale: 2 }),
1223
- // Absolute epoch-millisecond event timestamps (~1.8e12) overflow integer
1224
- // (int4): completed requests carrying observed stream timings failed
1225
- // their telemetry insert and vanished from the console. bigint "number"
1226
- // mode is safe here — epoch millis stay far below MAX_SAFE_INTEGER —
1227
- // and keeps the columns JSON-safe like the token-budget bigints above.
1228
- firstContentDeltaAtMs: bigint("first_content_delta_at_ms", { mode: "number" }),
1229
- lastEventAtMs: bigint("last_event_at_ms", { mode: "number" }),
1230
- /**
1231
- * Why this request's drawer payloads were not captured, or `captured` when
1232
- * they were. Remote-diagnosis column: the drawer reads empty for reasons
1233
- * that are invisible from the client — the tenant's capture mode, a missing
1234
- * tenant, a non-terminal attempt — and without this an operator could not
1235
- * tell "not configured" from "broken".
1236
- */
1237
- payloadCaptureState: text("payload_capture_state"),
1238
- },
1239
- (table) => [
1240
- index("telemetry_events_created_at_idx").on(table.createdAt),
1241
- index("telemetry_events_tenant_created_idx").on(table.tenantId, table.createdAt),
1242
- index("telemetry_events_request_id_idx").on(table.requestId),
1243
- index("telemetry_events_tenant_account_created_idx").on(
1244
- table.tenantId,
1245
- table.accountId,
1246
- table.createdAt,
1247
- ),
1248
- /**
1249
- * Serves the public share page's per-key aggregate (`share-usage.ts`):
1250
- * equality on `api_key_id`, range and aggregate on `created_at`. Without
1251
- * it every share render scanned the largest table in the schema.
1252
- */
1253
- index("telemetry_events_api_key_created_idx").on(table.apiKeyId, table.createdAt),
1254
- ],
1255
- );
1256
-
1257
- /** Durable aggregates retained after metadata telemetry expires. */
1258
- export const TELEMETRY_USAGE_IDENTITY_TYPES = ["account", "api_key"] as const;
1259
- export type TelemetryUsageIdentityType = (typeof TELEMETRY_USAGE_IDENTITY_TYPES)[number];
1260
-
1261
- export const telemetryUsageTotals = pgTable(
1262
- "telemetry_usage_totals",
1263
- {
1264
- tenantId: tenantRefRequired(),
1265
- identityType: text("identity_type").$type<TelemetryUsageIdentityType>().notNull(),
1266
- entityId: uuid("entity_id").notNull(),
1267
- requests: bigint("requests", { mode: "number" }).notNull().default(0),
1268
- errors: bigint("errors", { mode: "number" }).notNull().default(0),
1269
- inputTokens: bigint("input_tokens", { mode: "number" }).notNull().default(0),
1270
- outputTokens: bigint("output_tokens", { mode: "number" }).notNull().default(0),
1271
- lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull(),
1272
- updatedAt: updatedAtColumn(),
1273
- },
1274
- (table) => [
1275
- primaryKey({
1276
- name: "telemetry_usage_totals_identity_pk",
1277
- columns: [table.tenantId, table.identityType, table.entityId],
1278
- }),
1279
- check(
1280
- "telemetry_usage_totals_identity_type_check",
1281
- sql`${table.identityType} IN ('account', 'api_key')`,
1282
- ),
1283
- ],
1284
- );
1285
-
1286
-
1287
- export const telemetryPayloads = pgTable(
1288
- "telemetry_payloads",
1289
- {
1290
- id: uuid("id").primaryKey().defaultRandom(),
1291
- tenantId: tenantRefRequired(),
1292
- requestId: uuid("request_id"),
1293
- capturedAt: timestamp("captured_at", { withTimezone: true }).notNull().defaultNow(),
1294
- expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
1295
- /**
1296
- * Typed index into the on-disk frame store. Prompt/response bodies live
1297
- * only in the frame file these columns name — never in Postgres, which is
1298
- * why this table carries no body column to write one into by accident.
1299
- */
1300
- storage: text("storage").notNull(),
1301
- file: text("file").notNull(),
1302
- offset: integer("offset").notNull(),
1303
- length: integer("length").notNull(),
1304
- checksum: text("checksum").notNull(),
1305
- version: integer("version").notNull(),
1306
- /**
1307
- * Which of the four payload stages were captured, as a bitmask. The Usage
1308
- * list shows one icon per stage; learning presence from the frame would
1309
- * cost one inflate per row, so the writer records it here and the list
1310
- * reads it off the indexed row.
1311
- *
1312
- * Bits: 1=request, 2=provider_request, 4=provider_response,
1313
- * 8=client_response. Nullable: NULL renders as all icons greyed rather
1314
- * than inventing presence.
1315
- */
1316
- stages: integer("stages"),
1317
- },
1318
- (table) => [
1319
- index("telemetry_payloads_request_id_idx").on(table.requestId),
1320
- index("telemetry_payloads_tenant_request_idx").on(table.tenantId, table.requestId),
1321
- index("telemetry_payloads_expires_idx").on(table.expiresAt),
1322
- check("telemetry_payloads_storage_check", sql`${table.storage} = 'jsonl-file'`),
1323
- check("telemetry_payloads_version_check", sql`${table.version} = 1`),
1324
- check("telemetry_payloads_offset_check", sql`${table.offset} >= 0`),
1325
- check("telemetry_payloads_length_check", sql`${table.length} > 0`),
1326
- ],
1327
- );
1328
-
1329
-
1330
-
1331
- // Daily check-in ledger: one row per (account, calendar day) once the sweep has
1332
- // attempted that account's claim.
1333
- //
1334
- // The day-scoped composite PK is the claim guard — an insert that conflicts is
1335
- // exactly the "already settled today" answer — so it replaces the Redis
1336
- // `SET NX EX` the Lite backend never had. The console reads the same table to
1337
- // answer "was this attempted today", which is what makes the Quota page show
1338
- // the real state instead of a permanent "Check-in due" when Redis is absent.
1339
- export const dailyCheckinLedger = pgTable(
1340
- "daily_checkin_ledger",
1341
- {
1342
- dayKey: text("day_key").notNull(),
1343
- accountId: uuid("account_id").notNull(),
1344
- claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
1345
- },
1346
- (table) => [
1347
- primaryKey({ columns: [table.dayKey, table.accountId] }),
1348
- ],
1349
- );
1350
-
1351
-
1352
- /**
1353
- * Per-day, per-model credit deductions the buddy billing meter reported.
1354
- *
1355
- * A snapshot, not a ledger: the upstream is the source of truth and answers the
1356
- * whole window on every call, so a row is upserted rather than appended. What it
1357
- * buys is that the console renders a *stored* figure — without it every page
1358
- * open would re-hit the billing endpoint for every account, and a failed read
1359
- * would show nothing at all instead of the last number obtained.
1360
- *
1361
- * Keyed (account, day, model): one row per bucket, and the conflict target is
1362
- * what makes the write idempotent across passes.
1363
- */
1364
- export const buddyUsageSnapshots = pgTable(
1365
- "buddy_usage_snapshots",
1366
- {
1367
- accountId: uuid("account_id")
1368
- .notNull()
1369
- .references(() => providerAccounts.id, { onDelete: "cascade" }),
1370
- /** Calendar day the requests were metered on, `YYYY-MM-DD`. */
1371
- dayKey: text("day_key").notNull(),
1372
- model: text("model").notNull(),
1373
- /** Credits deducted across every request in this bucket. */
1374
- credit: numeric("credit", { precision: 14, scale: 4 }).notNull(),
1375
- syncedAt: timestamp("synced_at", { withTimezone: true }).notNull().defaultNow(),
1376
- },
1377
- (table) => [
1378
- primaryKey({ columns: [table.accountId, table.dayKey, table.model] }),
1379
- index("buddy_usage_snapshots_day_idx").on(table.dayKey),
1380
- ],
1381
- );
1382
-
1383
- /**
1384
- * One row per (day, account, growth task) once that day's claim was attempted.
1385
- *
1386
- * The composite PK is the idempotency guard: an insert that conflicts is
1387
- * exactly the "already settled today" answer, which is what stops a sweep from
1388
- * re-POSTing a claim on every pass. Same shape as `daily_checkin_ledger`
1389
- * because it is the same problem — one claim per account per day — widened by
1390
- * the task name now that there is more than one thing to claim.
1391
- */
1392
- export const buddyTaskLedger = pgTable(
1393
- "buddy_task_ledger",
1394
- {
1395
- dayKey: text("day_key").notNull(),
1396
- accountId: uuid("account_id")
1397
- .notNull()
1398
- .references(() => providerAccounts.id, { onDelete: "cascade" }),
1399
- task: text("task").notNull(),
1400
- claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
1401
- },
1402
- (table) => [
1403
- primaryKey({ columns: [table.dayKey, table.accountId, table.task] }),
1404
- index("buddy_task_ledger_account_idx").on(table.accountId),
1405
- ],
1406
- );
1
+ /** Canonical Drizzle schema: every persistence table, enum, index, and shared type. */
2
+ import { desc, sql } from "drizzle-orm";
3
+ import {
4
+ bigint, boolean, check, customType, index, integer, jsonb, numeric, pgEnum, pgTable,
5
+ primaryKey, text, timestamp, uniqueIndex, uuid, type AnyPgColumn,
6
+ } from "drizzle-orm/pg-core";
7
+
8
+ export const TENANT_STATUSES = ["active", "suspended", "deleted"] as const;
9
+ export type TenantStatus = (typeof TENANT_STATUSES)[number];
10
+
11
+ export const tenants = pgTable(
12
+ "tenants",
13
+ {
14
+ id: uuid("id").primaryKey().defaultRandom(),
15
+ name: text("name").notNull(),
16
+ status: text("status").$type<TenantStatus>().notNull(),
17
+ // Intentionally inline (not createdAtColumn()): this root table is imported
18
+ // by the helpers below; sharing the helper here would make a use-before-define.
19
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
20
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
21
+ },
22
+ (table) => [
23
+ check("tenants_status_check", sql`${table.status} IN ('active', 'suspended', 'deleted')`),
24
+ ],
25
+ );
26
+
27
+ // Canonical tenant/timestamp column definitions shared by every table.
28
+
29
+ export function tenantRefNullable() {
30
+ return uuid("tenant_id").references(() => tenants.id, { onDelete: "cascade" });
31
+ }
32
+
33
+ export function tenantRefRequired() {
34
+ return uuid("tenant_id")
35
+ .notNull()
36
+ .references(() => tenants.id, { onDelete: "cascade" });
37
+ }
38
+
39
+ export function tenantRefPrimaryKey() {
40
+ return uuid("tenant_id")
41
+ .primaryKey()
42
+ .references(() => tenants.id, { onDelete: "cascade" });
43
+ }
44
+
45
+ export function tenantCascadeSetNull() {
46
+ return uuid("tenant_id").references(() => tenants.id, { onDelete: "set null" });
47
+ }
48
+
49
+ export function createdAtColumn() {
50
+ return timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
51
+ }
52
+
53
+ export function updatedAtColumn() {
54
+ return timestamp("updated_at", { withTimezone: true }).notNull().defaultNow();
55
+ }
56
+
57
+ export function timestampColumns() {
58
+ return { createdAt: createdAtColumn(), updatedAt: updatedAtColumn() };
59
+ }
60
+
61
+ /**
62
+ * `bytea` column for encrypted credential ciphertext (application-layer
63
+ * envelope encryption: the ciphertext stored here is opaque to the
64
+ * database). Drizzle's pg-core does not ship a first-class `bytea`
65
+ * builder, so this is the documented `customType` escape hatch (the same
66
+ * DDL-shape escape-hatch principle applied to column-type gaps in the
67
+ * ORM's builder).
68
+ */
69
+ export const bytea = customType<{ data: Buffer }>({
70
+ dataType() {
71
+ return "bytea";
72
+ },
73
+ });
74
+
75
+ // Shared three-state health machine for provider_accounts and network_pools —
76
+ // the account state machine is reused for network pools rather than
77
+ // inventing a second one.
78
+ //
79
+ // `degraded` was retired. It sat between "working" and "parked" and every
80
+ // consumer had to decide what it meant: routing treated it as a hard exclusion,
81
+ // the sweep treated it as recoverable, and the console showed it as a third
82
+ // badge — three readings of one value, and the operator reported the result as
83
+ // ambiguous. The two facts it mixed are now each stated by a value that means
84
+ // exactly one thing: a fault that clears on its own is `cooldown` (with a
85
+ // deadline the sweep honors), and a fault that needs an operator is `disabled`.
86
+ export const healthStatus = pgEnum("health_status", ["active", "cooldown", "disabled"]);
87
+
88
+ /**
89
+ * The health state machine shared by `provider_accounts` and `network_pools`.
90
+ * One definition, because the two tables are driven by the same transitions and
91
+ * a column added to one of them by hand is how they drift.
92
+ */
93
+ export function healthColumns() {
94
+ return {
95
+ status: healthStatus("status").notNull().default("active"),
96
+ consecutiveFailures: integer("consecutive_failures").notNull().default(0),
97
+ lastSuccessAt: timestamp("last_success_at", { withTimezone: true }),
98
+ lastError: text("last_error"),
99
+ lastErrorCategory: text("last_error_category"),
100
+ lastErrorAt: timestamp("last_error_at", { withTimezone: true }),
101
+ cooldownUntil: timestamp("cooldown_until", { withTimezone: true }),
102
+ lastRecoveredAt: timestamp("last_recovered_at", { withTimezone: true }),
103
+ };
104
+ }
105
+
106
+ export const credentialKind = pgEnum("credential_kind", ["api_key", "oauth", "none"]);
107
+
108
+ // Shared wire-family domain used by providers.wire_family_default and
109
+ // models.wire_family.
110
+ //
111
+ // `gemini` is Factory's native generateContent wire. Added by forward
112
+ // migration `0002_add_gemini_wire_family.sql`; the enum here must stay in
113
+ // step with what that migration applies, or a fresh database (built from the
114
+ // baseline) and a migrated one would disagree.
115
+ export const wireFamily = pgEnum("wire_family", ["chat", "responses", "messages", "gemini"]);
116
+
117
+ // `https` is a hosted relay front door (Vercel, Cloudflare, Deno, Netlify):
118
+ // plain HTTP CONNECT to an origin that terminates TLS itself, so the transport
119
+ // is recorded as what it speaks rather than as a generic proxy.
120
+ export const networkPoolKind = pgEnum("network_pool_kind", ["http", "https", "socks5"]);
121
+
122
+ // health_events.entity_kind — distinguishes which owning
123
+ // table entity_id/account_id/network_pool_id references.
124
+ export const healthEntityKind = pgEnum("health_entity_kind", ["account", "pool"]);
125
+
126
+ export const telemetrySourceSurface = pgEnum("telemetry_source_surface", [
127
+ "chat",
128
+ "responses",
129
+ "messages",
130
+ "completion",
131
+ ]);
132
+
133
+ export const telemetryStatus = pgEnum("telemetry_status", [
134
+ "completed",
135
+ "failed",
136
+ "cancelled",
137
+ "truncated",
138
+ ]);
139
+
140
+ // The id matches the seven canonical builtin IDs (openai, anthropic, claude,
141
+ // codex, opencode-zen, ollamacloud, cerebras) or a custom/BYOK slug
142
+ // rejected at the identity boundary when it collides case-insensitively with
143
+ // a reserved ID — that rejection is application-layer, not a DB constraint
144
+ // here. `capability_profile` is a JSONB routing-capability map with no index:
145
+ // it is read with the provider row the snapshot builder already loads, so a
146
+ // separate index would only add write cost. `tenant_id`
147
+ // null means the provider is global/built-in, matching the same convention
148
+ // as `provider_accounts.tenant_id`; populated means tenant-owned/BYOK.
149
+ // `base_url`/`compatibility_profile` are BYOK-only: `compatibility_profile`
150
+ // carries HTTP dispatch config (extra headers/query params/streaming mode)
151
+ // and is intentionally a separate column from `capability_profile`, which
152
+ // drives routing capability flags — conflating the two would corrupt routing
153
+ // data with unrelated HTTP config.
154
+ export const providers = pgTable(
155
+ "providers",
156
+ {
157
+ id: text("id").primaryKey(),
158
+ tenantId: tenantRefNullable(),
159
+ wireFamilyDefault: wireFamily("wire_family_default"),
160
+ capabilityProfile: jsonb("capability_profile"),
161
+ baseUrl: text("base_url"),
162
+ compatibilityProfile: jsonb("compatibility_profile"),
163
+ enabled: boolean("enabled").notNull().default(true),
164
+ // Builtin default `true` (most providers need a real credential). Only a
165
+ // genuinely public/unauthenticated builtin (OpenCode Free) is seeded
166
+ // `false`. When `false`, the snapshot builder emits a routable candidate
167
+ // with zero `provider_accounts` rows instead of marking it `disabled` —
168
+ // see `src/transport/routing/route-catalog.ts`.
169
+ requiresAccount: boolean("requires_account").notNull().default(true),
170
+ },
171
+ (table) => [
172
+ // `globalOrOwnedBy(providers.tenantId, …)` is the predicate behind
173
+ // `listModelsForTenant` and `/quota/overview`, and it is a bare
174
+ // `tenant_id IS NULL OR tenant_id = ?` — which no other index here can
175
+ // serve, so both reads scanned the whole provider list. Cheap to keep:
176
+ // providers are few and written rarely.
177
+ index("providers_tenant_id_idx").on(table.tenantId),
178
+ ],
179
+ );
180
+
181
+ // Upstream account credentials plus the full health state machine.
182
+ // `tenant_id` null means the account is shared pool-wide; populated means
183
+ // tenant-owned/BYOK. The concurrency ceiling lives in provider routing
184
+ // (`provider_routing_settings.max_inflight`) and network pools
185
+ // (`network_pools.max_inflight`); there is no per-account override.
186
+ export const providerAccounts = pgTable("provider_accounts", {
187
+ id: uuid("id").primaryKey().defaultRandom(),
188
+ providerId: text("provider_id")
189
+ .notNull()
190
+ .references(() => providers.id, { onDelete: "cascade" }),
191
+ tenantId: tenantRefNullable(),
192
+ label: text("label").notNull(),
193
+ credentialCiphertext: bytea("credential_ciphertext"),
194
+ /**
195
+ * HMAC fingerprint of the credential, never the plaintext secret. Nullable
196
+ * for credential-less accounts and legacy rows without an identity.
197
+ */
198
+ credentialFingerprint: text("credential_fingerprint"),
199
+ credentialKind: credentialKind("credential_kind").notNull(),
200
+ /**
201
+ * Per-account upstream auth configuration that is not a secret: auth method,
202
+ * region, profile ARN, OAuth client id, token endpoint, scopes. The adapter
203
+ * reads it where the credential is resolved (it selects the regional endpoint
204
+ * and the profile the upstream binds the request to) and token refresh reads
205
+ * it again to pick the right endpoint and client. Secrets never live here —
206
+ * see `credential_ciphertext` and `provider_oauth_states`.
207
+ */
208
+ authState: jsonb("auth_state"),
209
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
210
+
211
+ ...healthColumns(),
212
+ modelCooldowns: jsonb("model_cooldowns").notNull().default({}),
213
+ /**
214
+ * How many times each model has been reported unsupported for this account:
215
+ * `{ [modelId]: hits }`. Written only by the unsupported-model path, and it
216
+ * is the escalation level for that pair's cooldown.
217
+ *
218
+ * Deliberately a separate column from `model_cooldowns`: that value is
219
+ * `{ [modelId]: deadline }`, read by routing (`route-catalog.ts`) and by the
220
+ * console, and widening it to `{until, hits}` would have changed a shape two
221
+ * independent readers parse. Nullable — an account that never hit an
222
+ * unsupported model has nothing to count.
223
+ */
224
+ modelBlockHits: jsonb("model_block_hits").$type<Readonly<Record<string, number>>>(),
225
+ /**
226
+ * Stable list position within (tenant, provider). Accounts were ordered by
227
+ * `created_at`, which let two same-millisecond rows swap between loads; this
228
+ * is explicit and can be rewritten when the operator reorders or appends.
229
+ */
230
+ sortIndex: integer("sort_index").notNull().default(0),
231
+ /**
232
+ * The credential is a static bearer token (a JWT/access token pasted on its
233
+ * own, or an account an operator has pinned): it is used exactly as issued and
234
+ * must never be sent to a refresh endpoint. Distinct from `credentialKind`,
235
+ * which says what family the credential belongs to, not whether it can be
236
+ * re-minted — an `oauth` account can carry a static token when its refresh
237
+ * grant is absent or dead while the token itself is still valid. A static
238
+ * account is skipped by the refresh sweep and is never disabled by a refresh
239
+ * failure, because there is no refresh to run.
240
+ */
241
+ staticToken: boolean("static_token").notNull().default(false),
242
+ /**
243
+ * When this account's credential was last read for its own identity (email,
244
+ * display name). Null means never read.
245
+ *
246
+ * This is the convergence stamp for the enrichment sweep, and the reason an
247
+ * import can be instant: the batch path persists the credential without
248
+ * parsing it, and the sweep fills the identity in afterwards. An account is
249
+ * only listed while this is null, so a completed pass never re-decrypts a
250
+ * credential it already read.
251
+ */
252
+ identityCheckedAt: timestamp("identity_checked_at", { withTimezone: true }),
253
+ /**
254
+ * Email read out of the account's own JWT claims. Cosmetic: it names the
255
+ * account in the console and is never used to authenticate or route.
256
+ */
257
+ accountEmail: text("account_email"),
258
+
259
+ /**
260
+ * The plan the provider reports for this account, last seen by a quota read.
261
+ *
262
+ * Persisted rather than read from the quota cache: that cache is a short
263
+ * in-process TTL and empty on a cold start, so the plan would be unknown
264
+ * exactly when a freshly booted process makes its first routing decisions.
265
+ *
266
+ * Nullable: most providers report no plan at all, and an account never read
267
+ * must say so rather than have a plan invented for it. Routing reads absence
268
+ * as "unknown", never as "free" — guessing free would send pro-only traffic
269
+ * to an account that may well be refused.
270
+ */
271
+ quotaPlan: text("quota_plan"),
272
+
273
+ },
274
+ (table) => [
275
+ uniqueIndex("provider_accounts_identity_uidx").on(
276
+ table.providerId,
277
+ // No `::uuid` cast: the migrations create this index without one on
278
+ // SQLite (baseline l.440) and with one on Postgres (l.423), and the
279
+ // Drizzle definition is what a test's in-memory database is built
280
+ // from — a Postgres-only cast there made every account insert fail
281
+ // with `unrecognized token: ":"`.
282
+ sql`coalesce(${table.tenantId}, '00000000-0000-0000-0000-000000000000')`,
283
+ table.credentialFingerprint,
284
+ ),
285
+ index("provider_accounts_status_cooldown_idx").on(table.status, table.cooldownUntil),
286
+ index("provider_accounts_provider_tenant_idx").on(table.providerId, table.tenantId),
287
+ // The bare `tenant_id IS NULL OR tenant_id = ?` that `listAllAccounts`
288
+ // uses is served by neither index above: `(status, cooldown_until)` leads
289
+ // with the wrong column, and `(provider_id, tenant_id)` cannot serve a
290
+ // predicate that does not constrain `provider_id`. This one matches the
291
+ // hot listing path.
292
+ index("provider_accounts_tenant_id_idx").on(table.tenantId),
293
+ ],
294
+ );
295
+
296
+
297
+ // OAuth refresh token + cross-process lease, normalized out of
298
+ // `provider_accounts` into its own 1:0/1 table. The vast majority of
299
+ // provider accounts are `credentialKind: "api_key"`, for which these four
300
+ // columns stayed null forever on the shared row; splitting them here keeps
301
+ // the hot health-machine row narrow and isolates the fenced compare-and-swap
302
+ // lease traffic (`providers/auth/refresh-service.ts`) from unrelated
303
+ // account reads/writes.
304
+ export const providerOauthStates = pgTable("provider_oauth_states", {
305
+ providerAccountId: uuid("provider_account_id")
306
+ .primaryKey()
307
+ .references(() => providerAccounts.id, { onDelete: "cascade" }),
308
+ /**
309
+ * Encrypted refresh token. Nullable: an account can be pasted with only an
310
+ * access token (or a credential whose refresh token never arrived), and the
311
+ * account must still be tracked so the refresh sweep can flag it for re-auth
312
+ * rather than leaving it invisible. A null value means "cannot refresh".
313
+ */
314
+ refreshCiphertext: bytea("refresh_ciphertext"),
315
+ /**
316
+ * Encrypted companion secret for the minority of flows whose refresh is not
317
+ * authorized by the refresh token alone: a device-flow client registration
318
+ * mints a client secret that must be replayed beside the token. Nullable —
319
+ * most providers refresh with the refresh token by itself.
320
+ */
321
+ clientSecretCiphertext: bytea("client_secret_ciphertext"),
322
+ /**
323
+ * Access-token expiry. Nullable: a credential pasted without an expiry (an
324
+ * opaque token, or a bare access token) has no known deadline, and reads as
325
+ * "always due" so the sweep refreshes it as soon as it can.
326
+ */
327
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
328
+ // OAuth refresh lease: fenced compare-and-swap coordination so only one
329
+ // process refreshes a given account's OAuth token at a time, and a losing
330
+ // process reloads instead of clobbering a peer's fresher token.
331
+ leaseOwner: text("lease_owner"),
332
+ leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }),
333
+ });
334
+
335
+
336
+ // One row per (provider_id, model_id, route), with `endpoint_path`
337
+ // identifying the route — the same model through two providers, or through
338
+ // two routes of one provider, is two rows. `enabled` is a hard routing
339
+ // invariant read by ProviderCatalogService, not a display-only flag.
340
+ export const models = pgTable(
341
+ "models",
342
+ {
343
+ id: uuid("id").primaryKey().defaultRandom(),
344
+ providerId: text("provider_id")
345
+ .notNull()
346
+ .references(() => providers.id, { onDelete: "cascade" }),
347
+ modelId: text("model_id").notNull(),
348
+ wireFamily: wireFamily("wire_family").notNull(),
349
+ // Which protocol shape at all (`llm` = the canonical chat pipeline). A
350
+ // non-`llm` row (System One) is served by its native passthrough route, not
351
+ // by the surface codecs. Kept a plain text column with a default rather than
352
+ // a pgEnum: unlike `wire_family`, the set is expected to grow (embeddings,
353
+ // tts, …) and a widened enum needs a migration per addition.
354
+ serviceKind: text("service_kind").notNull().default("llm"),
355
+ endpointPath: text("endpoint_path").notNull(),
356
+ contextLimit: integer("context_limit"),
357
+ outputLimit: integer("output_limit"),
358
+ modalities: jsonb("modalities"),
359
+ reasoning: boolean("reasoning").notNull().default(false),
360
+ toolCall: boolean("tool_call").notNull().default(false),
361
+ cost: jsonb("cost"),
362
+ source: text("source"),
363
+ sourceUpdatedAt: timestamp("source_updated_at", { withTimezone: true }),
364
+ enabled: boolean("enabled").notNull().default(true),
365
+ /**
366
+ * Whether the upstream refuses this model on a free plan.
367
+ *
368
+ * Nullable three-valued: `true` refused on free, `false` served on free,
369
+ * `NULL` nobody established it. `NULL` is the state of every model on every
370
+ * provider that publishes nothing about plan access, and collapsing it to
371
+ * `false` would assert a measured fact for rows nobody measured.
372
+ *
373
+ * A column rather than a derived rule because CodeBuddy publishes nothing
374
+ * that predicts access — `credits` and `vendor` were both measured against
375
+ * real dispatches and neither correlates. Only the refusal itself is a
376
+ * signal, so the answer has to be recorded. See
377
+ * `.skills/cartethyia-reverse-engineering/references/codebuddy-dispatch-headers.md`.
378
+ */
379
+ proOnly: boolean("pro_only"),
380
+ /**
381
+ * Whether the provider publishes this model as free.
382
+ *
383
+ * Mirrors `proOnly` but sourced from the provider's own directory rather
384
+ * than inferred: Qoder states `is_free` per row, so the tier tracks a plan
385
+ * change without a code change. Nullable three-valued — absence means the
386
+ * provider did not state it, which is not the same as billed.
387
+ */
388
+ freeTier: boolean("free_tier"),
389
+ },
390
+ (table) => [
391
+ uniqueIndex("models_provider_model_route_uidx").on(
392
+ table.providerId,
393
+ table.modelId,
394
+ table.endpointPath,
395
+ ),
396
+ index("models_provider_enabled_idx").on(table.providerId, table.enabled),
397
+ // Bare-id lookups, with no provider named: see migration
398
+ // `0003_add_models_model_id_index`. Without it those seq-scan this table,
399
+ // which carries one row per provider x model x route.
400
+ index("models_model_id_idx").on(table.modelId),
401
+ ],
402
+ );
403
+
404
+ // Tenant-scoped suppression of a GLOBAL/built-in model. Tenants cannot mutate
405
+ // the shared `models` row (see `DrizzleProviderCatalogStore.setModelEnabled`),
406
+ // so a disable is recorded here and filtered out of that tenant's routing
407
+ // snapshot. Tenant-owned/BYOK models are still toggled directly on `models`.
408
+ export const tenantDisabledModels = pgTable(
409
+ "tenant_disabled_models",
410
+ {
411
+ id: uuid("id").primaryKey().defaultRandom(),
412
+ tenantId: tenantRefRequired(),
413
+ providerId: text("provider_id")
414
+ .notNull()
415
+ .references(() => providers.id, { onDelete: "cascade" }),
416
+ modelId: text("model_id").notNull(),
417
+ endpointPath: text("endpoint_path").notNull(),
418
+ createdAt: createdAtColumn(),
419
+ },
420
+ (t) => [
421
+ uniqueIndex("tenant_disabled_models_tenant_provider_model_uidx").on(
422
+ t.tenantId,
423
+ t.providerId,
424
+ t.modelId,
425
+ t.endpointPath,
426
+ ),
427
+ ],
428
+ );
429
+
430
+
431
+ // Tenant-owned HTTP/HTTPS/SOCKS5 pools share the provider-account health state machine.
432
+ export const networkPools = pgTable("network_pools", {
433
+ id: uuid("id").primaryKey().defaultRandom(),
434
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
435
+ kind: networkPoolKind("kind").notNull(),
436
+ /**
437
+ * Operator-set family/tag for this pool, e.g. `residential-id` or
438
+ * `datacenter-sg`. Purely organisational: it names which family the node
439
+ * belongs to and nothing in routing or dispatch reads it. Null when the
440
+ * operator has not tagged the pool.
441
+ */
442
+ family: text("family"),
443
+ endpointConfig: jsonb("endpoint_config").notNull(),
444
+ credentialCiphertext: bytea("credential_ciphertext"),
445
+ maxInflight: integer("max_inflight"),
446
+ /** Routing weight for weighted selection across the tenant pool group. */
447
+ weight: integer("weight"),
448
+ tenantId: tenantRefRequired(),
449
+
450
+ ...healthColumns(),
451
+ lastLatencyMs: integer("last_latency_ms"),
452
+ lastHealthCheckAt: timestamp("last_health_check_at", { withTimezone: true }),
453
+ /** Public egress address reported by the last successful probe; null until probed. */
454
+ egressIp: text("egress_ip"),
455
+ /** Operator-set egress allowance in bytes; null means unmetered. */
456
+ quotaBytes: bigint("quota_bytes", { mode: "number" }),
457
+ /** Last speed-test payload actually transferred, in bytes; null until measured. */
458
+ lastSpeedtestBytes: integer("last_speedtest_bytes"),
459
+ /** Wall-clock duration of the last speed-test transfer, in milliseconds. */
460
+ lastSpeedtestDurationMs: integer("last_speedtest_duration_ms"),
461
+ /** "ok" | "failed" for the last speed-test; null until measured. */
462
+ lastSpeedtestStatus: text("last_speedtest_status"),
463
+ /** Failure message of the last speed-test; null on success or unmeasured. */
464
+ lastSpeedtestError: text("last_speedtest_error"),
465
+ /** When the last speed-test ran. */
466
+ lastSpeedtestAt: timestamp("last_speedtest_at", { withTimezone: true }),
467
+ /**
468
+ * Provider-scoped probe verdict, stored beside the other last-known facts
469
+ * (`egress_ip`, `last_latency_ms`, the speed-test columns) rather than in the
470
+ * browser.
471
+ *
472
+ * Browser storage is not enough: a verdict held only there is visible to the
473
+ * operator who ran it and to nobody else — not to another machine, not to the
474
+ * gateway, and not to admission. A pool would read "OpenCode blocked" on
475
+ * screen while dispatch kept sending traffic through it, because nothing
476
+ * outside that one browser had heard the verdict.
477
+ *
478
+ * `null` means never probed. These describe the *address* recorded in
479
+ * `egress_ip` at the time, so a changed endpoint clears them.
480
+ */
481
+ providerProbeProviderId: text("provider_probe_provider_id"),
482
+ /** `allowed` | `blocked` | `throttled` | `unreachable`; null until probed. */
483
+ providerProbeStatus: text("provider_probe_status"),
484
+ /** Upstream HTTP status when a response arrived. */
485
+ providerProbeHttpStatus: integer("provider_probe_http_status"),
486
+ /** Operator-facing reason: the upstream's own words, or why the probe failed. */
487
+ providerProbeReason: text("provider_probe_reason"),
488
+ providerProbeLatencyMs: integer("provider_probe_latency_ms"),
489
+ providerProbeAt: timestamp("provider_probe_at", { withTimezone: true }),
490
+ /** The endpoint the verdict was measured against. Compared on read, so an
491
+ * edited endpoint reports "never probed" instead of a stale verdict. */
492
+ providerProbeEndpoint: text("provider_probe_endpoint"),
493
+ /** Metered egress totals, flushed from the in-memory socket counters on
494
+ * every pool-touching write so the quota bar survives a restart. */
495
+ bytesSentTotal: bigint("bytes_sent_total", { mode: "number" }).notNull().default(0),
496
+ /** Metered ingress totals; see `bytesSentTotal`. */
497
+ bytesReceivedTotal: bigint("bytes_received_total", { mode: "number" }).notNull().default(0),
498
+ },
499
+ (table) => [
500
+ index("network_pools_tenant_id_idx").on(table.tenantId),
501
+ index("network_pools_status_cooldown_idx").on(table.status, table.cooldownUntil),
502
+ index("network_pools_family_idx").on(table.family),
503
+ ],
504
+ );
505
+
506
+
507
+ // Account and network-pool health events share one table. The entity kind
508
+ // selects exactly one owner FK, and the CHECK constraint below preserves
509
+ // referential integrity for both variants.
510
+ export const healthEvents = pgTable(
511
+ "health_events",
512
+ {
513
+ id: uuid("id").primaryKey().defaultRandom(),
514
+ entityKind: healthEntityKind("entity_kind").notNull(),
515
+ accountId: uuid("account_id").references(() => providerAccounts.id, { onDelete: "cascade" }),
516
+ networkPoolId: uuid("network_pool_id").references(() => networkPools.id, {
517
+ onDelete: "cascade",
518
+ }),
519
+ fromStatus: healthStatus("from_status"),
520
+ toStatus: healthStatus("to_status").notNull(),
521
+ reason: text("reason"),
522
+ errorCategory: text("error_category"),
523
+ modelId: text("model_id"),
524
+ createdAt: createdAtColumn(),
525
+ },
526
+ (table) => [
527
+ index("health_events_account_id_created_at_idx").on(table.accountId, table.createdAt),
528
+ // Descending `created_at` is deliberate: health history is always read
529
+ // newest-first. The SQLite baseline drops the direction (see
530
+ // `scripts/tools/generate-sqlite.ts` — SQLite scans an index either way),
531
+ // so this is a Postgres-only access-path choice, not a cross-backend
532
+ // disagreement.
533
+ index("health_events_account_model_created_idx").on(
534
+ table.accountId,
535
+ table.modelId,
536
+ desc(table.createdAt),
537
+ ),
538
+ index("health_events_network_pool_id_created_at_idx").on(table.networkPoolId, table.createdAt),
539
+ check(
540
+ "health_events_entity_reference_check",
541
+ sql`(${table.entityKind} = 'account' AND ${table.accountId} IS NOT NULL AND ${table.networkPoolId} IS NULL) OR (${table.entityKind} = 'pool' AND ${table.networkPoolId} IS NOT NULL AND ${table.accountId} IS NULL)`,
542
+ ),
543
+ ],
544
+ );
545
+
546
+
547
+ // Model aliasing & combos. Aliases resolve a client-facing name to a
548
+ // real model; combos back a client-facing name with >=1 real models,
549
+ // auto-selected via `fallback` or `round_robin`.
550
+ export const modelComboStrategy = pgEnum("model_combo_strategy", [
551
+ "fallback",
552
+ "round_robin",
553
+ ]);
554
+
555
+ /** Canonical `ComboStrategy` union, derived from the enum above. Console
556
+ * domain and routing layers import this instead of hand-typing their own mirror.
557
+ * `ProviderRoutingStrategy` covers the same two values but persists on
558
+ * `provider_routing_settings` beside `rotateCount`. */
559
+ export type ComboStrategy = (typeof modelComboStrategy.enumValues)[number];
560
+
561
+ export const modelAliases = pgTable(
562
+ "model_aliases",
563
+ {
564
+ id: uuid("id").primaryKey().defaultRandom(),
565
+ tenantId: tenantRefRequired(),
566
+ alias: text("alias").notNull(),
567
+ targetModel: text("target_model").notNull(),
568
+ /** Explicit list position. */
569
+ sortIndex: integer("sort_index").notNull().default(0),
570
+ ...timestampColumns(),
571
+ },
572
+ (t) => [uniqueIndex("model_aliases_tenant_alias_uidx").on(t.tenantId, t.alias)],
573
+ );
574
+ export type ModelAlias = typeof modelAliases.$inferSelect;
575
+
576
+ export const modelCombos = pgTable(
577
+ "model_combos",
578
+ {
579
+ id: uuid("id").primaryKey().defaultRandom(),
580
+ tenantId: tenantRefRequired(),
581
+ name: text("name").notNull(),
582
+ members: jsonb("members").notNull().$type<string[]>(),
583
+ strategy: modelComboStrategy("strategy").notNull().default("fallback"),
584
+ /** Explicit list position. */
585
+ sortIndex: integer("sort_index").notNull().default(0),
586
+ ...timestampColumns(),
587
+ },
588
+ (t) => [uniqueIndex("model_combos_tenant_name_uidx").on(t.tenantId, t.name)],
589
+ );
590
+ export type ModelCombo = typeof modelCombos.$inferSelect;
591
+
592
+ export const providerRoutingStrategy = pgEnum("provider_routing_strategy", [
593
+ "fallback",
594
+ "round_robin",
595
+ ]);
596
+
597
+ /**
598
+ * Canonical provider-routing strategy union, derived from the enum above so
599
+ * the persisted values and the type cannot drift. The console catalog and the
600
+ * routing engine both import this instead of restating the two literals.
601
+ */
602
+ export type ProviderRoutingStrategy = (typeof providerRoutingStrategy.enumValues)[number];
603
+
604
+ /**
605
+ * Per-account inflight ceiling applied when a provider has no routing row yet.
606
+ * One literal: the console default and the route catalog's fallback both read
607
+ * it. `null` is the "unlimited" choice, and it is also this default — an
608
+ * operator who has never opened the routing card asked for no ceiling, and
609
+ * inventing one silently capped their concurrency. A ceiling they *did* set is
610
+ * still honoured, so this only decides the untouched case.
611
+ */
612
+ export const DEFAULT_PROVIDER_MAX_INFLIGHT: null = null;
613
+
614
+ export const providerRoutingSettings = pgTable(
615
+ "provider_routing_settings",
616
+ {
617
+ providerId: text("provider_id")
618
+ .notNull()
619
+ .references(() => providers.id, { onDelete: "cascade" }),
620
+ tenantId: tenantRefNullable(),
621
+ strategy: providerRoutingStrategy("strategy").notNull().default("fallback"),
622
+ /** Requests served by one account before round robin advances. */
623
+ rotateCount: integer("rotate_count").notNull().default(1),
624
+ /** Per-account inflight ceiling applied to every account of this provider
625
+ * (the field sits next to the failover/round-robin strategy in the UI).
626
+ * `null` = UNLIMITED concurrency per account. */
627
+ maxInflight: integer("max_inflight"),
628
+ /**
629
+ * Per-pool inflight ceiling for this provider: how many concurrent
630
+ * requests one proxy pool may carry *for this provider*.
631
+ *
632
+ * A second, independent axis from `maxInflight`. That one bounds a single
633
+ * account; `network_pools.max_inflight` bounds a pool across every
634
+ * provider; this one bounds one provider's share of one pool. All three
635
+ * are enforced together — none replaces another.
636
+ *
637
+ * `null` = unlimited, and that is the default: an unconfigured provider
638
+ * costs no extra Redis key and no extra round trip.
639
+ */
640
+ maxInflightPerProxy: integer("max_inflight_per_proxy"),
641
+ /**
642
+ * Minimum remaining allowance an account must hold to stay routable, for
643
+ * this provider. `null` means no floor. Compared against the account's own
644
+ * unit: absolute remaining credit when the provider reports credits, the
645
+ * aggregate credit percentage and lowest independent percentage window
646
+ * otherwise. An account below it is skipped and the request fails over to
647
+ * the next account instead of erroring.
648
+ */
649
+ usageFloor: integer("usage_floor"),
650
+ /** `credit` compares the summed remaining credit; `percent` compares the
651
+ * aggregate credit percentage and lowest independent percentage-only
652
+ * window. Ignored while `usageFloor` is null. */
653
+ usageFloorUnit: text("usage_floor_unit").$type<"credit" | "percent">().notNull().default("percent"),
654
+ enabled: boolean("enabled").notNull().default(false),
655
+ // Route-selected User-Agent for built-in API-key providers; OAuth and BYOK identities stay native.
656
+ // The column default is the Claude Code CLI identity; the live default for a
657
+ // row that never set one is `buildClaudeCliUserAgent()` in
658
+ // providers/operations/custom-cli-headers.ts, which this mirrors.
659
+ userAgent: text("user_agent").notNull().default("claude-cli/2.1.293 (external, cli)"),
660
+ // When true, this provider's requests always dial direct. When false,
661
+ // dispatch automatically selects among every active network pool the
662
+ // tenant owns (least-loaded/non-cooldown) — never an admin-pinned pool.
663
+ skipProxy: boolean("skip_proxy").notNull().default(false),
664
+ },
665
+ (table) => ({
666
+ tenantProviderUnique: uniqueIndex("provider_routing_settings_tenant_provider_idx").on(
667
+ table.tenantId,
668
+ table.providerId,
669
+ ),
670
+ globalProviderUnique: uniqueIndex("provider_routing_settings_global_provider_idx")
671
+ .on(table.providerId)
672
+ .where(sql`tenant_id IS NULL`),
673
+ }),
674
+ );
675
+
676
+ export const poolRoutingStrategy = pgEnum("pool_routing_strategy", [
677
+ "least_loaded",
678
+ "round_robin",
679
+ ]);
680
+ export type PoolRoutingStrategy = (typeof poolRoutingStrategy.enumValues)[number];
681
+
682
+ /**
683
+ * Per-tenant selection strategy across the tenant's active network pools —
684
+ * the pool-group counterpart to `provider_routing_settings`. `least_loaded`
685
+ * (default) keeps the weighted least-loaded scan with its rotating tie-break;
686
+ * `round_robin` serves `rotate_count` requests per pool before advancing,
687
+ * mirroring the account strategy, and skips capacity/cooldown pools the way
688
+ * account failover does. An absent row reads as the default, so tenants that
689
+ * never touch the toggle own no rows.
690
+ */
691
+ export const poolRoutingSettings = pgTable("pool_routing_settings", {
692
+ tenantId: tenantRefPrimaryKey(),
693
+ strategy: poolRoutingStrategy("strategy").notNull().default("least_loaded"),
694
+ /** Requests served by one pool before round robin advances. */
695
+ rotateCount: integer("rotate_count").notNull().default(1),
696
+ });
697
+
698
+
699
+ export const API_KEY_MODES = ["personal", "share"] as const;
700
+ export type ApiKeyMode = (typeof API_KEY_MODES)[number];
701
+
702
+ /**
703
+ * How a key's `modelList` is interpreted. Exactly two modes, never a
704
+ * combination: `whitelist` allows only the listed names (an empty list allows
705
+ * everything), `blacklist` refuses the listed names (an empty list refuses
706
+ * nothing). Every model-authorization surface — the request path, admission,
707
+ * `/v1/models`, and the share page — reads this one pair.
708
+ */
709
+ export const API_KEY_MODEL_ACCESS_MODES = ["whitelist", "blacklist"] as const;
710
+ export type ApiKeyModelAccessMode = (typeof API_KEY_MODEL_ACCESS_MODES)[number];
711
+
712
+ /**
713
+ * Inbound `/v1/*` keys never store plaintext secrets. Personal keys carry an
714
+ * authentication hash; share templates carry policy and child keys point back
715
+ * to the template that issued them.
716
+ */
717
+ export const apiKeys = pgTable(
718
+ "api_keys",
719
+ {
720
+ id: uuid("id").primaryKey().defaultRandom(),
721
+ tenantId: tenantRefRequired(),
722
+ keyHash: text("key_hash"),
723
+ keyMode: text("key_mode").$type<ApiKeyMode>().notNull().default("personal"),
724
+ parentKeyId: uuid("parent_key_id").references(
725
+ (): AnyPgColumn => apiKeys.id,
726
+ { onDelete: "cascade" },
727
+ ),
728
+ issuedClientIp: text("issued_client_ip"),
729
+ issuedClientIpKey: text("issued_client_ip_key"),
730
+ label: text("label").notNull(),
731
+ scopes: jsonb("scopes").notNull().$type<readonly string[]>(),
732
+ // Only the non-secret public key prefix is returned to the owner.
733
+ keyPrefix: text("key_prefix"),
734
+ // Personal keys retain an encrypted copy for the CLI handoff. Shared
735
+ // children are revealed once and persist only their authentication hash.
736
+ keyEncrypted: bytea("key_encrypted"),
737
+ notesTitle: text("notes_title"),
738
+ notesSubtitle: text("notes_subtitle"),
739
+ notesBody: text("notes_body"),
740
+ /** Explicit list position. */
741
+ sortIndex: integer("sort_index").notNull().default(0),
742
+ requestsPerMinute: integer("requests_per_minute"),
743
+ dailyTokenLimit: bigint("daily_token_limit", { mode: "number" }),
744
+ monthlyTokenLimit: bigint("monthly_token_limit", { mode: "number" }),
745
+ lifetimeTokenBudget: bigint("lifetime_token_budget", { mode: "number" }),
746
+ lifetimeTokensConsumed: bigint("lifetime_tokens_consumed", { mode: "number" })
747
+ .notNull()
748
+ .default(0),
749
+ maxConcurrentRequests: integer("max_concurrent_requests"),
750
+ /**
751
+ * `whitelist` = only the names in `modelList` may be used (empty = all);
752
+ * `blacklist` = the names in `modelList` are refused (empty = none). One
753
+ * list, one mode.
754
+ */
755
+ modelAccessMode: text("model_access_mode")
756
+ .$type<ApiKeyModelAccessMode>()
757
+ .notNull()
758
+ .default("whitelist"),
759
+ modelList: jsonb("model_list").$type<readonly string[]>(),
760
+ /**
761
+ * Client-router ids this key refuses. A request whose fingerprint names one
762
+ * of these is rejected before routing, so an operator can stop a downstream
763
+ * router from spending the key. Empty/NULL means "no router is refused".
764
+ *
765
+ * A label, not a security control: a client that sends no fingerprint is
766
+ * simply not matched, which is why this is a per-key policy rather than a
767
+ * boundary anything else in the system trusts.
768
+ */
769
+ clientRouterDenylist: jsonb("client_router_denylist").$type<readonly string[]>(),
770
+ modelPrefix: text("model_prefix"),
771
+ createdAt: createdAtColumn(),
772
+ enabled: boolean("enabled").notNull().default(true),
773
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
774
+ /**
775
+ * The tenant's mandatory gateway key. Seeded at first boot whether or not
776
+ * `CARTETHYIA_API_KEY` was configured, and the one key the console refuses
777
+ * to revoke — it may only be rotated. A column rather than a label match,
778
+ * so renaming the key cannot break the CLI lookup or let a second
779
+ * default be created.
780
+ */
781
+ isDefault: boolean("is_default").notNull().default(false),
782
+ },
783
+ (table) => [
784
+ uniqueIndex("api_keys_key_hash_idx").on(table.keyHash),
785
+ /**
786
+ * One *live* default key per tenant, enforced by the database.
787
+ *
788
+ * This is the only database-level guarantee behind `isDefault`, and
789
+ * `test/persistence/sqlite-baseline.test.ts` pins it by name — so the
790
+ * schema must keep declaring it to match the baseline.
791
+ *
792
+ * Partial on `revoked_at IS NULL` so a tenant can rotate its default key
793
+ * without the old, revoked row blocking the new one.
794
+ */
795
+ uniqueIndex("api_keys_one_default_per_tenant_uidx")
796
+ .on(table.tenantId)
797
+ .where(sql`${table.isDefault} = true AND ${table.revokedAt} IS NULL`),
798
+ index("api_keys_tenant_id_idx").on(table.tenantId),
799
+ index("api_keys_enabled_tenant_idx").on(table.tenantId).where(sql`${table.enabled} = true`),
800
+ index("api_keys_parent_key_id_idx").on(table.parentKeyId),
801
+ check(
802
+ "api_keys_mode_shape_check",
803
+ sql`(
804
+ (${table.keyMode} = 'personal' AND ${table.keyHash} IS NOT NULL
805
+ AND ${table.parentKeyId} IS NULL AND ${table.issuedClientIp} IS NULL
806
+ AND ${table.issuedClientIpKey} IS NULL)
807
+ OR
808
+ (${table.keyMode} = 'share' AND (
809
+ (${table.parentKeyId} IS NULL AND ${table.keyHash} IS NULL
810
+ AND ${table.keyEncrypted} IS NULL
811
+ AND ${table.issuedClientIp} IS NULL AND ${table.issuedClientIpKey} IS NULL)
812
+ OR
813
+ (${table.parentKeyId} IS NOT NULL AND ${table.keyHash} IS NOT NULL
814
+ AND ${table.keyEncrypted} IS NULL
815
+ AND ${table.issuedClientIp} IS NOT NULL AND ${table.issuedClientIpKey} IS NOT NULL)
816
+ ))
817
+ )`,
818
+ ),
819
+ ],
820
+ );
821
+
822
+ /**
823
+ * The optional popup a share link shows its recipient. Kept off `api_keys`
824
+ * because the auth path reads that row on every request and the popup is
825
+ * absent for nearly all of them. One row per key, present only when the owner
826
+ * configured one.
827
+ */
828
+ export const sharePopups = pgTable(
829
+ "share_popups",
830
+ {
831
+ apiKeyId: uuid("api_key_id")
832
+ .primaryKey()
833
+ .references(() => apiKeys.id, { onDelete: "cascade" }),
834
+ enabled: boolean("enabled").notNull().default(false),
835
+ image: bytea("image"),
836
+ imageMime: text("image_mime"),
837
+ title: text("title"),
838
+ body: text("body"),
839
+ },
840
+ (table) => [
841
+ check(
842
+ "share_popups_image_mime_check",
843
+ sql`${table.imageMime} IS NULL OR ${table.imageMime} IN ('image/png', 'image/jpeg', 'image/webp', 'image/gif')`,
844
+ ),
845
+ check(
846
+ "share_popups_image_size_check",
847
+ sql`${table.image} IS NULL OR octet_length(${table.image}) <= 2097152`,
848
+ // SQLite has no octet_length. The generator rewrites this check to
849
+ // `length(`; both count bytes for a blob column.
850
+ ),
851
+ ],
852
+ );
853
+
854
+
855
+ // Public bearer links for enrolling one shared key per resolved client IP, and
856
+ // for handing a personal key to its owner. The hash is the lookup key; the
857
+ // token is additionally retained encrypted so the owner's console can show the
858
+ // link again instead of losing it after the one response that minted it.
859
+ /**
860
+ * Share-link kinds, as a runtime tuple. Route schemas and DTOs project this
861
+ * list rather than restating it.
862
+ */
863
+ export const SHARE_LINK_KINDS = ["enroll", "handoff"] as const;
864
+
865
+ /** One share-link kind. */
866
+ export type ShareLinkKind = (typeof SHARE_LINK_KINDS)[number];
867
+
868
+
869
+ export const shareLinks = pgTable(
870
+ "share_links",
871
+ {
872
+ id: uuid("id").primaryKey().defaultRandom(),
873
+ apiKeyId: uuid("api_key_id")
874
+ .notNull()
875
+ .references(() => apiKeys.id, { onDelete: "cascade" }),
876
+ tokenHash: text("token_hash").notNull(),
877
+ // Encrypted bearer token. Present so the console can re-display a stable
878
+ // link; nullable, and a NULL row simply cannot be shown again.
879
+ tokenEncrypted: bytea("token_encrypted"),
880
+ /**
881
+ * The public slug this page is reachable at, in the clear.
882
+ *
883
+ * `tokenEncrypted` is the bearer token under the deployment's encryption
884
+ * key, so a link cannot be re-displayed once that key changes — which is
885
+ * exactly what a restore onto a fresh deployment does. Holding the slug
886
+ * unencrypted keeps every restored share page reachable under the address
887
+ * it was created with.
888
+ *
889
+ * Nullable: a row without a slug has no address, and inventing one
890
+ * would publish an address the page never had.
891
+ */
892
+ slug: text("slug"),
893
+ kind: text("kind").$type<ShareLinkKind>().notNull().default("enroll"),
894
+ active: boolean("active").notNull().default(true),
895
+ createdAt: createdAtColumn(),
896
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
897
+ lastViewedAt: timestamp("last_viewed_at", { withTimezone: true }),
898
+ },
899
+ (table) => [
900
+ uniqueIndex("share_links_token_hash_idx").on(table.tokenHash),
901
+ // Partial: rows without a slug are not constrained, so backfilling is never
902
+ // blocked by a uniqueness collision.
903
+ uniqueIndex("share_links_slug_uidx").on(table.slug).where(sql`${table.slug} IS NOT NULL`),
904
+ index("share_links_api_key_idx").on(table.apiKeyId),
905
+ index("share_links_active_kind_expires_idx").on(table.active, table.kind, table.expiresAt),
906
+ check("share_links_kind_check", sql`${table.kind} IN ('enroll', 'handoff')`),
907
+ ],
908
+ );
909
+
910
+
911
+ /**
912
+ * Console dashboard users — distinct from API-key tenants.
913
+ */
914
+ export const consoleUsers = pgTable(
915
+ "console_users",
916
+ {
917
+ id: uuid("id").primaryKey().defaultRandom(),
918
+ tenantId: tenantRefRequired(),
919
+ username: text("username").notNull(),
920
+ email: text("email"),
921
+ passwordHash: text("password_hash").notNull(),
922
+ displayName: text("display_name"),
923
+ isActive: boolean("is_active").notNull().default(true),
924
+ isPlatformAdmin: boolean("is_platform_admin").notNull().default(false),
925
+ isFirstBoot: boolean("is_first_boot").notNull().default(true),
926
+ ...timestampColumns(),
927
+ },
928
+ (table) => [
929
+ index("console_users_tenant_id_idx").on(table.tenantId),
930
+ uniqueIndex("console_users_tenant_username_uidx").on(table.tenantId, table.username),
931
+ ],
932
+ );
933
+
934
+ export type ConsoleUser = typeof consoleUsers.$inferSelect;
935
+
936
+ /**
937
+ * Console sessions — HTTP-only, signed cookies.
938
+ * Session metadata only; no secrets in JSON.
939
+ */
940
+ export const consoleSessions = pgTable(
941
+ "console_sessions",
942
+ {
943
+ id: uuid("id").primaryKey().defaultRandom(),
944
+ userId: uuid("user_id")
945
+ .notNull()
946
+ .references(() => consoleUsers.id, { onDelete: "cascade" }),
947
+ sessionToken: text("session_token").notNull().unique(),
948
+ ipAddress: text("ip_address"),
949
+ userAgent: text("user_agent"),
950
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
951
+ createdAt: createdAtColumn(),
952
+ },
953
+ (table) => [
954
+ index("console_sessions_user_id_idx").on(table.userId),
955
+ ],
956
+ );
957
+
958
+ export type ConsoleSession = typeof consoleSessions.$inferSelect;
959
+
960
+ /**
961
+ * Console login lockout tracking — fail-closed rate limiting keyed by
962
+ * client IP (login attempts happen before any user identity is known, so
963
+ * this cannot be keyed by `console_users.id`). Persisted so a ban survives
964
+ * a process restart and is shared across every app instance, instead of a
965
+ * static in-process map that a second instance or a redeploy would forget.
966
+ */
967
+ export const consoleLockouts = pgTable(
968
+ "console_lockouts",
969
+ {
970
+ id: uuid("id").primaryKey().defaultRandom(),
971
+ ip: text("ip").notNull(),
972
+ failureCount: integer("failure_count").notNull().default(0),
973
+ windowUntil: timestamp("window_until", { withTimezone: true }),
974
+ lockedUntil: timestamp("locked_until", { withTimezone: true }),
975
+ reason: text("reason"),
976
+ ...timestampColumns(),
977
+ },
978
+ (table) => [
979
+ uniqueIndex("idx_console_lockouts_ip").on(table.ip),
980
+ ],
981
+ );
982
+
983
+
984
+ // Configuration-change audit has its own retention lifecycle; it is separate
985
+ // from metadata telemetry and short-lived optional payload capture.
986
+ export const adminAuditLog = pgTable(
987
+ "admin_audit_log",
988
+ {
989
+ id: uuid("id").primaryKey().defaultRandom(),
990
+ createdAt: createdAtColumn(),
991
+ actor: text("actor").notNull(),
992
+ tenantId: tenantCascadeSetNull(),
993
+ action: text("action").notNull(),
994
+ target: text("target").notNull(),
995
+ detail: jsonb("detail"),
996
+ },
997
+ (table) => [
998
+ index("admin_audit_log_tenant_id_created_at_id_idx").on(
999
+ table.tenantId,
1000
+ table.createdAt,
1001
+ table.id,
1002
+ ),
1003
+ ],
1004
+ );
1005
+
1006
+
1007
+ // Runtime preferences are stored in one validated JSONB bag. The filter
1008
+ // master toggle remains a typed column because it is read on the proxy path.
1009
+ export interface ConsoleSettingsPreferences {
1010
+ tenantConcurrencyLimit?: number | null;
1011
+ /** Normalizes provider-native thinking config for non-user final turns. */
1012
+ thinkingNormalizationEnabled?: boolean;
1013
+ responsesReasoningSummary?: "auto" | "concise" | "detailed";
1014
+ /**
1015
+ * Request compression: compact bulky tool-result text in the message history
1016
+ * before dispatch (RTK prune). Default off.
1017
+ */
1018
+ rtkPruneEnabled?: boolean;
1019
+ /** RTK prune strength when enabled (`lite`/`full`/`ultra`); `full` when unset. */
1020
+ rtkPruneLevel?: "lite" | "full" | "ultra";
1021
+ /**
1022
+ * Request compression: append a minimal-code directive to the system content.
1023
+ * Default off; `ponyTailLevel` selects the intensity when on.
1024
+ */
1025
+ ponyTailEnabled?: boolean;
1026
+ /** PonyTail directive intensity; `full` when unset. Only applied when enabled. */
1027
+ ponyTailLevel?: "lite" | "full" | "ultra" | null;
1028
+ /**
1029
+ * Payload capture mode (default `none` when unset):
1030
+ * - `metadata` — Proxy→Provider method + allowlisted headers only
1031
+ * - `full` — full redacted bodies up to the configured capture limit
1032
+ * - `none` — no drawer capture (request metadata events still retained)
1033
+ */
1034
+ telemetryPayloads?: "full" | "metadata" | "none";
1035
+ /**
1036
+ * Capture depth for `full` mode (default `minimum` when unset). Fixed size
1037
+ * tiers so every debug sees all four drawer panels — only the cap changes:
1038
+ * - `minimum` — 1 MiB combined cap, light enough to leave on
1039
+ * - `moderate` — 8 MiB combined cap, expect CPU/memory spikes while active
1040
+ * - `maximum` — 16 MiB combined cap, expect CPU/memory spikes while active
1041
+ */
1042
+ telemetryPayloadDepth?: "minimum" | "moderate" | "maximum";
1043
+
1044
+ privacyMode?: "masked" | "full";
1045
+ /**
1046
+ * Ignores an account-wide cooldown when building route candidates.
1047
+ *
1048
+ * Off by default, and deliberately dangerous: an account in cooldown was
1049
+ * parked because the upstream refused it, and dialing it anyway reproduces
1050
+ * that refusal on every attempt. Turning this on can exhaust the candidate
1051
+ * list and fail a request that would otherwise have been routed.
1052
+ *
1053
+ * The override is read-only — the cooldown is still recorded, still shown in
1054
+ * Health, and still recovered by the sweep. Nothing is written back, so the
1055
+ * toggle can be turned off again without cleaning anything up.
1056
+ *
1057
+ * Per-(account, model) cooldowns are NOT overridden. Those are usually a
1058
+ * named quota ("daily free limit reached, try again in 11h") where the
1059
+ * upstream stated a real reset; ignoring it only buys repeated refusals.
1060
+ */
1061
+ forceDisableCooldown?: boolean;
1062
+ /**
1063
+ * Stops every invoke and route for this tenant. Off by default.
1064
+ *
1065
+ * A kill switch, not a routing tweak: while it is on, `/v1/*` returns 503 and
1066
+ * nothing is dispatched upstream. The console keeps working, which is the
1067
+ * point — an operator must be able to stop traffic and still reach the UI
1068
+ * that turns it back on.
1069
+ */
1070
+ invokeDisabled?: boolean;
1071
+ /**
1072
+ * Slack added on top of every key's concurrency limit for this tenant.
1073
+ *
1074
+ * `maxConcurrentRequests` is the number an operator chose and what a
1075
+ * refusal names; this is deliberate allowance above it so a run landing a
1076
+ * few over is not refused at that number. Effective ceiling is
1077
+ * `limit + tolerance`, and only requests past it are refused — never ones
1078
+ * already admitted.
1079
+ */
1080
+ concurrencyTolerance?: number | null;
1081
+ /**
1082
+ * Per-tenant upstream request timeout in seconds, overriding
1083
+ * `CARTETHYIA_UPSTREAM_TIMEOUT_MS`. `null` or absent means use the
1084
+ * deployment default.
1085
+ */
1086
+ upstreamTimeoutSeconds?: number | null;
1087
+ /** Captured payload detail lifetime in minutes; null means deployment default. */
1088
+ telemetryWindowMinutes?: number | null;
1089
+
1090
+ /**
1091
+ * Per-tenant ceiling for one inline attachment, in bytes, overriding the
1092
+ * deployment default of 10 MB.
1093
+ *
1094
+ * Raising this asserts that this tenant's upstreams accept larger
1095
+ * attachments. The clamp is a hard send gate precisely because an oversized
1096
+ * block poisons every retry and every replayed turn — the upstream rejects
1097
+ * the whole request rather than the one block — so an override larger than
1098
+ * the provider actually accepts trades that protection away. Lowering it is
1099
+ * the safer direction and the more common need.
1100
+ */
1101
+ imageClampMaxBytes?: number | null;
1102
+ }
1103
+
1104
+ export const consoleSettings = pgTable("console_settings", {
1105
+ tenantId: tenantRefPrimaryKey(),
1106
+ preferences: jsonb("preferences").notNull().default({}).$type<ConsoleSettingsPreferences>(),
1107
+ updatedAt: updatedAtColumn(),
1108
+ });
1109
+
1110
+ // InferHub `config_read` key. One row for the whole deployment.
1111
+ //
1112
+ // Deliberately NOT `provider_accounts.auth_state`: that field is documented as
1113
+ // per-account *non-secret* upstream config and travels with the account through
1114
+ // backup/restore, so a credential stored there is copied around and readable by
1115
+ // anything that reads accounts. This key is a secret and gets its own encrypted
1116
+ // row, unreachable from the account paths.
1117
+ export const inferhubConfigKeys = pgTable("inferhub_config_keys", {
1118
+ id: text("id").primaryKey(),
1119
+ /** `encryptCredential` output — never plaintext, never returned by an API. */
1120
+ ciphertext: bytea("ciphertext").notNull(),
1121
+ /** Hash only, for "did it change" checks. Not used to verify the key. */
1122
+ fingerprint: text("fingerprint").notNull(),
1123
+ updatedAt: updatedAtColumn(),
1124
+ });
1125
+
1126
+
1127
+ // CLI Tools (CLI tool configuration): per-tenant mapping table that lets a coding-agent
1128
+ // slot (e.g. Claude Code's "opus" slot) target a specific Cartethyia model. Rows
1129
+ // are opaque strings — validation happens at the API boundary against the
1130
+ // tool registry, not in Postgres.
1131
+ export const cliToolMappings = pgTable(
1132
+ "cli_tool_mappings",
1133
+ {
1134
+ id: uuid("id").primaryKey().defaultRandom(),
1135
+ tenantId: tenantRefRequired(),
1136
+ apiKeyId: uuid("api_key_id")
1137
+ .notNull()
1138
+ .references((): AnyPgColumn => apiKeys.id, { onDelete: "cascade" }),
1139
+ toolId: text("tool_id").notNull(),
1140
+ slotKey: text("slot_key").notNull(),
1141
+ sourceModel: text("source_model").notNull(),
1142
+ targetModel: text("target_model").notNull(),
1143
+ enabled: boolean("enabled").notNull().default(true),
1144
+ updatedAt: updatedAtColumn(),
1145
+ },
1146
+ (table) => [
1147
+ uniqueIndex("cli_tool_mappings_key").on(
1148
+ table.tenantId,
1149
+ table.toolId,
1150
+ table.apiKeyId,
1151
+ table.slotKey,
1152
+ ),
1153
+ ],
1154
+ );
1155
+
1156
+
1157
+ // Per-(tenant, tool, api_key) settings: whether mappings are active and which
1158
+ // mode the tool is configured for. Primary key is the composite of all three.
1159
+ export const cliToolSettings = pgTable(
1160
+ "cli_tool_settings",
1161
+ {
1162
+ tenantId: tenantRefRequired(),
1163
+ apiKeyId: uuid("api_key_id")
1164
+ .notNull()
1165
+ .references((): AnyPgColumn => apiKeys.id, { onDelete: "cascade" }),
1166
+ toolId: text("tool_id").notNull(),
1167
+ mappingsEnabled: boolean("mappings_enabled").notNull().default(false),
1168
+ mode: text("mode").notNull().default("remote"),
1169
+ updatedAt: updatedAtColumn(),
1170
+ },
1171
+ (table) => [primaryKey({ columns: [table.tenantId, table.toolId, table.apiKeyId] })],
1172
+ );
1173
+
1174
+
1175
+ // Metadata-only request telemetry. Retention is configured independently of
1176
+ // payload capture (`CARTETHYIA_TELEMETRY_RETENTION_DAYS`, default 30 days).
1177
+ // No prompt text, raw body, API key, encrypted-reasoning payload, or generic
1178
+ // `payload` column — the schema structurally prevents those fields.
1179
+ // Correlation columns intentionally have no catalog foreign keys, so a
1180
+ // telemetry write never depends on provider-row lifetime.
1181
+ export const telemetryEvents = pgTable(
1182
+ "telemetry_events",
1183
+ {
1184
+ id: uuid("id").primaryKey().defaultRandom(),
1185
+ createdAt: createdAtColumn(),
1186
+ tenantId: tenantRefRequired(),
1187
+ requestId: uuid("request_id").notNull(),
1188
+ sourceSurface: telemetrySourceSurface("source_surface"),
1189
+ requestedModel: text("requested_model"),
1190
+ /**
1191
+ * Canonical client name derived from `user_agent` at ingestion
1192
+ * (`clientNameFromUserAgent`). Stamped once at write time so client
1193
+ * breakdowns group in SQL; the raw `user_agent` stays for detail.
1194
+ */
1195
+ clientName: text("client_name"),
1196
+ /**
1197
+ * The `model` the upstream put in its own response, verbatim.
1198
+ *
1199
+ * Separate from `requested_model` because they answer different questions:
1200
+ * one is what the client asked for, the other is what the provider said it
1201
+ * ran. A provider that answers `muse-spark` as `deepseek-v4.1-flash` is
1202
+ * telling the operator something, and overwriting one with the other would
1203
+ * erase it. NULL when the response carried no model.
1204
+ */
1205
+ reportedModel: text("reported_model"),
1206
+ /** Canonical reasoning effort the client asked for; NULL when it asked for none. */
1207
+ requestedEffort: text("requested_effort"),
1208
+ providerId: text("provider_id"),
1209
+ accountId: uuid("account_id"),
1210
+ networkPoolId: uuid("network_pool_id"),
1211
+ endpoint: text("endpoint"),
1212
+ apiKeyId: uuid("api_key_id"),
1213
+ userAgent: text("user_agent"),
1214
+ clientIp: text("client_ip"),
1215
+ latencyMs: integer("latency_ms"),
1216
+ ttfbMs: integer("ttfb_ms"),
1217
+ stream: boolean("stream"),
1218
+ status: telemetryStatus("status"),
1219
+ /**
1220
+ * HTTP status the client actually received. The `status` enum is the
1221
+ * internal terminal state (`completed`/`failed`/…), which collapses every
1222
+ * failure to one bucket; this column keeps the wire code, so a 503
1223
+ * admission rejection stays distinguishable from a 500 upstream failure
1224
+ * in the Usage status breakdown. NULL on rows written before the column.
1225
+ */
1226
+ httpStatus: integer("http_status"),
1227
+ /**
1228
+ * Which layer produced the failure: the gateway itself, the upstream
1229
+ * provider, or the network in between. Recorded beside `error_category`
1230
+ * because the category alone cannot separate them — `invalid_request` is
1231
+ * both "the caller sent a bad body" (ours) and "the upstream rejected the
1232
+ * body" (theirs), and an operator triaging a failure needs to know which.
1233
+ */
1234
+ errorOrigin: text("error_origin"),
1235
+ errorCategory: text("error_category"),
1236
+ inputTokens: integer("input_tokens"),
1237
+ cachedInputTokens: integer("cached_input_tokens"),
1238
+ outputTokens: integer("output_tokens"),
1239
+ reasoningTokens: integer("reasoning_tokens"),
1240
+ estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }),
1241
+ /** Provider-billed credits (Tencent buddy-meter `credit`); NULL when unreported. */
1242
+ creditUsed: numeric("credit_used", { precision: 12, scale: 4 }),
1243
+ /**
1244
+ * Gateway-side time before the upstream dispatch began: routing,
1245
+ * credential lease, and request preparation. `ttfbMs` is measured from
1246
+ * the dispatch start, so this is the overhead the client waited on before
1247
+ * the provider was even called.
1248
+ */
1249
+ resolveMs: integer("resolve_ms"),
1250
+ tokensPerSec: numeric("tokens_per_sec", { precision: 10, scale: 2 }),
1251
+ // Absolute epoch-millisecond event timestamps (~1.8e12) overflow integer
1252
+ // (int4): completed requests carrying observed stream timings failed
1253
+ // their telemetry insert and vanished from the console. bigint "number"
1254
+ // mode is safe here — epoch millis stay far below MAX_SAFE_INTEGER —
1255
+ // and keeps the columns JSON-safe like the token-budget bigints above.
1256
+ firstContentDeltaAtMs: bigint("first_content_delta_at_ms", { mode: "number" }),
1257
+ lastEventAtMs: bigint("last_event_at_ms", { mode: "number" }),
1258
+ /**
1259
+ * Why this request's drawer payloads were not captured, or `captured` when
1260
+ * they were. Remote-diagnosis column: the drawer reads empty for reasons
1261
+ * that are invisible from the client — the tenant's capture mode, a missing
1262
+ * tenant, a non-terminal attempt — and without this an operator could not
1263
+ * tell "not configured" from "broken".
1264
+ */
1265
+ payloadCaptureState: text("payload_capture_state"),
1266
+ },
1267
+ (table) => [
1268
+ index("telemetry_events_created_at_idx").on(table.createdAt),
1269
+ // Keyset pagination, newest-first: `(created_at, id)` DESC breaks ties
1270
+ // deterministically (created_at is second-resolution), so a cursor on the
1271
+ // pair never skips or repeats rows at a boundary. Replaces the plain
1272
+ // `(tenant_id, created_at)` index, which could not break ties.
1273
+ index("telemetry_events_tenant_created_id_idx").on(
1274
+ table.tenantId,
1275
+ desc(table.createdAt),
1276
+ desc(table.id),
1277
+ ),
1278
+ /**
1279
+ * Cross-process idempotency boundary: one terminal event per
1280
+ * (tenant, request). Retried drains and cross-process races converge to
1281
+ * one row (and one usage charge) via `ON CONFLICT DO NOTHING`.
1282
+ */
1283
+ uniqueIndex("telemetry_events_tenant_request_uidx").on(
1284
+ table.tenantId,
1285
+ table.requestId,
1286
+ ),
1287
+ // Usage status breakdown: equality on `http_status`, keyset range+order.
1288
+ index("telemetry_events_tenant_http_created_id_idx").on(
1289
+ table.tenantId,
1290
+ table.httpStatus,
1291
+ desc(table.createdAt),
1292
+ desc(table.id),
1293
+ ),
1294
+ // Per-key family spend: equality on `api_key_id`, keyset range+order.
1295
+ index("telemetry_events_tenant_api_key_created_id_idx").on(
1296
+ table.tenantId,
1297
+ table.apiKeyId,
1298
+ desc(table.createdAt),
1299
+ desc(table.id),
1300
+ ),
1301
+ index("telemetry_events_request_id_idx").on(table.requestId),
1302
+ index("telemetry_events_tenant_account_created_idx").on(
1303
+ table.tenantId,
1304
+ table.accountId,
1305
+ table.createdAt,
1306
+ ),
1307
+ /**
1308
+ * Serves the public share page's per-key aggregate (`share-usage.ts`):
1309
+ * equality on `api_key_id`, range and aggregate on `created_at`. Without
1310
+ * it every share render scanned the largest table in the schema.
1311
+ */
1312
+ index("telemetry_events_api_key_created_idx").on(table.apiKeyId, table.createdAt),
1313
+ ],
1314
+ );
1315
+
1316
+ /**
1317
+ * Backfill/reconciliation checkpoint for `carte telemetry reconcile`.
1318
+ *
1319
+ * One row per job name. The checkpoint (file_name, byte_offset) commits in
1320
+ * the same transaction as each backfill batch, so a crash resumes from the
1321
+ * last committed offset. The lease columns let exactly one worker run the
1322
+ * job: a second worker is rejected until the lease expires.
1323
+ */
1324
+ export const telemetryReconciliationState = pgTable(
1325
+ "telemetry_reconciliation_state",
1326
+ {
1327
+ jobName: text("job_name").primaryKey(),
1328
+ leaseOwner: text("lease_owner"),
1329
+ leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }),
1330
+ fileName: text("file_name").notNull().default(""),
1331
+ byteOffset: bigint("byte_offset", { mode: "number" }).notNull().default(0),
1332
+ filesCompleted: bigint("files_completed", { mode: "number" }).default(0),
1333
+ eventsExamined: bigint("events_examined", { mode: "number" }).default(0),
1334
+ eventsInserted: bigint("events_inserted", { mode: "number" }).default(0),
1335
+ conflicts: bigint("conflicts", { mode: "number" }).default(0),
1336
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
1337
+ },
1338
+ (table) => [
1339
+ check("telemetry_reconciliation_state_offset_nonneg", sql`${table.byteOffset} >= 0`),
1340
+ check("telemetry_reconciliation_state_files_nonneg", sql`${table.filesCompleted} >= 0`),
1341
+ check("telemetry_reconciliation_state_examined_nonneg", sql`${table.eventsExamined} >= 0`),
1342
+ check("telemetry_reconciliation_state_inserted_nonneg", sql`${table.eventsInserted} >= 0`),
1343
+ check("telemetry_reconciliation_state_conflicts_nonneg", sql`${table.conflicts} >= 0`),
1344
+ ],
1345
+ );
1346
+
1347
+ /** Durable aggregates retained after metadata telemetry expires. */
1348
+ export const TELEMETRY_USAGE_IDENTITY_TYPES = ["account", "api_key"] as const;
1349
+ export type TelemetryUsageIdentityType = (typeof TELEMETRY_USAGE_IDENTITY_TYPES)[number];
1350
+
1351
+ export const telemetryUsageTotals = pgTable(
1352
+ "telemetry_usage_totals",
1353
+ {
1354
+ tenantId: tenantRefRequired(),
1355
+ identityType: text("identity_type").$type<TelemetryUsageIdentityType>().notNull(),
1356
+ entityId: uuid("entity_id").notNull(),
1357
+ requests: bigint("requests", { mode: "number" }).notNull().default(0),
1358
+ errors: bigint("errors", { mode: "number" }).notNull().default(0),
1359
+ inputTokens: bigint("input_tokens", { mode: "number" }).notNull().default(0),
1360
+ outputTokens: bigint("output_tokens", { mode: "number" }).notNull().default(0),
1361
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull(),
1362
+ updatedAt: updatedAtColumn(),
1363
+ },
1364
+ (table) => [
1365
+ primaryKey({
1366
+ name: "telemetry_usage_totals_identity_pk",
1367
+ columns: [table.tenantId, table.identityType, table.entityId],
1368
+ }),
1369
+ check(
1370
+ "telemetry_usage_totals_identity_type_check",
1371
+ sql`${table.identityType} IN ('account', 'api_key')`,
1372
+ ),
1373
+ ],
1374
+ );
1375
+
1376
+
1377
+ export const telemetryPayloads = pgTable(
1378
+ "telemetry_payloads",
1379
+ {
1380
+ id: uuid("id").primaryKey().defaultRandom(),
1381
+ tenantId: tenantRefRequired(),
1382
+ requestId: uuid("request_id"),
1383
+ capturedAt: timestamp("captured_at", { withTimezone: true }).notNull().defaultNow(),
1384
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
1385
+ /**
1386
+ * Typed index into the on-disk frame store. Prompt/response bodies live
1387
+ * only in the frame file these columns name — never in Postgres, which is
1388
+ * why this table carries no body column to write one into by accident.
1389
+ */
1390
+ storage: text("storage").notNull(),
1391
+ file: text("file").notNull(),
1392
+ offset: integer("offset").notNull(),
1393
+ length: integer("length").notNull(),
1394
+ checksum: text("checksum").notNull(),
1395
+ version: integer("version").notNull(),
1396
+ /**
1397
+ * Which of the four payload stages were captured, as a bitmask. The Usage
1398
+ * list shows one icon per stage; learning presence from the frame would
1399
+ * cost one inflate per row, so the writer records it here and the list
1400
+ * reads it off the indexed row.
1401
+ *
1402
+ * Bits: 1=request, 2=provider_request, 4=provider_response,
1403
+ * 8=client_response. Nullable: NULL renders as all icons greyed rather
1404
+ * than inventing presence.
1405
+ */
1406
+ stages: integer("stages"),
1407
+ },
1408
+ (table) => [
1409
+ index("telemetry_payloads_request_id_idx").on(table.requestId),
1410
+ index("telemetry_payloads_tenant_request_idx").on(table.tenantId, table.requestId),
1411
+ index("telemetry_payloads_expires_idx").on(table.expiresAt),
1412
+ check("telemetry_payloads_storage_check", sql`${table.storage} = 'jsonl-file'`),
1413
+ check("telemetry_payloads_version_check", sql`${table.version} = 1`),
1414
+ check("telemetry_payloads_offset_check", sql`${table.offset} >= 0`),
1415
+ check("telemetry_payloads_length_check", sql`${table.length} > 0`),
1416
+ ],
1417
+ );
1418
+
1419
+
1420
+
1421
+ // Daily check-in ledger: one row per (account, calendar day) once the sweep has
1422
+ // attempted that account's claim.
1423
+ //
1424
+ // The day-scoped composite PK is the claim guard — an insert that conflicts is
1425
+ // exactly the "already settled today" answer — so it replaces the Redis
1426
+ // `SET NX EX` the Lite backend never had. The console reads the same table to
1427
+ // answer "was this attempted today", which is what makes the Quota page show
1428
+ // the real state instead of a permanent "Check-in due" when Redis is absent.
1429
+ export const dailyCheckinLedger = pgTable(
1430
+ "daily_checkin_ledger",
1431
+ {
1432
+ dayKey: text("day_key").notNull(),
1433
+ accountId: uuid("account_id").notNull(),
1434
+ claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
1435
+ },
1436
+ (table) => [
1437
+ primaryKey({ columns: [table.dayKey, table.accountId] }),
1438
+ ],
1439
+ );
1440
+
1441
+
1442
+ /**
1443
+ * Per-day, per-model credit deductions the buddy billing meter reported.
1444
+ *
1445
+ * A snapshot, not a ledger: the upstream is the source of truth and answers the
1446
+ * whole window on every call, so a row is upserted rather than appended. What it
1447
+ * buys is that the console renders a *stored* figure — without it every page
1448
+ * open would re-hit the billing endpoint for every account, and a failed read
1449
+ * would show nothing at all instead of the last number obtained.
1450
+ *
1451
+ * Keyed (account, day, model): one row per bucket, and the conflict target is
1452
+ * what makes the write idempotent across passes.
1453
+ */
1454
+ export const buddyUsageSnapshots = pgTable(
1455
+ "buddy_usage_snapshots",
1456
+ {
1457
+ accountId: uuid("account_id")
1458
+ .notNull()
1459
+ .references(() => providerAccounts.id, { onDelete: "cascade" }),
1460
+ /** Calendar day the requests were metered on, `YYYY-MM-DD`. */
1461
+ dayKey: text("day_key").notNull(),
1462
+ model: text("model").notNull(),
1463
+ /** Credits deducted across every request in this bucket. */
1464
+ credit: numeric("credit", { precision: 14, scale: 4 }).notNull(),
1465
+ syncedAt: timestamp("synced_at", { withTimezone: true }).notNull().defaultNow(),
1466
+ },
1467
+ (table) => [
1468
+ primaryKey({ columns: [table.accountId, table.dayKey, table.model] }),
1469
+ index("buddy_usage_snapshots_day_idx").on(table.dayKey),
1470
+ ],
1471
+ );
1472
+
1473
+ /**
1474
+ * One row per (day, account, growth task) once that day's claim was attempted.
1475
+ *
1476
+ * The composite PK is the idempotency guard: an insert that conflicts is
1477
+ * exactly the "already settled today" answer, which is what stops a sweep from
1478
+ * re-POSTing a claim on every pass. Same shape as `daily_checkin_ledger`
1479
+ * because it is the same problem — one claim per account per day — widened by
1480
+ * the task name now that there is more than one thing to claim.
1481
+ */
1482
+ export const buddyTaskLedger = pgTable(
1483
+ "buddy_task_ledger",
1484
+ {
1485
+ dayKey: text("day_key").notNull(),
1486
+ accountId: uuid("account_id")
1487
+ .notNull()
1488
+ .references(() => providerAccounts.id, { onDelete: "cascade" }),
1489
+ task: text("task").notNull(),
1490
+ claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
1491
+ },
1492
+ (table) => [
1493
+ primaryKey({ columns: [table.dayKey, table.accountId, table.task] }),
1494
+ index("buddy_task_ledger_account_idx").on(table.accountId),
1495
+ ],
1496
+ );