cartethyia 2.6.6 → 2.7.0

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