cartethyia 2.5.4 → 2.5.7

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