cartethyia 2.5.2 → 2.5.4

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 (277) hide show
  1. package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-C2orBsI2.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  3. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +1 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-BV6pxVQo.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-Bsan4679.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-CW-wPmJ6.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-0p8uX7vB.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  13. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js +1 -0
  14. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-BvzCx8Kr.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  17. package/dist/dashboard/assets/{ModelPicker-CWkTUtpX.js → ModelPicker-BY1aKEkP.js} +1 -1
  18. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  19. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +1 -0
  20. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  21. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +3 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js +1 -0
  24. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  25. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +7 -0
  26. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  27. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +1 -0
  28. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  29. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +1 -0
  30. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  31. package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-DDbMQsoo.js} +1 -1
  32. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  33. package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-CB3Z7VTf.js} +1 -1
  34. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  35. package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-D95UCPHI.js} +3 -3
  36. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  37. package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-Cnae9SWA.js} +13 -13
  38. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  39. package/dist/dashboard/assets/{api-keys-uNywYlfv.js → api-keys-VY68Mhd1.js} +1 -1
  40. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  41. package/dist/dashboard/assets/app-COlK95oi.css +2 -0
  42. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  43. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-Chi7pCnt.js} +1 -1
  44. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  45. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-D4pQapKj.js} +1 -1
  46. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  47. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-DH6cg-_B.js} +1 -1
  48. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  49. package/dist/dashboard/assets/{card--AtFedAq.js → card-BKqlUOe4.js} +1 -1
  50. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  51. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-fTgljTGA.js} +1 -1
  52. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  53. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-D5Ys_sM0.js} +1 -1
  54. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  55. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  56. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  57. package/dist/dashboard/assets/{dialog-BtpsJU98.js → dialog-BYj-8WuK.js} +1 -1
  58. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  59. package/dist/dashboard/assets/{drawer-WwwtzihF.js → drawer-CmXIZ6H1.js} +1 -1
  60. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  61. package/dist/dashboard/assets/entry-CBg5zeu8.js +1 -0
  62. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  63. package/dist/dashboard/assets/entry-CQBr6U9c.js +1 -0
  64. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CgdSAzEV.js +4 -0
  66. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  67. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-BlptL8Uf.js} +1 -1
  68. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  69. package/dist/dashboard/assets/{index-DCBtROoK.js → index-js6TJvdr.js} +2 -2
  70. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  71. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-DUWDSmDV.js} +1 -1
  72. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  73. package/dist/dashboard/assets/{input-BG41et9G.js → input-LVjgZq4f.js} +1 -1
  74. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  75. package/dist/dashboard/assets/{layout-C8mer_Ak.js → layout-CPfxpsfy.js} +1 -1
  76. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  77. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-DupvLfDx.js} +1 -1
  78. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  79. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-C4pEJUqI.js} +1 -1
  80. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  81. package/dist/dashboard/assets/model-bans-BRiuufkB.js +1 -0
  82. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  83. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-B4ROwk8L.js} +1 -1
  84. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  85. package/dist/dashboard/assets/numbers-YNcHvRY7.js +2 -0
  86. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  87. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-gmjzC3RS.js} +1 -1
  88. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js.gz → query-keys-gmjzC3RS.js.gz} +0 -0
  89. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-C7tBT5o-.js} +1 -1
  90. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  91. package/dist/dashboard/assets/{select-DsX5QT8K.js → select-WUCXorXw.js} +2 -2
  92. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  93. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-C_zKl1GS.js} +1 -1
  94. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  95. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-_3O0F8Xn.js} +1 -1
  96. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  97. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-BgTxHcJS.js} +1 -1
  98. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  99. package/dist/dashboard/assets/switch-DEjTtOWf.js +1 -0
  100. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  101. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  102. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-BWSC0UAS.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  105. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  106. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  107. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  108. package/dist/dashboard/index.html +1 -1
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +54 -54
  111. package/package.json +1 -1
  112. package/scripts/cli/autostart.ts +237 -0
  113. package/scripts/cli/command/console-menu.ts +244 -246
  114. package/scripts/cli/command/tray.ts +208 -204
  115. package/scripts/cli/command/update.ts +27 -10
  116. package/scripts/cli/main.ts +28 -1
  117. package/src/boot/app.ts +693 -693
  118. package/src/boot/dependencies.ts +646 -616
  119. package/src/config/spec.ts +81 -2
  120. package/src/config/telemetry.ts +46 -0
  121. package/src/console/api-keys/bucket-spend.ts +15 -4
  122. package/src/console/backup/contracts.ts +15 -2
  123. package/src/console/backup/routes.ts +52 -1
  124. package/src/console/backup/service.ts +60 -4
  125. package/src/console/console-router.ts +8 -0
  126. package/src/console/data-management/contracts.ts +93 -0
  127. package/src/console/data-management/operations.ts +229 -0
  128. package/src/console/data-management/routes.ts +104 -0
  129. package/src/console/domain-registration.ts +39 -0
  130. package/src/console/observability/contracts.ts +524 -485
  131. package/src/console/observability/store.ts +1115 -1018
  132. package/src/console/providers/catalog/contracts.ts +6 -0
  133. package/src/console/providers/catalog/provider-operations.ts +20 -0
  134. package/src/console/providers/catalog/routes.ts +7 -0
  135. package/src/console/providers/catalog/store.ts +22 -1
  136. package/src/console/quota/account-quota-shared.ts +52 -39
  137. package/src/console/quota/refresh.ts +441 -429
  138. package/src/console/routing/pools/import-parse.ts +206 -0
  139. package/src/console/routing/pools/operations.ts +543 -501
  140. package/src/console/routing/pools/routes.ts +181 -164
  141. package/src/console/settings/contracts.ts +37 -0
  142. package/src/console/settings/store.ts +51 -0
  143. package/src/console/share/share-router.ts +6 -9
  144. package/src/console/updates/routes.ts +16 -0
  145. package/src/main.ts +10 -0
  146. package/src/observability/event-compaction.ts +315 -0
  147. package/src/observability/event-day-index.ts +442 -0
  148. package/src/observability/event-log.ts +152 -3
  149. package/src/observability/payload-capture.ts +373 -344
  150. package/src/observability/payload-store.ts +440 -366
  151. package/src/observability/telemetry-status.ts +28 -16
  152. package/src/persistence/health-event-flush.ts +36 -0
  153. package/src/persistence/schema.ts +1402 -1360
  154. package/src/persistence/stores/api-key-store.ts +6 -0
  155. package/src/persistence/stores/share-store.ts +583 -556
  156. package/src/persistence/stores/telemetry-store.ts +237 -214
  157. package/src/protocol/codecs.ts +1 -2
  158. package/src/protocol/image-clamp.ts +19 -1
  159. package/src/protocol/registry.ts +8 -0
  160. package/src/protocol/request/gemini.ts +9 -2
  161. package/src/protocol/request/messages.ts +14 -2
  162. package/src/protocol/response/gemini.ts +18 -4
  163. package/src/providers/adapters/compatible-adapter.ts +3 -0
  164. package/src/providers/bundled-discovery-providers.ts +122 -119
  165. package/src/providers/configured-openai-providers.ts +136 -134
  166. package/src/providers/default-registry.ts +5 -4
  167. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  168. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  169. package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
  170. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
  171. package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
  172. package/src/providers/integrations/cline/cline-quota.ts +132 -128
  173. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  174. package/src/providers/integrations/codex/codex.ts +2 -29
  175. package/src/providers/integrations/cursor/cursor-quota.ts +310 -0
  176. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  177. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  178. package/src/providers/integrations/cursor/cursor.ts +1485 -960
  179. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  180. package/src/providers/integrations/factory/factory.ts +8 -1
  181. package/src/providers/integrations/gemini.ts +6 -1
  182. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  183. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  184. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  185. package/src/providers/integrations/opencode/provider.ts +531 -532
  186. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  187. package/src/providers/integrations/tokenharbor.ts +2 -2
  188. package/src/providers/integrations/trae/trae-catalog.ts +5 -3
  189. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  190. package/src/providers/operations/account-health-service.ts +1523 -1457
  191. package/src/providers/operations/client-versions.ts +9 -2
  192. package/src/providers/provider-contracts.ts +7 -0
  193. package/src/providers/provider-metadata.ts +13 -1
  194. package/src/providers/quota/quota-support.ts +8 -0
  195. package/src/security/api-key-auth.ts +504 -494
  196. package/src/transport/dispatch/attempt-finalize.ts +46 -5
  197. package/src/transport/dispatch/generation.ts +6 -0
  198. package/src/transport/dispatch/proxy-request.ts +33 -1
  199. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  200. package/src/transport/dispatch/streaming/stream.ts +30 -2
  201. package/src/transport/dispatch/upstream.ts +9 -0
  202. package/src/transport/request/preparer.ts +7 -6
  203. package/src/transport/request/state.ts +9 -0
  204. package/src/transport/request/tenant-preferences.ts +60 -0
  205. package/src/transport/routing/route-catalog.ts +854 -799
  206. package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
  207. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js +0 -1
  208. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
  209. package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
  210. package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
  211. package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
  212. package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
  213. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js +0 -1
  214. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
  215. package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
  216. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  217. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js +0 -1
  218. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
  219. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
  220. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
  221. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js +0 -1
  222. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
  223. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js +0 -7
  224. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
  225. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js +0 -1
  226. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
  227. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
  228. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
  229. package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
  230. package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
  231. package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
  232. package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
  233. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  234. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  235. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  236. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  237. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  238. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  239. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  240. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  241. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  242. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  243. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  244. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  245. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  246. package/dist/dashboard/assets/entry-Dj9AR0gY.js +0 -4
  247. package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
  248. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  249. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  250. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  251. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  252. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  253. package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
  254. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  255. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  256. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  257. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  258. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  259. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  260. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  261. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  262. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  263. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  264. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  265. package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
  266. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  267. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  268. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  269. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  270. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  271. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  272. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  273. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  274. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  275. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  276. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-DCQZRmHY.js} +0 -0
  277. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-DCQZRmHY.js.gz} +0 -0
package/src/boot/app.ts CHANGED
@@ -1,693 +1,693 @@
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 { unknownApiKeyError } 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
- import type { ModelStrikeService } from "../security/model-abuse";
64
-
65
-
66
- import type { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
67
- import type { ShutdownCoordinator } from "./lifecycle";
68
- import type { ShutdownDrainSource } from "../transport/contracts";
69
-
70
- /**
71
- * Route-only shell dependencies: the dashboard, `/health`, and `/metrics`
72
- * without the transport pipeline. Used when no database is available (AOT
73
- * manifest capture during `bun run build:aot`) and by tests that exercise
74
- * routing and static serving in isolation.
75
- *
76
- * Only the knobs route-only mode actually reads appear here. Transport and
77
- * console options (`db`, `resolvePeerAddress`, `trustedProxyBoundary`,
78
- * `maxBodyBytes`, `requestDeadlineMs`, `verifiedHttps`) are deliberately
79
- * absent: they are read inside the `mode === "production"` branches, where the
80
- * discriminant narrows `deps` to `ProductionAppDeps`. Listing them here would
81
- * let a caller pass a value the shell silently ignores.
82
- */
83
- export interface GatewayShellDeps {
84
- readonly mode: "route-only";
85
- readonly dashboardDist?: string;
86
- readonly checkDb?: () => Promise<void>;
87
- readonly checkRedis?: () => Promise<void>;
88
- readonly readiness?: () => Promise<ReadinessCheckResult>;
89
- /**
90
- * Drain state the readiness route reads. The shell has no coordinator, so it
91
- * takes the narrow read surface (`ShutdownDrainSource`) rather than a
92
- * hand-maintained copy of the whole `ShutdownCoordinator`.
93
- */
94
- readonly shutdownCoordinator?: ShutdownDrainSource;
95
- /**
96
- * Memory sampler when the caller owns one. The shell never constructs it, so
97
- * it stays optional; production always supplies it and binds its in-flight
98
- * source to the request store below.
99
- */
100
- readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
101
- }
102
-
103
- /**
104
- * Full production dependencies. Every transport, pool, snapshot, telemetry,
105
- * and console field is required — the composition root in
106
- * `runtime/dependencies.ts` either supplies it or the app does not boot, so
107
- * there is no runtime shape check to fall through.
108
- */
109
- export interface ProductionAppDeps {
110
- readonly mode: "production";
111
- readonly db: CartethyiaDatabase;
112
- readonly proxyPreparer: ProxyRequestPreparer;
113
- readonly resolveProviderAdapter: (providerId: string) => Promise<ProviderAdapter | undefined>;
114
- readonly providerAdapters?: ReadonlyMap<string, ProviderAdapter>;
115
- /** Live lookup of a provider's SSRF-validated upstream host. */
116
- readonly byokUpstreamHosts: { readonly get: (providerId: string) => ByokUpstreamHost | undefined };
117
- readonly networkBindingFactory: ValidatedNetworkBindingFactory;
118
- readonly ipAbuseProtection: IpAbuseProtectionService;
119
- readonly trustedProxyBoundary: TrustedProxyBoundary;
120
- readonly poolSelector: NetworkPoolSelector;
121
- readonly snapshotService: RouteSnapshotService;
122
- readonly readiness: () => Promise<ReadinessCheckResult>;
123
- readonly telemetryBuffer: TelemetryBatchBuffer;
124
- readonly resolveOAuthRefresher: (providerId: string) => Promise<OAuthTokenRefresher | undefined>;
125
- readonly oauthRefreshService: OAuthRefreshService;
126
- /** Graduated strikes for repeated invalid-model requests. */
127
- readonly modelStrikes?: ModelStrikeService;
128
- /**
129
- * The console control plane. Optional so reduced compositions (route-only
130
- * shell, console stubs) stay valid; production always mounts it. Without
131
- * REDIS_URL the console runs on the in-memory backend like the data plane
132
- * (OAuth-flow state and the quota cache fall back to process memory;
133
- * sessions live in `console_sessions` in Postgres and CSRF is stateless).
134
- */
135
- readonly consoleApi?: ConsoleApiCompositionDeps;
136
- /**
137
- * The shared maintenance scheduler. The in-flight backstop sweep registers
138
- * here rather than owning a private timer, so every periodic task in the
139
- * process is visible in one place. Absent in reduced compositions, where the
140
- * backstop is not mounted.
141
- */
142
- readonly scheduledTasks?: ScheduledTaskRegistry;
143
- /** Memory sampler; bound to the request store created below. */
144
- readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
145
- /** The real coordinator: production wires its abort hook and tracks requests. */
146
- readonly shutdownCoordinator: ShutdownCoordinator;
147
- readonly dashboardDist?: string;
148
- readonly resolvePeerAddress?: (request: Request) => string | null;
149
- readonly maxBodyBytes?: number;
150
- readonly requestDeadlineMs?: number;
151
- readonly verifiedHttps?: boolean;
152
- /**
153
- * Secret for the operator drain endpoint. When set (with `triggerDrain`),
154
- * `POST /admin/drain` from loopback with a matching `x-drain-token` drains
155
- * gracefully — the signal-free stop Windows needs.
156
- */
157
- readonly drainToken?: string;
158
- readonly triggerDrain?: () => void;
159
- }
160
-
161
- /** Either app mode. The discriminant decides which builder path runs. */
162
- export type GatewayAppDeps = GatewayShellDeps | ProductionAppDeps;
163
-
164
- function checkDatabaseConnection(): Promise<void> {
165
- return getDbHandle()
166
- .query("SELECT 1")
167
- .then(() => undefined);
168
- }
169
-
170
- function checkRedisConnection(): Promise<void> {
171
- // Memory backend is healthy by construction — there is no connection to
172
- // probe — so readiness only pings a real client.
173
- const redis = resolveRedisClient();
174
- if (!redis) return Promise.resolve();
175
- return redis.ping().then(() => undefined);
176
- }
177
-
178
- function runDependencyReadinessCheck(check: () => Promise<void>, label: string): Promise<void> {
179
- return withTimeout(Promise.resolve().then(check), 2000, `${label} timeout after 2000ms`);
180
- }
181
-
182
- /**
183
- * Builds the public HTTP composition root with route ordering guarantee.
184
- * The one builder behind both modes: `deps.mode` selects which planes mount,
185
- * and each mode's dependency interface admits no partial set.
186
- */
187
- export function createGatewayApp(deps: GatewayAppDeps) {
188
- const dbCheck =
189
- deps.mode === "route-only"
190
- ? (deps.checkDb ?? checkDatabaseConnection)
191
- : checkDatabaseConnection;
192
- const redisCheck =
193
- deps.mode === "route-only"
194
- ? (deps.checkRedis ?? checkRedisConnection)
195
- : checkRedisConnection;
196
- const registry = new SurfaceAdapterRegistry([
197
- chatAdapter,
198
- responsesAdapter,
199
- messagesAdapter,
200
- completionAdapter,
201
- ]);
202
- const dashboardDist = deps.dashboardDist ?? resolveDashboardDist();
203
- const staticHandler = createStaticHandler({ buildDir: dashboardDist });
204
- const peerAddresses = new WeakMap<Request, string>();
205
- const serveDashboard = async ({ request }: { request: Request }): Promise<Response> => {
206
- const pathname = fastPathname(request.url);
207
- // The client's `accept-encoding` is what decides whether a precompressed
208
- // `.gz` sibling can be served instead of the plain asset.
209
- const result = await staticHandler(pathname, request.headers.get("accept-encoding"));
210
- return new Response(result.body ? (result.body as unknown as BodyInit) : null, {
211
- status: result.status,
212
- headers: result.headers,
213
- });
214
- };
215
- /**
216
- * Root catch-all. Non-document namespaces (`/v1`, `/health`, `/metrics`) keep
217
- * their JSON 404 so an unknown API path can never be answered with the SPA
218
- * document; everything else resolves through the static handler.
219
- */
220
- const API_NAMESPACES = ["/v1", "/health", "/metrics"] as const;
221
- const serveRootFallback = async ({ request }: { request: Request }): Promise<Response> => {
222
- const pathname = fastPathname(request.url);
223
- const reserved = API_NAMESPACES.some(
224
- (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
225
- );
226
- if (reserved) {
227
- const notFound = new GatewayError("not_found", 404, "No endpoint exists at this path. Check the route against the API reference.");
228
- return new Response(JSON.stringify(publicGatewayErrorBody(notFound)), {
229
- status: notFound.status,
230
- headers: {
231
- "content-type": "application/json; charset=utf-8",
232
- "cache-control": "no-store",
233
- "content-security-policy": API_CONTENT_SECURITY_POLICY,
234
- "x-frame-options": X_FRAME_OPTIONS,
235
- "x-content-type-options": "nosniff",
236
- },
237
- });
238
- }
239
- return serveDashboard({ request });
240
- };
241
-
242
- // Build the Elysia app with strict route ordering.
243
- const requestStateStore = new ProxyRequestStateStore(
244
- deps.mode === "production" ? deps.shutdownCoordinator : undefined,
245
- );
246
- deps.runtimeMetricsSampler?.bindInFlight(() => requestStateStore.inFlightCount());
247
- // Shutdown ordering: abort in-flight proxy controllers first so the
248
- // bounded drain observes cancellation and finalizers run before
249
- // telemetry flush and pool close. The reason is threaded through so the
250
- // streaming path emits a drain-aware terminal frame (`restart_for_update`
251
- // vs `shutting_down`) instead of a bare abort that reads as a client drop.
252
- // Only production has a coordinator to wire; the shell never drains.
253
- if (deps.mode === "production") {
254
- const coordinator = deps.shutdownCoordinator;
255
- coordinator.setAbortInflight(() =>
256
- requestStateStore.abortAll(shutdownError(coordinator.shutdownReason())),
257
- );
258
- }
259
- // Backstop: the gauge must settle even if an abort path never fires (a
260
- // half-closed socket that neither pulls, cancels, nor aborts). This sweep
261
- // force-releases any flight past its deadline plus a grace window, so a
262
- // missed release cannot leave a permanently wrong number. The grace is a
263
- // safety margin over the request deadline, not a deployment knob.
264
- const INFLIGHT_BACKSTOP_INTERVAL_MS = 30_000;
265
- const INFLIGHT_BACKSTOP_GRACE_MS = 30_000;
266
- const INFLIGHT_BACKSTOP_HARD_GRACE_MS = 120_000;
267
- if (deps.mode === "production") deps.scheduledTasks?.register({
268
- name: "inflight-backstop",
269
- intervalMs: INFLIGHT_BACKSTOP_INTERVAL_MS,
270
- run: () => {
271
- requestStateStore.sweepOverdueInFlight(
272
- Date.now(),
273
- INFLIGHT_BACKSTOP_GRACE_MS,
274
- INFLIGHT_BACKSTOP_HARD_GRACE_MS,
275
- );
276
- },
277
- });
278
- const app = new Elysia({ precompile: resolveElysiaPrecompile() })
279
- .beforeHandle(
280
- ({
281
- request,
282
- server,
283
- }: {
284
- request: Request;
285
- server?: { requestIP(request: Request): { address: string } | null } | null;
286
- }) => {
287
- const address = server?.requestIP(request)?.address;
288
- if (address) peerAddresses.set(request, address);
289
- },
290
- )
291
- .get("/health", () => ({ status: "ok" as const }))
292
- .get("/health/ready", async () => {
293
- // A draining process reports not-ready so the orchestrator stops
294
- // sending it traffic and the replacement takes over without the old
295
- // one crashing first: SIGTERM → draining → 503 here → Docker routes
296
- // to the new container → old one finishes in flight and exits 0.
297
- // The reason distinguishes an ordinary stop from an in-place update,
298
- // whose replacement is seconds away, so a probe consumer can back off
299
- // briefly instead of treating the drain as a permanent outage.
300
- if (deps.shutdownCoordinator?.isDraining()) {
301
- const notice = shutdownNotice(deps.shutdownCoordinator.shutdownReason?.());
302
- return new Response(JSON.stringify({ status: "not_ready" as const, reason: notice.code }), {
303
- status: 503,
304
- headers: { "content-type": "application/json" },
305
- });
306
- }
307
- const readiness = deps.readiness ? await deps.readiness() : undefined;
308
- const results = readiness
309
- ? [
310
- { status: readiness.db === "connected" ? "fulfilled" : "rejected" },
311
- { status: readiness.redis !== "disconnected" ? "fulfilled" : "rejected" },
312
- ]
313
- : await Promise.allSettled([
314
- runDependencyReadinessCheck(dbCheck, "db"),
315
- runDependencyReadinessCheck(redisCheck, "redis"),
316
- ]);
317
- const dbOk = results[0]?.status === "fulfilled";
318
- const redisOk = results[1]?.status === "fulfilled";
319
- const migrationsOk = readiness ? readiness.migrations === "applied" : true;
320
-
321
- if (dbOk && redisOk && migrationsOk) {
322
- return {
323
- status: "ready" as const,
324
- db: "connected" as const,
325
- };
326
- }
327
-
328
- return new Response(
329
- JSON.stringify({
330
- status: "not_ready" as const,
331
- db: dbOk ? ("connected" as const) : ("disconnected" as const),
332
- redis: redisOk ? ("connected" as const) : ("disconnected" as const),
333
- ...(readiness ? { migrations: readiness.migrations } : {}),
334
- reason: !migrationsOk
335
- ? "database migrations pending"
336
- : "dependency not ready",
337
- }),
338
- { status: 503, headers: { "content-type": "application/json" } },
339
- );
340
- })
341
- .get("/metrics", () => {
342
- const handle = getDbHandle();
343
- // Pool gauges describe the external server's connection budget; the
344
- // embedded backend has no pool to report.
345
- if (isPgHandle(handle)) {
346
- metrics.cartethyia_pg_pool_total.set(handle.pool.totalCount);
347
- metrics.cartethyia_pg_pool_idle.set(handle.pool.idleCount);
348
- metrics.cartethyia_pg_pool_waiting.set(handle.pool.waitingCount);
349
- }
350
- // No client means the memory backend, which is always up; a real client
351
- // reports its own readiness so the scrape never throws.
352
- const redisClient = resolveRedisClient();
353
- metrics.cartethyia_redis_up.set(redisClient === undefined || redisClient.status === "ready" ? 1 : 0);
354
- return new Response(metrics.render(), {
355
- headers: { "content-type": "text/plain; version=0.0.4" },
356
- });
357
- });
358
-
359
- if (deps.mode === "production") {
360
- const transportPipeline = createTransportPipeline({
361
- db: deps.db,
362
- stateStore: requestStateStore,
363
- surfaceRegistry: registry,
364
- adapters: new Map<string, CanonicalAdapter>([
365
- ["chat", chatAdapter],
366
- ["responses", responsesAdapter],
367
- ["messages", messagesAdapter],
368
- ["completion", completionAdapter],
369
- ]),
370
- preparer: deps.proxyPreparer,
371
- readiness: deps.readiness,
372
- trustedProxyBoundary: deps.trustedProxyBoundary,
373
- ...(deps.resolvePeerAddress ? { resolvePeerAddress: deps.resolvePeerAddress } : {}),
374
- ipAbuseProtection: deps.ipAbuseProtection,
375
- ...(deps.maxBodyBytes === undefined ? {} : { maxBodyBytes: deps.maxBodyBytes }),
376
- ...(deps.requestDeadlineMs === undefined ? {} : { requestDeadlineMs: deps.requestDeadlineMs }),
377
- ...(deps.verifiedHttps ? { verifiedHttps: true } : {}),
378
- shutdownCoordinator: deps.shutdownCoordinator,
379
- telemetry: deps.telemetryBuffer,
380
- ...(deps.modelStrikes ? { modelStrikes: deps.modelStrikes } : {}),
381
- ...(deps.consoleApi?.redis ? { redis: deps.consoleApi.redis } : {}),
382
- });
383
- transportPipeline.mountRoot(app);
384
- const proxyDeps: ProviderProxyHandlerDeps = {
385
- db: deps.db,
386
- providerAdapters: deps.providerAdapters ?? new Map(),
387
- resolveProviderAdapter: deps.resolveProviderAdapter,
388
- stateStore: requestStateStore,
389
- proxyPreparer: deps.proxyPreparer,
390
- snapshotService: deps.snapshotService,
391
- networkBindingFactory: deps.networkBindingFactory,
392
- byokUpstreamHosts: deps.byokUpstreamHosts,
393
- poolSelector: deps.poolSelector,
394
- telemetryBuffer: deps.telemetryBuffer,
395
- resolveOAuthRefresher: deps.resolveOAuthRefresher,
396
- oauthRefreshService: deps.oauthRefreshService,
397
- };
398
- const proxyHandler = ({ request }: { request: Request }) =>
399
- handleProviderProxyRequest(request, proxyDeps);
400
-
401
- const publicModelStore = new PublicModelCatalogStore(deps.db);
402
- const queryAllowedModels = (auth: {
403
- snapshot: ApiKeyAuthorizationSnapshot;
404
- tenantId: string | null;
405
- modelPrefix?: string;
406
- }): Promise<AllowedModelEntry[]> =>
407
- publicModelStore.listPublicModels(auth.tenantId, auth.snapshot, auth.modelPrefix);
408
-
409
- const handleModelsList = async ({ request }: { request: Request }): Promise<Response> => {
410
- const state = requestStateStore.require(request);
411
- const auth = state.authorization;
412
- if (!auth) throw unknownApiKeyError();
413
- const data = await queryAllowedModels(auth);
414
- return new Response(JSON.stringify({ object: "list", data }), {
415
- headers: {
416
- "content-type": "application/json",
417
- "cache-control": "no-store",
418
- "x-request-id": state.requestId,
419
- "access-control-allow-origin": "*",
420
- },
421
- });
422
- };
423
-
424
- const handleModelsDetail = async ({
425
- request,
426
- params,
427
- }: {
428
- request: Request;
429
- // Elysia omits `params` entirely for the literal `/models/info` route
430
- // (it declares no path parameters), so this must stay optional.
431
- params?: Record<string, string> | undefined;
432
- }): Promise<Response> => {
433
- const state = requestStateStore.require(request);
434
- const auth = state.authorization;
435
- if (!auth) throw unknownApiKeyError();
436
- const raw = params?.["*"] ?? params?.id ?? "";
437
- const targetId = new URL(request.url).searchParams.get("id") ?? raw;
438
- const found = await publicModelStore.getPublicModelDetail(
439
- targetId,
440
- auth.tenantId,
441
- auth.snapshot,
442
- auth.modelPrefix,
443
- );
444
- if (!found)
445
- throw new GatewayError(
446
- "model_not_found",
447
- 404,
448
- `The model '${targetId}' does not exist or you do not have access to it.`,
449
- );
450
- return new Response(JSON.stringify(found), {
451
- headers: {
452
- "content-type": "application/json",
453
- "cache-control": "no-store",
454
- "x-request-id": state.requestId,
455
- "access-control-allow-origin": "*",
456
- },
457
- });
458
- };
459
-
460
- const handleResponsesCompact = createResponsesCompactHandler({
461
- db: deps.db,
462
- providerAdapters: deps.providerAdapters ?? new Map(),
463
- resolveProviderAdapter: deps.resolveProviderAdapter,
464
- proxyPreparer: deps.proxyPreparer,
465
- stateStore: requestStateStore,
466
- poolSelector: deps.poolSelector,
467
- networkBindingFactory: deps.networkBindingFactory,
468
- snapshotService: deps.snapshotService,
469
- resolveOAuthRefresher: deps.resolveOAuthRefresher,
470
- oauthRefreshService: deps.oauthRefreshService,
471
- // The compact handler completes its attempt through `completeAttempt`,
472
- // which marks the request completed and finalizes telemetry itself —
473
- // so the afterResponse lifecycle skips it. Without the buffer here the
474
- // route would emit no telemetry row at all.
475
- telemetryBuffer: deps.telemetryBuffer,
476
- });
477
-
478
- const handleSystemone = createSystemoneHandler({
479
- db: deps.db,
480
- providerAdapters: deps.providerAdapters ?? new Map(),
481
- resolveProviderAdapter: deps.resolveProviderAdapter,
482
- proxyPreparer: deps.proxyPreparer,
483
- stateStore: requestStateStore,
484
- poolSelector: deps.poolSelector,
485
- networkBindingFactory: deps.networkBindingFactory,
486
- snapshotService: deps.snapshotService,
487
- resolveOAuthRefresher: deps.resolveOAuthRefresher,
488
- oauthRefreshService: deps.oauthRefreshService,
489
- // Same reasoning as compact: the handler finalizes telemetry itself, so
490
- // the buffer must be threaded here or the route emits no row.
491
- telemetryBuffer: deps.telemetryBuffer,
492
- });
493
-
494
- const handleWebsearch = createWebsearchHandler({
495
- db: deps.db,
496
- providerAdapters: deps.providerAdapters ?? new Map(),
497
- resolveProviderAdapter: deps.resolveProviderAdapter,
498
- proxyPreparer: deps.proxyPreparer,
499
- stateStore: requestStateStore,
500
- poolSelector: deps.poolSelector,
501
- networkBindingFactory: deps.networkBindingFactory,
502
- snapshotService: deps.snapshotService,
503
- resolveOAuthRefresher: deps.resolveOAuthRefresher,
504
- oauthRefreshService: deps.oauthRefreshService,
505
- telemetryBuffer: deps.telemetryBuffer,
506
- });
507
-
508
- const generationDeps = {
509
- db: deps.db,
510
- providerAdapters: deps.providerAdapters ?? new Map(),
511
- resolveProviderAdapter: deps.resolveProviderAdapter,
512
- proxyPreparer: deps.proxyPreparer,
513
- stateStore: requestStateStore,
514
- poolSelector: deps.poolSelector,
515
- networkBindingFactory: deps.networkBindingFactory,
516
- snapshotService: deps.snapshotService,
517
- resolveOAuthRefresher: deps.resolveOAuthRefresher,
518
- oauthRefreshService: deps.oauthRefreshService,
519
- telemetryBuffer: deps.telemetryBuffer,
520
- };
521
- const handleImageGeneration = createImageGenerationHandler(generationDeps);
522
- // Mounted with no model behind it on purpose: the surface exists so it can
523
- // be finished when an upstream is known, rather than 404ing like a typo.
524
- const handleVideoGeneration = createVideoGenerationHandler(generationDeps);
525
- // Passthrough surfaces: routed and accounted for, but never interpreted.
526
- // No bundled adapter implements embeddings or audio, and inventing a wire
527
- // for them would be a guess; what these need to be useful is the route, the
528
- // pipeline, and a compatible upstream behind it.
529
- const handleEmbedding = createEmbeddingHandler(generationDeps);
530
- const handleAudioTranscription = createAudioTranscriptionHandler(generationDeps);
531
- const handleAudioSpeech = createAudioSpeechHandler(generationDeps);
532
- // Shares the generation dependency bundle: same pipeline, and a count is
533
- // neither chat-shaped nor generation-shaped, so it needs exactly the same
534
- // routing, leasing, and accounting surface.
535
- const handleTokenCount = createTokenCountHandler(generationDeps);
536
-
537
- // Gateway mounting: the pipeline owner composes stages, telemetry, and
538
- // cleanup; app only registers the public route table.
539
- app.use(
540
- transportPipeline.createGateway((routes) => {
541
- routes.post("/chat/completions", proxyHandler);
542
- routes.post("/responses", proxyHandler);
543
- routes.post("/responses/compact", handleResponsesCompact);
544
- routes.post("/messages", proxyHandler);
545
- // The token counter is a sibling of the chat route on the same model, and
546
- // it is forwarded rather than estimated — see dispatch/token-count.ts.
547
- routes.post("/messages/count_tokens", handleTokenCount);
548
- routes.post("/completions", proxyHandler);
549
- routes.post("/systemone", handleSystemone);
550
- routes.post("/search", handleWebsearch);
551
- routes.post("/images/generations", handleImageGeneration);
552
- routes.post("/videos/generations", handleVideoGeneration);
553
- routes.post("/embeddings", handleEmbedding);
554
- routes.post("/audio/transcriptions", handleAudioTranscription);
555
- routes.post("/audio/speech", handleAudioSpeech);
556
- routes.get("/models", handleModelsList);
557
- routes.get("/models/info", handleModelsDetail);
558
- routes.get("/models/*", handleModelsDetail);
559
- }),
560
- );
561
-
562
- // Operator drain endpoint. Registered before the SPA catch-all and only
563
- // when a token is configured, so an unconfigured gateway has no such route
564
- // at all. It is a graceful stop for platforms where a catchable signal
565
- // cannot be delivered (Windows), and a second signal-free path elsewhere.
566
- if (deps.drainToken && deps.triggerDrain) {
567
- const drainHandler = createDrainHandler({
568
- token: deps.drainToken,
569
- triggerDrain: deps.triggerDrain,
570
- resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
571
- });
572
- app.post("/admin/drain", drainHandler);
573
- }
574
-
575
- // Operator backup export. Same double gate as drain and reset, and no
576
- // console password: the CLI already runs as the OS user who owns the
577
- // database, and a forgotten password must not block the backup that is
578
- // meant to rescue the install.
579
- if (deps.drainToken) {
580
- app.post(
581
- "/admin/export-backup",
582
- createBackupExportHandler({
583
- db: deps.db,
584
- token: deps.drainToken,
585
- resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
586
- resolveTenantId: async () => {
587
- const [row] = await deps.db.select({ id: tenants.id }).from(tenants).limit(1);
588
- return row?.id ?? null;
589
- },
590
- }),
591
- );
592
- }
593
-
594
- // Console account reset. Same double gate as drain — loopback plus the
595
- // token — because it deletes every operator credential on the install.
596
- // Only authentication state goes; providers, keys, and routing stay.
597
- if (deps.drainToken) {
598
- app.post(
599
- "/admin/reset-console-account",
600
- createConsoleAccountResetHandler({
601
- token: deps.drainToken,
602
- resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
603
- resetConsoleAccount: () => resetConsoleAuthState(deps.db),
604
- }),
605
- );
606
- }
607
-
608
- // Manual task trigger. Same gate as drain: loopback plus the drain token,
609
- // so a leaked token still cannot reach it from outside the host. 202 once
610
- // the run has started; 409 when that task is already mid-run, because the
611
- // scheduler refuses to overlap a task with itself and a second trigger
612
- // would otherwise look like it ran.
613
- if (deps.drainToken && deps.scheduledTasks) {
614
- const tasks = deps.scheduledTasks;
615
- const token = deps.drainToken;
616
- app.post("/admin/tasks/:name/run", async ({ request, params }) => {
617
- if (!isLoopbackPeer(peerAddresses.get(request) ?? null))
618
- throw new GatewayError("invalid_request", 403, "task trigger is only available from loopback");
619
- if (!tokenMatches(request.headers.get("x-drain-token"), token))
620
- throw new GatewayError("invalid_request", 401, "invalid drain token");
621
- const name = params.name;
622
- if (!tasks.names().includes(name))
623
- throw new GatewayError("invalid_request", 404, `no such task: ${name}`);
624
- if (tasks.isRunning(name))
625
- throw new GatewayError("invalid_request", 409, `${name} is already running`);
626
- void tasks.runNow(name);
627
- return new Response(JSON.stringify({ task: name, status: "started" }), {
628
- status: 202,
629
- headers: { "content-type": "application/json" },
630
- });
631
- });
632
- }
633
-
634
- // `peerAddresses` is populated by the root `beforeHandle` above from
635
- // `server.requestIP`. The console login route needs it to key its lockout
636
- // bucket, and the trusted-proxy boundary decides whether an
637
- // `X-Forwarded-For` hop may override it — the same pair the transport
638
- // pipeline receives, so console and proxy agree on client identity.
639
- if (deps.consoleApi) {
640
- app.use(
641
- createConsoleRouter({
642
- ...deps.consoleApi,
643
- stateStore: requestStateStore,
644
- resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
645
- trustedProxyBoundary: deps.trustedProxyBoundary,
646
- }),
647
- );
648
- }
649
-
650
- // Public share API routes are registered before the SPA catch-all, and
651
- // enrollment uses only the peer address captured by the trusted boundary.
652
- app.use(
653
- createShareRouter({
654
- db: deps.db,
655
- shareStore: new DrizzleShareLinkStore(deps.db),
656
- stats: createShareStatsPort({ redis: deps.consoleApi?.redis }),
657
- resolveClientIp: (request) => {
658
- const peer = peerAddresses.get(request);
659
- return peer
660
- ? resolveClientIdentity(request, deps.trustedProxyBoundary, peer)
661
- : null;
662
- },
663
- }),
664
- );
665
- }
666
-
667
- app.all("/", serveDashboard);
668
- app.all("/share", serveDashboard);
669
- app.all("/share/*", serveDashboard);
670
- app.all("/console", serveDashboard);
671
- app.all("/console/*", serveDashboard);
672
- // Root-level build output (hashed bundles, chapter art, provider icons,
673
- // favicons). Registered last so every real API route above wins.
674
- app.all("/*", serveRootFallback);
675
- return app;
676
- }
677
-
678
- /**
679
- * Route-only convenience wrapper. It earns its place (unlike a bare forwarder)
680
- * by defaulting `options` and injecting the discriminant, so callers never
681
- * write `mode` for the shell path.
682
- */
683
- export function createGatewayShell(options: Omit<GatewayShellDeps, "mode"> = {}) {
684
- return createGatewayApp({ mode: "route-only", ...options });
685
- }
686
-
687
- /**
688
- * Elysia route-tree type for Eden Treaty dashboard consumption.
689
- *
690
-
691
- * inferred return type.
692
- */
693
- 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
+ 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
+ import type { ModelStrikeService } from "../security/model-abuse";
64
+
65
+
66
+ import type { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
67
+ import type { ShutdownCoordinator } from "./lifecycle";
68
+ import type { ShutdownDrainSource } from "../transport/contracts";
69
+
70
+ /**
71
+ * Route-only shell dependencies: the dashboard, `/health`, and `/metrics`
72
+ * without the transport pipeline. Used when no database is available (AOT
73
+ * manifest capture during `bun run build:aot`) and by tests that exercise
74
+ * routing and static serving in isolation.
75
+ *
76
+ * Only the knobs route-only mode actually reads appear here. Transport and
77
+ * console options (`db`, `resolvePeerAddress`, `trustedProxyBoundary`,
78
+ * `maxBodyBytes`, `requestDeadlineMs`, `verifiedHttps`) are deliberately
79
+ * absent: they are read inside the `mode === "production"` branches, where the
80
+ * discriminant narrows `deps` to `ProductionAppDeps`. Listing them here would
81
+ * let a caller pass a value the shell silently ignores.
82
+ */
83
+ export interface GatewayShellDeps {
84
+ readonly mode: "route-only";
85
+ readonly dashboardDist?: string;
86
+ readonly checkDb?: () => Promise<void>;
87
+ readonly checkRedis?: () => Promise<void>;
88
+ readonly readiness?: () => Promise<ReadinessCheckResult>;
89
+ /**
90
+ * Drain state the readiness route reads. The shell has no coordinator, so it
91
+ * takes the narrow read surface (`ShutdownDrainSource`) rather than a
92
+ * hand-maintained copy of the whole `ShutdownCoordinator`.
93
+ */
94
+ readonly shutdownCoordinator?: ShutdownDrainSource;
95
+ /**
96
+ * Memory sampler when the caller owns one. The shell never constructs it, so
97
+ * it stays optional; production always supplies it and binds its in-flight
98
+ * source to the request store below.
99
+ */
100
+ readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
101
+ }
102
+
103
+ /**
104
+ * Full production dependencies. Every transport, pool, snapshot, telemetry,
105
+ * and console field is required — the composition root in
106
+ * `runtime/dependencies.ts` either supplies it or the app does not boot, so
107
+ * there is no runtime shape check to fall through.
108
+ */
109
+ export interface ProductionAppDeps {
110
+ readonly mode: "production";
111
+ readonly db: CartethyiaDatabase;
112
+ readonly proxyPreparer: ProxyRequestPreparer;
113
+ readonly resolveProviderAdapter: (providerId: string) => Promise<ProviderAdapter | undefined>;
114
+ readonly providerAdapters?: ReadonlyMap<string, ProviderAdapter>;
115
+ /** Live lookup of a provider's SSRF-validated upstream host. */
116
+ readonly byokUpstreamHosts: { readonly get: (providerId: string) => ByokUpstreamHost | undefined };
117
+ readonly networkBindingFactory: ValidatedNetworkBindingFactory;
118
+ readonly ipAbuseProtection: IpAbuseProtectionService;
119
+ readonly trustedProxyBoundary: TrustedProxyBoundary;
120
+ readonly poolSelector: NetworkPoolSelector;
121
+ readonly snapshotService: RouteSnapshotService;
122
+ readonly readiness: () => Promise<ReadinessCheckResult>;
123
+ readonly telemetryBuffer: TelemetryBatchBuffer;
124
+ readonly resolveOAuthRefresher: (providerId: string) => Promise<OAuthTokenRefresher | undefined>;
125
+ readonly oauthRefreshService: OAuthRefreshService;
126
+ /** Graduated strikes for repeated invalid-model requests. */
127
+ readonly modelStrikes?: ModelStrikeService;
128
+ /**
129
+ * The console control plane. Optional so reduced compositions (route-only
130
+ * shell, console stubs) stay valid; production always mounts it. Without
131
+ * REDIS_URL the console runs on the in-memory backend like the data plane
132
+ * (OAuth-flow state and the quota cache fall back to process memory;
133
+ * sessions live in `console_sessions` in Postgres and CSRF is stateless).
134
+ */
135
+ readonly consoleApi?: ConsoleApiCompositionDeps;
136
+ /**
137
+ * The shared maintenance scheduler. The in-flight backstop sweep registers
138
+ * here rather than owning a private timer, so every periodic task in the
139
+ * process is visible in one place. Absent in reduced compositions, where the
140
+ * backstop is not mounted.
141
+ */
142
+ readonly scheduledTasks?: ScheduledTaskRegistry;
143
+ /** Memory sampler; bound to the request store created below. */
144
+ readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
145
+ /** The real coordinator: production wires its abort hook and tracks requests. */
146
+ readonly shutdownCoordinator: ShutdownCoordinator;
147
+ readonly dashboardDist?: string;
148
+ readonly resolvePeerAddress?: (request: Request) => string | null;
149
+ readonly maxBodyBytes?: number;
150
+ readonly requestDeadlineMs?: number;
151
+ readonly verifiedHttps?: boolean;
152
+ /**
153
+ * Secret for the operator drain endpoint. When set (with `triggerDrain`),
154
+ * `POST /admin/drain` from loopback with a matching `x-drain-token` drains
155
+ * gracefully — the signal-free stop Windows needs.
156
+ */
157
+ readonly drainToken?: string;
158
+ readonly triggerDrain?: () => void;
159
+ }
160
+
161
+ /** Either app mode. The discriminant decides which builder path runs. */
162
+ export type GatewayAppDeps = GatewayShellDeps | ProductionAppDeps;
163
+
164
+ function checkDatabaseConnection(): Promise<void> {
165
+ return getDbHandle()
166
+ .query("SELECT 1")
167
+ .then(() => undefined);
168
+ }
169
+
170
+ function checkRedisConnection(): Promise<void> {
171
+ // Memory backend is healthy by construction — there is no connection to
172
+ // probe — so readiness only pings a real client.
173
+ const redis = resolveRedisClient();
174
+ if (!redis) return Promise.resolve();
175
+ return redis.ping().then(() => undefined);
176
+ }
177
+
178
+ function runDependencyReadinessCheck(check: () => Promise<void>, label: string): Promise<void> {
179
+ return withTimeout(Promise.resolve().then(check), 2000, `${label} timeout after 2000ms`);
180
+ }
181
+
182
+ /**
183
+ * Builds the public HTTP composition root with route ordering guarantee.
184
+ * The one builder behind both modes: `deps.mode` selects which planes mount,
185
+ * and each mode's dependency interface admits no partial set.
186
+ */
187
+ export function createGatewayApp(deps: GatewayAppDeps) {
188
+ const dbCheck =
189
+ deps.mode === "route-only"
190
+ ? (deps.checkDb ?? checkDatabaseConnection)
191
+ : checkDatabaseConnection;
192
+ const redisCheck =
193
+ deps.mode === "route-only"
194
+ ? (deps.checkRedis ?? checkRedisConnection)
195
+ : checkRedisConnection;
196
+ const registry = new SurfaceAdapterRegistry([
197
+ chatAdapter,
198
+ responsesAdapter,
199
+ messagesAdapter,
200
+ completionAdapter,
201
+ ]);
202
+ const dashboardDist = deps.dashboardDist ?? resolveDashboardDist();
203
+ const staticHandler = createStaticHandler({ buildDir: dashboardDist });
204
+ const peerAddresses = new WeakMap<Request, string>();
205
+ const serveDashboard = async ({ request }: { request: Request }): Promise<Response> => {
206
+ const pathname = fastPathname(request.url);
207
+ // The client's `accept-encoding` is what decides whether a precompressed
208
+ // `.gz` sibling can be served instead of the plain asset.
209
+ const result = await staticHandler(pathname, request.headers.get("accept-encoding"));
210
+ return new Response(result.body ? (result.body as unknown as BodyInit) : null, {
211
+ status: result.status,
212
+ headers: result.headers,
213
+ });
214
+ };
215
+ /**
216
+ * Root catch-all. Non-document namespaces (`/v1`, `/health`, `/metrics`) keep
217
+ * their JSON 404 so an unknown API path can never be answered with the SPA
218
+ * document; everything else resolves through the static handler.
219
+ */
220
+ const API_NAMESPACES = ["/v1", "/health", "/metrics"] as const;
221
+ const serveRootFallback = async ({ request }: { request: Request }): Promise<Response> => {
222
+ const pathname = fastPathname(request.url);
223
+ const reserved = API_NAMESPACES.some(
224
+ (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
225
+ );
226
+ if (reserved) {
227
+ const notFound = new GatewayError("not_found", 404, "No endpoint exists at this path. Check the route against the API reference.");
228
+ return new Response(JSON.stringify(publicGatewayErrorBody(notFound)), {
229
+ status: notFound.status,
230
+ headers: {
231
+ "content-type": "application/json; charset=utf-8",
232
+ "cache-control": "no-store",
233
+ "content-security-policy": API_CONTENT_SECURITY_POLICY,
234
+ "x-frame-options": X_FRAME_OPTIONS,
235
+ "x-content-type-options": "nosniff",
236
+ },
237
+ });
238
+ }
239
+ return serveDashboard({ request });
240
+ };
241
+
242
+ // Build the Elysia app with strict route ordering.
243
+ const requestStateStore = new ProxyRequestStateStore(
244
+ deps.mode === "production" ? deps.shutdownCoordinator : undefined,
245
+ );
246
+ deps.runtimeMetricsSampler?.bindInFlight(() => requestStateStore.inFlightCount());
247
+ // Shutdown ordering: abort in-flight proxy controllers first so the
248
+ // bounded drain observes cancellation and finalizers run before
249
+ // telemetry flush and pool close. The reason is threaded through so the
250
+ // streaming path emits a drain-aware terminal frame (`restart_for_update`
251
+ // vs `shutting_down`) instead of a bare abort that reads as a client drop.
252
+ // Only production has a coordinator to wire; the shell never drains.
253
+ if (deps.mode === "production") {
254
+ const coordinator = deps.shutdownCoordinator;
255
+ coordinator.setAbortInflight(() =>
256
+ requestStateStore.abortAll(shutdownError(coordinator.shutdownReason())),
257
+ );
258
+ }
259
+ // Backstop: the gauge must settle even if an abort path never fires (a
260
+ // half-closed socket that neither pulls, cancels, nor aborts). This sweep
261
+ // force-releases any flight past its deadline plus a grace window, so a
262
+ // missed release cannot leave a permanently wrong number. The grace is a
263
+ // safety margin over the request deadline, not a deployment knob.
264
+ const INFLIGHT_BACKSTOP_INTERVAL_MS = 30_000;
265
+ const INFLIGHT_BACKSTOP_GRACE_MS = 30_000;
266
+ const INFLIGHT_BACKSTOP_HARD_GRACE_MS = 120_000;
267
+ if (deps.mode === "production") deps.scheduledTasks?.register({
268
+ name: "inflight-backstop",
269
+ intervalMs: INFLIGHT_BACKSTOP_INTERVAL_MS,
270
+ run: () => {
271
+ requestStateStore.sweepOverdueInFlight(
272
+ Date.now(),
273
+ INFLIGHT_BACKSTOP_GRACE_MS,
274
+ INFLIGHT_BACKSTOP_HARD_GRACE_MS,
275
+ );
276
+ },
277
+ });
278
+ const app = new Elysia({ precompile: resolveElysiaPrecompile() })
279
+ .beforeHandle(
280
+ ({
281
+ request,
282
+ server,
283
+ }: {
284
+ request: Request;
285
+ server?: { requestIP(request: Request): { address: string } | null } | null;
286
+ }) => {
287
+ const address = server?.requestIP(request)?.address;
288
+ if (address) peerAddresses.set(request, address);
289
+ },
290
+ )
291
+ .get("/health", () => ({ status: "ok" as const }))
292
+ .get("/health/ready", async () => {
293
+ // A draining process reports not-ready so the orchestrator stops
294
+ // sending it traffic and the replacement takes over without the old
295
+ // one crashing first: SIGTERM → draining → 503 here → Docker routes
296
+ // to the new container → old one finishes in flight and exits 0.
297
+ // The reason distinguishes an ordinary stop from an in-place update,
298
+ // whose replacement is seconds away, so a probe consumer can back off
299
+ // briefly instead of treating the drain as a permanent outage.
300
+ if (deps.shutdownCoordinator?.isDraining()) {
301
+ const notice = shutdownNotice(deps.shutdownCoordinator.shutdownReason?.());
302
+ return new Response(JSON.stringify({ status: "not_ready" as const, reason: notice.code }), {
303
+ status: 503,
304
+ headers: { "content-type": "application/json" },
305
+ });
306
+ }
307
+ const readiness = deps.readiness ? await deps.readiness() : undefined;
308
+ const results = readiness
309
+ ? [
310
+ { status: readiness.db === "connected" ? "fulfilled" : "rejected" },
311
+ { status: readiness.redis !== "disconnected" ? "fulfilled" : "rejected" },
312
+ ]
313
+ : await Promise.allSettled([
314
+ runDependencyReadinessCheck(dbCheck, "db"),
315
+ runDependencyReadinessCheck(redisCheck, "redis"),
316
+ ]);
317
+ const dbOk = results[0]?.status === "fulfilled";
318
+ const redisOk = results[1]?.status === "fulfilled";
319
+ const migrationsOk = readiness ? readiness.migrations === "applied" : true;
320
+
321
+ if (dbOk && redisOk && migrationsOk) {
322
+ return {
323
+ status: "ready" as const,
324
+ db: "connected" as const,
325
+ };
326
+ }
327
+
328
+ return new Response(
329
+ JSON.stringify({
330
+ status: "not_ready" as const,
331
+ db: dbOk ? ("connected" as const) : ("disconnected" as const),
332
+ redis: redisOk ? ("connected" as const) : ("disconnected" as const),
333
+ ...(readiness ? { migrations: readiness.migrations } : {}),
334
+ reason: !migrationsOk
335
+ ? "database migrations pending"
336
+ : "dependency not ready",
337
+ }),
338
+ { status: 503, headers: { "content-type": "application/json" } },
339
+ );
340
+ })
341
+ .get("/metrics", () => {
342
+ const handle = getDbHandle();
343
+ // Pool gauges describe the external server's connection budget; the
344
+ // embedded backend has no pool to report.
345
+ if (isPgHandle(handle)) {
346
+ metrics.cartethyia_pg_pool_total.set(handle.pool.totalCount);
347
+ metrics.cartethyia_pg_pool_idle.set(handle.pool.idleCount);
348
+ metrics.cartethyia_pg_pool_waiting.set(handle.pool.waitingCount);
349
+ }
350
+ // No client means the memory backend, which is always up; a real client
351
+ // reports its own readiness so the scrape never throws.
352
+ const redisClient = resolveRedisClient();
353
+ metrics.cartethyia_redis_up.set(redisClient === undefined || redisClient.status === "ready" ? 1 : 0);
354
+ return new Response(metrics.render(), {
355
+ headers: { "content-type": "text/plain; version=0.0.4" },
356
+ });
357
+ });
358
+
359
+ if (deps.mode === "production") {
360
+ const transportPipeline = createTransportPipeline({
361
+ db: deps.db,
362
+ stateStore: requestStateStore,
363
+ surfaceRegistry: registry,
364
+ adapters: new Map<string, CanonicalAdapter>([
365
+ ["chat", chatAdapter],
366
+ ["responses", responsesAdapter],
367
+ ["messages", messagesAdapter],
368
+ ["completion", completionAdapter],
369
+ ]),
370
+ preparer: deps.proxyPreparer,
371
+ readiness: deps.readiness,
372
+ trustedProxyBoundary: deps.trustedProxyBoundary,
373
+ ...(deps.resolvePeerAddress ? { resolvePeerAddress: deps.resolvePeerAddress } : {}),
374
+ ipAbuseProtection: deps.ipAbuseProtection,
375
+ ...(deps.maxBodyBytes === undefined ? {} : { maxBodyBytes: deps.maxBodyBytes }),
376
+ ...(deps.requestDeadlineMs === undefined ? {} : { requestDeadlineMs: deps.requestDeadlineMs }),
377
+ ...(deps.verifiedHttps ? { verifiedHttps: true } : {}),
378
+ shutdownCoordinator: deps.shutdownCoordinator,
379
+ telemetry: deps.telemetryBuffer,
380
+ ...(deps.modelStrikes ? { modelStrikes: deps.modelStrikes } : {}),
381
+ ...(deps.consoleApi?.redis ? { redis: deps.consoleApi.redis } : {}),
382
+ });
383
+ transportPipeline.mountRoot(app);
384
+ const proxyDeps: ProviderProxyHandlerDeps = {
385
+ db: deps.db,
386
+ providerAdapters: deps.providerAdapters ?? new Map(),
387
+ resolveProviderAdapter: deps.resolveProviderAdapter,
388
+ stateStore: requestStateStore,
389
+ proxyPreparer: deps.proxyPreparer,
390
+ snapshotService: deps.snapshotService,
391
+ networkBindingFactory: deps.networkBindingFactory,
392
+ byokUpstreamHosts: deps.byokUpstreamHosts,
393
+ poolSelector: deps.poolSelector,
394
+ telemetryBuffer: deps.telemetryBuffer,
395
+ resolveOAuthRefresher: deps.resolveOAuthRefresher,
396
+ oauthRefreshService: deps.oauthRefreshService,
397
+ };
398
+ const proxyHandler = ({ request }: { request: Request }) =>
399
+ handleProviderProxyRequest(request, proxyDeps);
400
+
401
+ const publicModelStore = new PublicModelCatalogStore(deps.db);
402
+ const queryAllowedModels = (auth: {
403
+ snapshot: ApiKeyAuthorizationSnapshot;
404
+ tenantId: string | null;
405
+ modelPrefix?: string;
406
+ }): Promise<AllowedModelEntry[]> =>
407
+ publicModelStore.listPublicModels(auth.tenantId, auth.snapshot, auth.modelPrefix);
408
+
409
+ const handleModelsList = async ({ request }: { request: Request }): Promise<Response> => {
410
+ const state = requestStateStore.require(request);
411
+ const auth = state.authorization;
412
+ if (!auth) throw modelsListApiKeyError();
413
+ const data = await queryAllowedModels(auth);
414
+ return new Response(JSON.stringify({ object: "list", data }), {
415
+ headers: {
416
+ "content-type": "application/json",
417
+ "cache-control": "no-store",
418
+ "x-request-id": state.requestId,
419
+ "access-control-allow-origin": "*",
420
+ },
421
+ });
422
+ };
423
+
424
+ const handleModelsDetail = async ({
425
+ request,
426
+ params,
427
+ }: {
428
+ request: Request;
429
+ // Elysia omits `params` entirely for the literal `/models/info` route
430
+ // (it declares no path parameters), so this must stay optional.
431
+ params?: Record<string, string> | undefined;
432
+ }): Promise<Response> => {
433
+ const state = requestStateStore.require(request);
434
+ const auth = state.authorization;
435
+ if (!auth) throw modelsListApiKeyError();
436
+ const raw = params?.["*"] ?? params?.id ?? "";
437
+ const targetId = new URL(request.url).searchParams.get("id") ?? raw;
438
+ const found = await publicModelStore.getPublicModelDetail(
439
+ targetId,
440
+ auth.tenantId,
441
+ auth.snapshot,
442
+ auth.modelPrefix,
443
+ );
444
+ if (!found)
445
+ throw new GatewayError(
446
+ "model_not_found",
447
+ 404,
448
+ `The model '${targetId}' does not exist or you do not have access to it.`,
449
+ );
450
+ return new Response(JSON.stringify(found), {
451
+ headers: {
452
+ "content-type": "application/json",
453
+ "cache-control": "no-store",
454
+ "x-request-id": state.requestId,
455
+ "access-control-allow-origin": "*",
456
+ },
457
+ });
458
+ };
459
+
460
+ const handleResponsesCompact = createResponsesCompactHandler({
461
+ db: deps.db,
462
+ providerAdapters: deps.providerAdapters ?? new Map(),
463
+ resolveProviderAdapter: deps.resolveProviderAdapter,
464
+ proxyPreparer: deps.proxyPreparer,
465
+ stateStore: requestStateStore,
466
+ poolSelector: deps.poolSelector,
467
+ networkBindingFactory: deps.networkBindingFactory,
468
+ snapshotService: deps.snapshotService,
469
+ resolveOAuthRefresher: deps.resolveOAuthRefresher,
470
+ oauthRefreshService: deps.oauthRefreshService,
471
+ // The compact handler completes its attempt through `completeAttempt`,
472
+ // which marks the request completed and finalizes telemetry itself —
473
+ // so the afterResponse lifecycle skips it. Without the buffer here the
474
+ // route would emit no telemetry row at all.
475
+ telemetryBuffer: deps.telemetryBuffer,
476
+ });
477
+
478
+ const handleSystemone = createSystemoneHandler({
479
+ db: deps.db,
480
+ providerAdapters: deps.providerAdapters ?? new Map(),
481
+ resolveProviderAdapter: deps.resolveProviderAdapter,
482
+ proxyPreparer: deps.proxyPreparer,
483
+ stateStore: requestStateStore,
484
+ poolSelector: deps.poolSelector,
485
+ networkBindingFactory: deps.networkBindingFactory,
486
+ snapshotService: deps.snapshotService,
487
+ resolveOAuthRefresher: deps.resolveOAuthRefresher,
488
+ oauthRefreshService: deps.oauthRefreshService,
489
+ // Same reasoning as compact: the handler finalizes telemetry itself, so
490
+ // the buffer must be threaded here or the route emits no row.
491
+ telemetryBuffer: deps.telemetryBuffer,
492
+ });
493
+
494
+ const handleWebsearch = createWebsearchHandler({
495
+ db: deps.db,
496
+ providerAdapters: deps.providerAdapters ?? new Map(),
497
+ resolveProviderAdapter: deps.resolveProviderAdapter,
498
+ proxyPreparer: deps.proxyPreparer,
499
+ stateStore: requestStateStore,
500
+ poolSelector: deps.poolSelector,
501
+ networkBindingFactory: deps.networkBindingFactory,
502
+ snapshotService: deps.snapshotService,
503
+ resolveOAuthRefresher: deps.resolveOAuthRefresher,
504
+ oauthRefreshService: deps.oauthRefreshService,
505
+ telemetryBuffer: deps.telemetryBuffer,
506
+ });
507
+
508
+ const generationDeps = {
509
+ db: deps.db,
510
+ providerAdapters: deps.providerAdapters ?? new Map(),
511
+ resolveProviderAdapter: deps.resolveProviderAdapter,
512
+ proxyPreparer: deps.proxyPreparer,
513
+ stateStore: requestStateStore,
514
+ poolSelector: deps.poolSelector,
515
+ networkBindingFactory: deps.networkBindingFactory,
516
+ snapshotService: deps.snapshotService,
517
+ resolveOAuthRefresher: deps.resolveOAuthRefresher,
518
+ oauthRefreshService: deps.oauthRefreshService,
519
+ telemetryBuffer: deps.telemetryBuffer,
520
+ };
521
+ const handleImageGeneration = createImageGenerationHandler(generationDeps);
522
+ // Mounted with no model behind it on purpose: the surface exists so it can
523
+ // be finished when an upstream is known, rather than 404ing like a typo.
524
+ const handleVideoGeneration = createVideoGenerationHandler(generationDeps);
525
+ // Passthrough surfaces: routed and accounted for, but never interpreted.
526
+ // No bundled adapter implements embeddings or audio, and inventing a wire
527
+ // for them would be a guess; what these need to be useful is the route, the
528
+ // pipeline, and a compatible upstream behind it.
529
+ const handleEmbedding = createEmbeddingHandler(generationDeps);
530
+ const handleAudioTranscription = createAudioTranscriptionHandler(generationDeps);
531
+ const handleAudioSpeech = createAudioSpeechHandler(generationDeps);
532
+ // Shares the generation dependency bundle: same pipeline, and a count is
533
+ // neither chat-shaped nor generation-shaped, so it needs exactly the same
534
+ // routing, leasing, and accounting surface.
535
+ const handleTokenCount = createTokenCountHandler(generationDeps);
536
+
537
+ // Gateway mounting: the pipeline owner composes stages, telemetry, and
538
+ // cleanup; app only registers the public route table.
539
+ app.use(
540
+ transportPipeline.createGateway((routes) => {
541
+ routes.post("/chat/completions", proxyHandler);
542
+ routes.post("/responses", proxyHandler);
543
+ routes.post("/responses/compact", handleResponsesCompact);
544
+ routes.post("/messages", proxyHandler);
545
+ // The token counter is a sibling of the chat route on the same model, and
546
+ // it is forwarded rather than estimated — see dispatch/token-count.ts.
547
+ routes.post("/messages/count_tokens", handleTokenCount);
548
+ routes.post("/completions", proxyHandler);
549
+ routes.post("/systemone", handleSystemone);
550
+ routes.post("/search", handleWebsearch);
551
+ routes.post("/images/generations", handleImageGeneration);
552
+ routes.post("/videos/generations", handleVideoGeneration);
553
+ routes.post("/embeddings", handleEmbedding);
554
+ routes.post("/audio/transcriptions", handleAudioTranscription);
555
+ routes.post("/audio/speech", handleAudioSpeech);
556
+ routes.get("/models", handleModelsList);
557
+ routes.get("/models/info", handleModelsDetail);
558
+ routes.get("/models/*", handleModelsDetail);
559
+ }),
560
+ );
561
+
562
+ // Operator drain endpoint. Registered before the SPA catch-all and only
563
+ // when a token is configured, so an unconfigured gateway has no such route
564
+ // at all. It is a graceful stop for platforms where a catchable signal
565
+ // cannot be delivered (Windows), and a second signal-free path elsewhere.
566
+ if (deps.drainToken && deps.triggerDrain) {
567
+ const drainHandler = createDrainHandler({
568
+ token: deps.drainToken,
569
+ triggerDrain: deps.triggerDrain,
570
+ resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
571
+ });
572
+ app.post("/admin/drain", drainHandler);
573
+ }
574
+
575
+ // Operator backup export. Same double gate as drain and reset, and no
576
+ // console password: the CLI already runs as the OS user who owns the
577
+ // database, and a forgotten password must not block the backup that is
578
+ // meant to rescue the install.
579
+ if (deps.drainToken) {
580
+ app.post(
581
+ "/admin/export-backup",
582
+ createBackupExportHandler({
583
+ db: deps.db,
584
+ token: deps.drainToken,
585
+ resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
586
+ resolveTenantId: async () => {
587
+ const [row] = await deps.db.select({ id: tenants.id }).from(tenants).limit(1);
588
+ return row?.id ?? null;
589
+ },
590
+ }),
591
+ );
592
+ }
593
+
594
+ // Console account reset. Same double gate as drain — loopback plus the
595
+ // token — because it deletes every operator credential on the install.
596
+ // Only authentication state goes; providers, keys, and routing stay.
597
+ if (deps.drainToken) {
598
+ app.post(
599
+ "/admin/reset-console-account",
600
+ createConsoleAccountResetHandler({
601
+ token: deps.drainToken,
602
+ resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
603
+ resetConsoleAccount: () => resetConsoleAuthState(deps.db),
604
+ }),
605
+ );
606
+ }
607
+
608
+ // Manual task trigger. Same gate as drain: loopback plus the drain token,
609
+ // so a leaked token still cannot reach it from outside the host. 202 once
610
+ // the run has started; 409 when that task is already mid-run, because the
611
+ // scheduler refuses to overlap a task with itself and a second trigger
612
+ // would otherwise look like it ran.
613
+ if (deps.drainToken && deps.scheduledTasks) {
614
+ const tasks = deps.scheduledTasks;
615
+ const token = deps.drainToken;
616
+ app.post("/admin/tasks/:name/run", async ({ request, params }) => {
617
+ if (!isLoopbackPeer(peerAddresses.get(request) ?? null))
618
+ throw new GatewayError("invalid_request", 403, "task trigger is only available from loopback");
619
+ if (!tokenMatches(request.headers.get("x-drain-token"), token))
620
+ throw new GatewayError("invalid_request", 401, "invalid drain token");
621
+ const name = params.name;
622
+ if (!tasks.names().includes(name))
623
+ throw new GatewayError("invalid_request", 404, `no such task: ${name}`);
624
+ if (tasks.isRunning(name))
625
+ throw new GatewayError("invalid_request", 409, `${name} is already running`);
626
+ void tasks.runNow(name);
627
+ return new Response(JSON.stringify({ task: name, status: "started" }), {
628
+ status: 202,
629
+ headers: { "content-type": "application/json" },
630
+ });
631
+ });
632
+ }
633
+
634
+ // `peerAddresses` is populated by the root `beforeHandle` above from
635
+ // `server.requestIP`. The console login route needs it to key its lockout
636
+ // bucket, and the trusted-proxy boundary decides whether an
637
+ // `X-Forwarded-For` hop may override it — the same pair the transport
638
+ // pipeline receives, so console and proxy agree on client identity.
639
+ if (deps.consoleApi) {
640
+ app.use(
641
+ createConsoleRouter({
642
+ ...deps.consoleApi,
643
+ stateStore: requestStateStore,
644
+ resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
645
+ trustedProxyBoundary: deps.trustedProxyBoundary,
646
+ }),
647
+ );
648
+ }
649
+
650
+ // Public share API routes are registered before the SPA catch-all, and
651
+ // enrollment uses only the peer address captured by the trusted boundary.
652
+ app.use(
653
+ createShareRouter({
654
+ db: deps.db,
655
+ shareStore: new DrizzleShareLinkStore(deps.db),
656
+ stats: createShareStatsPort({ redis: deps.consoleApi?.redis }),
657
+ resolveClientIp: (request) => {
658
+ const peer = peerAddresses.get(request);
659
+ return peer
660
+ ? resolveClientIdentity(request, deps.trustedProxyBoundary, peer)
661
+ : null;
662
+ },
663
+ }),
664
+ );
665
+ }
666
+
667
+ app.all("/", serveDashboard);
668
+ app.all("/share", serveDashboard);
669
+ app.all("/share/*", serveDashboard);
670
+ app.all("/console", serveDashboard);
671
+ app.all("/console/*", serveDashboard);
672
+ // Root-level build output (hashed bundles, chapter art, provider icons,
673
+ // favicons). Registered last so every real API route above wins.
674
+ app.all("/*", serveRootFallback);
675
+ return app;
676
+ }
677
+
678
+ /**
679
+ * Route-only convenience wrapper. It earns its place (unlike a bare forwarder)
680
+ * by defaulting `options` and injecting the discriminant, so callers never
681
+ * write `mode` for the shell path.
682
+ */
683
+ export function createGatewayShell(options: Omit<GatewayShellDeps, "mode"> = {}) {
684
+ return createGatewayApp({ mode: "route-only", ...options });
685
+ }
686
+
687
+ /**
688
+ * Elysia route-tree type for Eden Treaty dashboard consumption.
689
+ *
690
+
691
+ * inferred return type.
692
+ */
693
+ export type App = ReturnType<typeof createGatewayApp>;