cartethyia 2.7.0 → 2.7.2

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 (330) hide show
  1. package/.env.example +107 -106
  2. package/README.md +25 -2
  3. package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-XaUmiBrC.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-0XUo4UMn.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-CXGS_Ct-.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-DAG5J9EY.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  17. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js +1 -0
  18. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-DKVslqG3.js → ModelPicker-BH8Tehdi.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
  26. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
  27. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +9 -0
  30. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  31. package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-7P2n--Fv.js} +1 -1
  32. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CxZ1fTkN.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  37. package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-Dqf4zpS-.js} +1 -1
  38. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-Byj2lAEm.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  43. package/dist/dashboard/assets/{api-keys-DFY_fZU4.js → api-keys-CUi6UXsR.js} +1 -1
  44. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  45. package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
  46. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-CwdnZARt.js → assert-67xXH5rf.js} +1 -1
  48. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-Dr-uf0Au.js → badge-DC2L43Mj.js} +1 -1
  50. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-BddmMdoS.js → banner-BBVcb0J3.js} +1 -1
  52. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-CK0PXWlX.js → card-Cca1XC6G.js} +1 -1
  54. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CcB6mGh2.js → cli-tools-KvsWa1Jl.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-DGb2P9Yx.js → clipboard-button-DUzEbynq.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-LkfuL50u.js → dialog-DFvWhwTa.js} +1 -1
  60. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-ZbEZ_84c.js → drawer-CFM2sMX1.js} +1 -1
  62. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  65. package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-ClCMCWWQ.js} +3 -3
  66. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  67. package/dist/dashboard/assets/{entry-CvC1-Zqx.js → entry-_xHo5x6r.js} +1 -1
  68. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  69. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js +1 -0
  70. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-BxpznFLt.js → github-badge-C9e8Ts7e.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-CDzP3ovJ.js → index-DFVFrnWW.js} +2 -2
  74. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-Ae13MQ5w.js → inline-BFUcvllj.js} +1 -1
  76. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-D23fo7cp.js → input-C_ILAvBb.js} +1 -1
  78. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  79. package/dist/dashboard/assets/{layout-CSsxjj5P.js → layout-CBIyT6Rt.js} +1 -1
  80. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-dmbwZMDY.js → list-row-ebJ_-Nsb.js} +1 -1
  82. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BG1oUsZZ.js → live-D0mm--ME.js} +1 -1
  84. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-DSF-e4ho.js → mutation-sMD965oO.js} +1 -1
  86. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-Bm_aECY7.js → numbers-BO9dKaFr.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  89. package/dist/dashboard/assets/providers/inception.svg +5 -0
  90. package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-CF56nYIV.js → query-keys-CfL_Zdoh.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CeA71n7G.js → routing-BnIpi1IJ.js} +1 -1
  94. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-KIT0rC6G.js → select-DBNVLdUu.js} +1 -1
  96. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-B_2gkePf.js → settings-BAFhDgX8.js} +1 -1
  98. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-D7WOSzcX.js → stack-EMrOMt4I.js} +1 -1
  100. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-BBxtKZOQ.js → state-CWoo7ty5.js} +1 -1
  102. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  103. package/dist/dashboard/assets/{switch-DNS_D5Ea.js → switch-CCjT6IkF.js} +1 -1
  104. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  105. package/dist/dashboard/assets/{useMutation-BgoxdoMk.js → useMutation-DjkLHUcE.js} +1 -1
  106. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  107. package/dist/dashboard/index.html +1 -1
  108. package/dist/dashboard/index.html.gz +0 -0
  109. package/dist/dashboard/precompressed-assets.json +54 -53
  110. package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
  111. package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
  112. package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
  113. package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
  114. package/package.json +96 -96
  115. package/scripts/cli/console-menu.ts +5 -2
  116. package/scripts/cli/tray.ts +21 -6
  117. package/src/boot/app.ts +684 -714
  118. package/src/boot/dependencies.ts +65 -62
  119. package/src/config/cooldowns.ts +16 -0
  120. package/src/config/providers.ts +13 -1
  121. package/src/config/resources.ts +70 -70
  122. package/src/config/security.ts +37 -37
  123. package/src/config/spec.ts +371 -358
  124. package/src/console/cli-tools/contracts.ts +10 -4
  125. package/src/console/console-router.ts +233 -225
  126. package/src/console/domain-registration.ts +398 -381
  127. package/src/console/providers/catalog/catalog-projections.ts +2 -6
  128. package/src/console/providers/catalog/contracts.ts +19 -0
  129. package/src/console/providers/catalog/provider-operations.ts +38 -0
  130. package/src/console/providers/catalog/routes.ts +14 -0
  131. package/src/console/providers/catalog/store-helpers.ts +326 -0
  132. package/src/console/providers/catalog/store.ts +1292 -1563
  133. package/src/console/quota/account-quota-global.ts +2 -2
  134. package/src/console/quota/account-quota-shared.ts +1 -1
  135. package/src/console/quota/account-quota-tenant.ts +35 -12
  136. package/src/console/quota/quota-view.ts +2 -2
  137. package/src/console/routing/pools/contracts.ts +41 -1
  138. package/src/console/routing/pools/operations.ts +183 -4
  139. package/src/console/routing/pools/relay-deploy.ts +17 -4
  140. package/src/console/routing/pools/routes.ts +30 -0
  141. package/src/console/routing/pools/store.ts +98 -8
  142. package/src/main.ts +183 -167
  143. package/src/network/outbound-fetch.ts +124 -208
  144. package/src/network/pool/agent.ts +15 -0
  145. package/src/network/pool/pool-state.ts +7 -0
  146. package/src/network/pool/selector.ts +110 -2
  147. package/src/network/pool-cooldown-store.ts +252 -0
  148. package/src/network/pool-health-machine.ts +5 -3
  149. package/src/network/pool-health.ts +36 -9
  150. package/src/observability/event-query.ts +57 -0
  151. package/src/observability/metrics.ts +467 -467
  152. package/src/observability/runtime-metrics.ts +269 -269
  153. package/src/persistence/health-event-flush.ts +2 -0
  154. package/src/persistence/schema.ts +19 -0
  155. package/src/persistence/usage-flush.ts +8 -3
  156. package/src/protocol/canonical-model.ts +1 -1
  157. package/src/protocol/dsml.ts +377 -0
  158. package/src/protocol/gateway-error.ts +232 -232
  159. package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
  160. package/src/protocol/request/chat.ts +7 -7
  161. package/src/protocol/request/codex.ts +2 -2
  162. package/src/protocol/request/gemini.ts +1 -1
  163. package/src/protocol/request/messages.ts +6 -6
  164. package/src/protocol/request/responses.ts +7 -7
  165. package/src/protocol/response/dsml.ts +12 -133
  166. package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
  167. package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
  168. package/src/{transport → protocol}/translation/capabilities.ts +2 -2
  169. package/src/{transport → protocol}/translation/normalize.ts +1 -1
  170. package/src/{transport → protocol}/translation/quirks.ts +1 -1
  171. package/src/{transport → protocol}/translation/search.ts +1 -1
  172. package/src/{transport → protocol}/translation/thinking.ts +23 -1
  173. package/src/{transport → protocol}/translation/tool-id.ts +1 -1
  174. package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
  175. package/src/protocol/upstream-parsing.ts +176 -0
  176. package/src/providers/adapters/compatible-adapter.ts +11 -2
  177. package/src/providers/authentication/oauth-refresh-service.ts +9 -5
  178. package/src/providers/default-registry.ts +22 -5
  179. package/src/providers/discovery/probe-phases.ts +16 -3
  180. package/src/providers/discovery/probing-service.ts +4 -1
  181. package/src/providers/integrations/anthropic.ts +1 -1
  182. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
  183. package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
  184. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
  185. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
  186. package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
  187. package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
  188. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
  189. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
  190. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
  191. package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
  192. package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
  193. package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
  194. package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
  195. package/src/providers/integrations/claude/claude.ts +1 -1
  196. package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
  197. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  198. package/src/providers/integrations/codex/codex.ts +1 -1
  199. package/src/providers/integrations/factory/factory.ts +1 -1
  200. package/src/providers/integrations/github/github.ts +1 -1
  201. package/src/providers/integrations/grok/grok.ts +1 -1
  202. package/src/providers/integrations/inception/provider.ts +91 -0
  203. package/src/providers/integrations/inferhub.ts +1 -1
  204. package/src/providers/integrations/kiro/kiro-request.ts +1 -1
  205. package/src/providers/integrations/mistral.ts +193 -19
  206. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  207. package/src/providers/integrations/opencode/fingerprint.ts +19 -2
  208. package/src/providers/integrations/opencode/prompt.ts +111 -0
  209. package/src/providers/integrations/opencode/provider.ts +101 -23
  210. package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
  211. package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
  212. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
  213. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  214. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  215. package/src/providers/integrations/qoder/qoder.ts +315 -400
  216. package/src/providers/integrations/shared/install-id.ts +2 -1
  217. package/src/providers/integrations/trae/trae-cache.ts +110 -0
  218. package/src/providers/integrations/trae/trae-oauth.ts +29 -4
  219. package/src/providers/integrations/trae/trae-protocol.ts +2 -1
  220. package/src/providers/integrations/trae/trae.ts +106 -23
  221. package/src/providers/operations/account-error-classification.ts +1684 -0
  222. package/src/providers/operations/account-health-service.ts +27 -1503
  223. package/src/providers/operations/account-reset-service.ts +2 -0
  224. package/src/providers/operations/provider-catalog-service.ts +11 -1
  225. package/src/providers/provider-contracts.ts +7 -4
  226. package/src/providers/provider-metadata.ts +25 -4
  227. package/src/providers/provider-probe.ts +2 -2
  228. package/src/{console → providers}/quota/cache.ts +1 -1
  229. package/src/{console → providers}/quota/refresh.ts +5 -5
  230. package/src/security/cli-client-fingerprint.ts +9 -2
  231. package/src/transport/dispatch/attempt-finalize.ts +114 -12
  232. package/src/transport/dispatch/attempt-loop.ts +3 -0
  233. package/src/transport/dispatch/empty-stream.ts +9 -2
  234. package/src/transport/dispatch/generation.ts +37 -55
  235. package/src/transport/dispatch/handler-deps.ts +32 -0
  236. package/src/transport/dispatch/leases.ts +9 -4
  237. package/src/transport/dispatch/proxy-request.ts +6 -30
  238. package/src/transport/dispatch/responses-compact.ts +22 -43
  239. package/src/transport/dispatch/streaming/finalize.ts +3 -0
  240. package/src/transport/dispatch/streaming/stream.ts +0 -8
  241. package/src/transport/dispatch/systemone.ts +22 -42
  242. package/src/transport/dispatch/token-count.ts +23 -39
  243. package/src/transport/dispatch/upstream.ts +1 -3
  244. package/src/transport/dispatch/websearch.ts +22 -41
  245. package/src/transport/failure-policy.ts +92 -178
  246. package/src/transport/guards/abuse-guard.ts +10 -0
  247. package/src/transport/guards/auth-guard.ts +109 -109
  248. package/src/transport/guards/error-lifecycle.ts +1 -1
  249. package/src/transport/guards/pipeline.ts +220 -220
  250. package/src/transport/guards/request-context.ts +245 -238
  251. package/src/transport/request/degradation.ts +20 -1
  252. package/src/transport/request/inflight.ts +4 -114
  253. package/src/transport/request/preparer.ts +6 -6
  254. package/src/transport/request/state.ts +66 -38
  255. package/src/transport/request/tenant-preferences.ts +1 -1
  256. package/src/transport/routing/route-catalog.ts +75 -43
  257. package/src/transport/routing/router.ts +30 -2
  258. package/src/transport/surface/messages/parse.ts +1 -1
  259. package/src/transport/surface/responses/encode.ts +2 -3
  260. package/src/transport/surface/responses/parse.ts +49 -7
  261. package/src/types/undici.d.ts +33 -0
  262. package/src/workers/buddy-growth-tasks.ts +346 -425
  263. package/src/workers/daily-checkin.ts +17 -6
  264. package/src/workers/quota-refresh-sweep.ts +2 -2
  265. package/src/workers/scheduler.ts +17 -1
  266. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  267. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  268. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  269. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  270. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
  271. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  272. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  273. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
  274. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  275. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
  276. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  277. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  278. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
  279. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  280. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
  281. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  282. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
  283. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  284. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
  285. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  286. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
  287. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  288. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  289. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  290. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
  291. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  292. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  293. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  294. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
  295. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  296. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  297. package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
  298. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  299. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  300. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  301. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  302. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  303. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  304. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  305. package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
  306. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  307. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  308. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  309. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  310. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  311. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  312. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  313. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  314. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  315. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  316. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  317. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  318. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  319. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  320. package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
  321. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  322. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  323. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  324. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  325. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  326. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  327. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  328. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  329. /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
  330. /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
package/src/boot/app.ts CHANGED
@@ -1,714 +1,684 @@
1
- import { Elysia } from "elysia";
2
- import { resolveDashboardDist, resolveElysiaPrecompile } from "../config/runtime";
3
- import { createConsoleRouter, type ConsoleApiCompositionDeps } from "../console/console-router";
4
- import type { ScheduledTaskRegistry } from "../workers/scheduler";
5
- import { createStaticHandler } from "../console/dashboard-assets";
6
- import { createShareRouter } from "../console/share/share-router";
7
- import { createShareStatsPort } from "../console/share/share-stats";
8
- import { DrizzleShareLinkStore } from "../persistence/stores/share-store";
9
- import { chatAdapter } from "../transport/surface/chat/adapter";
10
- import { responsesAdapter } from "../transport/surface/responses/adapter";
11
- import { messagesAdapter } from "../transport/surface/messages/adapter";
12
- import { completionAdapter } from "../transport/surface/completion";
13
- import { SurfaceAdapterRegistry } from "../transport/surface/adapters";
14
- import { GatewayError, publicGatewayErrorBody } from "../protocol/gateway-error";
15
- import { shutdownError, shutdownNotice } from "../transport/shutdown-notice";
16
- import type { CanonicalAdapter } from "../transport/contracts";
17
- import type { ApiKeyAuthorizationSnapshot } from "../security/api-key-auth";
18
- import { modelsListApiKeyError } from "../security/api-key-auth";
19
- import { getDbHandle } from "../persistence/boot";
20
- import { isPgHandle } from "../persistence/db-pg";
21
- import { resolveRedisClient } from "../persistence/redis";
22
- import type { CartethyiaDatabase } from "../persistence/db-handle";
23
- import type { ProviderAdapter } from "../providers/provider-contracts";
24
- import type { OAuthTokenRefresher, OAuthRefreshService } from "../providers/authentication/oauth-refresh-service";
25
- import { withTimeout } from "./timeout";
26
- import type { ByokUpstreamHost } from "../providers/operations/provider-catalog-service";
27
- import type { NetworkPoolSelector } from "../network/pool/selector";
28
- import type { ProxyRequestPreparer } from "../transport/request/preparer";
29
- import { ProxyRequestStateStore } from "../transport/request/state";
30
- import { fastPathname } from "../transport/pathname";
31
- import {
32
- handleProviderProxyRequest,
33
- type ProviderProxyHandlerDeps,
34
- } from "../transport/dispatch/proxy-request";
35
- import { createResponsesCompactHandler } from "../transport/dispatch/responses-compact";
36
- import { createSystemoneHandler } from "../transport/dispatch/systemone";
37
- import { createWebsearchHandler } from "../transport/dispatch/websearch";
38
- import {
39
- createAudioSpeechHandler,
40
- createAudioTranscriptionHandler,
41
- createEmbeddingHandler,
42
- createImageGenerationHandler,
43
- createVideoGenerationHandler,
44
- } from "../transport/dispatch/generation";
45
- import { createTokenCountHandler } from "../transport/dispatch/token-count";
46
-
47
- import { createTransportPipeline } from "../transport/guards/pipeline";
48
- import { createDrainHandler, isLoopbackPeer, tokenMatches } from "../transport/drain-endpoint";
49
- import { createConsoleAccountResetHandler } from "../console/auth/account-reset";
50
- import { resetConsoleAuthState } from "../console/auth/account-reset-store";
51
- import { createBackupExportHandler } from "../console/backup/export-endpoint";
52
- import { tenants } from "../persistence/schema";
53
- import { PublicModelCatalogStore, type AllowedModelEntry } from "../console/providers/catalog/public-model-store";
54
- import type { IpAbuseProtectionService } from "../security/abuse";
55
- import type { ReadinessCheckResult } from "../persistence/readiness";
56
- import type { RouteSnapshotService } from "../transport/routing/route-model";
57
- import type { TrustedProxyBoundary } from "../config/network";
58
- import { resolveClientIdentity } from "../security/ip-boundary";
59
- import type { ValidatedNetworkBindingFactory } from "../network/pool/resolver";
60
- import { metrics } from "../observability/metrics";
61
- import type { RuntimeMetricsSampler } from "../observability/runtime-metrics";
62
- import { API_CONTENT_SECURITY_POLICY, X_FRAME_OPTIONS } from "../security/outbound-headers";
63
-
64
-
65
- import type { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
66
- import type { ShutdownCoordinator } from "./lifecycle";
67
- import type { ShutdownDrainSource } from "../transport/contracts";
68
-
69
- /**
70
- * Route-only shell dependencies: the dashboard, `/health`, and `/metrics`
71
- * without the transport pipeline. Used when no database is available (AOT
72
- * manifest capture during `bun run build:aot`) and by tests that exercise
73
- * routing and static serving in isolation.
74
- *
75
- * Only the knobs route-only mode actually reads appear here. Transport and
76
- * console options (`db`, `resolvePeerAddress`, `trustedProxyBoundary`,
77
- * `maxBodyBytes`, `requestDeadlineMs`, `verifiedHttps`) are deliberately
78
- * absent: they are read inside the `mode === "production"` branches, where the
79
- * discriminant narrows `deps` to `ProductionAppDeps`. Listing them here would
80
- * let a caller pass a value the shell silently ignores.
81
- */
82
- interface GatewayShellDeps {
83
- readonly mode: "route-only";
84
- readonly dashboardDist?: string;
85
- readonly checkDb?: () => Promise<void>;
86
- readonly checkRedis?: () => Promise<void>;
87
- readonly readiness?: () => Promise<ReadinessCheckResult>;
88
- /**
89
- * Drain state the readiness route reads. The shell has no coordinator, so it
90
- * takes the narrow read surface (`ShutdownDrainSource`) rather than a
91
- * hand-maintained copy of the whole `ShutdownCoordinator`.
92
- */
93
- readonly shutdownCoordinator?: ShutdownDrainSource;
94
- /**
95
- * Memory sampler when the caller owns one. The shell never constructs it, so
96
- * it stays optional; production always supplies it and binds its in-flight
97
- * source to the request store below.
98
- */
99
- readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
100
- }
101
-
102
- /**
103
- * Full production dependencies. Every transport, pool, snapshot, telemetry,
104
- * and console field is required — the composition root in
105
- * `runtime/dependencies.ts` either supplies it or the app does not boot, so
106
- * there is no runtime shape check to fall through.
107
- */
108
- export interface ProductionAppDeps {
109
- readonly mode: "production";
110
- readonly db: CartethyiaDatabase;
111
- readonly proxyPreparer: ProxyRequestPreparer;
112
- readonly resolveProviderAdapter: (providerId: string) => Promise<ProviderAdapter | undefined>;
113
- readonly providerAdapters?: ReadonlyMap<string, ProviderAdapter>;
114
- /** Live lookup of a provider's SSRF-validated upstream host. */
115
- readonly byokUpstreamHosts: { readonly get: (providerId: string) => ByokUpstreamHost | undefined };
116
- readonly networkBindingFactory: ValidatedNetworkBindingFactory;
117
- readonly ipAbuseProtection: IpAbuseProtectionService;
118
- readonly trustedProxyBoundary: TrustedProxyBoundary;
119
- readonly poolSelector: NetworkPoolSelector;
120
- readonly snapshotService: RouteSnapshotService;
121
- readonly readiness: () => Promise<ReadinessCheckResult>;
122
- readonly telemetryBuffer: TelemetryBatchBuffer;
123
- readonly resolveOAuthRefresher: (providerId: string) => Promise<OAuthTokenRefresher | undefined>;
124
- readonly oauthRefreshService: OAuthRefreshService;
125
- /** Graduated strikes for repeated invalid-model requests. */
126
- /**
127
- * The console control plane. Optional so reduced compositions (route-only
128
- * shell, console stubs) stay valid; production always mounts it. Without
129
- * REDIS_URL the console runs on the in-memory backend like the data plane
130
- * (OAuth-flow state and the quota cache fall back to process memory;
131
- * sessions live in `console_sessions` in Postgres and CSRF is stateless).
132
- */
133
- readonly consoleApi?: ConsoleApiCompositionDeps;
134
- /**
135
- * The shared maintenance scheduler. The in-flight backstop sweep registers
136
- * here rather than owning a private timer, so every periodic task in the
137
- * process is visible in one place. Absent in reduced compositions, where the
138
- * backstop is not mounted.
139
- */
140
- readonly scheduledTasks?: ScheduledTaskRegistry;
141
- /** Memory sampler; bound to the request store created below. */
142
- readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
143
- /** The real coordinator: production wires its abort hook and tracks requests. */
144
- readonly shutdownCoordinator: ShutdownCoordinator;
145
- readonly dashboardDist?: string;
146
- readonly resolvePeerAddress?: (request: Request) => string | null;
147
- readonly maxBodyBytes?: number;
148
- readonly requestDeadlineMs?: number;
149
- readonly verifiedHttps?: boolean;
150
- /**
151
- * Secret for the operator drain endpoint. When set (with `triggerDrain`),
152
- * `POST /admin/drain` from loopback with a matching `x-drain-token` drains
153
- * gracefully — the signal-free stop Windows needs.
154
- */
155
- readonly drainToken?: string;
156
- readonly triggerDrain?: () => void;
157
- }
158
-
159
- /** Either app mode. The discriminant decides which builder path runs. */
160
- type GatewayAppDeps = GatewayShellDeps | ProductionAppDeps;
161
-
162
- function checkDatabaseConnection(): Promise<void> {
163
- return getDbHandle()
164
- .query("SELECT 1")
165
- .then(() => undefined);
166
- }
167
-
168
- function checkRedisConnection(): Promise<void> {
169
- // Memory backend is healthy by construction — there is no connection to
170
- // probe — so readiness only pings a real client.
171
- const redis = resolveRedisClient();
172
- if (!redis) return Promise.resolve();
173
- return redis.ping().then(() => undefined);
174
- }
175
-
176
- function runDependencyReadinessCheck(check: () => Promise<void>, label: string): Promise<void> {
177
- return withTimeout(Promise.resolve().then(check), 2000, `${label} timeout after 2000ms`);
178
- }
179
-
180
- /**
181
- * Builds the public HTTP composition root with route ordering guarantee.
182
- * The one builder behind both modes: `deps.mode` selects which planes mount,
183
- * and each mode's dependency interface admits no partial set.
184
- */
185
- export function createGatewayApp(deps: GatewayAppDeps) {
186
- const dbCheck =
187
- deps.mode === "route-only"
188
- ? (deps.checkDb ?? checkDatabaseConnection)
189
- : checkDatabaseConnection;
190
- const redisCheck =
191
- deps.mode === "route-only"
192
- ? (deps.checkRedis ?? checkRedisConnection)
193
- : checkRedisConnection;
194
- const registry = new SurfaceAdapterRegistry([
195
- chatAdapter,
196
- responsesAdapter,
197
- messagesAdapter,
198
- completionAdapter,
199
- ]);
200
- const dashboardDist = deps.dashboardDist ?? resolveDashboardDist();
201
- const staticHandler = createStaticHandler({ buildDir: dashboardDist });
202
- const peerAddresses = new WeakMap<Request, string>();
203
- const serveDashboard = async ({ request }: { request: Request }): Promise<Response> => {
204
- const pathname = fastPathname(request.url);
205
- // The client's `accept-encoding` is what decides whether a precompressed
206
- // `.gz` sibling can be served instead of the plain asset.
207
- const result = await staticHandler(pathname, request.headers.get("accept-encoding"));
208
- return new Response(result.body ? (result.body as unknown as BodyInit) : null, {
209
- status: result.status,
210
- headers: result.headers,
211
- });
212
- };
213
- /**
214
- * Root catch-all. Non-document namespaces (`/v1`, `/health`, `/metrics`) keep
215
- * their JSON 404 so an unknown API path can never be answered with the SPA
216
- * document; everything else resolves through the static handler.
217
- */
218
- const API_NAMESPACES = ["/v1", "/health", "/metrics"] as const;
219
- const serveRootFallback = async ({ request }: { request: Request }): Promise<Response> => {
220
- const pathname = fastPathname(request.url);
221
- const reserved = API_NAMESPACES.some(
222
- (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
223
- );
224
- if (reserved) {
225
- const notFound = new GatewayError("not_found", 404, "No endpoint exists at this path. Check the route against the API reference.");
226
- return new Response(JSON.stringify(publicGatewayErrorBody(notFound)), {
227
- status: notFound.status,
228
- headers: {
229
- "content-type": "application/json; charset=utf-8",
230
- "cache-control": "no-store",
231
- "content-security-policy": API_CONTENT_SECURITY_POLICY,
232
- "x-frame-options": X_FRAME_OPTIONS,
233
- "x-content-type-options": "nosniff",
234
- },
235
- });
236
- }
237
- return serveDashboard({ request });
238
- };
239
-
240
- // Build the Elysia app with strict route ordering.
241
- const requestStateStore = new ProxyRequestStateStore(
242
- deps.mode === "production" ? deps.shutdownCoordinator : undefined,
243
- );
244
- deps.runtimeMetricsSampler?.bindInFlight(() => requestStateStore.inFlightCount());
245
- // Shutdown ordering: abort in-flight proxy controllers first so the
246
- // bounded drain observes cancellation and finalizers run before
247
- // telemetry flush and pool close. The reason is threaded through so the
248
- // streaming path emits a drain-aware terminal frame (`restart_for_update`
249
- // vs `shutting_down`) instead of a bare abort that reads as a client drop.
250
- // Only production has a coordinator to wire; the shell never drains.
251
- if (deps.mode === "production") {
252
- const coordinator = deps.shutdownCoordinator;
253
- coordinator.setAbortInflight(() =>
254
- requestStateStore.abortAll(shutdownError(coordinator.shutdownReason())),
255
- );
256
- }
257
- // Backstop: the gauge must settle even if an abort path never fires (a
258
- // half-closed socket that neither pulls, cancels, nor aborts). This sweep
259
- // force-releases any flight past its deadline plus a grace window, so a
260
- // missed release cannot leave a permanently wrong number. The grace is a
261
- // safety margin over the request deadline, not a deployment knob.
262
- const INFLIGHT_BACKSTOP_INTERVAL_MS = 30_000;
263
- const INFLIGHT_BACKSTOP_GRACE_MS = 30_000;
264
- const INFLIGHT_BACKSTOP_HARD_GRACE_MS = 120_000;
265
- // Registered in every mode, not just production. The gauge is the operator's
266
- // concurrency signal, and a cancelled request that never ran a finalizer
267
- // leaves its flight behind — gating the only thing that reaps those on
268
- // production made a permanently wrong count depend on how the gateway was
269
- // started. The registry exists in every mode, so there is no mode where
270
- // running this is wrong.
271
- // `scheduledTasks` is declared on the production shape only, so it is
272
- // narrowed rather than reached through the union — a shell composition
273
- // without the registry simply skips the backstop, exactly as before.
274
- const backstopTasks = "scheduledTasks" in deps ? deps.scheduledTasks : undefined;
275
- backstopTasks?.register({
276
- name: "inflight-backstop",
277
- intervalMs: INFLIGHT_BACKSTOP_INTERVAL_MS,
278
- run: () => {
279
- requestStateStore.sweepOverdueInFlight(
280
- Date.now(),
281
- INFLIGHT_BACKSTOP_GRACE_MS,
282
- INFLIGHT_BACKSTOP_HARD_GRACE_MS,
283
- );
284
- },
285
- });
286
- const app = new Elysia({ precompile: resolveElysiaPrecompile() })
287
- .beforeHandle(
288
- ({
289
- request,
290
- server,
291
- }: {
292
- request: Request;
293
- server?: { requestIP(request: Request): { address: string } | null } | null;
294
- }) => {
295
- const address = server?.requestIP(request)?.address;
296
- if (address) peerAddresses.set(request, address);
297
- },
298
- )
299
- .get("/health", () => ({ status: "ok" as const }))
300
- .get("/health/ready", async () => {
301
- // A draining process reports not-ready so the orchestrator stops
302
- // sending it traffic and the replacement takes over without the old
303
- // one crashing first: SIGTERM → draining → 503 here → Docker routes
304
- // to the new container → old one finishes in flight and exits 0.
305
- // The reason distinguishes an ordinary stop from an in-place update,
306
- // whose replacement is seconds away, so a probe consumer can back off
307
- // briefly instead of treating the drain as a permanent outage.
308
- if (deps.shutdownCoordinator?.isDraining()) {
309
- const notice = shutdownNotice(deps.shutdownCoordinator.shutdownReason?.());
310
- return new Response(JSON.stringify({ status: "not_ready" as const, reason: notice.code }), {
311
- status: 503,
312
- headers: { "content-type": "application/json" },
313
- });
314
- }
315
- const readiness = deps.readiness ? await deps.readiness() : undefined;
316
- const results = readiness
317
- ? [
318
- { status: readiness.db === "connected" ? "fulfilled" : "rejected" },
319
- { status: readiness.redis !== "disconnected" ? "fulfilled" : "rejected" },
320
- ]
321
- : await Promise.allSettled([
322
- runDependencyReadinessCheck(dbCheck, "db"),
323
- runDependencyReadinessCheck(redisCheck, "redis"),
324
- ]);
325
- const dbOk = results[0]?.status === "fulfilled";
326
- const redisOk = results[1]?.status === "fulfilled";
327
- const migrationsOk = readiness ? readiness.migrations === "applied" : true;
328
-
329
- if (dbOk && redisOk && migrationsOk) {
330
- return {
331
- status: "ready" as const,
332
- db: "connected" as const,
333
- };
334
- }
335
-
336
- return new Response(
337
- JSON.stringify({
338
- status: "not_ready" as const,
339
- db: dbOk ? ("connected" as const) : ("disconnected" as const),
340
- redis: redisOk ? ("connected" as const) : ("disconnected" as const),
341
- ...(readiness ? { migrations: readiness.migrations } : {}),
342
- reason: !migrationsOk
343
- ? "database migrations pending"
344
- : "dependency not ready",
345
- }),
346
- { status: 503, headers: { "content-type": "application/json" } },
347
- );
348
- })
349
- .get("/metrics", () => {
350
- const handle = getDbHandle();
351
- // Pool gauges describe the external server's connection budget; the
352
- // embedded backend has no pool to report.
353
- if (isPgHandle(handle)) {
354
- metrics.cartethyia_pg_pool_total.set(handle.pool.totalCount);
355
- metrics.cartethyia_pg_pool_idle.set(handle.pool.idleCount);
356
- metrics.cartethyia_pg_pool_waiting.set(handle.pool.waitingCount);
357
- }
358
- // No client means the memory backend, which is always up; a real client
359
- // reports its own readiness so the scrape never throws.
360
- const redisClient = resolveRedisClient();
361
- metrics.cartethyia_redis_up.set(redisClient === undefined || redisClient.status === "ready" ? 1 : 0);
362
- return new Response(metrics.render(), {
363
- headers: { "content-type": "text/plain; version=0.0.4" },
364
- });
365
- });
366
-
367
- if (deps.mode === "production") {
368
- const transportPipeline = createTransportPipeline({
369
- db: deps.db,
370
- stateStore: requestStateStore,
371
- surfaceRegistry: registry,
372
- adapters: new Map<string, CanonicalAdapter>([
373
- ["chat", chatAdapter],
374
- ["responses", responsesAdapter],
375
- ["messages", messagesAdapter],
376
- ["completion", completionAdapter],
377
- ]),
378
- preparer: deps.proxyPreparer,
379
- readiness: deps.readiness,
380
- trustedProxyBoundary: deps.trustedProxyBoundary,
381
- ...(deps.resolvePeerAddress ? { resolvePeerAddress: deps.resolvePeerAddress } : {}),
382
- ipAbuseProtection: deps.ipAbuseProtection,
383
- ...(deps.maxBodyBytes === undefined ? {} : { maxBodyBytes: deps.maxBodyBytes }),
384
- ...(deps.requestDeadlineMs === undefined ? {} : { requestDeadlineMs: deps.requestDeadlineMs }),
385
- ...(deps.verifiedHttps ? { verifiedHttps: true } : {}),
386
- shutdownCoordinator: deps.shutdownCoordinator,
387
- telemetry: deps.telemetryBuffer,
388
- ...(deps.consoleApi?.redis ? { redis: deps.consoleApi.redis } : {}),
389
- // Bound at construction so the guard holds the sampler's live snapshot
390
- // rather than a value captured at boot.
391
- ...(deps.runtimeMetricsSampler
392
- ? { memorySnapshot: () => deps.runtimeMetricsSampler?.memorySnapshot() }
393
- : {}),
394
- // Reads the cached route snapshot, which the settings route invalidates
395
- // when the switch is saved — so flipping it takes effect on the next
396
- // request and adds no query to the proxy path.
397
- isInvokeDisabled: async (tenantId) =>
398
- (await deps.snapshotService.getSnapshot())?.invokeDisabledTenants?.has(tenantId),
399
- });
400
- transportPipeline.mountRoot(app);
401
- const proxyDeps: ProviderProxyHandlerDeps = {
402
- db: deps.db,
403
- providerAdapters: deps.providerAdapters ?? new Map(),
404
- resolveProviderAdapter: deps.resolveProviderAdapter,
405
- stateStore: requestStateStore,
406
- proxyPreparer: deps.proxyPreparer,
407
- snapshotService: deps.snapshotService,
408
- networkBindingFactory: deps.networkBindingFactory,
409
- byokUpstreamHosts: deps.byokUpstreamHosts,
410
- poolSelector: deps.poolSelector,
411
- telemetryBuffer: deps.telemetryBuffer,
412
- resolveOAuthRefresher: deps.resolveOAuthRefresher,
413
- oauthRefreshService: deps.oauthRefreshService,
414
- };
415
- const proxyHandler = ({ request }: { request: Request }) =>
416
- handleProviderProxyRequest(request, proxyDeps);
417
-
418
- const publicModelStore = new PublicModelCatalogStore(deps.db);
419
- const queryAllowedModels = (auth: {
420
- snapshot: ApiKeyAuthorizationSnapshot;
421
- tenantId: string | null;
422
- modelPrefix?: string;
423
- }): Promise<AllowedModelEntry[]> =>
424
- publicModelStore.listPublicModels(auth.tenantId, auth.snapshot, auth.modelPrefix);
425
-
426
- const handleModelsList = async ({ request }: { request: Request }): Promise<Response> => {
427
- const state = requestStateStore.require(request);
428
- const auth = state.authorization;
429
- if (!auth) throw modelsListApiKeyError();
430
- const data = await queryAllowedModels(auth);
431
- return new Response(JSON.stringify({ object: "list", data }), {
432
- headers: {
433
- "content-type": "application/json",
434
- "cache-control": "no-store",
435
- "x-request-id": state.requestId,
436
- "access-control-allow-origin": "*",
437
- },
438
- });
439
- };
440
-
441
- const handleModelsDetail = async ({
442
- request,
443
- params,
444
- }: {
445
- request: Request;
446
- // Elysia omits `params` entirely for the literal `/models/info` route
447
- // (it declares no path parameters), so this must stay optional.
448
- params?: Record<string, string> | undefined;
449
- }): Promise<Response> => {
450
- const state = requestStateStore.require(request);
451
- const auth = state.authorization;
452
- if (!auth) throw modelsListApiKeyError();
453
- const raw = params?.["*"] ?? params?.id ?? "";
454
- const targetId = new URL(request.url).searchParams.get("id") ?? raw;
455
- const found = await publicModelStore.getPublicModelDetail(
456
- targetId,
457
- auth.tenantId,
458
- auth.snapshot,
459
- auth.modelPrefix,
460
- );
461
- if (!found)
462
- throw new GatewayError(
463
- "model_not_found",
464
- 404,
465
- `The model '${targetId}' does not exist or you do not have access to it.`,
466
- );
467
- return new Response(JSON.stringify(found), {
468
- headers: {
469
- "content-type": "application/json",
470
- "cache-control": "no-store",
471
- "x-request-id": state.requestId,
472
- "access-control-allow-origin": "*",
473
- },
474
- });
475
- };
476
-
477
- const handleResponsesCompact = createResponsesCompactHandler({
478
- db: deps.db,
479
- providerAdapters: deps.providerAdapters ?? new Map(),
480
- resolveProviderAdapter: deps.resolveProviderAdapter,
481
- proxyPreparer: deps.proxyPreparer,
482
- stateStore: requestStateStore,
483
- poolSelector: deps.poolSelector,
484
- networkBindingFactory: deps.networkBindingFactory,
485
- snapshotService: deps.snapshotService,
486
- resolveOAuthRefresher: deps.resolveOAuthRefresher,
487
- oauthRefreshService: deps.oauthRefreshService,
488
- // The compact handler completes its attempt through `completeAttempt`,
489
- // which marks the request completed and finalizes telemetry itself —
490
- // so the afterResponse lifecycle skips it. Without the buffer here the
491
- // route would emit no telemetry row at all.
492
- telemetryBuffer: deps.telemetryBuffer,
493
- });
494
-
495
- const handleSystemone = createSystemoneHandler({
496
- db: deps.db,
497
- providerAdapters: deps.providerAdapters ?? new Map(),
498
- resolveProviderAdapter: deps.resolveProviderAdapter,
499
- proxyPreparer: deps.proxyPreparer,
500
- stateStore: requestStateStore,
501
- poolSelector: deps.poolSelector,
502
- networkBindingFactory: deps.networkBindingFactory,
503
- snapshotService: deps.snapshotService,
504
- resolveOAuthRefresher: deps.resolveOAuthRefresher,
505
- oauthRefreshService: deps.oauthRefreshService,
506
- // Same reasoning as compact: the handler finalizes telemetry itself, so
507
- // the buffer must be threaded here or the route emits no row.
508
- telemetryBuffer: deps.telemetryBuffer,
509
- });
510
-
511
- const handleWebsearch = createWebsearchHandler({
512
- db: deps.db,
513
- providerAdapters: deps.providerAdapters ?? new Map(),
514
- resolveProviderAdapter: deps.resolveProviderAdapter,
515
- proxyPreparer: deps.proxyPreparer,
516
- stateStore: requestStateStore,
517
- poolSelector: deps.poolSelector,
518
- networkBindingFactory: deps.networkBindingFactory,
519
- snapshotService: deps.snapshotService,
520
- resolveOAuthRefresher: deps.resolveOAuthRefresher,
521
- oauthRefreshService: deps.oauthRefreshService,
522
- telemetryBuffer: deps.telemetryBuffer,
523
- });
524
-
525
- const generationDeps = {
526
- db: deps.db,
527
- providerAdapters: deps.providerAdapters ?? new Map(),
528
- resolveProviderAdapter: deps.resolveProviderAdapter,
529
- proxyPreparer: deps.proxyPreparer,
530
- stateStore: requestStateStore,
531
- poolSelector: deps.poolSelector,
532
- networkBindingFactory: deps.networkBindingFactory,
533
- snapshotService: deps.snapshotService,
534
- resolveOAuthRefresher: deps.resolveOAuthRefresher,
535
- oauthRefreshService: deps.oauthRefreshService,
536
- telemetryBuffer: deps.telemetryBuffer,
537
- };
538
- const handleImageGeneration = createImageGenerationHandler(generationDeps);
539
- // Mounted with no model behind it on purpose: the surface exists so it can
540
- // be finished when an upstream is known, rather than 404ing like a typo.
541
- const handleVideoGeneration = createVideoGenerationHandler(generationDeps);
542
- // Passthrough surfaces: routed and accounted for, but never interpreted.
543
- // No bundled adapter implements embeddings or audio, and inventing a wire
544
- // for them would be a guess; what these need to be useful is the route, the
545
- // pipeline, and a compatible upstream behind it.
546
- const handleEmbedding = createEmbeddingHandler(generationDeps);
547
- const handleAudioTranscription = createAudioTranscriptionHandler(generationDeps);
548
- const handleAudioSpeech = createAudioSpeechHandler(generationDeps);
549
- // Shares the generation dependency bundle: same pipeline, and a count is
550
- // neither chat-shaped nor generation-shaped, so it needs exactly the same
551
- // routing, leasing, and accounting surface.
552
- const handleTokenCount = createTokenCountHandler(generationDeps);
553
-
554
- // Gateway mounting: the pipeline owner composes stages, telemetry, and
555
- // cleanup; app only registers the public route table.
556
- app.use(
557
- transportPipeline.createGateway((routes) => {
558
- routes.post("/chat/completions", proxyHandler);
559
- routes.post("/responses", proxyHandler);
560
- routes.post("/responses/compact", handleResponsesCompact);
561
- routes.post("/messages", proxyHandler);
562
- // The token counter is a sibling of the chat route on the same model, and
563
- // it is forwarded rather than estimated — see dispatch/token-count.ts.
564
- routes.post("/messages/count_tokens", handleTokenCount);
565
- routes.post("/completions", proxyHandler);
566
- routes.post("/systemone", handleSystemone);
567
- routes.post("/search", handleWebsearch);
568
- routes.post("/images/generations", handleImageGeneration);
569
- routes.post("/videos/generations", handleVideoGeneration);
570
- routes.post("/embeddings", handleEmbedding);
571
- routes.post("/audio/transcriptions", handleAudioTranscription);
572
- routes.post("/audio/speech", handleAudioSpeech);
573
- routes.get("/models", handleModelsList);
574
- routes.get("/models/info", handleModelsDetail);
575
- routes.get("/models/*", handleModelsDetail);
576
- }),
577
- );
578
-
579
- // Operator drain endpoint. Registered before the SPA catch-all and only
580
- // when a token is configured, so an unconfigured gateway has no such route
581
- // at all. It is a graceful stop for platforms where a catchable signal
582
- // cannot be delivered (Windows), and a second signal-free path elsewhere.
583
- if (deps.drainToken && deps.triggerDrain) {
584
- const drainHandler = createDrainHandler({
585
- token: deps.drainToken,
586
- triggerDrain: deps.triggerDrain,
587
- resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
588
- });
589
- app.post("/admin/drain", drainHandler);
590
- }
591
-
592
- // Operator backup export. Same double gate as drain and reset, and no
593
- // console password: the CLI already runs as the OS user who owns the
594
- // database, and a forgotten password must not block the backup that is
595
- // meant to rescue the install.
596
- if (deps.drainToken) {
597
- app.post(
598
- "/admin/export-backup",
599
- createBackupExportHandler({
600
- db: deps.db,
601
- token: deps.drainToken,
602
- resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
603
- resolveTenantId: async () => {
604
- const [row] = await deps.db.select({ id: tenants.id }).from(tenants).limit(1);
605
- return row?.id ?? null;
606
- },
607
- }),
608
- );
609
- }
610
-
611
- // Console account reset. Same double gate as drain — loopback plus the
612
- // token — because it deletes every operator credential on the install.
613
- // Only authentication state goes; providers, keys, and routing stay.
614
- if (deps.drainToken) {
615
- app.post(
616
- "/admin/reset-console-account",
617
- createConsoleAccountResetHandler({
618
- token: deps.drainToken,
619
- resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
620
- resetConsoleAccount: () => resetConsoleAuthState(deps.db),
621
- }),
622
- );
623
- }
624
-
625
- // Manual task trigger. Same gate as drain: loopback plus the drain token,
626
- // so a leaked token still cannot reach it from outside the host. 202 once
627
- // the run has started; 409 when that task is already mid-run, because the
628
- // scheduler refuses to overlap a task with itself and a second trigger
629
- // would otherwise look like it ran.
630
- if (deps.drainToken && deps.scheduledTasks) {
631
- const tasks = deps.scheduledTasks;
632
- const token = deps.drainToken;
633
- app.post("/admin/tasks/:name/run", async ({ request, params }) => {
634
- // `policy_rejected`, not `invalid_request`: the request may be perfectly
635
- // well-formed. What refused it is where it came from — the client asked
636
- // for something only the local host may ask. `invalid_request` told an
637
- // operator to fix the body, when the body was never the problem.
638
- if (!isLoopbackPeer(peerAddresses.get(request) ?? null))
639
- throw new GatewayError("policy_rejected", 403, "task trigger is only available from loopback");
640
- if (!tokenMatches(request.headers.get("x-drain-token"), token))
641
- throw new GatewayError("authentication_failed", 401, "invalid drain token");
642
- const name = params.name;
643
- if (!tasks.names().includes(name))
644
- throw new GatewayError("invalid_request", 404, `no such task: ${name}`);
645
- if (tasks.isRunning(name))
646
- throw new GatewayError("invalid_request", 409, `${name} is already running`);
647
- void tasks.runNow(name);
648
- return new Response(JSON.stringify({ task: name, status: "started" }), {
649
- status: 202,
650
- headers: { "content-type": "application/json" },
651
- });
652
- });
653
- }
654
-
655
- // `peerAddresses` is populated by the root `beforeHandle` above from
656
- // `server.requestIP`. The console login route needs it to key its lockout
657
- // bucket, and the trusted-proxy boundary decides whether an
658
- // `X-Forwarded-For` hop may override it — the same pair the transport
659
- // pipeline receives, so console and proxy agree on client identity.
660
- if (deps.consoleApi) {
661
- app.use(
662
- createConsoleRouter({
663
- ...deps.consoleApi,
664
- stateStore: requestStateStore,
665
- resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
666
- trustedProxyBoundary: deps.trustedProxyBoundary,
667
- }),
668
- );
669
- }
670
-
671
- // Public share API routes are registered before the SPA catch-all, and
672
- // enrollment uses only the peer address captured by the trusted boundary.
673
- app.use(
674
- createShareRouter({
675
- db: deps.db,
676
- shareStore: new DrizzleShareLinkStore(deps.db),
677
- stats: createShareStatsPort({ db: deps.db, redis: deps.consoleApi?.redis }),
678
- resolveClientIp: (request) => {
679
- const peer = peerAddresses.get(request);
680
- return peer
681
- ? resolveClientIdentity(request, deps.trustedProxyBoundary, peer)
682
- : null;
683
- },
684
- }),
685
- );
686
- }
687
-
688
- app.all("/", serveDashboard);
689
- app.all("/share", serveDashboard);
690
- app.all("/share/*", serveDashboard);
691
- app.all("/console", serveDashboard);
692
- app.all("/console/*", serveDashboard);
693
- // Root-level build output (hashed bundles, chapter art, provider icons,
694
- // favicons). Registered last so every real API route above wins.
695
- app.all("/*", serveRootFallback);
696
- return app;
697
- }
698
-
699
- /**
700
- * Route-only convenience wrapper. It earns its place (unlike a bare forwarder)
701
- * by defaulting `options` and injecting the discriminant, so callers never
702
- * write `mode` for the shell path.
703
- */
704
- export function createGatewayShell(options: Omit<GatewayShellDeps, "mode"> = {}) {
705
- return createGatewayApp({ mode: "route-only", ...options });
706
- }
707
-
708
- /**
709
- * Elysia route-tree type for Eden Treaty dashboard consumption.
710
- *
711
-
712
- * inferred return type.
713
- */
714
- export type App = ReturnType<typeof createGatewayApp>;
1
+ import { Elysia } from "elysia";
2
+ import { resolveDashboardDist, resolveElysiaPrecompile } from "../config/runtime";
3
+ import { createConsoleRouter, type ConsoleApiCompositionDeps } from "../console/console-router";
4
+ import type { ScheduledTaskRegistry } from "../workers/scheduler";
5
+ import { createStaticHandler } from "../console/dashboard-assets";
6
+ import { createShareRouter } from "../console/share/share-router";
7
+ import { createShareStatsPort } from "../console/share/share-stats";
8
+ import { DrizzleShareLinkStore } from "../persistence/stores/share-store";
9
+ import { chatAdapter } from "../transport/surface/chat/adapter";
10
+ import { responsesAdapter } from "../transport/surface/responses/adapter";
11
+ import { messagesAdapter } from "../transport/surface/messages/adapter";
12
+ import { completionAdapter } from "../transport/surface/completion";
13
+ import { SurfaceAdapterRegistry } from "../transport/surface/adapters";
14
+ import { GatewayError, publicGatewayErrorBody } from "../protocol/gateway-error";
15
+ import { shutdownError, shutdownNotice } from "../transport/shutdown-notice";
16
+ import type { CanonicalAdapter } from "../transport/contracts";
17
+ import type { ApiKeyAuthorizationSnapshot } from "../security/api-key-auth";
18
+ import { modelsListApiKeyError } from "../security/api-key-auth";
19
+ import { getDbHandle } from "../persistence/boot";
20
+ import { isPgHandle } from "../persistence/db-pg";
21
+ import { resolveRedisClient } from "../persistence/redis";
22
+ import type { CartethyiaDatabase } from "../persistence/db-handle";
23
+ import type { ProviderAdapter } from "../providers/provider-contracts";
24
+ import type { OAuthTokenRefresher, OAuthRefreshService } from "../providers/authentication/oauth-refresh-service";
25
+ import { withTimeout } from "./timeout";
26
+ import type { ByokUpstreamHost } from "../providers/operations/provider-catalog-service";
27
+ import type { NetworkPoolSelector } from "../network/pool/selector";
28
+ import type { ProxyRequestPreparer } from "../transport/request/preparer";
29
+ import { ProxyRequestStateStore } from "../transport/request/state";
30
+ import { fastPathname } from "../transport/pathname";
31
+ import {
32
+ handleProviderProxyRequest,
33
+ } from "../transport/dispatch/proxy-request";
34
+ import type { GatewayHandlerDeps } from "../transport/dispatch/handler-deps";
35
+ import { createResponsesCompactHandler } from "../transport/dispatch/responses-compact";
36
+ import { createSystemoneHandler } from "../transport/dispatch/systemone";
37
+ import { createWebsearchHandler } from "../transport/dispatch/websearch";
38
+ import {
39
+ createAudioSpeechHandler,
40
+ createAudioTranscriptionHandler,
41
+ createEmbeddingHandler,
42
+ createImageGenerationHandler,
43
+ createVideoGenerationHandler,
44
+ } from "../transport/dispatch/generation";
45
+ import { createTokenCountHandler } from "../transport/dispatch/token-count";
46
+
47
+ import { createTransportPipeline } from "../transport/guards/pipeline";
48
+ import { createDrainHandler, isLoopbackPeer, tokenMatches } from "../transport/drain-endpoint";
49
+ import { createConsoleAccountResetHandler } from "../console/auth/account-reset";
50
+ import { resetConsoleAuthState } from "../console/auth/account-reset-store";
51
+ import { createBackupExportHandler } from "../console/backup/export-endpoint";
52
+ import { tenants } from "../persistence/schema";
53
+ import { PublicModelCatalogStore, type AllowedModelEntry } from "../console/providers/catalog/public-model-store";
54
+ import type { IpAbuseProtectionService } from "../security/abuse";
55
+ import type { ReadinessCheckResult } from "../persistence/readiness";
56
+ import type { RouteSnapshotService } from "../transport/routing/route-model";
57
+ import type { TrustedProxyBoundary } from "../config/network";
58
+ import { resolveClientIdentity } from "../security/ip-boundary";
59
+ import type { ValidatedNetworkBindingFactory } from "../network/pool/resolver";
60
+ import { metrics } from "../observability/metrics";
61
+ import type { RuntimeMetricsSampler } from "../observability/runtime-metrics";
62
+ import { API_CONTENT_SECURITY_POLICY, X_FRAME_OPTIONS } from "../security/outbound-headers";
63
+
64
+
65
+ import type { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
66
+ import type { ShutdownCoordinator } from "./lifecycle";
67
+ import type { ShutdownDrainSource } from "../transport/contracts";
68
+
69
+ /**
70
+ * Route-only shell dependencies: the dashboard, `/health`, and `/metrics`
71
+ * without the transport pipeline. Used when no database is available (AOT
72
+ * manifest capture during `bun run build:aot`) and by tests that exercise
73
+ * routing and static serving in isolation.
74
+ *
75
+ * Only the knobs route-only mode actually reads appear here. Transport and
76
+ * console options (`db`, `resolvePeerAddress`, `trustedProxyBoundary`,
77
+ * `maxBodyBytes`, `requestDeadlineMs`, `verifiedHttps`) are deliberately
78
+ * absent: they are read inside the `mode === "production"` branches, where the
79
+ * discriminant narrows `deps` to `ProductionAppDeps`. Listing them here would
80
+ * let a caller pass a value the shell silently ignores.
81
+ */
82
+ interface GatewayShellDeps {
83
+ readonly mode: "route-only";
84
+ readonly dashboardDist?: string;
85
+ readonly checkDb?: () => Promise<void>;
86
+ readonly checkRedis?: () => Promise<void>;
87
+ readonly readiness?: () => Promise<ReadinessCheckResult>;
88
+ /**
89
+ * Drain state the readiness route reads. The shell has no coordinator, so it
90
+ * takes the narrow read surface (`ShutdownDrainSource`) rather than a
91
+ * hand-maintained copy of the whole `ShutdownCoordinator`.
92
+ */
93
+ readonly shutdownCoordinator?: ShutdownDrainSource;
94
+ /**
95
+ * Memory sampler when the caller owns one. The shell never constructs it, so
96
+ * it stays optional; production always supplies it and binds its in-flight
97
+ * source to the request store below.
98
+ */
99
+ readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
100
+ }
101
+
102
+ /**
103
+ * Full production dependencies. Every transport, pool, snapshot, telemetry,
104
+ * and console field is required — the composition root in
105
+ * `runtime/dependencies.ts` either supplies it or the app does not boot, so
106
+ * there is no runtime shape check to fall through.
107
+ */
108
+ export interface ProductionAppDeps {
109
+ readonly mode: "production";
110
+ readonly db: CartethyiaDatabase;
111
+ readonly proxyPreparer: ProxyRequestPreparer;
112
+ readonly resolveProviderAdapter: (providerId: string) => Promise<ProviderAdapter | undefined>;
113
+ /** Live lookup of a provider's SSRF-validated upstream host. */
114
+ readonly byokUpstreamHosts: { readonly get: (providerId: string) => ByokUpstreamHost | undefined };
115
+ readonly networkBindingFactory: ValidatedNetworkBindingFactory;
116
+ readonly ipAbuseProtection: IpAbuseProtectionService;
117
+ readonly trustedProxyBoundary: TrustedProxyBoundary;
118
+ readonly poolSelector: NetworkPoolSelector;
119
+ readonly snapshotService: RouteSnapshotService;
120
+ readonly readiness: () => Promise<ReadinessCheckResult>;
121
+ readonly telemetryBuffer: TelemetryBatchBuffer;
122
+ readonly resolveOAuthRefresher: (providerId: string) => Promise<OAuthTokenRefresher | undefined>;
123
+ readonly oauthRefreshService: OAuthRefreshService;
124
+ /** Graduated strikes for repeated invalid-model requests. */
125
+ /**
126
+ * The console control plane. Optional so reduced compositions (route-only
127
+ * shell, console stubs) stay valid; production always mounts it. Without
128
+ * REDIS_URL the console runs on the in-memory backend like the data plane
129
+ * (OAuth-flow state and the quota cache fall back to process memory;
130
+ * sessions live in `console_sessions` in Postgres and CSRF is stateless).
131
+ */
132
+ readonly consoleApi?: ConsoleApiCompositionDeps;
133
+ /**
134
+ * The shared maintenance scheduler. The in-flight backstop sweep registers
135
+ * here rather than owning a private timer, so every periodic task in the
136
+ * process is visible in one place. Absent in reduced compositions, where the
137
+ * backstop is not mounted.
138
+ */
139
+ readonly scheduledTasks?: ScheduledTaskRegistry;
140
+ /** Memory sampler; bound to the request store created below. */
141
+ readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
142
+ /** The real coordinator: production wires its abort hook and tracks requests. */
143
+ readonly shutdownCoordinator: ShutdownCoordinator;
144
+ readonly dashboardDist?: string;
145
+ readonly resolvePeerAddress?: (request: Request) => string | null;
146
+ readonly maxBodyBytes?: number;
147
+ readonly requestDeadlineMs?: number;
148
+ readonly verifiedHttps?: boolean;
149
+ /**
150
+ * Secret for the operator drain endpoint. When set (with `triggerDrain`),
151
+ * `POST /admin/drain` from loopback with a matching `x-drain-token` drains
152
+ * gracefully — the signal-free stop Windows needs.
153
+ */
154
+ readonly drainToken?: string;
155
+ readonly triggerDrain?: () => void;
156
+ }
157
+
158
+ /** Either app mode. The discriminant decides which builder path runs. */
159
+ type GatewayAppDeps = GatewayShellDeps | ProductionAppDeps;
160
+
161
+ function checkDatabaseConnection(): Promise<void> {
162
+ return getDbHandle()
163
+ .query("SELECT 1")
164
+ .then(() => undefined);
165
+ }
166
+
167
+ function checkRedisConnection(): Promise<void> {
168
+ // Memory backend is healthy by construction — there is no connection to
169
+ // probe — so readiness only pings a real client.
170
+ const redis = resolveRedisClient();
171
+ if (!redis) return Promise.resolve();
172
+ return redis.ping().then(() => undefined);
173
+ }
174
+
175
+ function runDependencyReadinessCheck(check: () => Promise<void>, label: string): Promise<void> {
176
+ return withTimeout(Promise.resolve().then(check), 2000, `${label} timeout after 2000ms`);
177
+ }
178
+
179
+ /**
180
+ * Builds the public HTTP composition root with route ordering guarantee.
181
+ * The one builder behind both modes: `deps.mode` selects which planes mount,
182
+ * and each mode's dependency interface admits no partial set.
183
+ */
184
+ export function createGatewayApp(deps: GatewayAppDeps) {
185
+ const dbCheck =
186
+ deps.mode === "route-only"
187
+ ? (deps.checkDb ?? checkDatabaseConnection)
188
+ : checkDatabaseConnection;
189
+ const redisCheck =
190
+ deps.mode === "route-only"
191
+ ? (deps.checkRedis ?? checkRedisConnection)
192
+ : checkRedisConnection;
193
+ const registry = new SurfaceAdapterRegistry([
194
+ chatAdapter,
195
+ responsesAdapter,
196
+ messagesAdapter,
197
+ completionAdapter,
198
+ ]);
199
+ const dashboardDist = deps.dashboardDist ?? resolveDashboardDist();
200
+ const staticHandler = createStaticHandler({ buildDir: dashboardDist });
201
+ const peerAddresses = new WeakMap<Request, string>();
202
+ const serveDashboard = async ({ request }: { request: Request }): Promise<Response> => {
203
+ const pathname = fastPathname(request.url);
204
+ // The client's `accept-encoding` is what decides whether a precompressed
205
+ // `.gz` sibling can be served instead of the plain asset.
206
+ const result = await staticHandler(pathname, request.headers.get("accept-encoding"));
207
+ return new Response(result.body ? (result.body as unknown as BodyInit) : null, {
208
+ status: result.status,
209
+ headers: result.headers,
210
+ });
211
+ };
212
+ /**
213
+ * Root catch-all. Non-document namespaces (`/v1`, `/health`, `/metrics`) keep
214
+ * their JSON 404 so an unknown API path can never be answered with the SPA
215
+ * document; everything else resolves through the static handler.
216
+ */
217
+ const API_NAMESPACES = ["/v1", "/health", "/metrics"] as const;
218
+ const serveRootFallback = async ({ request }: { request: Request }): Promise<Response> => {
219
+ const pathname = fastPathname(request.url);
220
+ const reserved = API_NAMESPACES.some(
221
+ (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
222
+ );
223
+ if (reserved) {
224
+ const notFound = new GatewayError("not_found", 404, "No endpoint exists at this path. Check the route against the API reference.");
225
+ return new Response(JSON.stringify(publicGatewayErrorBody(notFound)), {
226
+ status: notFound.status,
227
+ headers: {
228
+ "content-type": "application/json; charset=utf-8",
229
+ "cache-control": "no-store",
230
+ "content-security-policy": API_CONTENT_SECURITY_POLICY,
231
+ "x-frame-options": X_FRAME_OPTIONS,
232
+ "x-content-type-options": "nosniff",
233
+ },
234
+ });
235
+ }
236
+ return serveDashboard({ request });
237
+ };
238
+
239
+ // Build the Elysia app with strict route ordering.
240
+ const requestStateStore = new ProxyRequestStateStore(
241
+ deps.mode === "production" ? deps.shutdownCoordinator : undefined,
242
+ );
243
+ deps.runtimeMetricsSampler?.bindInFlight(() => requestStateStore.inFlightCount());
244
+ // Shutdown ordering: abort in-flight proxy controllers first so the
245
+ // bounded drain observes cancellation and finalizers run before
246
+ // telemetry flush and pool close. The reason is threaded through so the
247
+ // streaming path emits a drain-aware terminal frame (`restart_for_update`
248
+ // vs `shutting_down`) instead of a bare abort that reads as a client drop.
249
+ // Only production has a coordinator to wire; the shell never drains.
250
+ if (deps.mode === "production") {
251
+ const coordinator = deps.shutdownCoordinator;
252
+ coordinator.setAbortInflight(() =>
253
+ requestStateStore.abortAll(shutdownError(coordinator.shutdownReason())),
254
+ );
255
+ }
256
+ // Backstop: the gauge must settle even if an abort path never fires (a
257
+ // half-closed socket that neither pulls, cancels, nor aborts). This sweep
258
+ // force-releases any flight past its deadline plus a grace window, so a
259
+ // missed release cannot leave a permanently wrong number. The grace is a
260
+ // safety margin over the request deadline, not a deployment knob.
261
+ const INFLIGHT_BACKSTOP_INTERVAL_MS = 30_000;
262
+ const INFLIGHT_BACKSTOP_GRACE_MS = 30_000;
263
+ const INFLIGHT_BACKSTOP_HARD_GRACE_MS = 120_000;
264
+ // Registered in every mode, not just production. The gauge is the operator's
265
+ // concurrency signal, and a cancelled request that never ran a finalizer
266
+ // leaves its flight behind — gating the only thing that reaps those on
267
+ // production made a permanently wrong count depend on how the gateway was
268
+ // started. The registry exists in every mode, so there is no mode where
269
+ // running this is wrong.
270
+ // `scheduledTasks` is declared on the production shape only, so it is
271
+ // narrowed rather than reached through the union — a shell composition
272
+ // without the registry simply skips the backstop, exactly as before.
273
+ const backstopTasks = "scheduledTasks" in deps ? deps.scheduledTasks : undefined;
274
+ backstopTasks?.register({
275
+ name: "inflight-backstop",
276
+ intervalMs: INFLIGHT_BACKSTOP_INTERVAL_MS,
277
+ // Same stagger rationale as account-health-sweep: keep the 30s cluster
278
+ // from aligning under the single-flight gate.
279
+ initialDelayMs: 15_000,
280
+ run: () => {
281
+ requestStateStore.sweepOverdueInFlight(
282
+ Date.now(),
283
+ INFLIGHT_BACKSTOP_GRACE_MS,
284
+ INFLIGHT_BACKSTOP_HARD_GRACE_MS,
285
+ );
286
+ },
287
+ });
288
+ const app = new Elysia({ precompile: resolveElysiaPrecompile() })
289
+ .beforeHandle(
290
+ ({
291
+ request,
292
+ server,
293
+ }: {
294
+ request: Request;
295
+ server?: { requestIP(request: Request): { address: string } | null } | null;
296
+ }) => {
297
+ const address = server?.requestIP(request)?.address;
298
+ if (address) peerAddresses.set(request, address);
299
+ },
300
+ )
301
+ .get("/health", () => ({ status: "ok" as const }))
302
+ .get("/health/ready", async () => {
303
+ // A draining process reports not-ready so the orchestrator stops
304
+ // sending it traffic and the replacement takes over without the old
305
+ // one crashing first: SIGTERM → draining → 503 here → Docker routes
306
+ // to the new container → old one finishes in flight and exits 0.
307
+ // The reason distinguishes an ordinary stop from an in-place update,
308
+ // whose replacement is seconds away, so a probe consumer can back off
309
+ // briefly instead of treating the drain as a permanent outage.
310
+ if (deps.shutdownCoordinator?.isDraining()) {
311
+ const notice = shutdownNotice(deps.shutdownCoordinator.shutdownReason?.());
312
+ return new Response(JSON.stringify({ status: "not_ready" as const, reason: notice.code }), {
313
+ status: 503,
314
+ headers: { "content-type": "application/json" },
315
+ });
316
+ }
317
+ const readiness = deps.readiness ? await deps.readiness() : undefined;
318
+ const results = readiness
319
+ ? [
320
+ { status: readiness.db === "connected" ? "fulfilled" : "rejected" },
321
+ { status: readiness.redis !== "disconnected" ? "fulfilled" : "rejected" },
322
+ ]
323
+ : await Promise.allSettled([
324
+ runDependencyReadinessCheck(dbCheck, "db"),
325
+ runDependencyReadinessCheck(redisCheck, "redis"),
326
+ ]);
327
+ const dbOk = results[0]?.status === "fulfilled";
328
+ const redisOk = results[1]?.status === "fulfilled";
329
+ const migrationsOk = readiness ? readiness.migrations === "applied" : true;
330
+
331
+ if (dbOk && redisOk && migrationsOk) {
332
+ return {
333
+ status: "ready" as const,
334
+ db: "connected" as const,
335
+ };
336
+ }
337
+
338
+ return new Response(
339
+ JSON.stringify({
340
+ status: "not_ready" as const,
341
+ db: dbOk ? ("connected" as const) : ("disconnected" as const),
342
+ redis: redisOk ? ("connected" as const) : ("disconnected" as const),
343
+ ...(readiness ? { migrations: readiness.migrations } : {}),
344
+ reason: !migrationsOk
345
+ ? "database migrations pending"
346
+ : "dependency not ready",
347
+ }),
348
+ { status: 503, headers: { "content-type": "application/json" } },
349
+ );
350
+ })
351
+ .get("/metrics", () => {
352
+ const handle = getDbHandle();
353
+ // Pool gauges describe the external server's connection budget; the
354
+ // embedded backend has no pool to report.
355
+ if (isPgHandle(handle)) {
356
+ metrics.cartethyia_pg_pool_total.set(handle.pool.totalCount);
357
+ metrics.cartethyia_pg_pool_idle.set(handle.pool.idleCount);
358
+ metrics.cartethyia_pg_pool_waiting.set(handle.pool.waitingCount);
359
+ }
360
+ // No client means the memory backend, which is always up; a real client
361
+ // reports its own readiness so the scrape never throws.
362
+ const redisClient = resolveRedisClient();
363
+ metrics.cartethyia_redis_up.set(redisClient === undefined || redisClient.status === "ready" ? 1 : 0);
364
+ return new Response(metrics.render(), {
365
+ headers: { "content-type": "text/plain; version=0.0.4" },
366
+ });
367
+ });
368
+
369
+ if (deps.mode === "production") {
370
+ const transportPipeline = createTransportPipeline({
371
+ db: deps.db,
372
+ stateStore: requestStateStore,
373
+ surfaceRegistry: registry,
374
+ adapters: new Map<string, CanonicalAdapter>([
375
+ ["chat", chatAdapter],
376
+ ["responses", responsesAdapter],
377
+ ["messages", messagesAdapter],
378
+ ["completion", completionAdapter],
379
+ ]),
380
+ preparer: deps.proxyPreparer,
381
+ readiness: deps.readiness,
382
+ trustedProxyBoundary: deps.trustedProxyBoundary,
383
+ ...(deps.resolvePeerAddress ? { resolvePeerAddress: deps.resolvePeerAddress } : {}),
384
+ ipAbuseProtection: deps.ipAbuseProtection,
385
+ ...(deps.maxBodyBytes === undefined ? {} : { maxBodyBytes: deps.maxBodyBytes }),
386
+ ...(deps.requestDeadlineMs === undefined ? {} : { requestDeadlineMs: deps.requestDeadlineMs }),
387
+ ...(deps.verifiedHttps ? { verifiedHttps: true } : {}),
388
+ shutdownCoordinator: deps.shutdownCoordinator,
389
+ telemetry: deps.telemetryBuffer,
390
+ ...(deps.consoleApi?.redis ? { redis: deps.consoleApi.redis } : {}),
391
+ // Bound at construction so the guard holds the sampler's live snapshot
392
+ // rather than a value captured at boot.
393
+ ...(deps.runtimeMetricsSampler
394
+ ? { memorySnapshot: () => deps.runtimeMetricsSampler?.memorySnapshot() }
395
+ : {}),
396
+ // Reads the cached route snapshot, which the settings route invalidates
397
+ // when the switch is saved — so flipping it takes effect on the next
398
+ // request and adds no query to the proxy path.
399
+ isInvokeDisabled: async (tenantId) =>
400
+ (await deps.snapshotService.getSnapshot())?.invokeDisabledTenants?.has(tenantId),
401
+ });
402
+ transportPipeline.mountRoot(app);
403
+ // One dependency bundle for every gateway handler. Proxy, generation,
404
+ // responses-compact, system one, web search, and token count all receive
405
+ // the same wiring — a handler dep is added once, here, not in five
406
+ // literals.
407
+ const gatewayDeps: GatewayHandlerDeps = {
408
+ db: deps.db,
409
+ resolveProviderAdapter: deps.resolveProviderAdapter,
410
+ stateStore: requestStateStore,
411
+ proxyPreparer: deps.proxyPreparer,
412
+ snapshotService: deps.snapshotService,
413
+ networkBindingFactory: deps.networkBindingFactory,
414
+ poolSelector: deps.poolSelector,
415
+ telemetryBuffer: deps.telemetryBuffer,
416
+ resolveOAuthRefresher: deps.resolveOAuthRefresher,
417
+ oauthRefreshService: deps.oauthRefreshService,
418
+ };
419
+ const proxyHandler = ({ request }: { request: Request }) =>
420
+ handleProviderProxyRequest(request, {
421
+ ...gatewayDeps,
422
+ byokUpstreamHosts: deps.byokUpstreamHosts,
423
+ });
424
+
425
+ const publicModelStore = new PublicModelCatalogStore(deps.db);
426
+ const queryAllowedModels = (auth: {
427
+ snapshot: ApiKeyAuthorizationSnapshot;
428
+ tenantId: string | null;
429
+ modelPrefix?: string;
430
+ }): Promise<AllowedModelEntry[]> =>
431
+ publicModelStore.listPublicModels(auth.tenantId, auth.snapshot, auth.modelPrefix);
432
+
433
+ const handleModelsList = async ({ request }: { request: Request }): Promise<Response> => {
434
+ const state = requestStateStore.require(request);
435
+ const auth = state.authorization;
436
+ if (!auth) throw modelsListApiKeyError();
437
+ const data = await queryAllowedModels(auth);
438
+ return new Response(JSON.stringify({ object: "list", data }), {
439
+ headers: {
440
+ "content-type": "application/json",
441
+ "cache-control": "no-store",
442
+ "x-request-id": state.requestId,
443
+ "access-control-allow-origin": "*",
444
+ },
445
+ });
446
+ };
447
+
448
+ const handleModelsDetail = async ({
449
+ request,
450
+ params,
451
+ }: {
452
+ request: Request;
453
+ // Elysia omits `params` entirely for the literal `/models/info` route
454
+ // (it declares no path parameters), so this must stay optional.
455
+ params?: Record<string, string> | undefined;
456
+ }): Promise<Response> => {
457
+ const state = requestStateStore.require(request);
458
+ const auth = state.authorization;
459
+ if (!auth) throw modelsListApiKeyError();
460
+ const raw = params?.["*"] ?? params?.id ?? "";
461
+ const targetId = new URL(request.url).searchParams.get("id") ?? raw;
462
+ const found = await publicModelStore.getPublicModelDetail(
463
+ targetId,
464
+ auth.tenantId,
465
+ auth.snapshot,
466
+ auth.modelPrefix,
467
+ );
468
+ if (!found)
469
+ throw new GatewayError(
470
+ "model_not_found",
471
+ 404,
472
+ `The model '${targetId}' does not exist or you do not have access to it.`,
473
+ );
474
+ return new Response(JSON.stringify(found), {
475
+ headers: {
476
+ "content-type": "application/json",
477
+ "cache-control": "no-store",
478
+ "x-request-id": state.requestId,
479
+ "access-control-allow-origin": "*",
480
+ },
481
+ });
482
+ };
483
+
484
+ const handleResponsesCompact = createResponsesCompactHandler({
485
+ ...gatewayDeps,
486
+ // The compact handler completes its attempt through `completeAttempt`,
487
+ // which marks the request completed and finalizes telemetry itself —
488
+ // so the afterResponse lifecycle skips it. Without the buffer here the
489
+ // route would emit no telemetry row at all.
490
+ telemetryBuffer: deps.telemetryBuffer,
491
+ });
492
+
493
+ const handleSystemone = createSystemoneHandler({
494
+ ...gatewayDeps,
495
+ // Same reasoning as compact: the handler finalizes telemetry itself, so
496
+ // the buffer must be threaded here or the route emits no row.
497
+ telemetryBuffer: deps.telemetryBuffer,
498
+ });
499
+
500
+ const handleWebsearch = createWebsearchHandler(gatewayDeps);
501
+ const generationDeps = gatewayDeps;
502
+ const handleImageGeneration = createImageGenerationHandler(generationDeps);
503
+ // Mounted with no model behind it on purpose: the surface exists so it can
504
+ // be finished when an upstream is known, rather than 404ing like a typo.
505
+ const handleVideoGeneration = createVideoGenerationHandler(generationDeps);
506
+ // Passthrough surfaces: routed and accounted for, but never interpreted.
507
+ // No bundled adapter implements embeddings or audio, and inventing a wire
508
+ // for them would be a guess; what these need to be useful is the route, the
509
+ // pipeline, and a compatible upstream behind it.
510
+ const handleEmbedding = createEmbeddingHandler(generationDeps);
511
+ const handleAudioTranscription = createAudioTranscriptionHandler(generationDeps);
512
+ const handleAudioSpeech = createAudioSpeechHandler(generationDeps);
513
+ // Shares the generation dependency bundle: same pipeline, and a count is
514
+ // neither chat-shaped nor generation-shaped, so it needs exactly the same
515
+ // routing, leasing, and accounting surface.
516
+ const handleTokenCount = createTokenCountHandler(generationDeps);
517
+
518
+ // Gateway mounting: the pipeline owner composes stages, telemetry, and
519
+ // cleanup; app only registers the public route table.
520
+ app.use(
521
+ transportPipeline.createGateway((routes) => {
522
+ routes.post("/chat/completions", proxyHandler);
523
+ routes.post("/responses", proxyHandler);
524
+ routes.post("/responses/compact", handleResponsesCompact);
525
+ routes.post("/messages", proxyHandler);
526
+ // The token counter is a sibling of the chat route on the same model, and
527
+ // it is forwarded rather than estimated — see dispatch/token-count.ts.
528
+ routes.post("/messages/count_tokens", handleTokenCount);
529
+ routes.post("/completions", proxyHandler);
530
+ routes.post("/systemone", handleSystemone);
531
+ routes.post("/search", handleWebsearch);
532
+ routes.post("/images/generations", handleImageGeneration);
533
+ routes.post("/videos/generations", handleVideoGeneration);
534
+ routes.post("/embeddings", handleEmbedding);
535
+ routes.post("/audio/transcriptions", handleAudioTranscription);
536
+ routes.post("/audio/speech", handleAudioSpeech);
537
+ routes.get("/models", handleModelsList);
538
+ routes.get("/models/info", handleModelsDetail);
539
+ routes.get("/models/*", handleModelsDetail);
540
+ }),
541
+ );
542
+
543
+ // Operator drain endpoint. Registered before the SPA catch-all and only
544
+ // when a token is configured, so an unconfigured gateway has no such route
545
+ // at all. It is a graceful stop for platforms where a catchable signal
546
+ // cannot be delivered (Windows), and a second signal-free path elsewhere.
547
+ if (deps.drainToken && deps.triggerDrain) {
548
+ const drainHandler = createDrainHandler({
549
+ token: deps.drainToken,
550
+ triggerDrain: deps.triggerDrain,
551
+ resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
552
+ });
553
+ app.post("/admin/drain", drainHandler);
554
+ }
555
+
556
+ // Operator backup export. Same double gate as drain and reset, and no
557
+ // console password: the CLI already runs as the OS user who owns the
558
+ // database, and a forgotten password must not block the backup that is
559
+ // meant to rescue the install.
560
+ if (deps.drainToken) {
561
+ app.post(
562
+ "/admin/export-backup",
563
+ createBackupExportHandler({
564
+ db: deps.db,
565
+ token: deps.drainToken,
566
+ resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
567
+ resolveTenantId: async () => {
568
+ const [row] = await deps.db.select({ id: tenants.id }).from(tenants).limit(1);
569
+ return row?.id ?? null;
570
+ },
571
+ }),
572
+ );
573
+ }
574
+
575
+ // Console account reset. Same double gate as drain — loopback plus the
576
+ // token — because it deletes every operator credential on the install.
577
+ // Only authentication state goes; providers, keys, and routing stay.
578
+ if (deps.drainToken) {
579
+ app.post(
580
+ "/admin/reset-console-account",
581
+ createConsoleAccountResetHandler({
582
+ token: deps.drainToken,
583
+ resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
584
+ resetConsoleAccount: () => resetConsoleAuthState(deps.db),
585
+ }),
586
+ );
587
+ }
588
+
589
+ // Manual task trigger. Same gate as drain: loopback plus the drain token,
590
+ // so a leaked token still cannot reach it from outside the host. 202 once
591
+ // the run has started; 409 when that task is already mid-run, because the
592
+ // scheduler refuses to overlap a task with itself and a second trigger
593
+ // would otherwise look like it ran.
594
+ if (deps.drainToken && deps.scheduledTasks) {
595
+ const tasks = deps.scheduledTasks;
596
+ const token = deps.drainToken;
597
+ app.post("/admin/tasks/:name/run", async ({ request, params }) => {
598
+ // `policy_rejected`, not `invalid_request`: the request may be perfectly
599
+ // well-formed. What refused it is where it came from — the client asked
600
+ // for something only the local host may ask. `invalid_request` told an
601
+ // operator to fix the body, when the body was never the problem.
602
+ if (!isLoopbackPeer(peerAddresses.get(request) ?? null))
603
+ throw new GatewayError("policy_rejected", 403, "task trigger is only available from loopback");
604
+ if (!tokenMatches(request.headers.get("x-drain-token"), token))
605
+ throw new GatewayError("authentication_failed", 401, "invalid drain token");
606
+ const name = params.name;
607
+ if (!tasks.names().includes(name))
608
+ throw new GatewayError("invalid_request", 404, `no such task: ${name}`);
609
+ if (tasks.isRunning(name))
610
+ throw new GatewayError("invalid_request", 409, `${name} is already running`);
611
+ void tasks.runNow(name);
612
+ return new Response(JSON.stringify({ task: name, status: "started" }), {
613
+ status: 202,
614
+ headers: { "content-type": "application/json" },
615
+ });
616
+ });
617
+ }
618
+
619
+ // One share-link store for both surfaces: the public share router and the
620
+ // console's API-key/share domain wrap the same table, and one instance
621
+ // keeps the "one owner per store" convention.
622
+ const shareStore = new DrizzleShareLinkStore(deps.db);
623
+
624
+ // `peerAddresses` is populated by the root `beforeHandle` above from
625
+ // `server.requestIP`. The console login route needs it to key its lockout
626
+ // bucket, and the trusted-proxy boundary decides whether an
627
+ // `X-Forwarded-For` hop may override it — the same pair the transport
628
+ // pipeline receives, so console and proxy agree on client identity.
629
+ if (deps.consoleApi) {
630
+ app.use(
631
+ createConsoleRouter({
632
+ ...deps.consoleApi,
633
+ ...(shareStore ? { shareStore } : {}),
634
+ stateStore: requestStateStore,
635
+ resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
636
+ trustedProxyBoundary: deps.trustedProxyBoundary,
637
+ }),
638
+ );
639
+ }
640
+
641
+ // Public share API routes are registered before the SPA catch-all, and
642
+ // enrollment uses only the peer address captured by the trusted boundary.
643
+ app.use(
644
+ createShareRouter({
645
+ db: deps.db,
646
+ shareStore,
647
+ stats: createShareStatsPort({ db: deps.db, redis: deps.consoleApi?.redis }),
648
+ resolveClientIp: (request) => {
649
+ const peer = peerAddresses.get(request);
650
+ return peer
651
+ ? resolveClientIdentity(request, deps.trustedProxyBoundary, peer)
652
+ : null;
653
+ },
654
+ }),
655
+ );
656
+ }
657
+
658
+ app.all("/", serveDashboard);
659
+ app.all("/share", serveDashboard);
660
+ app.all("/share/*", serveDashboard);
661
+ app.all("/console", serveDashboard);
662
+ app.all("/console/*", serveDashboard);
663
+ // Root-level build output (hashed bundles, chapter art, provider icons,
664
+ // favicons). Registered last so every real API route above wins.
665
+ app.all("/*", serveRootFallback);
666
+ return app;
667
+ }
668
+
669
+ /**
670
+ * Route-only convenience wrapper. It earns its place (unlike a bare forwarder)
671
+ * by defaulting `options` and injecting the discriminant, so callers never
672
+ * write `mode` for the shell path.
673
+ */
674
+ export function createGatewayShell(options: Omit<GatewayShellDeps, "mode"> = {}) {
675
+ return createGatewayApp({ mode: "route-only", ...options });
676
+ }
677
+
678
+ /**
679
+ * Elysia route-tree type for Eden Treaty dashboard consumption.
680
+ *
681
+
682
+ * inferred return type.
683
+ */
684
+ export type App = ReturnType<typeof createGatewayApp>;