cartethyia 2.4.6 → 2.5.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 (302) hide show
  1. package/README.md +45 -13
  2. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  3. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-Bcy150Jo.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DyNXefph.js → CliToolsPage-D3xqZbN-.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  8. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  9. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CATpAEOz.js → ConsoleLogPage-u6W1UXSc.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-D6jEU7nL.js → CustomizationPage-CTTpCb25.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-CZkpn0a7.js → HealthEventsModal-C5XpWlvy.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BJlMVN4U.js → LoginPage-DHoB3Agn.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-Br3CXEau.js → ModelPicker-oQ-SDXp8.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  25. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  26. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  27. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  28. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  29. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  30. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  31. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  32. package/dist/dashboard/assets/{SettingsPage-BHW4Zt06.js → SettingsPage-CvjZxHE4.js} +1 -1
  33. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  34. package/dist/dashboard/assets/{SetupPage-VOYC_lIa.js → SetupPage-D3nza7LH.js} +1 -1
  35. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  36. package/dist/dashboard/assets/SortableList-Dtt12WT1.js +1 -0
  37. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-BNIDLiU6.js → StudioPage-CbK3hXvU.js} +4 -4
  39. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  40. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  41. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BETfaMYZ.js → api-keys-BmDbsior.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  44. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  45. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  47. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-BTyGk_DL.js → badge-DpXT4Ixt.js} +1 -1
  49. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-bujTWYM4.js → banner-CSAAWLJv.js} +1 -1
  51. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-B0Bnx5CA.js → card-DEhRXXw4.js} +1 -1
  53. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-DE2ug812.js → cli-tools-B0wBq-RL.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-R_rArH8S.js → clipboard-button-DOo6EU8O.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-BWIP_EvW.js → dialog-BvQeBMfP.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BKcgVugF.js → drawer-CMwPXwQB.js} +1 -1
  61. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-CcWkGR4a.js → entry-D4O2229X.js} +1 -1
  63. package/dist/dashboard/assets/{entry-CcWkGR4a.js.gz → entry-D4O2229X.js.gz} +0 -0
  64. package/dist/dashboard/assets/{entry-kTiTFrvh.js → entry-DKvr5Q9K.js} +1 -1
  65. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  67. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-Booc5mNe.js → github-badge-zliLvBwr.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-D70pChfn.js → index-CXm3FIyt.js} +2 -2
  71. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-D1TCqM2D.js → inline-D1IlZZ8H.js} +1 -1
  73. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-B9oyU93y.js → input-BpVZdrbc.js} +1 -1
  75. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-BArzXirQ.js → layout-BioLW7gN.js} +1 -1
  77. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-BVZzaoHF.js → list-row-CwVcGAIc.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-EmuIayqp.js → live-yfEJFF9T.js} +1 -1
  81. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CSPQ9xB0.js → model-bans-CT-TxSNX.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  84. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  85. package/dist/dashboard/assets/providers/trae.webp +0 -0
  86. package/dist/dashboard/assets/{routing-BExgxxBU.js → routing-Cs6jl49L.js} +1 -1
  87. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  88. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  89. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  90. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  91. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  92. package/dist/dashboard/assets/{stack-Ca5HxI5i.js → stack-C9_uUjaA.js} +1 -1
  93. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  94. package/dist/dashboard/assets/{state-CzUPfLXo.js → state-DA0B95UQ.js} +1 -1
  95. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  96. package/dist/dashboard/assets/{switch-DOUIky_i.js → switch-Cittxy-x.js} +1 -1
  97. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  98. package/dist/dashboard/assets/{toast-C76WrMhc.js → toast-CNt_jzO4.js} +1 -1
  99. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-D87FZwH0.js → useMutation-BQKMcJ1H.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -52
  105. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  106. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  107. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  108. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  109. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  110. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  111. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  112. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  113. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  114. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  115. package/package.json +3 -2
  116. package/scripts/cli/command/console-menu.ts +30 -11
  117. package/scripts/cli/command/start.ts +77 -13
  118. package/scripts/cli/command/update.ts +1 -1
  119. package/scripts/cli/gateway/instance-lock.ts +105 -0
  120. package/scripts/cli/gateway/spawn.ts +173 -101
  121. package/scripts/cli/ui/background.ts +110 -49
  122. package/src/boot/app.ts +20 -6
  123. package/src/boot/dependencies.ts +19 -0
  124. package/src/boot/elysia-typebox-bridge.ts +37 -0
  125. package/src/boot/lifecycle.ts +2 -0
  126. package/src/config/cooldowns.ts +138 -138
  127. package/src/config/providers.ts +5 -5
  128. package/src/config/runtime.ts +19 -3
  129. package/src/config/spec.ts +9 -0
  130. package/src/console/api-keys/contracts.ts +414 -391
  131. package/src/console/api-keys/routes.ts +880 -870
  132. package/src/console/auth/account-reset-store.ts +14 -0
  133. package/src/console/auth/account-reset.ts +3 -2
  134. package/src/console/auth/setup-service.ts +6 -3
  135. package/src/console/domain-registration.ts +9 -2
  136. package/src/console/observability/contracts.ts +475 -455
  137. package/src/console/observability/store.ts +1015 -952
  138. package/src/console/providers/catalog/contracts.ts +31 -0
  139. package/src/console/providers/catalog/provider-operations.ts +8 -0
  140. package/src/console/providers/catalog/store.ts +1532 -1525
  141. package/src/console/providers/oauth/callback-listener.ts +18 -3
  142. package/src/console/providers/oauth/routes.ts +38 -15
  143. package/src/console/quota/refresh.ts +19 -0
  144. package/src/console/updates/routes.ts +162 -4
  145. package/src/main.ts +3 -0
  146. package/src/network/http2-bidi.ts +85 -0
  147. package/src/network/outbound-fetch.ts +1 -26
  148. package/src/network/pool/agent.ts +478 -460
  149. package/src/network/pool/resolver.ts +1104 -544
  150. package/src/network/pool-health-machine.ts +12 -5
  151. package/src/observability/payload-capture.ts +344 -324
  152. package/src/persistence/db-pg.ts +9 -3
  153. package/src/persistence/db-sqlite.ts +497 -495
  154. package/src/persistence/health-event-flush.ts +137 -0
  155. package/src/persistence/migrate.ts +181 -129
  156. package/src/persistence/schema.ts +1360 -1295
  157. package/src/persistence/settings-revision.ts +25 -0
  158. package/src/persistence/shared-cache.ts +217 -0
  159. package/src/persistence/stores/share-store.ts +556 -528
  160. package/src/persistence/usage-flush.ts +61 -1
  161. package/src/protocol/canonical-model.ts +23 -1
  162. package/src/protocol/response/chat.ts +143 -10
  163. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  164. package/src/providers/default-registry.ts +173 -1
  165. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  166. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  167. package/src/providers/discovery/probing-service.ts +1022 -1001
  168. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +39 -0
  169. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  170. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +18 -5
  171. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  172. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  173. package/src/providers/integrations/buddy/codebuddy/shared.ts +15 -6
  174. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  175. package/src/providers/integrations/buddy/workbuddy/shared.ts +3 -3
  176. package/src/providers/integrations/claude/claude.ts +606 -582
  177. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  178. package/src/providers/integrations/cline/cline.ts +539 -468
  179. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  180. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  181. package/src/providers/integrations/commandcode/quota.ts +160 -0
  182. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  183. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  184. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  185. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  186. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  187. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  188. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  189. package/src/providers/integrations/cursor/cursor.ts +960 -0
  190. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  191. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  192. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  193. package/src/providers/integrations/github/github.ts +4 -4
  194. package/src/providers/integrations/hermes.ts +79 -8
  195. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  196. package/src/providers/integrations/kilo/kilo.ts +153 -146
  197. package/src/providers/integrations/opencode/provider.ts +532 -538
  198. package/src/providers/integrations/openrouter/provider.ts +139 -281
  199. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  200. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -257
  201. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  202. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  203. package/src/providers/integrations/qoder/qoder.ts +865 -730
  204. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  205. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  206. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  207. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  208. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  209. package/src/providers/integrations/trae/trae.ts +458 -0
  210. package/src/providers/operations/account-health-service.ts +107 -10
  211. package/src/providers/operations/client-versions.ts +133 -6
  212. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  213. package/src/providers/operations/provider-credential-service.ts +46 -7
  214. package/src/providers/provider-contracts.ts +76 -0
  215. package/src/providers/provider-metadata.ts +78 -6
  216. package/src/providers/provider-model-definition.ts +373 -349
  217. package/src/security/api-key-auth.ts +126 -8
  218. package/src/transport/contracts.ts +3 -0
  219. package/src/transport/dispatch/attempt-finalize.ts +32 -1
  220. package/src/transport/dispatch/attempt-loop.ts +530 -515
  221. package/src/transport/dispatch/generation.ts +88 -21
  222. package/src/transport/dispatch/leases.ts +99 -2
  223. package/src/transport/dispatch/proxy-request.ts +60 -3
  224. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  225. package/src/transport/dispatch/streaming/stream.ts +22 -2
  226. package/src/transport/dispatch/upstream.ts +19 -1
  227. package/src/transport/guards/auth-guard.ts +26 -1
  228. package/src/transport/guards/body-policy.ts +5 -4
  229. package/src/transport/request/preparer.ts +103 -0
  230. package/src/transport/request/state.ts +14 -0
  231. package/src/transport/routing/plan-gate.ts +45 -0
  232. package/src/transport/routing/route-catalog.ts +799 -777
  233. package/src/transport/routing/route-model.ts +52 -1
  234. package/src/transport/routing/router.ts +51 -0
  235. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js +0 -1
  236. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
  237. package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
  238. package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
  239. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +0 -1
  240. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
  241. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
  242. package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
  243. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
  244. package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
  245. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
  246. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js +0 -1
  247. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
  248. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +0 -3
  249. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
  250. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  251. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
  252. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +0 -1
  253. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
  254. package/dist/dashboard/assets/ProxyPage-CXiumA53.js +0 -7
  255. package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
  256. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +0 -1
  257. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
  258. package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
  259. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
  260. package/dist/dashboard/assets/SortableList-DpOTnGuH.js +0 -1
  261. package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
  262. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
  263. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js +0 -56
  264. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
  265. package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
  266. package/dist/dashboard/assets/app-BLCvGdzb.css +0 -2
  267. package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
  268. package/dist/dashboard/assets/assert-7J6_LVD3.js +0 -1
  269. package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
  270. package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
  271. package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
  272. package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
  273. package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
  274. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
  275. package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
  276. package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
  277. package/dist/dashboard/assets/entry-7dpgvpJ3.js +0 -4
  278. package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
  279. package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
  280. package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
  281. package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
  282. package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
  283. package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
  284. package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
  285. package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
  286. package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
  287. package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
  288. package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
  289. package/dist/dashboard/assets/select-C9mMoEpJ.js +0 -41
  290. package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
  291. package/dist/dashboard/assets/settings-C4_Gn_wq.js +0 -1
  292. package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
  293. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  294. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
  295. package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
  296. package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
  297. package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
  298. package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
  299. package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
  300. package/src/network/tls/bun-client-hello.ts +0 -112
  301. /package/dist/dashboard/assets/{app-Cjq7nrwu.js → app-C-Y4oZT0.js} +0 -0
  302. /package/dist/dashboard/assets/{app-Cjq7nrwu.js.gz → app-C-Y4oZT0.js.gz} +0 -0
@@ -1,1295 +1,1360 @@
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`); the per-account override no longer exists.
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
- (table) => [
261
- uniqueIndex("provider_accounts_identity_uidx").on(
262
- table.providerId,
263
- // No `::uuid` cast: the migrations create this index without one on
264
- // SQLite (baseline l.440) and with one on Postgres (l.423), and the
265
- // Drizzle definition is what a test's in-memory database is built
266
- // from — a Postgres-only cast there made every account insert fail
267
- // with `unrecognized token: ":"`.
268
- sql`coalesce(${table.tenantId}, '00000000-0000-0000-0000-000000000000')`,
269
- table.credentialFingerprint,
270
- ),
271
- index("provider_accounts_status_cooldown_idx").on(table.status, table.cooldownUntil),
272
- index("provider_accounts_provider_tenant_idx").on(table.providerId, table.tenantId),
273
- // The bare `tenant_id IS NULL OR tenant_id = ?` that `listAllAccounts`
274
- // uses is served by neither index above: `(status, cooldown_until)` leads
275
- // with the wrong column, and `(provider_id, tenant_id)` cannot serve a
276
- // predicate that does not constrain `provider_id`. This one matches the
277
- // hot listing path.
278
- index("provider_accounts_tenant_id_idx").on(table.tenantId),
279
- ],
280
- );
281
-
282
-
283
- // OAuth refresh token + cross-process lease, normalized out of
284
- // `provider_accounts` into its own 1:0/1 table. The vast majority of
285
- // provider accounts are `credentialKind: "api_key"`, for which these four
286
- // columns stayed null forever on the shared row; splitting them here keeps
287
- // the hot health-machine row narrow and isolates the fenced compare-and-swap
288
- // lease traffic (`providers/auth/refresh-service.ts`) from unrelated
289
- // account reads/writes.
290
- export const providerOauthStates = pgTable("provider_oauth_states", {
291
- providerAccountId: uuid("provider_account_id")
292
- .primaryKey()
293
- .references(() => providerAccounts.id, { onDelete: "cascade" }),
294
- /**
295
- * Encrypted refresh token. Nullable: an account can be pasted with only an
296
- * access token (or a credential whose refresh token never arrived), and the
297
- * account must still be tracked so the refresh sweep can flag it for re-auth
298
- * rather than leaving it invisible. A null value means "cannot refresh".
299
- */
300
- refreshCiphertext: bytea("refresh_ciphertext"),
301
- /**
302
- * Encrypted companion secret for the minority of flows whose refresh is not
303
- * authorized by the refresh token alone: a device-flow client registration
304
- * mints a client secret that must be replayed beside the token. Nullable —
305
- * most providers refresh with the refresh token by itself.
306
- */
307
- clientSecretCiphertext: bytea("client_secret_ciphertext"),
308
- /**
309
- * Access-token expiry. Nullable: a credential pasted without an expiry (an
310
- * opaque token, or a bare access token) has no known deadline, and reads as
311
- * "always due" so the sweep refreshes it as soon as it can.
312
- */
313
- expiresAt: timestamp("expires_at", { withTimezone: true }),
314
- // OAuth refresh lease: fenced compare-and-swap coordination so only one
315
- // process refreshes a given account's OAuth token at a time, and a losing
316
- // process reloads instead of clobbering a peer's fresher token.
317
- leaseOwner: text("lease_owner"),
318
- leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }),
319
- });
320
-
321
-
322
- // One row per (provider_id, model_id, route), with `endpoint_path`
323
- // identifying the route — the same model through two providers, or through
324
- // two routes of one provider, is two rows. `enabled` is a hard routing
325
- // invariant read by ProviderCatalogService, not a display-only flag.
326
- export const models = pgTable(
327
- "models",
328
- {
329
- id: uuid("id").primaryKey().defaultRandom(),
330
- providerId: text("provider_id")
331
- .notNull()
332
- .references(() => providers.id, { onDelete: "cascade" }),
333
- modelId: text("model_id").notNull(),
334
- wireFamily: wireFamily("wire_family").notNull(),
335
- // Which protocol shape at all (`llm` = the canonical chat pipeline). A
336
- // non-`llm` row (System One) is served by its native passthrough route, not
337
- // by the surface codecs. Kept a plain text column with a default rather than
338
- // a pgEnum: unlike `wire_family`, the set is expected to grow (embeddings,
339
- // tts, …) and a widened enum needs a migration per addition.
340
- serviceKind: text("service_kind").notNull().default("llm"),
341
- endpointPath: text("endpoint_path").notNull(),
342
- contextLimit: integer("context_limit"),
343
- outputLimit: integer("output_limit"),
344
- modalities: jsonb("modalities"),
345
- reasoning: boolean("reasoning").notNull().default(false),
346
- toolCall: boolean("tool_call").notNull().default(false),
347
- cost: jsonb("cost"),
348
- source: text("source"),
349
- sourceUpdatedAt: timestamp("source_updated_at", { withTimezone: true }),
350
- enabled: boolean("enabled").notNull().default(true),
351
- },
352
- (table) => [
353
- uniqueIndex("models_provider_model_route_uidx").on(
354
- table.providerId,
355
- table.modelId,
356
- table.endpointPath,
357
- ),
358
- index("models_provider_enabled_idx").on(table.providerId, table.enabled),
359
- // Bare-id lookups, with no provider named: see migration
360
- // `0003_add_models_model_id_index`. Without it those seq-scan this table,
361
- // which carries one row per provider x model x route.
362
- index("models_model_id_idx").on(table.modelId),
363
- ],
364
- );
365
-
366
- // Tenant-scoped suppression of a GLOBAL/built-in model. Tenants cannot mutate
367
- // the shared `models` row (see `DrizzleProviderCatalogStore.setModelEnabled`),
368
- // so a disable is recorded here and filtered out of that tenant's routing
369
- // snapshot. Tenant-owned/BYOK models are still toggled directly on `models`.
370
- export const tenantDisabledModels = pgTable(
371
- "tenant_disabled_models",
372
- {
373
- id: uuid("id").primaryKey().defaultRandom(),
374
- tenantId: tenantRefRequired(),
375
- providerId: text("provider_id")
376
- .notNull()
377
- .references(() => providers.id, { onDelete: "cascade" }),
378
- modelId: text("model_id").notNull(),
379
- endpointPath: text("endpoint_path").notNull(),
380
- createdAt: createdAtColumn(),
381
- },
382
- (t) => [
383
- uniqueIndex("tenant_disabled_models_tenant_provider_model_uidx").on(
384
- t.tenantId,
385
- t.providerId,
386
- t.modelId,
387
- t.endpointPath,
388
- ),
389
- ],
390
- );
391
-
392
-
393
- // Tenant-owned HTTP/HTTPS/SOCKS5 pools share the provider-account health state machine.
394
- export const networkPools = pgTable("network_pools", {
395
- id: uuid("id").primaryKey().defaultRandom(),
396
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
397
- kind: networkPoolKind("kind").notNull(),
398
- /**
399
- * Operator-set family/tag for this pool, e.g. `residential-id` or
400
- * `datacenter-sg`. Purely organisational: it names which family the node
401
- * belongs to and nothing in routing or dispatch reads it. Null when the
402
- * operator has not tagged the pool.
403
- */
404
- family: text("family"),
405
- endpointConfig: jsonb("endpoint_config").notNull(),
406
- credentialCiphertext: bytea("credential_ciphertext"),
407
- maxInflight: integer("max_inflight"),
408
- /** Routing weight for weighted selection across the tenant pool group. */
409
- weight: integer("weight"),
410
- tenantId: tenantRefRequired(),
411
-
412
- ...healthColumns(),
413
- lastLatencyMs: integer("last_latency_ms"),
414
- lastHealthCheckAt: timestamp("last_health_check_at", { withTimezone: true }),
415
- /** Public egress address reported by the last successful probe; null until probed. */
416
- egressIp: text("egress_ip"),
417
- /** Operator-set egress allowance in bytes; null means unmetered. */
418
- quotaBytes: bigint("quota_bytes", { mode: "number" }),
419
- /** Last speed-test payload actually transferred, in bytes; null until measured. */
420
- lastSpeedtestBytes: integer("last_speedtest_bytes"),
421
- /** Wall-clock duration of the last speed-test transfer, in milliseconds. */
422
- lastSpeedtestDurationMs: integer("last_speedtest_duration_ms"),
423
- /** "ok" | "failed" for the last speed-test; null until measured. */
424
- lastSpeedtestStatus: text("last_speedtest_status"),
425
- /** Failure message of the last speed-test; null on success or unmeasured. */
426
- lastSpeedtestError: text("last_speedtest_error"),
427
- /** When the last speed-test ran. */
428
- lastSpeedtestAt: timestamp("last_speedtest_at", { withTimezone: true }),
429
- /**
430
- * Provider-scoped probe verdict, stored beside the other last-known facts
431
- * (`egress_ip`, `last_latency_ms`, the speed-test columns) rather than in the
432
- * browser.
433
- *
434
- * It lived in `localStorage` first, and that made it decorative: the verdict
435
- * was visible to the operator who ran it and to nobody else — not to another
436
- * machine, not to the gateway, and not to admission. A pool could read
437
- * "OpenCode blocked" on screen while dispatch kept sending traffic through
438
- * it, because nothing outside that one browser had ever heard the verdict.
439
- *
440
- * `null` means never probed. These describe the *address* recorded in
441
- * `egress_ip` at the time, so a changed endpoint clears them.
442
- */
443
- providerProbeProviderId: text("provider_probe_provider_id"),
444
- /** `allowed` | `blocked` | `throttled` | `unreachable`; null until probed. */
445
- providerProbeStatus: text("provider_probe_status"),
446
- /** Upstream HTTP status when a response arrived. */
447
- providerProbeHttpStatus: integer("provider_probe_http_status"),
448
- /** Operator-facing reason: the upstream's own words, or why the probe failed. */
449
- providerProbeReason: text("provider_probe_reason"),
450
- providerProbeLatencyMs: integer("provider_probe_latency_ms"),
451
- providerProbeAt: timestamp("provider_probe_at", { withTimezone: true }),
452
- /** The endpoint the verdict was measured against. Compared on read, so an
453
- * edited endpoint reports "never probed" instead of a stale verdict. */
454
- providerProbeEndpoint: text("provider_probe_endpoint"),
455
- /** Metered egress totals, flushed from the in-memory socket counters on
456
- * every pool-touching write so the quota bar survives a restart. */
457
- bytesSentTotal: bigint("bytes_sent_total", { mode: "number" }).notNull().default(0),
458
- /** Metered ingress totals; see `bytesSentTotal`. */
459
- bytesReceivedTotal: bigint("bytes_received_total", { mode: "number" }).notNull().default(0),
460
- },
461
- (table) => [
462
- index("network_pools_tenant_id_idx").on(table.tenantId),
463
- index("network_pools_status_cooldown_idx").on(table.status, table.cooldownUntil),
464
- index("network_pools_family_idx").on(table.family),
465
- ],
466
- );
467
-
468
-
469
- // Account and network-pool health events share one table. The entity kind
470
- // selects exactly one owner FK, and the CHECK constraint below preserves
471
- // referential integrity for both variants.
472
- export const healthEvents = pgTable(
473
- "health_events",
474
- {
475
- id: uuid("id").primaryKey().defaultRandom(),
476
- entityKind: healthEntityKind("entity_kind").notNull(),
477
- accountId: uuid("account_id").references(() => providerAccounts.id, { onDelete: "cascade" }),
478
- networkPoolId: uuid("network_pool_id").references(() => networkPools.id, {
479
- onDelete: "cascade",
480
- }),
481
- fromStatus: healthStatus("from_status"),
482
- toStatus: healthStatus("to_status").notNull(),
483
- reason: text("reason"),
484
- errorCategory: text("error_category"),
485
- modelId: text("model_id"),
486
- createdAt: createdAtColumn(),
487
- },
488
- (table) => [
489
- index("health_events_account_id_created_at_idx").on(table.accountId, table.createdAt),
490
- // Descending `created_at` is deliberate: health history is always read
491
- // newest-first. The SQLite baseline drops the direction (see
492
- // `scripts/generate/sqlite-ddl.ts` — SQLite scans an index either way),
493
- // so this is a Postgres-only access-path choice, not a cross-backend
494
- // disagreement.
495
- index("health_events_account_model_created_idx").on(
496
- table.accountId,
497
- table.modelId,
498
- desc(table.createdAt),
499
- ),
500
- index("health_events_network_pool_id_created_at_idx").on(table.networkPoolId, table.createdAt),
501
- check(
502
- "health_events_entity_reference_check",
503
- 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)`,
504
- ),
505
- ],
506
- );
507
-
508
-
509
- // Model aliasing & combos: replaces the dormant, disconnected
510
- // `routing_policies` scaffolding. Aliases resolve a client-facing name to a
511
- // real model; combos back a client-facing name with >=1 real models,
512
- // auto-selected via `fallback` or `round_robin`.
513
- export const modelComboStrategy = pgEnum("model_combo_strategy", [
514
- "fallback",
515
- "round_robin",
516
- ]);
517
-
518
- /** Canonical `ComboStrategy` union, derived from the enum above. Console
519
- * domain and routing layers import this instead of hand-typing their own mirror.
520
- * `ProviderRoutingStrategy` covers the same two values but persists on
521
- * `provider_routing_settings` beside `rotateCount`; sticky routing is gone. */
522
- export type ComboStrategy = (typeof modelComboStrategy.enumValues)[number];
523
-
524
- export const modelAliases = pgTable(
525
- "model_aliases",
526
- {
527
- id: uuid("id").primaryKey().defaultRandom(),
528
- tenantId: tenantRefRequired(),
529
- alias: text("alias").notNull(),
530
- targetModel: text("target_model").notNull(),
531
- /** Explicit list position. */
532
- sortIndex: integer("sort_index").notNull().default(0),
533
- ...timestampColumns(),
534
- },
535
- (t) => [uniqueIndex("model_aliases_tenant_alias_uidx").on(t.tenantId, t.alias)],
536
- );
537
- export type ModelAlias = typeof modelAliases.$inferSelect;
538
-
539
- export const modelCombos = pgTable(
540
- "model_combos",
541
- {
542
- id: uuid("id").primaryKey().defaultRandom(),
543
- tenantId: tenantRefRequired(),
544
- name: text("name").notNull(),
545
- members: jsonb("members").notNull().$type<string[]>(),
546
- strategy: modelComboStrategy("strategy").notNull().default("fallback"),
547
- /** Explicit list position. */
548
- sortIndex: integer("sort_index").notNull().default(0),
549
- ...timestampColumns(),
550
- },
551
- (t) => [uniqueIndex("model_combos_tenant_name_uidx").on(t.tenantId, t.name)],
552
- );
553
- export type ModelCombo = typeof modelCombos.$inferSelect;
554
-
555
- export const providerRoutingStrategy = pgEnum("provider_routing_strategy", [
556
- "fallback",
557
- "round_robin",
558
- ]);
559
-
560
- /**
561
- * Canonical provider-routing strategy union, derived from the enum above so
562
- * the persisted values and the type cannot drift. The console catalog and the
563
- * routing engine both import this instead of restating the two literals.
564
- */
565
- export type ProviderRoutingStrategy = (typeof providerRoutingStrategy.enumValues)[number];
566
-
567
- /**
568
- * Per-account inflight ceiling applied when a provider has no routing row yet.
569
- * One literal: the console default and the route catalog's fallback both read
570
- * it. `null` is the "unlimited" choice, and it is also this default — an
571
- * operator who has never opened the routing card asked for no ceiling, and
572
- * inventing one silently capped their concurrency. A ceiling they *did* set is
573
- * still honoured, so this only decides the untouched case.
574
- */
575
- export const DEFAULT_PROVIDER_MAX_INFLIGHT: null = null;
576
-
577
- export const providerRoutingSettings = pgTable(
578
- "provider_routing_settings",
579
- {
580
- providerId: text("provider_id")
581
- .notNull()
582
- .references(() => providers.id, { onDelete: "cascade" }),
583
- tenantId: tenantRefNullable(),
584
- strategy: providerRoutingStrategy("strategy").notNull().default("fallback"),
585
- /** Requests served by one account before round robin advances. */
586
- rotateCount: integer("rotate_count").notNull().default(1),
587
- /** Per-account inflight ceiling applied to every account of this provider
588
- * (the field sits next to the failover/round-robin strategy in the UI).
589
- * `null` = UNLIMITED concurrency per account. */
590
- maxInflight: integer("max_inflight"),
591
- /**
592
- * Per-pool inflight ceiling for this provider: how many concurrent
593
- * requests one proxy pool may carry *for this provider*.
594
- *
595
- * A second, independent axis from `maxInflight`. That one bounds a single
596
- * account; `network_pools.max_inflight` bounds a pool across every
597
- * provider; this one bounds one provider's share of one pool. All three
598
- * are enforced together — none replaces another.
599
- *
600
- * `null` = unlimited, and that is the default: a provider nobody has
601
- * configured behaves exactly as it did before this column existed, with no
602
- * extra Redis key and no extra round trip.
603
- */
604
- maxInflightPerProxy: integer("max_inflight_per_proxy"),
605
- /**
606
- * Minimum remaining allowance an account must hold to stay routable, for
607
- * this provider. `null` means no floor. Compared against the account's own
608
- * unit: absolute remaining credit when the provider reports credits, the
609
- * aggregate credit percentage and lowest independent percentage window
610
- * otherwise. An account below it is skipped and the request fails over to
611
- * the next account instead of erroring.
612
- */
613
- usageFloor: integer("usage_floor"),
614
- /** `credit` compares the summed remaining credit; `percent` compares the
615
- * aggregate credit percentage and lowest independent percentage-only
616
- * window. Ignored while `usageFloor` is null. */
617
- usageFloorUnit: text("usage_floor_unit").$type<"credit" | "percent">().notNull().default("percent"),
618
- enabled: boolean("enabled").notNull().default(false),
619
- // Route-selected User-Agent for built-in API-key providers; OAuth and BYOK identities stay native.
620
- // The column default is the Claude Code CLI identity; the live default for a
621
- // row that never set one is `buildClaudeCliUserAgent()` in
622
- // providers/operations/custom-cli-headers.ts, which this mirrors.
623
- userAgent: text("user_agent").notNull().default("claude-cli/2.1.293 (external, cli)"),
624
- // When true, this provider's requests always dial direct. When false,
625
- // dispatch automatically selects among every active network pool the
626
- // tenant owns (least-loaded/non-cooldown) — never an admin-pinned pool.
627
- skipProxy: boolean("skip_proxy").notNull().default(false),
628
- },
629
- (table) => ({
630
- tenantProviderUnique: uniqueIndex("provider_routing_settings_tenant_provider_idx").on(
631
- table.tenantId,
632
- table.providerId,
633
- ),
634
- globalProviderUnique: uniqueIndex("provider_routing_settings_global_provider_idx")
635
- .on(table.providerId)
636
- .where(sql`tenant_id IS NULL`),
637
- }),
638
- );
639
-
640
- export const poolRoutingStrategy = pgEnum("pool_routing_strategy", [
641
- "least_loaded",
642
- "round_robin",
643
- ]);
644
- export type PoolRoutingStrategy = (typeof poolRoutingStrategy.enumValues)[number];
645
-
646
- /**
647
- * Per-tenant selection strategy across the tenant's active network pools —
648
- * the pool-group counterpart to `provider_routing_settings`. `least_loaded`
649
- * (default) keeps the weighted least-loaded scan with its rotating tie-break;
650
- * `round_robin` serves `rotate_count` requests per pool before advancing,
651
- * mirroring the account strategy, and skips capacity/cooldown pools the way
652
- * account failover does. An absent row reads as the default, so tenants that
653
- * never touch the toggle own no rows.
654
- */
655
- export const poolRoutingSettings = pgTable("pool_routing_settings", {
656
- tenantId: tenantRefPrimaryKey(),
657
- strategy: poolRoutingStrategy("strategy").notNull().default("least_loaded"),
658
- /** Requests served by one pool before round robin advances. */
659
- rotateCount: integer("rotate_count").notNull().default(1),
660
- });
661
-
662
-
663
- export const API_KEY_MODES = ["personal", "share"] as const;
664
- export type ApiKeyMode = (typeof API_KEY_MODES)[number];
665
-
666
- /**
667
- * How a key's `modelList` is interpreted. Exactly two modes, never a
668
- * combination: `whitelist` allows only the listed names (an empty list allows
669
- * everything), `blacklist` refuses the listed names (an empty list refuses
670
- * nothing). Every model-authorization surface — the request path, admission,
671
- * `/v1/models`, and the share page — reads this one pair.
672
- */
673
- export const API_KEY_MODEL_ACCESS_MODES = ["whitelist", "blacklist"] as const;
674
- export type ApiKeyModelAccessMode = (typeof API_KEY_MODEL_ACCESS_MODES)[number];
675
-
676
- /**
677
- * Inbound `/v1/*` keys never store plaintext secrets. Personal keys carry an
678
- * authentication hash; share templates carry policy and child keys point back
679
- * to the template that issued them.
680
- */
681
- export const apiKeys = pgTable(
682
- "api_keys",
683
- {
684
- id: uuid("id").primaryKey().defaultRandom(),
685
- tenantId: tenantRefRequired(),
686
- keyHash: text("key_hash"),
687
- keyMode: text("key_mode").$type<ApiKeyMode>().notNull().default("personal"),
688
- parentKeyId: uuid("parent_key_id").references(
689
- (): AnyPgColumn => apiKeys.id,
690
- { onDelete: "cascade" },
691
- ),
692
- issuedClientIp: text("issued_client_ip"),
693
- issuedClientIpKey: text("issued_client_ip_key"),
694
- label: text("label").notNull(),
695
- scopes: jsonb("scopes").notNull().$type<readonly string[]>(),
696
- // Only the non-secret public key prefix is returned to the owner.
697
- keyPrefix: text("key_prefix"),
698
- // Personal keys retain an encrypted copy for the CLI handoff. Shared
699
- // children are revealed once and persist only their authentication hash.
700
- keyEncrypted: bytea("key_encrypted"),
701
- notesTitle: text("notes_title"),
702
- notesSubtitle: text("notes_subtitle"),
703
- notesBody: text("notes_body"),
704
- /** Explicit list position. */
705
- sortIndex: integer("sort_index").notNull().default(0),
706
- requestsPerMinute: integer("requests_per_minute"),
707
- dailyTokenLimit: bigint("daily_token_limit", { mode: "number" }),
708
- monthlyTokenLimit: bigint("monthly_token_limit", { mode: "number" }),
709
- lifetimeTokenBudget: bigint("lifetime_token_budget", { mode: "number" }),
710
- lifetimeTokensConsumed: bigint("lifetime_tokens_consumed", { mode: "number" })
711
- .notNull()
712
- .default(0),
713
- maxConcurrentRequests: integer("max_concurrent_requests"),
714
- /**
715
- * `whitelist` = only the names in `modelList` may be used (empty = all);
716
- * `blacklist` = the names in `modelList` are refused (empty = none). One
717
- * list, one mode — the old separate allow/deny columns are retired.
718
- */
719
- modelAccessMode: text("model_access_mode")
720
- .$type<ApiKeyModelAccessMode>()
721
- .notNull()
722
- .default("whitelist"),
723
- modelList: jsonb("model_list").$type<readonly string[]>(),
724
- /**
725
- * Client-router ids this key refuses. A request whose fingerprint names one
726
- * of these is rejected before routing, so an operator can stop a downstream
727
- * router from spending the key. Empty/NULL means "no router is refused".
728
- *
729
- * A label, not a security control: a client that sends no fingerprint is
730
- * simply not matched, which is why this is a per-key policy rather than a
731
- * boundary anything else in the system trusts.
732
- */
733
- clientRouterDenylist: jsonb("client_router_denylist").$type<readonly string[]>(),
734
- modelPrefix: text("model_prefix"),
735
- createdAt: createdAtColumn(),
736
- enabled: boolean("enabled").notNull().default(true),
737
- revokedAt: timestamp("revoked_at", { withTimezone: true }),
738
- /**
739
- * The tenant's mandatory gateway key. Seeded at first boot whether or not
740
- * `CARTETHYIA_API_KEY` was configured, and the one key the console refuses
741
- * to revoke — it may only be rotated. A column rather than a label match,
742
- * so renaming the key cannot break the CLI lookup or let a second
743
- * default be created.
744
- */
745
- isDefault: boolean("is_default").notNull().default(false),
746
- },
747
- (table) => [
748
- uniqueIndex("api_keys_key_hash_idx").on(table.keyHash),
749
- /**
750
- * One *live* default key per tenant, enforced by the database.
751
- *
752
- * The baseline SQL has always created this; the schema never declared it,
753
- * so the two descriptions disagreed and nothing noticed. It is the only
754
- * database-level guarantee behind `isDefault`, and
755
- * `test/persistence/sqlite-baseline.test.ts` pins it by name — so the
756
- * schema was the incomplete side, not the migration.
757
- *
758
- * Partial on `revoked_at IS NULL` so a tenant can rotate its default key
759
- * without the old, revoked row blocking the new one.
760
- */
761
- uniqueIndex("api_keys_one_default_per_tenant_uidx")
762
- .on(table.tenantId)
763
- .where(sql`${table.isDefault} = true AND ${table.revokedAt} IS NULL`),
764
- index("api_keys_tenant_id_idx").on(table.tenantId),
765
- index("api_keys_enabled_tenant_idx").on(table.tenantId).where(sql`${table.enabled} = true`),
766
- index("api_keys_parent_key_id_idx").on(table.parentKeyId),
767
- check(
768
- "api_keys_mode_shape_check",
769
- sql`(
770
- (${table.keyMode} = 'personal' AND ${table.keyHash} IS NOT NULL
771
- AND ${table.parentKeyId} IS NULL AND ${table.issuedClientIp} IS NULL
772
- AND ${table.issuedClientIpKey} IS NULL)
773
- OR
774
- (${table.keyMode} = 'share' AND (
775
- (${table.parentKeyId} IS NULL AND ${table.keyHash} IS NULL
776
- AND ${table.keyEncrypted} IS NULL
777
- AND ${table.issuedClientIp} IS NULL AND ${table.issuedClientIpKey} IS NULL)
778
- OR
779
- (${table.parentKeyId} IS NOT NULL AND ${table.keyHash} IS NOT NULL
780
- AND ${table.keyEncrypted} IS NULL
781
- AND ${table.issuedClientIp} IS NOT NULL AND ${table.issuedClientIpKey} IS NOT NULL)
782
- ))
783
- )`,
784
- ),
785
- ],
786
- );
787
-
788
- /**
789
- * The optional popup a share link shows its recipient. Kept off `api_keys`
790
- * because the auth path reads that row on every request and the popup is
791
- * absent for nearly all of them. One row per key, present only when the owner
792
- * configured one.
793
- */
794
- export const sharePopups = pgTable(
795
- "share_popups",
796
- {
797
- apiKeyId: uuid("api_key_id")
798
- .primaryKey()
799
- .references(() => apiKeys.id, { onDelete: "cascade" }),
800
- enabled: boolean("enabled").notNull().default(false),
801
- image: bytea("image"),
802
- imageMime: text("image_mime"),
803
- title: text("title"),
804
- body: text("body"),
805
- },
806
- (table) => [
807
- check(
808
- "share_popups_image_mime_check",
809
- sql`${table.imageMime} IS NULL OR ${table.imageMime} IN ('image/png', 'image/jpeg', 'image/webp', 'image/gif')`,
810
- ),
811
- check(
812
- "share_popups_image_size_check",
813
- sql`${table.image} IS NULL OR octet_length(${table.image}) <= 2097152`,
814
- // SQLite has no octet_length. The generator rewrites this check to
815
- // `length(`; both count bytes for a blob column.
816
- ),
817
- ],
818
- );
819
-
820
-
821
- // Public bearer links for enrolling one shared key per resolved client IP, and
822
- // for handing a personal key to its owner. The hash is the lookup key; the
823
- // token is additionally retained encrypted so the owner's console can show the
824
- // link again instead of losing it after the one response that minted it.
825
- /**
826
- * Share-link kinds, as a runtime tuple. Route schemas and DTOs project this
827
- * list rather than restating it.
828
- */
829
- export const SHARE_LINK_KINDS = ["enroll", "handoff"] as const;
830
-
831
- /** One share-link kind. */
832
- export type ShareLinkKind = (typeof SHARE_LINK_KINDS)[number];
833
-
834
-
835
- export const shareLinks = pgTable(
836
- "share_links",
837
- {
838
- id: uuid("id").primaryKey().defaultRandom(),
839
- apiKeyId: uuid("api_key_id")
840
- .notNull()
841
- .references(() => apiKeys.id, { onDelete: "cascade" }),
842
- tokenHash: text("token_hash").notNull(),
843
- // Encrypted bearer token. Present so the console can re-display a stable
844
- // link; historical rows written before this column carry NULL and simply
845
- // cannot be shown again.
846
- tokenEncrypted: bytea("token_encrypted"),
847
- kind: text("kind").$type<ShareLinkKind>().notNull().default("enroll"),
848
- active: boolean("active").notNull().default(true),
849
- createdAt: createdAtColumn(),
850
- expiresAt: timestamp("expires_at", { withTimezone: true }),
851
- lastViewedAt: timestamp("last_viewed_at", { withTimezone: true }),
852
- },
853
- (table) => [
854
- uniqueIndex("share_links_token_hash_idx").on(table.tokenHash),
855
- index("share_links_api_key_idx").on(table.apiKeyId),
856
- index("share_links_active_kind_expires_idx").on(table.active, table.kind, table.expiresAt),
857
- check("share_links_kind_check", sql`${table.kind} IN ('enroll', 'handoff')`),
858
- ],
859
- );
860
-
861
-
862
- /**
863
- * Console dashboard users — distinct from API-key tenants.
864
- */
865
- export const consoleUsers = pgTable(
866
- "console_users",
867
- {
868
- id: uuid("id").primaryKey().defaultRandom(),
869
- tenantId: tenantRefRequired(),
870
- username: text("username").notNull(),
871
- email: text("email"),
872
- passwordHash: text("password_hash").notNull(),
873
- displayName: text("display_name"),
874
- isActive: boolean("is_active").notNull().default(true),
875
- isPlatformAdmin: boolean("is_platform_admin").notNull().default(false),
876
- isFirstBoot: boolean("is_first_boot").notNull().default(true),
877
- ...timestampColumns(),
878
- },
879
- (table) => [
880
- index("console_users_tenant_id_idx").on(table.tenantId),
881
- uniqueIndex("console_users_tenant_username_uidx").on(table.tenantId, table.username),
882
- ],
883
- );
884
-
885
- export type ConsoleUser = typeof consoleUsers.$inferSelect;
886
-
887
- /**
888
- * Console sessions — HTTP-only, signed cookies.
889
- * Session metadata only; no secrets in JSON.
890
- */
891
- export const consoleSessions = pgTable(
892
- "console_sessions",
893
- {
894
- id: uuid("id").primaryKey().defaultRandom(),
895
- userId: uuid("user_id")
896
- .notNull()
897
- .references(() => consoleUsers.id, { onDelete: "cascade" }),
898
- sessionToken: text("session_token").notNull().unique(),
899
- ipAddress: text("ip_address"),
900
- userAgent: text("user_agent"),
901
- expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
902
- createdAt: createdAtColumn(),
903
- },
904
- (table) => [
905
- index("console_sessions_user_id_idx").on(table.userId),
906
- ],
907
- );
908
-
909
- export type ConsoleSession = typeof consoleSessions.$inferSelect;
910
-
911
- /**
912
- * Console login lockout tracking — fail-closed rate limiting keyed by
913
- * client IP (login attempts happen before any user identity is known, so
914
- * this cannot be keyed by `console_users.id`). Persisted so a ban survives
915
- * a process restart and is shared across every app instance, instead of a
916
- * static in-process map that a second instance or a redeploy would forget.
917
- */
918
- export const consoleLockouts = pgTable(
919
- "console_lockouts",
920
- {
921
- id: uuid("id").primaryKey().defaultRandom(),
922
- ip: text("ip").notNull(),
923
- failureCount: integer("failure_count").notNull().default(0),
924
- windowUntil: timestamp("window_until", { withTimezone: true }),
925
- lockedUntil: timestamp("locked_until", { withTimezone: true }),
926
- reason: text("reason"),
927
- ...timestampColumns(),
928
- },
929
- (table) => [
930
- uniqueIndex("idx_console_lockouts_ip").on(table.ip),
931
- ],
932
- );
933
-
934
-
935
- // Configuration-change audit has its own retention lifecycle; it is separate
936
- // from metadata telemetry and short-lived optional payload capture.
937
- export const adminAuditLog = pgTable(
938
- "admin_audit_log",
939
- {
940
- id: uuid("id").primaryKey().defaultRandom(),
941
- createdAt: createdAtColumn(),
942
- actor: text("actor").notNull(),
943
- tenantId: tenantCascadeSetNull(),
944
- action: text("action").notNull(),
945
- target: text("target").notNull(),
946
- detail: jsonb("detail"),
947
- },
948
- (table) => [
949
- index("admin_audit_log_tenant_id_created_at_id_idx").on(
950
- table.tenantId,
951
- table.createdAt,
952
- table.id,
953
- ),
954
- ],
955
- );
956
-
957
-
958
- // Runtime preferences are stored in one validated JSONB bag. The filter
959
- // master toggle remains a typed column because it is read on the proxy path.
960
- export interface ConsoleSettingsPreferences {
961
- tenantConcurrencyLimit?: number | null;
962
- /** Normalizes provider-native thinking config for non-user final turns. */
963
- thinkingNormalizationEnabled?: boolean;
964
- responsesReasoningSummary?: "auto" | "concise" | "detailed";
965
- /**
966
- * Request compression: compact bulky tool-result text in the message history
967
- * before dispatch (RTK prune). Default off.
968
- */
969
- rtkPruneEnabled?: boolean;
970
- /** RTK prune strength when enabled (`lite`/`full`/`ultra`); `full` when unset. */
971
- rtkPruneLevel?: "lite" | "full" | "ultra";
972
- /**
973
- * Request compression: append a minimal-code directive to the system content.
974
- * Default off; `ponyTailLevel` selects the intensity when on.
975
- */
976
- ponyTailEnabled?: boolean;
977
- /** PonyTail directive intensity; `full` when unset. Only applied when enabled. */
978
- ponyTailLevel?: "lite" | "full" | "ultra" | null;
979
- /**
980
- * Payload capture mode (default `none` when unset):
981
- * - `metadata` — Proxy→Provider method + allowlisted headers only
982
- * - `full` — full redacted bodies up to the configured capture limit
983
- * - `none` — no drawer capture (request metadata events still retained)
984
- */
985
- telemetryPayloads?: "full" | "metadata" | "none";
986
- /**
987
- * Capture depth for `full` mode (default `minimum` when unset). Fixed size
988
- * tiers so every debug sees all four drawer panels — only the cap changes:
989
- * - `minimum` — 1 MiB combined cap, light enough to leave on
990
- * - `moderate` — 8 MiB combined cap, expect CPU/memory spikes while active
991
- * - `maximum` — 16 MiB combined cap, expect CPU/memory spikes while active
992
- */
993
- telemetryPayloadDepth?: "minimum" | "moderate" | "maximum";
994
- privacyMode?: "masked" | "full";
995
- }
996
-
997
- export const consoleSettings = pgTable("console_settings", {
998
- tenantId: tenantRefPrimaryKey(),
999
- preferences: jsonb("preferences").notNull().default({}).$type<ConsoleSettingsPreferences>(),
1000
- updatedAt: updatedAtColumn(),
1001
- });
1002
-
1003
-
1004
- // CLI Tools (CLI tool configuration): per-tenant mapping table that lets a coding-agent
1005
- // slot (e.g. Claude Code's "opus" slot) target a specific Cartethyia model. Rows
1006
- // are opaque strings — validation happens at the API boundary against the
1007
- // tool registry, not in Postgres.
1008
- export const cliToolMappings = pgTable(
1009
- "cli_tool_mappings",
1010
- {
1011
- id: uuid("id").primaryKey().defaultRandom(),
1012
- tenantId: tenantRefRequired(),
1013
- apiKeyId: uuid("api_key_id")
1014
- .notNull()
1015
- .references((): AnyPgColumn => apiKeys.id, { onDelete: "cascade" }),
1016
- toolId: text("tool_id").notNull(),
1017
- slotKey: text("slot_key").notNull(),
1018
- sourceModel: text("source_model").notNull(),
1019
- targetModel: text("target_model").notNull(),
1020
- enabled: boolean("enabled").notNull().default(true),
1021
- updatedAt: updatedAtColumn(),
1022
- },
1023
- (table) => [
1024
- uniqueIndex("cli_tool_mappings_key").on(
1025
- table.tenantId,
1026
- table.toolId,
1027
- table.apiKeyId,
1028
- table.slotKey,
1029
- ),
1030
- ],
1031
- );
1032
-
1033
-
1034
- // Per-(tenant, tool, api_key) settings: whether mappings are active and which
1035
- // mode the tool is configured for. Primary key is the composite of all three.
1036
- export const cliToolSettings = pgTable(
1037
- "cli_tool_settings",
1038
- {
1039
- tenantId: tenantRefRequired(),
1040
- apiKeyId: uuid("api_key_id")
1041
- .notNull()
1042
- .references((): AnyPgColumn => apiKeys.id, { onDelete: "cascade" }),
1043
- toolId: text("tool_id").notNull(),
1044
- mappingsEnabled: boolean("mappings_enabled").notNull().default(false),
1045
- mode: text("mode").notNull().default("remote"),
1046
- updatedAt: updatedAtColumn(),
1047
- },
1048
- (table) => [primaryKey({ columns: [table.tenantId, table.toolId, table.apiKeyId] })],
1049
- );
1050
-
1051
-
1052
- // Metadata-only request telemetry. Retention is configured independently of
1053
- // payload capture (`CARTETHYIA_TELEMETRY_RETENTION_DAYS`, default 30 days).
1054
- // No prompt text, raw body, API key, encrypted-reasoning payload, or generic
1055
- // `payload` column — the schema structurally prevents those fields.
1056
- // Correlation columns intentionally have no catalog foreign keys, so a
1057
- // telemetry write never depends on provider-row lifetime.
1058
- export const telemetryEvents = pgTable(
1059
- "telemetry_events",
1060
- {
1061
- id: uuid("id").primaryKey().defaultRandom(),
1062
- createdAt: createdAtColumn(),
1063
- tenantId: tenantRefRequired(),
1064
- requestId: uuid("request_id").notNull(),
1065
- sourceSurface: telemetrySourceSurface("source_surface"),
1066
- requestedModel: text("requested_model"),
1067
- /**
1068
- * The `model` the upstream put in its own response, verbatim.
1069
- *
1070
- * Separate from `requested_model` because they answer different questions:
1071
- * one is what the client asked for, the other is what the provider said it
1072
- * ran. A provider that answers `muse-spark` as `deepseek-v4.1-flash` is
1073
- * telling the operator something, and overwriting one with the other would
1074
- * erase it. NULL when the response carried no model.
1075
- */
1076
- reportedModel: text("reported_model"),
1077
- /** Canonical reasoning effort the client asked for; NULL when it asked for none. */
1078
- requestedEffort: text("requested_effort"),
1079
- providerId: text("provider_id"),
1080
- accountId: uuid("account_id"),
1081
- networkPoolId: uuid("network_pool_id"),
1082
- endpoint: text("endpoint"),
1083
- apiKeyId: uuid("api_key_id"),
1084
- userAgent: text("user_agent"),
1085
- clientIp: text("client_ip"),
1086
- latencyMs: integer("latency_ms"),
1087
- ttfbMs: integer("ttfb_ms"),
1088
- stream: boolean("stream"),
1089
- status: telemetryStatus("status"),
1090
- /**
1091
- * HTTP status the client actually received. The `status` enum is the
1092
- * internal terminal state (`completed`/`failed`/…), which collapses every
1093
- * failure to one bucket; this column keeps the wire code, so a 503
1094
- * admission rejection stays distinguishable from a 500 upstream failure
1095
- * in the Usage status breakdown. NULL on rows written before the column.
1096
- */
1097
- httpStatus: integer("http_status"),
1098
- /**
1099
- * Which layer produced the failure: the gateway itself, the upstream
1100
- * provider, or the network in between. Recorded beside `error_category`
1101
- * because the category alone cannot separate them — `invalid_request` is
1102
- * both "the caller sent a bad body" (ours) and "the upstream rejected the
1103
- * body" (theirs), and an operator triaging a failure needs to know which.
1104
- */
1105
- errorOrigin: text("error_origin"),
1106
- errorCategory: text("error_category"),
1107
- inputTokens: integer("input_tokens"),
1108
- cachedInputTokens: integer("cached_input_tokens"),
1109
- outputTokens: integer("output_tokens"),
1110
- reasoningTokens: integer("reasoning_tokens"),
1111
- estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }),
1112
- /** Provider-billed credits (Tencent buddy-meter `credit`); NULL when unreported. */
1113
- creditUsed: numeric("credit_used", { precision: 12, scale: 4 }),
1114
- /**
1115
- * Gateway-side time before the upstream dispatch began: routing,
1116
- * credential lease, and request preparation. `ttfbMs` is measured from
1117
- * the dispatch start, so this is the overhead the client waited on before
1118
- * the provider was even called.
1119
- */
1120
- resolveMs: integer("resolve_ms"),
1121
- tokensPerSec: numeric("tokens_per_sec", { precision: 10, scale: 2 }),
1122
- // Absolute epoch-millisecond event timestamps (~1.8e12) overflow integer
1123
- // (int4): completed requests carrying observed stream timings failed
1124
- // their telemetry insert and vanished from the console. bigint "number"
1125
- // mode is safe here — epoch millis stay far below MAX_SAFE_INTEGER —
1126
- // and keeps the columns JSON-safe like the token-budget bigints above.
1127
- firstContentDeltaAtMs: bigint("first_content_delta_at_ms", { mode: "number" }),
1128
- lastEventAtMs: bigint("last_event_at_ms", { mode: "number" }),
1129
- /**
1130
- * Why this request's drawer payloads were not captured, or `captured` when
1131
- * they were. Remote-diagnosis column: the drawer reads empty for reasons
1132
- * that are invisible from the client — the tenant's capture mode, a missing
1133
- * tenant, a non-terminal attempt — and without this an operator could not
1134
- * tell "not configured" from "broken".
1135
- */
1136
- payloadCaptureState: text("payload_capture_state"),
1137
- },
1138
- (table) => [
1139
- index("telemetry_events_created_at_idx").on(table.createdAt),
1140
- index("telemetry_events_tenant_created_idx").on(table.tenantId, table.createdAt),
1141
- index("telemetry_events_request_id_idx").on(table.requestId),
1142
- index("telemetry_events_tenant_account_created_idx").on(
1143
- table.tenantId,
1144
- table.accountId,
1145
- table.createdAt,
1146
- ),
1147
- /**
1148
- * Serves the public share page's per-key aggregate (`share-usage.ts`):
1149
- * equality on `api_key_id`, range and aggregate on `created_at`. Without
1150
- * it every share render scanned the largest table in the schema.
1151
- */
1152
- index("telemetry_events_api_key_created_idx").on(table.apiKeyId, table.createdAt),
1153
- ],
1154
- );
1155
-
1156
- /** Durable aggregates retained after metadata telemetry expires. */
1157
- export const TELEMETRY_USAGE_IDENTITY_TYPES = ["account", "api_key"] as const;
1158
- export type TelemetryUsageIdentityType = (typeof TELEMETRY_USAGE_IDENTITY_TYPES)[number];
1159
-
1160
- export const telemetryUsageTotals = pgTable(
1161
- "telemetry_usage_totals",
1162
- {
1163
- tenantId: tenantRefRequired(),
1164
- identityType: text("identity_type").$type<TelemetryUsageIdentityType>().notNull(),
1165
- entityId: uuid("entity_id").notNull(),
1166
- requests: bigint("requests", { mode: "number" }).notNull().default(0),
1167
- errors: bigint("errors", { mode: "number" }).notNull().default(0),
1168
- inputTokens: bigint("input_tokens", { mode: "number" }).notNull().default(0),
1169
- outputTokens: bigint("output_tokens", { mode: "number" }).notNull().default(0),
1170
- lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull(),
1171
- updatedAt: updatedAtColumn(),
1172
- },
1173
- (table) => [
1174
- primaryKey({
1175
- name: "telemetry_usage_totals_identity_pk",
1176
- columns: [table.tenantId, table.identityType, table.entityId],
1177
- }),
1178
- check(
1179
- "telemetry_usage_totals_identity_type_check",
1180
- sql`${table.identityType} IN ('account', 'api_key')`,
1181
- ),
1182
- ],
1183
- );
1184
-
1185
-
1186
- export const telemetryPayloads = pgTable(
1187
- "telemetry_payloads",
1188
- {
1189
- id: uuid("id").primaryKey().defaultRandom(),
1190
- tenantId: tenantRefRequired(),
1191
- requestId: uuid("request_id"),
1192
- capturedAt: timestamp("captured_at", { withTimezone: true }).notNull().defaultNow(),
1193
- expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
1194
- /**
1195
- * Typed index into the on-disk frame store. Prompt/response bodies live
1196
- * only in the frame file these columns name — never in Postgres. The
1197
- * previous `request_body jsonb` column is gone so a body cannot be
1198
- * written here by accident.
1199
- */
1200
- storage: text("storage").notNull(),
1201
- file: text("file").notNull(),
1202
- offset: integer("offset").notNull(),
1203
- length: integer("length").notNull(),
1204
- checksum: text("checksum").notNull(),
1205
- version: integer("version").notNull(),
1206
- },
1207
- (table) => [
1208
- index("telemetry_payloads_request_id_idx").on(table.requestId),
1209
- index("telemetry_payloads_tenant_request_idx").on(table.tenantId, table.requestId),
1210
- index("telemetry_payloads_expires_idx").on(table.expiresAt),
1211
- check("telemetry_payloads_storage_check", sql`${table.storage} IN ('jsonb-file', 'jsonl-file')`),
1212
- check("telemetry_payloads_version_check", sql`${table.version} = 1`),
1213
- check("telemetry_payloads_offset_check", sql`${table.offset} >= 0`),
1214
- check("telemetry_payloads_length_check", sql`${table.length} > 0`),
1215
- ],
1216
- );
1217
-
1218
-
1219
-
1220
- // Daily check-in ledger: one row per (account, calendar day) once the sweep has
1221
- // attempted that account's claim.
1222
- //
1223
- // The day-scoped composite PK is the claim guard — an insert that conflicts is
1224
- // exactly the "already settled today" answer — so it replaces the Redis
1225
- // `SET NX EX` the Lite backend never had. The console reads the same table to
1226
- // answer "was this attempted today", which is what makes the Quota page show
1227
- // the real state instead of a permanent "Check-in due" when Redis is absent.
1228
- export const dailyCheckinLedger = pgTable(
1229
- "daily_checkin_ledger",
1230
- {
1231
- dayKey: text("day_key").notNull(),
1232
- accountId: uuid("account_id").notNull(),
1233
- claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
1234
- },
1235
- (table) => [
1236
- primaryKey({ columns: [table.dayKey, table.accountId] }),
1237
- ],
1238
- );
1239
-
1240
-
1241
- /**
1242
- * Per-day, per-model credit deductions the buddy billing meter reported.
1243
- *
1244
- * A snapshot, not a ledger: the upstream is the source of truth and answers the
1245
- * whole window on every call, so a row is upserted rather than appended. What it
1246
- * buys is that the console renders a *stored* figure — without it every page
1247
- * open would re-hit the billing endpoint for every account, and a failed read
1248
- * would show nothing at all instead of the last number obtained.
1249
- *
1250
- * Keyed (account, day, model): one row per bucket, and the conflict target is
1251
- * what makes the write idempotent across passes.
1252
- */
1253
- export const buddyUsageSnapshots = pgTable(
1254
- "buddy_usage_snapshots",
1255
- {
1256
- accountId: uuid("account_id")
1257
- .notNull()
1258
- .references(() => providerAccounts.id, { onDelete: "cascade" }),
1259
- /** Calendar day the requests were metered on, `YYYY-MM-DD`. */
1260
- dayKey: text("day_key").notNull(),
1261
- model: text("model").notNull(),
1262
- /** Credits deducted across every request in this bucket. */
1263
- credit: numeric("credit", { precision: 14, scale: 4 }).notNull(),
1264
- syncedAt: timestamp("synced_at", { withTimezone: true }).notNull().defaultNow(),
1265
- },
1266
- (table) => [
1267
- primaryKey({ columns: [table.accountId, table.dayKey, table.model] }),
1268
- index("buddy_usage_snapshots_day_idx").on(table.dayKey),
1269
- ],
1270
- );
1271
-
1272
- /**
1273
- * One row per (day, account, growth task) once that day's claim was attempted.
1274
- *
1275
- * The composite PK is the idempotency guard: an insert that conflicts is
1276
- * exactly the "already settled today" answer, which is what stops a sweep from
1277
- * re-POSTing a claim on every pass. Same shape as `daily_checkin_ledger`
1278
- * because it is the same problem — one claim per account per day — widened by
1279
- * the task name now that there is more than one thing to claim.
1280
- */
1281
- export const buddyTaskLedger = pgTable(
1282
- "buddy_task_ledger",
1283
- {
1284
- dayKey: text("day_key").notNull(),
1285
- accountId: uuid("account_id")
1286
- .notNull()
1287
- .references(() => providerAccounts.id, { onDelete: "cascade" }),
1288
- task: text("task").notNull(),
1289
- claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
1290
- },
1291
- (table) => [
1292
- primaryKey({ columns: [table.dayKey, table.accountId, table.task] }),
1293
- index("buddy_task_ledger_account_idx").on(table.accountId),
1294
- ],
1295
- );
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`); the per-account override no longer exists.
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
+ * It lived in `localStorage` first, and that made it decorative: the verdict
473
+ * was visible to the operator who ran it and to nobody else — not to another
474
+ * machine, not to the gateway, and not to admission. A pool could read
475
+ * "OpenCode blocked" on screen while dispatch kept sending traffic through
476
+ * it, because nothing outside that one browser had ever 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/generate/sqlite-ddl.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: replaces the dormant, disconnected
548
+ // `routing_policies` scaffolding. Aliases resolve a client-facing name to a
549
+ // real model; combos back a client-facing name with >=1 real models,
550
+ // auto-selected via `fallback` or `round_robin`.
551
+ export const modelComboStrategy = pgEnum("model_combo_strategy", [
552
+ "fallback",
553
+ "round_robin",
554
+ ]);
555
+
556
+ /** Canonical `ComboStrategy` union, derived from the enum above. Console
557
+ * domain and routing layers import this instead of hand-typing their own mirror.
558
+ * `ProviderRoutingStrategy` covers the same two values but persists on
559
+ * `provider_routing_settings` beside `rotateCount`; sticky routing is gone. */
560
+ export type ComboStrategy = (typeof modelComboStrategy.enumValues)[number];
561
+
562
+ export const modelAliases = pgTable(
563
+ "model_aliases",
564
+ {
565
+ id: uuid("id").primaryKey().defaultRandom(),
566
+ tenantId: tenantRefRequired(),
567
+ alias: text("alias").notNull(),
568
+ targetModel: text("target_model").notNull(),
569
+ /** Explicit list position. */
570
+ sortIndex: integer("sort_index").notNull().default(0),
571
+ ...timestampColumns(),
572
+ },
573
+ (t) => [uniqueIndex("model_aliases_tenant_alias_uidx").on(t.tenantId, t.alias)],
574
+ );
575
+ export type ModelAlias = typeof modelAliases.$inferSelect;
576
+
577
+ export const modelCombos = pgTable(
578
+ "model_combos",
579
+ {
580
+ id: uuid("id").primaryKey().defaultRandom(),
581
+ tenantId: tenantRefRequired(),
582
+ name: text("name").notNull(),
583
+ members: jsonb("members").notNull().$type<string[]>(),
584
+ strategy: modelComboStrategy("strategy").notNull().default("fallback"),
585
+ /** Explicit list position. */
586
+ sortIndex: integer("sort_index").notNull().default(0),
587
+ ...timestampColumns(),
588
+ },
589
+ (t) => [uniqueIndex("model_combos_tenant_name_uidx").on(t.tenantId, t.name)],
590
+ );
591
+ export type ModelCombo = typeof modelCombos.$inferSelect;
592
+
593
+ export const providerRoutingStrategy = pgEnum("provider_routing_strategy", [
594
+ "fallback",
595
+ "round_robin",
596
+ ]);
597
+
598
+ /**
599
+ * Canonical provider-routing strategy union, derived from the enum above so
600
+ * the persisted values and the type cannot drift. The console catalog and the
601
+ * routing engine both import this instead of restating the two literals.
602
+ */
603
+ export type ProviderRoutingStrategy = (typeof providerRoutingStrategy.enumValues)[number];
604
+
605
+ /**
606
+ * Per-account inflight ceiling applied when a provider has no routing row yet.
607
+ * One literal: the console default and the route catalog's fallback both read
608
+ * it. `null` is the "unlimited" choice, and it is also this default — an
609
+ * operator who has never opened the routing card asked for no ceiling, and
610
+ * inventing one silently capped their concurrency. A ceiling they *did* set is
611
+ * still honoured, so this only decides the untouched case.
612
+ */
613
+ export const DEFAULT_PROVIDER_MAX_INFLIGHT: null = null;
614
+
615
+ export const providerRoutingSettings = pgTable(
616
+ "provider_routing_settings",
617
+ {
618
+ providerId: text("provider_id")
619
+ .notNull()
620
+ .references(() => providers.id, { onDelete: "cascade" }),
621
+ tenantId: tenantRefNullable(),
622
+ strategy: providerRoutingStrategy("strategy").notNull().default("fallback"),
623
+ /** Requests served by one account before round robin advances. */
624
+ rotateCount: integer("rotate_count").notNull().default(1),
625
+ /** Per-account inflight ceiling applied to every account of this provider
626
+ * (the field sits next to the failover/round-robin strategy in the UI).
627
+ * `null` = UNLIMITED concurrency per account. */
628
+ maxInflight: integer("max_inflight"),
629
+ /**
630
+ * Per-pool inflight ceiling for this provider: how many concurrent
631
+ * requests one proxy pool may carry *for this provider*.
632
+ *
633
+ * A second, independent axis from `maxInflight`. That one bounds a single
634
+ * account; `network_pools.max_inflight` bounds a pool across every
635
+ * provider; this one bounds one provider's share of one pool. All three
636
+ * are enforced together — none replaces another.
637
+ *
638
+ * `null` = unlimited, and that is the default: a provider nobody has
639
+ * configured behaves exactly as it did before this column existed, with no
640
+ * extra Redis key and no extra round trip.
641
+ */
642
+ maxInflightPerProxy: integer("max_inflight_per_proxy"),
643
+ /**
644
+ * Minimum remaining allowance an account must hold to stay routable, for
645
+ * this provider. `null` means no floor. Compared against the account's own
646
+ * unit: absolute remaining credit when the provider reports credits, the
647
+ * aggregate credit percentage and lowest independent percentage window
648
+ * otherwise. An account below it is skipped and the request fails over to
649
+ * the next account instead of erroring.
650
+ */
651
+ usageFloor: integer("usage_floor"),
652
+ /** `credit` compares the summed remaining credit; `percent` compares the
653
+ * aggregate credit percentage and lowest independent percentage-only
654
+ * window. Ignored while `usageFloor` is null. */
655
+ usageFloorUnit: text("usage_floor_unit").$type<"credit" | "percent">().notNull().default("percent"),
656
+ enabled: boolean("enabled").notNull().default(false),
657
+ // Route-selected User-Agent for built-in API-key providers; OAuth and BYOK identities stay native.
658
+ // The column default is the Claude Code CLI identity; the live default for a
659
+ // row that never set one is `buildClaudeCliUserAgent()` in
660
+ // providers/operations/custom-cli-headers.ts, which this mirrors.
661
+ userAgent: text("user_agent").notNull().default("claude-cli/2.1.293 (external, cli)"),
662
+ // When true, this provider's requests always dial direct. When false,
663
+ // dispatch automatically selects among every active network pool the
664
+ // tenant owns (least-loaded/non-cooldown) — never an admin-pinned pool.
665
+ skipProxy: boolean("skip_proxy").notNull().default(false),
666
+ },
667
+ (table) => ({
668
+ tenantProviderUnique: uniqueIndex("provider_routing_settings_tenant_provider_idx").on(
669
+ table.tenantId,
670
+ table.providerId,
671
+ ),
672
+ globalProviderUnique: uniqueIndex("provider_routing_settings_global_provider_idx")
673
+ .on(table.providerId)
674
+ .where(sql`tenant_id IS NULL`),
675
+ }),
676
+ );
677
+
678
+ export const poolRoutingStrategy = pgEnum("pool_routing_strategy", [
679
+ "least_loaded",
680
+ "round_robin",
681
+ ]);
682
+ export type PoolRoutingStrategy = (typeof poolRoutingStrategy.enumValues)[number];
683
+
684
+ /**
685
+ * Per-tenant selection strategy across the tenant's active network pools —
686
+ * the pool-group counterpart to `provider_routing_settings`. `least_loaded`
687
+ * (default) keeps the weighted least-loaded scan with its rotating tie-break;
688
+ * `round_robin` serves `rotate_count` requests per pool before advancing,
689
+ * mirroring the account strategy, and skips capacity/cooldown pools the way
690
+ * account failover does. An absent row reads as the default, so tenants that
691
+ * never touch the toggle own no rows.
692
+ */
693
+ export const poolRoutingSettings = pgTable("pool_routing_settings", {
694
+ tenantId: tenantRefPrimaryKey(),
695
+ strategy: poolRoutingStrategy("strategy").notNull().default("least_loaded"),
696
+ /** Requests served by one pool before round robin advances. */
697
+ rotateCount: integer("rotate_count").notNull().default(1),
698
+ });
699
+
700
+
701
+ export const API_KEY_MODES = ["personal", "share"] as const;
702
+ export type ApiKeyMode = (typeof API_KEY_MODES)[number];
703
+
704
+ /**
705
+ * How a key's `modelList` is interpreted. Exactly two modes, never a
706
+ * combination: `whitelist` allows only the listed names (an empty list allows
707
+ * everything), `blacklist` refuses the listed names (an empty list refuses
708
+ * nothing). Every model-authorization surface — the request path, admission,
709
+ * `/v1/models`, and the share page — reads this one pair.
710
+ */
711
+ export const API_KEY_MODEL_ACCESS_MODES = ["whitelist", "blacklist"] as const;
712
+ export type ApiKeyModelAccessMode = (typeof API_KEY_MODEL_ACCESS_MODES)[number];
713
+
714
+ /**
715
+ * Inbound `/v1/*` keys never store plaintext secrets. Personal keys carry an
716
+ * authentication hash; share templates carry policy and child keys point back
717
+ * to the template that issued them.
718
+ */
719
+ export const apiKeys = pgTable(
720
+ "api_keys",
721
+ {
722
+ id: uuid("id").primaryKey().defaultRandom(),
723
+ tenantId: tenantRefRequired(),
724
+ keyHash: text("key_hash"),
725
+ keyMode: text("key_mode").$type<ApiKeyMode>().notNull().default("personal"),
726
+ parentKeyId: uuid("parent_key_id").references(
727
+ (): AnyPgColumn => apiKeys.id,
728
+ { onDelete: "cascade" },
729
+ ),
730
+ issuedClientIp: text("issued_client_ip"),
731
+ issuedClientIpKey: text("issued_client_ip_key"),
732
+ label: text("label").notNull(),
733
+ scopes: jsonb("scopes").notNull().$type<readonly string[]>(),
734
+ // Only the non-secret public key prefix is returned to the owner.
735
+ keyPrefix: text("key_prefix"),
736
+ // Personal keys retain an encrypted copy for the CLI handoff. Shared
737
+ // children are revealed once and persist only their authentication hash.
738
+ keyEncrypted: bytea("key_encrypted"),
739
+ notesTitle: text("notes_title"),
740
+ notesSubtitle: text("notes_subtitle"),
741
+ notesBody: text("notes_body"),
742
+ /** Explicit list position. */
743
+ sortIndex: integer("sort_index").notNull().default(0),
744
+ requestsPerMinute: integer("requests_per_minute"),
745
+ dailyTokenLimit: bigint("daily_token_limit", { mode: "number" }),
746
+ monthlyTokenLimit: bigint("monthly_token_limit", { mode: "number" }),
747
+ lifetimeTokenBudget: bigint("lifetime_token_budget", { mode: "number" }),
748
+ lifetimeTokensConsumed: bigint("lifetime_tokens_consumed", { mode: "number" })
749
+ .notNull()
750
+ .default(0),
751
+ maxConcurrentRequests: integer("max_concurrent_requests"),
752
+ /**
753
+ * `whitelist` = only the names in `modelList` may be used (empty = all);
754
+ * `blacklist` = the names in `modelList` are refused (empty = none). One
755
+ * list, one mode — the old separate allow/deny columns are retired.
756
+ */
757
+ modelAccessMode: text("model_access_mode")
758
+ .$type<ApiKeyModelAccessMode>()
759
+ .notNull()
760
+ .default("whitelist"),
761
+ modelList: jsonb("model_list").$type<readonly string[]>(),
762
+ /**
763
+ * Client-router ids this key refuses. A request whose fingerprint names one
764
+ * of these is rejected before routing, so an operator can stop a downstream
765
+ * router from spending the key. Empty/NULL means "no router is refused".
766
+ *
767
+ * A label, not a security control: a client that sends no fingerprint is
768
+ * simply not matched, which is why this is a per-key policy rather than a
769
+ * boundary anything else in the system trusts.
770
+ */
771
+ clientRouterDenylist: jsonb("client_router_denylist").$type<readonly string[]>(),
772
+ modelPrefix: text("model_prefix"),
773
+ createdAt: createdAtColumn(),
774
+ enabled: boolean("enabled").notNull().default(true),
775
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
776
+ /**
777
+ * The tenant's mandatory gateway key. Seeded at first boot whether or not
778
+ * `CARTETHYIA_API_KEY` was configured, and the one key the console refuses
779
+ * to revoke — it may only be rotated. A column rather than a label match,
780
+ * so renaming the key cannot break the CLI lookup or let a second
781
+ * default be created.
782
+ */
783
+ isDefault: boolean("is_default").notNull().default(false),
784
+ },
785
+ (table) => [
786
+ uniqueIndex("api_keys_key_hash_idx").on(table.keyHash),
787
+ /**
788
+ * One *live* default key per tenant, enforced by the database.
789
+ *
790
+ * The baseline SQL has always created this; the schema never declared it,
791
+ * so the two descriptions disagreed and nothing noticed. It is the only
792
+ * database-level guarantee behind `isDefault`, and
793
+ * `test/persistence/sqlite-baseline.test.ts` pins it by name — so the
794
+ * schema was the incomplete side, not the migration.
795
+ *
796
+ * Partial on `revoked_at IS NULL` so a tenant can rotate its default key
797
+ * without the old, revoked row blocking the new one.
798
+ */
799
+ uniqueIndex("api_keys_one_default_per_tenant_uidx")
800
+ .on(table.tenantId)
801
+ .where(sql`${table.isDefault} = true AND ${table.revokedAt} IS NULL`),
802
+ index("api_keys_tenant_id_idx").on(table.tenantId),
803
+ index("api_keys_enabled_tenant_idx").on(table.tenantId).where(sql`${table.enabled} = true`),
804
+ index("api_keys_parent_key_id_idx").on(table.parentKeyId),
805
+ check(
806
+ "api_keys_mode_shape_check",
807
+ sql`(
808
+ (${table.keyMode} = 'personal' AND ${table.keyHash} IS NOT NULL
809
+ AND ${table.parentKeyId} IS NULL AND ${table.issuedClientIp} IS NULL
810
+ AND ${table.issuedClientIpKey} IS NULL)
811
+ OR
812
+ (${table.keyMode} = 'share' AND (
813
+ (${table.parentKeyId} IS NULL AND ${table.keyHash} IS NULL
814
+ AND ${table.keyEncrypted} IS NULL
815
+ AND ${table.issuedClientIp} IS NULL AND ${table.issuedClientIpKey} IS NULL)
816
+ OR
817
+ (${table.parentKeyId} IS NOT NULL AND ${table.keyHash} IS NOT NULL
818
+ AND ${table.keyEncrypted} IS NULL
819
+ AND ${table.issuedClientIp} IS NOT NULL AND ${table.issuedClientIpKey} IS NOT NULL)
820
+ ))
821
+ )`,
822
+ ),
823
+ ],
824
+ );
825
+
826
+ /**
827
+ * The optional popup a share link shows its recipient. Kept off `api_keys`
828
+ * because the auth path reads that row on every request and the popup is
829
+ * absent for nearly all of them. One row per key, present only when the owner
830
+ * configured one.
831
+ */
832
+ export const sharePopups = pgTable(
833
+ "share_popups",
834
+ {
835
+ apiKeyId: uuid("api_key_id")
836
+ .primaryKey()
837
+ .references(() => apiKeys.id, { onDelete: "cascade" }),
838
+ enabled: boolean("enabled").notNull().default(false),
839
+ image: bytea("image"),
840
+ imageMime: text("image_mime"),
841
+ title: text("title"),
842
+ body: text("body"),
843
+ },
844
+ (table) => [
845
+ check(
846
+ "share_popups_image_mime_check",
847
+ sql`${table.imageMime} IS NULL OR ${table.imageMime} IN ('image/png', 'image/jpeg', 'image/webp', 'image/gif')`,
848
+ ),
849
+ check(
850
+ "share_popups_image_size_check",
851
+ sql`${table.image} IS NULL OR octet_length(${table.image}) <= 2097152`,
852
+ // SQLite has no octet_length. The generator rewrites this check to
853
+ // `length(`; both count bytes for a blob column.
854
+ ),
855
+ ],
856
+ );
857
+
858
+
859
+ // Public bearer links for enrolling one shared key per resolved client IP, and
860
+ // for handing a personal key to its owner. The hash is the lookup key; the
861
+ // token is additionally retained encrypted so the owner's console can show the
862
+ // link again instead of losing it after the one response that minted it.
863
+ /**
864
+ * Share-link kinds, as a runtime tuple. Route schemas and DTOs project this
865
+ * list rather than restating it.
866
+ */
867
+ export const SHARE_LINK_KINDS = ["enroll", "handoff"] as const;
868
+
869
+ /** One share-link kind. */
870
+ export type ShareLinkKind = (typeof SHARE_LINK_KINDS)[number];
871
+
872
+
873
+ export const shareLinks = pgTable(
874
+ "share_links",
875
+ {
876
+ id: uuid("id").primaryKey().defaultRandom(),
877
+ apiKeyId: uuid("api_key_id")
878
+ .notNull()
879
+ .references(() => apiKeys.id, { onDelete: "cascade" }),
880
+ tokenHash: text("token_hash").notNull(),
881
+ // Encrypted bearer token. Present so the console can re-display a stable
882
+ // link; historical rows written before this column carry NULL and simply
883
+ // cannot be shown again.
884
+ tokenEncrypted: bytea("token_encrypted"),
885
+ /**
886
+ * The public slug this page is reachable at, in the clear.
887
+ *
888
+ * `tokenEncrypted` is the bearer token under the deployment's encryption
889
+ * key, so a link cannot be re-displayed once that key changes — which is
890
+ * exactly what a restore onto a fresh deployment does. Holding the slug
891
+ * unencrypted keeps every restored share page reachable under the address
892
+ * it was created with.
893
+ *
894
+ * Nullable: rows written before this column have no slug, and inventing one
895
+ * would publish an address the page never had.
896
+ */
897
+ slug: text("slug"),
898
+ kind: text("kind").$type<ShareLinkKind>().notNull().default("enroll"),
899
+ active: boolean("active").notNull().default(true),
900
+ createdAt: createdAtColumn(),
901
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
902
+ lastViewedAt: timestamp("last_viewed_at", { withTimezone: true }),
903
+ },
904
+ (table) => [
905
+ uniqueIndex("share_links_token_hash_idx").on(table.tokenHash),
906
+ // Partial: rows without a slug (written before the column) are not
907
+ // constrained, so backfilling is never blocked by a uniqueness collision.
908
+ uniqueIndex("share_links_slug_uidx").on(table.slug).where(sql`${table.slug} IS NOT NULL`),
909
+ index("share_links_api_key_idx").on(table.apiKeyId),
910
+ index("share_links_active_kind_expires_idx").on(table.active, table.kind, table.expiresAt),
911
+ check("share_links_kind_check", sql`${table.kind} IN ('enroll', 'handoff')`),
912
+ ],
913
+ );
914
+
915
+
916
+ /**
917
+ * Console dashboard users — distinct from API-key tenants.
918
+ */
919
+ export const consoleUsers = pgTable(
920
+ "console_users",
921
+ {
922
+ id: uuid("id").primaryKey().defaultRandom(),
923
+ tenantId: tenantRefRequired(),
924
+ username: text("username").notNull(),
925
+ email: text("email"),
926
+ passwordHash: text("password_hash").notNull(),
927
+ displayName: text("display_name"),
928
+ isActive: boolean("is_active").notNull().default(true),
929
+ isPlatformAdmin: boolean("is_platform_admin").notNull().default(false),
930
+ isFirstBoot: boolean("is_first_boot").notNull().default(true),
931
+ ...timestampColumns(),
932
+ },
933
+ (table) => [
934
+ index("console_users_tenant_id_idx").on(table.tenantId),
935
+ uniqueIndex("console_users_tenant_username_uidx").on(table.tenantId, table.username),
936
+ ],
937
+ );
938
+
939
+ export type ConsoleUser = typeof consoleUsers.$inferSelect;
940
+
941
+ /**
942
+ * Console sessions — HTTP-only, signed cookies.
943
+ * Session metadata only; no secrets in JSON.
944
+ */
945
+ export const consoleSessions = pgTable(
946
+ "console_sessions",
947
+ {
948
+ id: uuid("id").primaryKey().defaultRandom(),
949
+ userId: uuid("user_id")
950
+ .notNull()
951
+ .references(() => consoleUsers.id, { onDelete: "cascade" }),
952
+ sessionToken: text("session_token").notNull().unique(),
953
+ ipAddress: text("ip_address"),
954
+ userAgent: text("user_agent"),
955
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
956
+ createdAt: createdAtColumn(),
957
+ },
958
+ (table) => [
959
+ index("console_sessions_user_id_idx").on(table.userId),
960
+ ],
961
+ );
962
+
963
+ export type ConsoleSession = typeof consoleSessions.$inferSelect;
964
+
965
+ /**
966
+ * Console login lockout tracking — fail-closed rate limiting keyed by
967
+ * client IP (login attempts happen before any user identity is known, so
968
+ * this cannot be keyed by `console_users.id`). Persisted so a ban survives
969
+ * a process restart and is shared across every app instance, instead of a
970
+ * static in-process map that a second instance or a redeploy would forget.
971
+ */
972
+ export const consoleLockouts = pgTable(
973
+ "console_lockouts",
974
+ {
975
+ id: uuid("id").primaryKey().defaultRandom(),
976
+ ip: text("ip").notNull(),
977
+ failureCount: integer("failure_count").notNull().default(0),
978
+ windowUntil: timestamp("window_until", { withTimezone: true }),
979
+ lockedUntil: timestamp("locked_until", { withTimezone: true }),
980
+ reason: text("reason"),
981
+ ...timestampColumns(),
982
+ },
983
+ (table) => [
984
+ uniqueIndex("idx_console_lockouts_ip").on(table.ip),
985
+ ],
986
+ );
987
+
988
+
989
+ // Configuration-change audit has its own retention lifecycle; it is separate
990
+ // from metadata telemetry and short-lived optional payload capture.
991
+ export const adminAuditLog = pgTable(
992
+ "admin_audit_log",
993
+ {
994
+ id: uuid("id").primaryKey().defaultRandom(),
995
+ createdAt: createdAtColumn(),
996
+ actor: text("actor").notNull(),
997
+ tenantId: tenantCascadeSetNull(),
998
+ action: text("action").notNull(),
999
+ target: text("target").notNull(),
1000
+ detail: jsonb("detail"),
1001
+ },
1002
+ (table) => [
1003
+ index("admin_audit_log_tenant_id_created_at_id_idx").on(
1004
+ table.tenantId,
1005
+ table.createdAt,
1006
+ table.id,
1007
+ ),
1008
+ ],
1009
+ );
1010
+
1011
+
1012
+ // Runtime preferences are stored in one validated JSONB bag. The filter
1013
+ // master toggle remains a typed column because it is read on the proxy path.
1014
+ export interface ConsoleSettingsPreferences {
1015
+ tenantConcurrencyLimit?: number | null;
1016
+ /** Normalizes provider-native thinking config for non-user final turns. */
1017
+ thinkingNormalizationEnabled?: boolean;
1018
+ responsesReasoningSummary?: "auto" | "concise" | "detailed";
1019
+ /**
1020
+ * Request compression: compact bulky tool-result text in the message history
1021
+ * before dispatch (RTK prune). Default off.
1022
+ */
1023
+ rtkPruneEnabled?: boolean;
1024
+ /** RTK prune strength when enabled (`lite`/`full`/`ultra`); `full` when unset. */
1025
+ rtkPruneLevel?: "lite" | "full" | "ultra";
1026
+ /**
1027
+ * Request compression: append a minimal-code directive to the system content.
1028
+ * Default off; `ponyTailLevel` selects the intensity when on.
1029
+ */
1030
+ ponyTailEnabled?: boolean;
1031
+ /** PonyTail directive intensity; `full` when unset. Only applied when enabled. */
1032
+ ponyTailLevel?: "lite" | "full" | "ultra" | null;
1033
+ /**
1034
+ * Payload capture mode (default `none` when unset):
1035
+ * - `metadata` — Proxy→Provider method + allowlisted headers only
1036
+ * - `full` — full redacted bodies up to the configured capture limit
1037
+ * - `none` — no drawer capture (request metadata events still retained)
1038
+ */
1039
+ telemetryPayloads?: "full" | "metadata" | "none";
1040
+ /**
1041
+ * Capture depth for `full` mode (default `minimum` when unset). Fixed size
1042
+ * tiers so every debug sees all four drawer panels — only the cap changes:
1043
+ * - `minimum` — 1 MiB combined cap, light enough to leave on
1044
+ * - `moderate` — 8 MiB combined cap, expect CPU/memory spikes while active
1045
+ * - `maximum` — 16 MiB combined cap, expect CPU/memory spikes while active
1046
+ */
1047
+ telemetryPayloadDepth?: "minimum" | "moderate" | "maximum";
1048
+ privacyMode?: "masked" | "full";
1049
+ }
1050
+
1051
+ export const consoleSettings = pgTable("console_settings", {
1052
+ tenantId: tenantRefPrimaryKey(),
1053
+ preferences: jsonb("preferences").notNull().default({}).$type<ConsoleSettingsPreferences>(),
1054
+ updatedAt: updatedAtColumn(),
1055
+ });
1056
+
1057
+
1058
+ // CLI Tools (CLI tool configuration): per-tenant mapping table that lets a coding-agent
1059
+ // slot (e.g. Claude Code's "opus" slot) target a specific Cartethyia model. Rows
1060
+ // are opaque strings — validation happens at the API boundary against the
1061
+ // tool registry, not in Postgres.
1062
+ export const cliToolMappings = pgTable(
1063
+ "cli_tool_mappings",
1064
+ {
1065
+ id: uuid("id").primaryKey().defaultRandom(),
1066
+ tenantId: tenantRefRequired(),
1067
+ apiKeyId: uuid("api_key_id")
1068
+ .notNull()
1069
+ .references((): AnyPgColumn => apiKeys.id, { onDelete: "cascade" }),
1070
+ toolId: text("tool_id").notNull(),
1071
+ slotKey: text("slot_key").notNull(),
1072
+ sourceModel: text("source_model").notNull(),
1073
+ targetModel: text("target_model").notNull(),
1074
+ enabled: boolean("enabled").notNull().default(true),
1075
+ updatedAt: updatedAtColumn(),
1076
+ },
1077
+ (table) => [
1078
+ uniqueIndex("cli_tool_mappings_key").on(
1079
+ table.tenantId,
1080
+ table.toolId,
1081
+ table.apiKeyId,
1082
+ table.slotKey,
1083
+ ),
1084
+ ],
1085
+ );
1086
+
1087
+
1088
+ // Per-(tenant, tool, api_key) settings: whether mappings are active and which
1089
+ // mode the tool is configured for. Primary key is the composite of all three.
1090
+ export const cliToolSettings = pgTable(
1091
+ "cli_tool_settings",
1092
+ {
1093
+ tenantId: tenantRefRequired(),
1094
+ apiKeyId: uuid("api_key_id")
1095
+ .notNull()
1096
+ .references((): AnyPgColumn => apiKeys.id, { onDelete: "cascade" }),
1097
+ toolId: text("tool_id").notNull(),
1098
+ mappingsEnabled: boolean("mappings_enabled").notNull().default(false),
1099
+ mode: text("mode").notNull().default("remote"),
1100
+ updatedAt: updatedAtColumn(),
1101
+ },
1102
+ (table) => [primaryKey({ columns: [table.tenantId, table.toolId, table.apiKeyId] })],
1103
+ );
1104
+
1105
+
1106
+ // Metadata-only request telemetry. Retention is configured independently of
1107
+ // payload capture (`CARTETHYIA_TELEMETRY_RETENTION_DAYS`, default 30 days).
1108
+ // No prompt text, raw body, API key, encrypted-reasoning payload, or generic
1109
+ // `payload` column — the schema structurally prevents those fields.
1110
+ // Correlation columns intentionally have no catalog foreign keys, so a
1111
+ // telemetry write never depends on provider-row lifetime.
1112
+ export const telemetryEvents = pgTable(
1113
+ "telemetry_events",
1114
+ {
1115
+ id: uuid("id").primaryKey().defaultRandom(),
1116
+ createdAt: createdAtColumn(),
1117
+ tenantId: tenantRefRequired(),
1118
+ requestId: uuid("request_id").notNull(),
1119
+ sourceSurface: telemetrySourceSurface("source_surface"),
1120
+ requestedModel: text("requested_model"),
1121
+ /**
1122
+ * The `model` the upstream put in its own response, verbatim.
1123
+ *
1124
+ * Separate from `requested_model` because they answer different questions:
1125
+ * one is what the client asked for, the other is what the provider said it
1126
+ * ran. A provider that answers `muse-spark` as `deepseek-v4.1-flash` is
1127
+ * telling the operator something, and overwriting one with the other would
1128
+ * erase it. NULL when the response carried no model.
1129
+ */
1130
+ reportedModel: text("reported_model"),
1131
+ /** Canonical reasoning effort the client asked for; NULL when it asked for none. */
1132
+ requestedEffort: text("requested_effort"),
1133
+ providerId: text("provider_id"),
1134
+ accountId: uuid("account_id"),
1135
+ networkPoolId: uuid("network_pool_id"),
1136
+ endpoint: text("endpoint"),
1137
+ apiKeyId: uuid("api_key_id"),
1138
+ userAgent: text("user_agent"),
1139
+ clientIp: text("client_ip"),
1140
+ latencyMs: integer("latency_ms"),
1141
+ ttfbMs: integer("ttfb_ms"),
1142
+ stream: boolean("stream"),
1143
+ status: telemetryStatus("status"),
1144
+ /**
1145
+ * HTTP status the client actually received. The `status` enum is the
1146
+ * internal terminal state (`completed`/`failed`/…), which collapses every
1147
+ * failure to one bucket; this column keeps the wire code, so a 503
1148
+ * admission rejection stays distinguishable from a 500 upstream failure
1149
+ * in the Usage status breakdown. NULL on rows written before the column.
1150
+ */
1151
+ httpStatus: integer("http_status"),
1152
+ /**
1153
+ * Which layer produced the failure: the gateway itself, the upstream
1154
+ * provider, or the network in between. Recorded beside `error_category`
1155
+ * because the category alone cannot separate them — `invalid_request` is
1156
+ * both "the caller sent a bad body" (ours) and "the upstream rejected the
1157
+ * body" (theirs), and an operator triaging a failure needs to know which.
1158
+ */
1159
+ errorOrigin: text("error_origin"),
1160
+ errorCategory: text("error_category"),
1161
+ inputTokens: integer("input_tokens"),
1162
+ cachedInputTokens: integer("cached_input_tokens"),
1163
+ outputTokens: integer("output_tokens"),
1164
+ reasoningTokens: integer("reasoning_tokens"),
1165
+ estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }),
1166
+ /** Provider-billed credits (Tencent buddy-meter `credit`); NULL when unreported. */
1167
+ creditUsed: numeric("credit_used", { precision: 12, scale: 4 }),
1168
+ /**
1169
+ * Gateway-side time before the upstream dispatch began: routing,
1170
+ * credential lease, and request preparation. `ttfbMs` is measured from
1171
+ * the dispatch start, so this is the overhead the client waited on before
1172
+ * the provider was even called.
1173
+ */
1174
+ resolveMs: integer("resolve_ms"),
1175
+ tokensPerSec: numeric("tokens_per_sec", { precision: 10, scale: 2 }),
1176
+ // Absolute epoch-millisecond event timestamps (~1.8e12) overflow integer
1177
+ // (int4): completed requests carrying observed stream timings failed
1178
+ // their telemetry insert and vanished from the console. bigint "number"
1179
+ // mode is safe here — epoch millis stay far below MAX_SAFE_INTEGER —
1180
+ // and keeps the columns JSON-safe like the token-budget bigints above.
1181
+ firstContentDeltaAtMs: bigint("first_content_delta_at_ms", { mode: "number" }),
1182
+ lastEventAtMs: bigint("last_event_at_ms", { mode: "number" }),
1183
+ /**
1184
+ * Why this request's drawer payloads were not captured, or `captured` when
1185
+ * they were. Remote-diagnosis column: the drawer reads empty for reasons
1186
+ * that are invisible from the client — the tenant's capture mode, a missing
1187
+ * tenant, a non-terminal attempt — and without this an operator could not
1188
+ * tell "not configured" from "broken".
1189
+ */
1190
+ payloadCaptureState: text("payload_capture_state"),
1191
+ },
1192
+ (table) => [
1193
+ index("telemetry_events_created_at_idx").on(table.createdAt),
1194
+ index("telemetry_events_tenant_created_idx").on(table.tenantId, table.createdAt),
1195
+ index("telemetry_events_request_id_idx").on(table.requestId),
1196
+ index("telemetry_events_tenant_account_created_idx").on(
1197
+ table.tenantId,
1198
+ table.accountId,
1199
+ table.createdAt,
1200
+ ),
1201
+ /**
1202
+ * Serves the public share page's per-key aggregate (`share-usage.ts`):
1203
+ * equality on `api_key_id`, range and aggregate on `created_at`. Without
1204
+ * it every share render scanned the largest table in the schema.
1205
+ */
1206
+ index("telemetry_events_api_key_created_idx").on(table.apiKeyId, table.createdAt),
1207
+ ],
1208
+ );
1209
+
1210
+ /** Durable aggregates retained after metadata telemetry expires. */
1211
+ export const TELEMETRY_USAGE_IDENTITY_TYPES = ["account", "api_key"] as const;
1212
+ export type TelemetryUsageIdentityType = (typeof TELEMETRY_USAGE_IDENTITY_TYPES)[number];
1213
+
1214
+ export const telemetryUsageTotals = pgTable(
1215
+ "telemetry_usage_totals",
1216
+ {
1217
+ tenantId: tenantRefRequired(),
1218
+ identityType: text("identity_type").$type<TelemetryUsageIdentityType>().notNull(),
1219
+ entityId: uuid("entity_id").notNull(),
1220
+ requests: bigint("requests", { mode: "number" }).notNull().default(0),
1221
+ errors: bigint("errors", { mode: "number" }).notNull().default(0),
1222
+ inputTokens: bigint("input_tokens", { mode: "number" }).notNull().default(0),
1223
+ outputTokens: bigint("output_tokens", { mode: "number" }).notNull().default(0),
1224
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull(),
1225
+ updatedAt: updatedAtColumn(),
1226
+ },
1227
+ (table) => [
1228
+ primaryKey({
1229
+ name: "telemetry_usage_totals_identity_pk",
1230
+ columns: [table.tenantId, table.identityType, table.entityId],
1231
+ }),
1232
+ check(
1233
+ "telemetry_usage_totals_identity_type_check",
1234
+ sql`${table.identityType} IN ('account', 'api_key')`,
1235
+ ),
1236
+ ],
1237
+ );
1238
+
1239
+
1240
+ export const telemetryPayloads = pgTable(
1241
+ "telemetry_payloads",
1242
+ {
1243
+ id: uuid("id").primaryKey().defaultRandom(),
1244
+ tenantId: tenantRefRequired(),
1245
+ requestId: uuid("request_id"),
1246
+ capturedAt: timestamp("captured_at", { withTimezone: true }).notNull().defaultNow(),
1247
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
1248
+ /**
1249
+ * Typed index into the on-disk frame store. Prompt/response bodies live
1250
+ * only in the frame file these columns name — never in Postgres. The
1251
+ * previous `request_body jsonb` column is gone so a body cannot be
1252
+ * written here by accident.
1253
+ */
1254
+ storage: text("storage").notNull(),
1255
+ file: text("file").notNull(),
1256
+ offset: integer("offset").notNull(),
1257
+ length: integer("length").notNull(),
1258
+ checksum: text("checksum").notNull(),
1259
+ version: integer("version").notNull(),
1260
+ /**
1261
+ * Which of the four payload stages were captured, as a bitmask. The Usage
1262
+ * list shows one icon per stage; learning presence from the frame would
1263
+ * cost one inflate per row, so the writer records it here and the list
1264
+ * reads it off the indexed row.
1265
+ *
1266
+ * Bits: 1=request, 2=provider_request, 4=provider_response,
1267
+ * 8=client_response. Nullable: NULL means the row predates this column,
1268
+ * which renders as all icons greyed rather than inventing presence.
1269
+ */
1270
+ stages: integer("stages"),
1271
+ },
1272
+ (table) => [
1273
+ index("telemetry_payloads_request_id_idx").on(table.requestId),
1274
+ index("telemetry_payloads_tenant_request_idx").on(table.tenantId, table.requestId),
1275
+ index("telemetry_payloads_expires_idx").on(table.expiresAt),
1276
+ check("telemetry_payloads_storage_check", sql`${table.storage} IN ('jsonb-file', 'jsonl-file')`),
1277
+ check("telemetry_payloads_version_check", sql`${table.version} = 1`),
1278
+ check("telemetry_payloads_offset_check", sql`${table.offset} >= 0`),
1279
+ check("telemetry_payloads_length_check", sql`${table.length} > 0`),
1280
+ ],
1281
+ );
1282
+
1283
+
1284
+
1285
+ // Daily check-in ledger: one row per (account, calendar day) once the sweep has
1286
+ // attempted that account's claim.
1287
+ //
1288
+ // The day-scoped composite PK is the claim guard — an insert that conflicts is
1289
+ // exactly the "already settled today" answer — so it replaces the Redis
1290
+ // `SET NX EX` the Lite backend never had. The console reads the same table to
1291
+ // answer "was this attempted today", which is what makes the Quota page show
1292
+ // the real state instead of a permanent "Check-in due" when Redis is absent.
1293
+ export const dailyCheckinLedger = pgTable(
1294
+ "daily_checkin_ledger",
1295
+ {
1296
+ dayKey: text("day_key").notNull(),
1297
+ accountId: uuid("account_id").notNull(),
1298
+ claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
1299
+ },
1300
+ (table) => [
1301
+ primaryKey({ columns: [table.dayKey, table.accountId] }),
1302
+ ],
1303
+ );
1304
+
1305
+
1306
+ /**
1307
+ * Per-day, per-model credit deductions the buddy billing meter reported.
1308
+ *
1309
+ * A snapshot, not a ledger: the upstream is the source of truth and answers the
1310
+ * whole window on every call, so a row is upserted rather than appended. What it
1311
+ * buys is that the console renders a *stored* figure — without it every page
1312
+ * open would re-hit the billing endpoint for every account, and a failed read
1313
+ * would show nothing at all instead of the last number obtained.
1314
+ *
1315
+ * Keyed (account, day, model): one row per bucket, and the conflict target is
1316
+ * what makes the write idempotent across passes.
1317
+ */
1318
+ export const buddyUsageSnapshots = pgTable(
1319
+ "buddy_usage_snapshots",
1320
+ {
1321
+ accountId: uuid("account_id")
1322
+ .notNull()
1323
+ .references(() => providerAccounts.id, { onDelete: "cascade" }),
1324
+ /** Calendar day the requests were metered on, `YYYY-MM-DD`. */
1325
+ dayKey: text("day_key").notNull(),
1326
+ model: text("model").notNull(),
1327
+ /** Credits deducted across every request in this bucket. */
1328
+ credit: numeric("credit", { precision: 14, scale: 4 }).notNull(),
1329
+ syncedAt: timestamp("synced_at", { withTimezone: true }).notNull().defaultNow(),
1330
+ },
1331
+ (table) => [
1332
+ primaryKey({ columns: [table.accountId, table.dayKey, table.model] }),
1333
+ index("buddy_usage_snapshots_day_idx").on(table.dayKey),
1334
+ ],
1335
+ );
1336
+
1337
+ /**
1338
+ * One row per (day, account, growth task) once that day's claim was attempted.
1339
+ *
1340
+ * The composite PK is the idempotency guard: an insert that conflicts is
1341
+ * exactly the "already settled today" answer, which is what stops a sweep from
1342
+ * re-POSTing a claim on every pass. Same shape as `daily_checkin_ledger`
1343
+ * because it is the same problem — one claim per account per day — widened by
1344
+ * the task name now that there is more than one thing to claim.
1345
+ */
1346
+ export const buddyTaskLedger = pgTable(
1347
+ "buddy_task_ledger",
1348
+ {
1349
+ dayKey: text("day_key").notNull(),
1350
+ accountId: uuid("account_id")
1351
+ .notNull()
1352
+ .references(() => providerAccounts.id, { onDelete: "cascade" }),
1353
+ task: text("task").notNull(),
1354
+ claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
1355
+ },
1356
+ (table) => [
1357
+ primaryKey({ columns: [table.dayKey, table.accountId, table.task] }),
1358
+ index("buddy_task_ledger_account_idx").on(table.accountId),
1359
+ ],
1360
+ );