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
@@ -1,750 +1,729 @@
1
-
2
- import { assertPoolFitsServerCapacity, bootDatabase, ensureMigrated } from "../persistence/boot";
3
- import { poolMaxFromEnv } from "../persistence/db-pg";
4
- import type { CartethyiaDatabase } from "../persistence/db-handle";
5
- import { resolveRedisBackend, resolveRedisClient } from "../persistence/redis";
6
- import { resolveProcessTopology, type ProcessTopology } from "../config/runtime";
7
- import { createRedisMaintenanceLeaseStore } from "../workers/maintenance-lease";
8
- import { resolveDbMode } from "../persistence/db-mode";
9
- import type { RedisClient } from "../persistence/redis";
10
- import {
11
- bundledModelCatalog,
12
- liveProviderUpstreamHosts,
13
- markCatalogSeeded,
14
- refreshActiveProviderCatalogs,
15
- registerByokProviders,
16
- retireUnbundledProviders,
17
- seedBundledProviders,
18
- } from "../providers/operations/provider-catalog-service";
19
- import type { BundledProviderCatalog } from "../providers/operations/provider-catalog-service";
20
- import { createDefaultProviderRegistry } from "../providers/default-registry";
21
- import { OAuthRefreshService, loadDueOAuthAccounts, reconcileStaticTokenAccounts } from "../providers/authentication/oauth-refresh-service";
22
- import type { OAuthTokenRefresher } from "../providers/authentication/oauth-refresh-service";
23
- import { oauthRefreshSweep } from "../workers/oauth-refresh-sweep";
24
- import { accountIdentitySweep } from "../workers/account-identity-sweep";
25
- import { pushStructuredConsoleLog } from "../observability/log-ring";
26
- import { seedBundledModels } from "../providers/operations/provider-catalog-seeder";
27
- import { createDatabaseSnapshotBuilder } from "../transport/routing/route-catalog";
28
- import { DrizzleProviderCatalogStore } from "../console/providers/catalog/store";
29
- import { InMemoryRouteSnapshotService } from "../transport/routing/route-model";
30
- import { RoutingEngine } from "../transport/routing/router";
31
- import { RedisAdmissionController } from "../transport/routing/admission";
32
- import { ApiKeyAdmissionService } from "../security/admission/service";
33
- import { InMemoryAdmissionCounterStore } from "../security/admission/in-memory-store";
34
- import { RedisAdmissionCounterStore } from "../security/admission/redis-store";
35
- import { sweepLeases } from "../security/admission/lease-sweep";
36
- import { DrizzleApiKeyStore } from "../persistence/stores/api-key-store";
37
- import { UsageFlushBuffer } from "../persistence/usage-flush";
38
- import { setPendingUsageSource } from "../security/api-key-auth";
39
- import { HealthEventFlushBuffer, setHealthEventBuffer } from "../persistence/health-event-flush";
40
- import { subscribePeerSettingsRevisions } from "../persistence/settings-revision";
41
- import { deleteExpiredSessions } from "../console/auth/session-service";
42
- import { InMemoryIpAbuseStore, IpAbuseProtectionService, RedisIpAbuseStore } from "../security/abuse";
43
- import {
44
- InMemoryModelAbuseStore,
45
- ModelStrikeService,
46
- RedisModelAbuseStore,
47
- } from "../security/model-abuse";
48
- import { checkReadiness } from "../persistence/readiness";
49
- import { ProxyRequestPreparer } from "../transport/request/preparer";
50
- import { DrizzleNetworkPoolLoader } from "../network/pool/loader";
51
- import {
52
- PoolAgentResolver,
53
- ValidatedNetworkBindingFactory,
54
- } from "../network/pool/resolver";
55
- import { NetworkPoolSelector } from "../network/pool/selector";
56
- import { ScheduledTaskRegistry } from "../workers/scheduler";
57
- import { quotaRefreshSweep } from "../workers/quota-refresh-sweep";
58
- import { markProviderApiKeyCredential } from "../console/quota/account-quota-shared";
59
- import { dailyCheckinEgressForPass } from "../workers/daily-checkin";
60
- import {
61
- buddyAdoptTask,
62
- buddyActivityReportTask,
63
- buddyLotteryTask,
64
- buddyRedeemTask,
65
- buddyStreakTask,
66
- buddyTravelTask,
67
- buddyTrialTask,
68
- syncBuddyUsage,
69
- } from "../workers/buddy-growth-tasks";
70
- import { createAccountSecretResolver } from "../providers/operations/provider-credential-service";
71
- import { quotaCacheSize } from "../console/quota/cache";
72
- import { modelCatalogCacheSize } from "../providers/operations/model-catalog-cache";
73
- import { preferencesReaderFor } from "../persistence/tenant-preferences";
74
- import { sweepExpiredCooldowns } from "../providers/operations/account-health-service";
75
- import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
76
- import { TelemetryPayloadCapture } from "../observability/payload-capture";
77
- import { sweepExpiredPoolCooldowns } from "../network/pool-health-machine";
78
- import { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
79
- import { RuntimeMetricsSampler } from "../observability/runtime-metrics";
80
- import {
81
- resolveIpRateLimit,
82
- resolveModelBanMaxTtlMs,
83
- resolveModelBanTtlMs,
84
- resolveModelStrikeThreshold,
85
- resolveModelStrikeWindowMs,
86
- } from "../config/security";
87
- import { resolveSsrfPolicy, resolveTrustedProxyBoundary } from "../config/network";
88
- import type { TrustedProxyBoundary } from "../config/network";
89
- import { resolveTelemetryRetentionDays } from "../config/telemetry";
90
- import { resolveConcurrencyTolerance } from "../config/resources";
91
- import { CONFIG_SPEC, readInt, readList } from "../config/spec";
92
- import type { ProviderAdapter } from "../providers/provider-contracts";
93
- import type { ProviderRegistry } from "../providers/provider-registry";
94
- import type { ByokUpstreamHost } from "../providers/operations/provider-catalog-service";
95
- import type { ReadinessCheckResult } from "../persistence/readiness";
96
- import { log } from "../observability/logger";
97
- import { refreshProviderClientVersions } from "../providers/operations/client-versions";
98
-
99
-
100
- export interface ProductionDeps {
101
- db: CartethyiaDatabase;
102
- redis: RedisClient | undefined;
103
- snapshotService: InMemoryRouteSnapshotService;
104
- /** Live routing admission snapshot scoped to one provider/tenant for console reads. */
105
- readRoutingAccountInflight: (
106
- providerId: string,
107
- tenantId: string | null,
108
- ) => Promise<readonly { accountId: string; inflight: number }[]>;
109
- /** On-demand adapter resolution keeps heavy providers out of the boot path. */
110
- resolveProviderAdapter: (providerId: string) => Promise<ProviderAdapter | undefined>;
111
- bundledModelCatalog: BundledProviderCatalog;
112
- /** Live lookup of a provider's SSRF-validated upstream host. */
113
- byokUpstreamHosts: { readonly get: (providerId: string) => ByokUpstreamHost | undefined };
114
- proxyPreparer: ProxyRequestPreparer;
115
- networkBindingFactory: ValidatedNetworkBindingFactory;
116
- ipAbuseProtection: IpAbuseProtectionService;
117
- readiness: () => Promise<ReadinessCheckResult>;
118
- scheduledTasks: ScheduledTaskRegistry;
119
- poolAgentResolver: PoolAgentResolver;
120
- poolSelector: NetworkPoolSelector;
121
- telemetryBuffer: TelemetryBatchBuffer;
122
- usageFlush: UsageFlushBuffer;
123
- healthEventFlush: HealthEventFlushBuffer;
124
- trustedProxyBoundary: TrustedProxyBoundary;
125
- providerRegistry: ProviderRegistry;
126
- resolveOAuthRefresher: (providerId: string) => Promise<OAuthTokenRefresher | undefined>;
127
- oauthRefreshService: OAuthRefreshService;
128
- admissionService: ApiKeyAdmissionService;
129
- /** Graduated strikes for repeated invalid-model requests. */
130
- modelStrikes: ModelStrikeService;
131
- /** Memory sampler; in-flight source is bound once the request store exists. */
132
- runtimeMetricsSampler: RuntimeMetricsSampler;
133
- }
134
-
135
- /**
136
- * Pause between accounts in the OAuth refresh sweep. The token endpoints
137
- * rate-limit a burst of refreshes even at low concurrency.
138
- */
139
- const OAUTH_REFRESH_INTER_ITEM_DELAY_MS = 1_500;
140
-
141
- /**
142
- * Client identities are refreshed out of band so provider dispatch never waits
143
- * on npm/provider version endpoints. Resolver TTLs deduplicate the sources.
144
- */
145
- // Matched to the resolver's own 30 min cache TTL: a shorter interval makes
146
- // every second pass a pure cache read that cannot discover anything new.
147
- const CLIENT_VERSION_MONITOR_INTERVAL_MS = 30 * 60_000;
148
- const CLIENT_VERSION_INITIAL_DELAY_MS = 10_000;
149
-
150
- /** Bound on the boot-time Redis probe: a start must fail, not hang. */
151
- const REDIS_PROBE_TIMEOUT_MS = 2_000;
152
-
153
- async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
154
- const { promise: timeout, reject } = Promise.withResolvers<never>();
155
- const timer = setTimeout(() => reject(new Error("timeout")), ms);
156
- try {
157
- return await Promise.race([promise, timeout]);
158
- } finally {
159
- clearTimeout(timer);
160
- }
161
- }
162
-
163
- /** Bounded Redis reachability probe: fails rather than hangs. */
164
- async function redisIsReachable(): Promise<boolean> {
165
- const client = resolveRedisClient();
166
- if (!client) return false;
167
- try {
168
- const result = await withTimeout(client.ping(), REDIS_PROBE_TIMEOUT_MS);
169
- return result === "PONG";
170
- } catch {
171
- return false;
172
- }
173
- }
174
-
175
- /**
176
- * Refuses to start a multi-process deployment whose shared state is not
177
- * actually shared.
178
- *
179
- * Every process shares one port via `SO_REUSEPORT`, so the kernel balances
180
- * across them — and each process keeps its own in-memory admission counters,
181
- * quota cache, and cooldown map. Those are only correct when they live in
182
- * Redis. Starting anyway does not fail loudly; it enforces every per-key limit
183
- * N times over, which then reads as "the limit did not work".
184
- *
185
- * Redis is checked by PING with a hard bound, not merely for being configured:
186
- * a configured-but-unreachable Redis is the failure mode that matters, and an
187
- * unbounded connect at boot would hang the start instead of failing it.
188
- */
189
- async function assertMultiProcessPreconditions(
190
- topology: ProcessTopology,
191
- redisReachable: () => Promise<boolean>,
192
- ): Promise<void> {
193
- if (!topology.multiProcess) return;
194
- if (process.platform !== "linux") {
195
- throw Object.assign(
196
- new Error(
197
- `CARTETHYIA_PROCESS_COUNT=${topology.count} requires Linux (SO_REUSEPORT); ` +
198
- `this process is on ${process.platform}`,
199
- ),
200
- { code: "multi_process_unsupported_platform" },
201
- );
202
- }
203
- if (resolveDbMode() !== "full") {
204
- throw Object.assign(
205
- new Error(
206
- `CARTETHYIA_PROCESS_COUNT=${topology.count} requires CARTETHYIA_DB_MODE=full (PostgreSQL); ` +
207
- "two processes on one SQLite file corrupt it — there is no cross-process lock in the driver",
208
- ),
209
- { code: "multi_process_requires_postgres" },
210
- );
211
- }
212
- const reachable = await redisReachable();
213
- if (!reachable) {
214
- throw Object.assign(
215
- new Error(
216
- `CARTETHYIA_PROCESS_COUNT=${topology.count} requires a REACHABLE Redis: admission counters, ` +
217
- "quotas, and cooldowns are per-process without one, so every per-key limit would be " +
218
- `enforced ${topology.count} times over`,
219
- ),
220
- { code: "multi_process_requires_redis" },
221
- );
222
- }
223
- }
224
-
225
- export async function buildProductionDeps(): Promise<ProductionDeps> {
226
- const startupStartedAt = performance.now();
227
- const startupMemoryBefore = process.memoryUsage();
228
- // Resolved first and validated before any connection is opened: a
229
- // half-configured multi-process deploy that starts anyway looks healthy
230
- // until a per-key limit is enforced N times over.
231
- const topology = resolveProcessTopology();
232
- await assertMultiProcessPreconditions(topology, redisIsReachable);
233
- const handle = await measureStartupPhase("database-connect", bootDatabase);
234
- const db = handle.db;
235
- // One rule, no mode flag: REDIS_URL set → shared client, unset → memory.
236
- const redis = resolveRedisClient();
237
- const ssrfPolicy = resolveSsrfPolicy();
238
- await measureStartupPhase("database-migrations", async () => {
239
- await ensureMigrated();
240
- // Pool sizing is an external-server concern; the embedded backend has no
241
- // connection budget to fit into.
242
- if (handle.kind === "pg") await assertPoolFitsServerCapacity(poolMaxFromEnv(), topology.count);
243
- });
244
- await measureStartupPhase("provider-seed", () => seedBundledProviders(db));
245
- // Boot-only: retire global rows for providers the bundle has dropped, so a
246
- // retired provider cannot linger as a card the console can no longer delete.
247
- // Kept off the test harness path, which shares one database across suites and
248
- // installs its own global fixtures.
249
- await measureStartupPhase("retired-provider-cleanup", () => retireUnbundledProviders(db));
250
- const registry = createDefaultProviderRegistry();
251
- // Live view, not a snapshot: custom providers registered after boot (or
252
- // edited from the console) must resolve their SSRF binding immediately.
253
- const byokUpstreamHosts = liveProviderUpstreamHosts(registry);
254
- await measureStartupPhase("byok-registration", () =>
255
- registerByokProviders(registry, db, ssrfPolicy),
256
- );
257
- const bundledCatalog = await measureStartupPhase("bundled-model-catalog", () =>
258
- bundledModelCatalog(registry),
259
- );
260
- await measureStartupPhase("bundled-model-seed", () =>
261
- seedBundledModels(db, bundledCatalog.modelsByProvider),
262
- );
263
- // The boot seed above is unconditional, so record it: the snapshot builder's
264
- // first rebuild would otherwise re-seed an identical catalog.
265
- markCatalogSeeded();
266
- const snapshotService = new InMemoryRouteSnapshotService(
267
- createDatabaseSnapshotBuilder(db, () => refreshActiveProviderCatalogs(db, registry)),
268
- );
269
- const routingEngine = new RoutingEngine(
270
- redis ? new RedisAdmissionController(redis) : undefined,
271
- redis,
272
- );
273
- const readRoutingAccountInflight = async (
274
- providerId: string,
275
- tenantId: string | null,
276
- ): Promise<readonly { accountId: string; inflight: number }[]> => {
277
- if (providerId === "" || tenantId === null) return [];
278
- const accounts = await new DrizzleProviderCatalogStore(db, {
279
- telemetryBuffer,
280
- bundledModelCatalog: bundledCatalog,
281
- providerRegistry: registry,
282
- outboundFetchFor: async () => ({ fetch: networkBindingFactory.fetch(undefined, tenantId) }),
283
- snapshotInvalidator: snapshotService,
284
- }).listAccounts(tenantId, providerId);
285
- const owned = new Set(accounts.map((account) => account.id));
286
- // Scoped read: only the provider's own candidates for this tenant are
287
- // queried, so the console read costs one exact MGET over the accounts it
288
- // is about to report on — not a fleet-wide `KEYS` scan whose cost is the
289
- // size of every bucket every process ever wrote.
290
- const routeSnapshot = await snapshotService.getSnapshot();
291
- const ownedCandidates = routeSnapshot.candidates.filter(
292
- (candidate) =>
293
- candidate.provider_id === providerId &&
294
- candidate.provider_account_id !== undefined &&
295
- owned.has(candidate.provider_account_id),
296
- );
297
- const snapshot = await routingEngine.accountInflightSnapshot(ownedCandidates);
298
- const totals = new Map<string, number>();
299
- for (const [bucket, count] of snapshot) {
300
- const accountId = bucket.slice(bucket.lastIndexOf(":") + 1);
301
- if (!owned.has(accountId)) continue;
302
- totals.set(accountId, (totals.get(accountId) ?? 0) + count);
303
- }
304
- return [...totals].map(([accountId, inflight]) => ({ accountId, inflight }));
305
- };
306
- // Peer processes' settings mutations arrive here. Subscribed once for the
307
- // process: the revision counter is process-global, and the handler only
308
- // moves it forward.
309
- subscribePeerSettingsRevisions();
310
- const usageFlush = new UsageFlushBuffer(db);
311
- // Auth reads lifetime usage to seed the admission counter. The persisted
312
- // value lags by up to one flush interval, so hand it the in-flight deltas
313
- // too — otherwise the seed is systematically under what has actually been
314
- // spent, worst exactly when the key is busiest.
315
- setPendingUsageSource((apiKeyId) => usageFlush.pendingFor(apiKeyId));
316
- // Health audit rows are append-only and read by nobody in dispatch, so they
317
- // are buffered out of the request. The status write they accompany stays
318
- // synchronous — routing reads it.
319
- const healthEventFlush = new HealthEventFlushBuffer(db);
320
- setHealthEventBuffer(healthEventFlush);
321
- const admissionStore = redis
322
- ? new RedisAdmissionCounterStore(redis)
323
- : new InMemoryAdmissionCounterStore();
324
- const admissionService = new ApiKeyAdmissionService(
325
- admissionStore,
326
- undefined,
327
- // The per-tenant concurrency cap is a `console_settings` preference, read
328
- // on every admitted request. Reading it directly cost one Postgres round
329
- // trip per request (multiplied by each failover candidate); the shared
330
- // revision-keyed reader answers from memory and converges the moment an
331
- // operator saves, because `DrizzleRuntimeSettingsStore.update` bumps the
332
- // revision every cached key embeds.
333
- (tenantId) =>
334
- preferencesReaderFor(db)
335
- .readPreferences(tenantId)
336
- .then((prefs) => prefs?.tenantConcurrencyLimit ?? null),
337
- ({ apiKeyId, delta }) => {
338
- // Coalesced: one UPDATE per key per flush instead of one per request.
339
- // The admission counter is the live total; this only persists it.
340
- usageFlush.enqueue(apiKeyId, delta);
341
- return Promise.resolve();
342
- },
343
- // Fresh lifetime reader — invoked by the store only when it is about to
344
- // seed a missing `admission:lifetime:<id>` counter, so the ≤3s-stale auth
345
- // snapshot value cannot freeze a low baseline in place. Family total: the
346
- // row plus every child (share recipients admit under the parent id).
347
- async (apiKeyId) => {
348
- const store = new DrizzleApiKeyStore(db);
349
- const row = await store.findActiveById(apiKeyId);
350
- if (!row) return undefined;
351
- const children = await store.sumChildrenConsumed(apiKeyId);
352
- return (row.lifetimeTokensConsumed ?? 0) + children;
353
- },
354
- // Slack above every key's concurrency limit. A tenant preference an
355
- // operator sets in the runtime settings modal, falling back to the
356
- // deployment default so an unset row does not silently lose the allowance.
357
- (tenantId) =>
358
- preferencesReaderFor(db)
359
- .readPreferences(tenantId)
360
- .then((prefs) => prefs?.concurrencyTolerance ?? resolveConcurrencyTolerance()),
361
- );
362
- const ipStore = redis
363
- ? new RedisIpAbuseStore(redis)
364
- : new InMemoryIpAbuseStore();
365
- const ipAbuseProtection = new IpAbuseProtectionService(ipStore, undefined, {
366
- maxRequestsPerWindow: resolveIpRateLimit(),
367
- });
368
- // Graduated model-abuse strikes: a client that keeps requesting models
369
- // outside its access is warned, then banned. Redis-backed when available so
370
- // the ban survives a restart and is shared across instances; in-memory
371
- // otherwise, where a restart clears it.
372
- const modelAbuseStore = redis ? new RedisModelAbuseStore(redis) : new InMemoryModelAbuseStore();
373
- const modelStrikes = new ModelStrikeService(modelAbuseStore, {
374
- threshold: resolveModelStrikeThreshold(),
375
- windowMs: resolveModelStrikeWindowMs(),
376
- banTtlMs: resolveModelBanTtlMs(),
377
- maxBanTtlMs: resolveModelBanMaxTtlMs(),
378
- });
379
- const readiness = () => checkReadiness(db, redis, resolveRedisBackend(redis));
380
- // One list covers every periodic task: a small deployment can cut the
381
- // heaviest sweeps (quota refresh, OAuth refresh, client-version polling)
382
- // without the codebase growing a flag per task.
383
- // Leases coordinate the SHARED sweeps across processes. Absent Redis there is
384
- // nothing to coordinate with, and the registry's local one-at-a-time rule is
385
- // the correct (and only) guard.
386
- const scheduledTasks = new ScheduledTaskRegistry(
387
- readList("CARTETHYIA_DISABLED_TASKS", CONFIG_SPEC.CARTETHYIA_DISABLED_TASKS) ?? [],
388
- {
389
- ...(redis ? { lease: createRedisMaintenanceLeaseStore(redis) } : {}),
390
- },
391
- );
392
- if (redis) {
393
- scheduledTasks.register({
394
- name: "lease-sweep",
395
- intervalMs: 30_000,
396
- run: () => sweepLeases(redis),
397
- });
398
- }
399
- scheduledTasks.register({
400
- name: "account-health-sweep",
401
- intervalMs: 30_000,
402
- // Recovers cooldowns deployment-wide and invalidates the shared snapshot.
403
- shared: true,
404
- run: async () => {
405
- const recovered = await sweepExpiredCooldowns(db);
406
- const poolRecovered = await sweepExpiredPoolCooldowns(db);
407
- if (recovered > 0 || poolRecovered > 0) await snapshotService.invalidate();
408
- },
409
- });
410
- const payloadCapture = new TelemetryPayloadCapture(db);
411
- const telemetryStore = new DrizzleTelemetryStore(db);
412
- scheduledTasks.register({
413
- name: "telemetry-payload-cleanup",
414
- intervalMs: 15 * 60_000,
415
- // Deletes payload frames for every tenant: two passes over the same
416
- // batches is duplicated work, not speedup.
417
- shared: true,
418
- run: () => payloadCapture.cleanupExpired(),
419
- });
420
- scheduledTasks.register({
421
- name: "console-session-sweep",
422
- // Sessions live 48 h, so an hourly sweep deleted nothing 47 times out of
423
- // 48 — each pass a full scan of the session table for rows that cannot yet
424
- // be expired. Half the session lifetime is the point where a pass can
425
- // actually find work, and still leaves a full grace period after it.
426
- intervalMs: 12 * 60 * 60_000,
427
- shared: true,
428
- run: () => deleteExpiredSessions(db),
429
- });
430
- scheduledTasks.register({
431
- name: "telemetry-retention",
432
- intervalMs: 6 * 60 * 60_000,
433
- // Retention deletes rows and files for the whole deployment; a second
434
- // concurrent pass would churn the same batches.
435
- shared: true,
436
- run: () =>
437
- telemetryStore.pruneTelemetry(
438
- new Date(Date.now() - resolveTelemetryRetentionDays() * 24 * 60 * 60_000),
439
- ),
440
- });
441
- const telemetryBuffer = new TelemetryBatchBuffer(db);
442
- const poolSelector = new NetworkPoolSelector(redis);
443
- const poolAgentResolver = new PoolAgentResolver(
444
- new DrizzleNetworkPoolLoader(db),
445
- { ssrfPolicy, db },
446
- );
447
- const networkBindingFactory = new ValidatedNetworkBindingFactory(
448
- ssrfPolicy,
449
- undefined,
450
- poolAgentResolver,
451
- );
452
- // Runtime gauges (memory + bounded-collection sizes) sampled on the shared
453
- // maintenance cadence. The sampler reads live sizes through closures so the
454
- // observability layer never holds direct references to routing/pool state.
455
- const runtimeMetricsSampler = new RuntimeMetricsSampler({
456
- routingRoundRobinEntries: () => routingEngine.roundRobinEntries(),
457
- ipAbuseKeys: () => (ipStore instanceof InMemoryIpAbuseStore ? ipStore.keyCount() : 0),
458
- quotaCacheEntries: () => quotaCacheSize(),
459
- modelCatalogEntries: () => modelCatalogCacheSize(),
460
- // Late-bound via bindInFlight once the request state store exists.
461
- inFlight: () => 0,
462
- // The buffer is built below; the closure keeps the sampler from holding a
463
- // reference during construction.
464
- telemetryBacklog: () => telemetryBuffer?.snapshot(),
465
- });
466
- // One sample NOW, not on the first tick. The headroom guard reads
467
- // `memorySnapshot()` on every gateway request and treats `undefined` as
468
- // "unknown" — so without this the guard would spend its first interval
469
- // (seconds, at the configured cadence) unable to shed at all, which is
470
- // exactly the window a cold start is most likely to be short of memory.
471
- runtimeMetricsSampler.sample();
472
- scheduledTasks.register({
473
- name: "runtime-metrics",
474
- intervalMs: readInt(
475
- "CARTETHYIA_RUNTIME_METRICS_INTERVAL_MS",
476
- CONFIG_SPEC.CARTETHYIA_RUNTIME_METRICS_INTERVAL_MS,
477
- ),
478
- run: () => runtimeMetricsSampler.sample(),
479
- });
480
- scheduledTasks.register({
481
- name: "pool-agent-sweep",
482
- // `resolveAgent` already self-sweeps every 30 s, so a 1 min timer adds a
483
- // second pass that almost always finds nothing to release. 5 min is well
484
- // inside the idle timeout this exists to enforce, and outside the path
485
- // that already runs.
486
- intervalMs: 5 * 60_000,
487
- run: () => poolAgentResolver.sweepIdleAgents(),
488
- });
489
- const oauthRefreshService = new OAuthRefreshService(db);
490
- // Converge accounts parked on a dead refresh grant: an account whose stored
491
- // access token is still usable becomes a static token (and returns to
492
- // rotation) instead of staying disabled for re-auth.
493
- await measureStartupPhase("static-token-reconciliation", () =>
494
- reconcileStaticTokenAccounts(db),
495
- );
496
- // Fills in the identity a batch import deliberately skipped, so a paste of
497
- // many credentials is one instant write and the email/label arrive shortly
498
- // after rather than blocking the import. Purely CPU on an already-stored
499
- // credential — no upstream call — so it runs often and cheaply.
500
- scheduledTasks.register({
501
- name: "account-enrichment-sweep",
502
- // Converges via `identity_checked_at`: once every stored credential has
503
- // been parsed, the listing query returns nothing — a 30 s timer would run
504
- // that empty query forever, two million times a year. 10 min is still
505
- // instant for the case that matters (a batch import, where the label and
506
- // email show up on the next tick) and costs nothing when idle.
507
- intervalMs: 10 * 60_000,
508
- run: () => accountIdentitySweep({ db }),
509
- });
510
- scheduledTasks.register({
511
- name: "oauth-refresh-sweep",
512
- intervalMs: 60_000,
513
- run: () =>
514
- oauthRefreshSweep({
515
- loadDueAccounts: () => loadDueOAuthAccounts(db),
516
- refreshService: oauthRefreshService,
517
- resolveRefresher: (providerId) => registry.resolveRefresher(providerId),
518
- // Sequential with a pause between accounts: the OAuth token endpoints
519
- // rate-limit a burst of refreshes, and a pass over a handful of
520
- // accounts must not look like one. Per-account outcomes are already
521
- // pushed to the Console Log ring by `OAuthRefreshService`; this only
522
- // covers an error that escaped before reaching it.
523
- interItemDelayMs: OAUTH_REFRESH_INTER_ITEM_DELAY_MS,
524
- onAccountError: (accountId, providerId, error) => {
525
- log.error(
526
- `[oauth-refresh] account=${accountId} provider=${providerId} failed:`,
527
- error as Error,
528
- );
529
- pushStructuredConsoleLog("error", "OAuth refresh sweep: account failed", {
530
- event: "token_refresh",
531
- accountId,
532
- providerId,
533
- errorCode: "refresh_sweep_failed",
534
- });
535
- },
536
- }),
537
- });
538
-
539
- // Client versions are monitor-only metadata; let the listener settle before
540
- // starting their external fetches. The scheduled task keeps the cache warm.
541
- scheduledTasks.register({
542
- name: "client-version-monitor",
543
- intervalMs: CLIENT_VERSION_MONITOR_INTERVAL_MS,
544
- initialDelayMs: CLIENT_VERSION_INITIAL_DELAY_MS,
545
- run: refreshProviderClientVersions,
546
- });
547
-
548
- const quotaResolveCredential = createAccountSecretResolver({
549
- db,
550
- resolveRefresher: (providerId) => registry.resolveRefresher(providerId),
551
- refreshService: oauthRefreshService,
552
- });
553
- // Keeps every account's cached quota warm so opening the Quota page is a
554
- // cache read. Without this the only refreshes are user-triggered, so the
555
- // first open after an expiry always blocks on the provider.
556
- //
557
- // It also carries the daily check-in ride-along: each account refreshed
558
- // here gets its once-per-day WorkBuddy/CodeBuddy credit grant claimed in
559
- // the same pass (one attempt per account per day, guarded by a day
560
- // marker), so no second timer duplicates the credential resolution.
561
- //
562
- // Registered in **every** mode, not only when Redis is present: gating it on
563
- // Redis would cost Lite proactive quota warming and the check-in ride-along
564
- // above that claims the once-per-day WorkBuddy/CodeBuddy credit grant. The
565
- // whole sweep chain accepts `RedisClient | undefined` and falls back to the
566
- // in-process TTL cache and an in-process day marker, so Redis is an
567
- // optimization here rather than a precondition — which is exactly what
568
- // Lite is for.
569
- scheduledTasks.register({
570
- name: "quota-refresh-sweep",
571
- intervalMs: 60_000,
572
- // Staggered off the oauth/lease/health ticks so the three sweeps that
573
- // strike providerAccounts do not land together every minute.
574
- initialDelayMs: 20_000,
575
- run: () =>
576
- quotaRefreshSweep({
577
- db,
578
- redis,
579
- providerRegistry: registry,
580
- resolveCredential: quotaResolveCredential,
581
- snapshotInvalidator: snapshotService,
582
- // Without this the sweep hands each collector the raw stored secret.
583
- // For `cline` that means an `sk_...` key reaching the OAuth branch,
584
- // which prepends `workos:` and is refused with "please make sure
585
- // you're using the latest version of Cline and re-authenticate" — so
586
- // every sweep cools a working account with `quota_fetch_failed` while
587
- // the same account refreshes fine from the console, which binds the
588
- // same marker through `createQuotaRefreshDeps`.
589
- markApiKeyCredential: markProviderApiKeyCredential,
590
- // Below the 60 s interval: a budget longer than the tick guarantees
591
- // skip/burst under load instead of steady progress.
592
- passBudgetMs: 45_000,
593
- // The check-in ride-along rotates egress per account: each account
594
- // gets the next active pool in its tenant's rotation so check-ins
595
- // spread across IPs instead of sharing one direct egress. No pool
596
- // (or an unreadable pool table) falls back to direct egress, and a
597
- // tenant never borrows another tenant's pool.
598
- checkinFetcherFor: dailyCheckinEgressForPass({ db, networkBindingFactory }),
599
- // Per-account lines and the pass summary are emitted by the sweep
600
- // itself at `info` level, so they survive the production LOG_LEVEL.
601
- // `onTick` is left for tests.
602
- }),
603
- });
604
- // The buddy growth/credit tasks: one per upstream capability, each
605
- // independently switchable with `CARTETHYIA_DISABLED_TASKS`. Every one of
606
- // them is a credit gain the account is entitled to, and none of them can mark
607
- // an account unhealthy — a growth endpoint that 404s is a display miss, not a
608
- // dispatch failure.
609
- //
610
- // Daily, not hourly: each claim is once per account per day and is
611
- // ledger-guarded, so at an hourly cadence 23 of every 24 passes are a full
612
- // scan of active accounts plus a ledger read per account that resolves to
613
- // nothing. Once a day is the shortest interval where a pass can still find
614
- // work, which is the only reason to run at all.
615
- const buddyTaskDeps = {
616
- db,
617
- resolveCredential: quotaResolveCredential,
618
- fetcher: fetch,
619
- // Spread across accounts so a pass does not look like a burst to the
620
- // upstream's risk gate.
621
- interAccountDelayMs: 800,
622
- };
623
- scheduledTasks.register({
624
- name: "buddy-streak",
625
- intervalMs: 24 * 60 * 60_000,
626
- initialDelayMs: 5 * 60_000,
627
- run: () => buddyStreakTask(buddyTaskDeps),
628
- });
629
- scheduledTasks.register({
630
- name: "buddy-redeem",
631
- intervalMs: 24 * 60 * 60_000,
632
- initialDelayMs: 8 * 60_000,
633
- run: () => buddyRedeemTask(buddyTaskDeps),
634
- });
635
- scheduledTasks.register({
636
- name: "buddy-lottery",
637
- intervalMs: 24 * 60 * 60_000,
638
- initialDelayMs: 11 * 60_000,
639
- run: () => buddyLotteryTask(buddyTaskDeps),
640
- });
641
- scheduledTasks.register({
642
- name: "buddy-travel",
643
- intervalMs: 24 * 60 * 60_000,
644
- initialDelayMs: 14 * 60_000,
645
- run: () => buddyTravelTask(buddyTaskDeps),
646
- });
647
- scheduledTasks.register({
648
- name: "buddy-adopt",
649
- intervalMs: 24 * 60 * 60_000,
650
- initialDelayMs: 17 * 60_000,
651
- run: () => buddyAdoptTask(buddyTaskDeps),
652
- });
653
- scheduledTasks.register({
654
- name: "buddy-trial",
655
- intervalMs: 24 * 60 * 60_000,
656
- initialDelayMs: 20 * 60_000,
657
- run: () => buddyTrialTask(buddyTaskDeps),
658
- });
659
- // Growth-activity report: the `/v2/report` batch that lights the login streak
660
- // and scores the daily growth event. Once a day with its own ledger — it used
661
- // to ride the quota sweep with no marker of its own, so its eight POSTs ran
662
- // every minute.
663
- scheduledTasks.register({
664
- name: "buddy-activity-report",
665
- intervalMs: 24 * 60 * 60_000,
666
- initialDelayMs: 15 * 60 * 60_000,
667
- run: () => buddyActivityReportTask(buddyTaskDeps),
668
- });
669
- // Real credit deductions, stored so the console renders a figure the upstream
670
- // actually reported. Six-hourly: the upstream's own window is 30 days, so a
671
- // day's number does not change intraday.
672
- scheduledTasks.register({
673
- name: "buddy-usage-sync",
674
- intervalMs: 6 * 60 * 60_000,
675
- initialDelayMs: 23 * 60_000,
676
- run: () => syncBuddyUsage(buddyTaskDeps),
677
- });
678
- // The scheduled tasks are registered here but started by the caller once the
679
- // listener is actually serving (`main.ts`). Starting them inside this builder
680
- // would let the first tick — the lease sweep, the health sweep — run against
681
- // a process that has not begun accepting traffic yet.
682
- const deps: ProductionDeps = {
683
- db,
684
- redis,
685
- snapshotService,
686
- readRoutingAccountInflight,
687
- resolveProviderAdapter: (providerId) => registry.resolve(providerId),
688
- bundledModelCatalog: bundledCatalog,
689
- byokUpstreamHosts,
690
- proxyPreparer: new ProxyRequestPreparer({
691
- snapshotService,
692
- routingEngine,
693
- admissionService,
694
- }),
695
- networkBindingFactory,
696
- ipAbuseProtection,
697
- modelStrikes,
698
- readiness,
699
- scheduledTasks,
700
- poolAgentResolver,
701
- telemetryBuffer,
702
- usageFlush,
703
- healthEventFlush,
704
- poolSelector,
705
- admissionService,
706
- trustedProxyBoundary: resolveTrustedProxyBoundary(),
707
- providerRegistry: registry,
708
- resolveOAuthRefresher: (providerId) => registry.resolveRefresher(providerId),
709
- oauthRefreshService,
710
- runtimeMetricsSampler,
711
- };
712
- const startupMemoryAfter = process.memoryUsage();
713
- log.info(
714
- `[startup] phase=dependencies-ready elapsed_ms=${Math.round(performance.now() - startupStartedAt)} ` +
715
- `rss_mb=${toMiB(startupMemoryAfter.rss)} ` +
716
- `rss_delta_mb=${toMiB(startupMemoryAfter.rss - startupMemoryBefore.rss)} ` +
717
- `heap_used_mb=${toMiB(startupMemoryAfter.heapUsed)} ` +
718
- `heap_delta_mb=${toMiB(startupMemoryAfter.heapUsed - startupMemoryBefore.heapUsed)}`,
719
- );
720
- return deps;
721
- }
722
-
723
- async function measureStartupPhase<T>(name: string, operation: () => Promise<T>): Promise<T> {
724
- const startedAt = performance.now();
725
- const before = process.memoryUsage();
726
- log.info(
727
- `[startup] phase=${name} status=started rss_mb=${toMiB(before.rss)} heap_used_mb=${toMiB(before.heapUsed)}`,
728
- );
729
- try {
730
- const result = await operation();
731
- const after = process.memoryUsage();
732
- log.info(
733
- `[startup] phase=${name} status=completed elapsed_ms=${Math.round(performance.now() - startedAt)} ` +
734
- `rss_mb=${toMiB(after.rss)} rss_delta_mb=${toMiB(after.rss - before.rss)} ` +
735
- `heap_used_mb=${toMiB(after.heapUsed)} heap_delta_mb=${toMiB(after.heapUsed - before.heapUsed)} ` +
736
- `external_mb=${toMiB(after.external)}`,
737
- );
738
- return result;
739
- } catch (error) {
740
- log.error(
741
- `[startup] phase=${name} status=failed elapsed_ms=${Math.round(performance.now() - startedAt)}`,
742
- error instanceof Error ? error : new Error(String(error)),
743
- );
744
- throw error;
745
- }
746
- }
747
-
748
- function toMiB(bytes: number): string {
749
- return (bytes / 1024 / 1024).toFixed(1);
750
- }
1
+
2
+ import { assertPoolFitsServerCapacity, bootDatabase, ensureMigrated } from "../persistence/boot";
3
+ import { poolMaxFromEnv } from "../persistence/db-pg";
4
+ import type { CartethyiaDatabase } from "../persistence/db-handle";
5
+ import { resolveRedisBackend, resolveRedisClient } from "../persistence/redis";
6
+ import { resolveProcessTopology, type ProcessTopology } from "../config/runtime";
7
+ import { createRedisMaintenanceLeaseStore } from "../workers/maintenance-lease";
8
+ import { resolveDbMode } from "../persistence/db-mode";
9
+ import type { RedisClient } from "../persistence/redis";
10
+ import {
11
+ bundledModelCatalog,
12
+ liveProviderUpstreamHosts,
13
+ markCatalogSeeded,
14
+ refreshActiveProviderCatalogs,
15
+ registerByokProviders,
16
+ retireUnbundledProviders,
17
+ seedBundledProviders,
18
+ } from "../providers/operations/provider-catalog-service";
19
+ import type { BundledProviderCatalog } from "../providers/operations/provider-catalog-service";
20
+ import { createDefaultProviderRegistry } from "../providers/default-registry";
21
+ import { OAuthRefreshService, loadDueOAuthAccounts, reconcileStaticTokenAccounts } from "../providers/authentication/oauth-refresh-service";
22
+ import type { OAuthTokenRefresher } from "../providers/authentication/oauth-refresh-service";
23
+ import { oauthRefreshSweep } from "../workers/oauth-refresh-sweep";
24
+ import { accountIdentitySweep } from "../workers/account-identity-sweep";
25
+ import { pushStructuredConsoleLog } from "../observability/log-ring";
26
+ import { seedBundledModels } from "../providers/operations/provider-catalog-seeder";
27
+ import { createDatabaseSnapshotBuilder } from "../transport/routing/route-catalog";
28
+ import { DrizzleProviderCatalogStore } from "../console/providers/catalog/store";
29
+ import { InMemoryRouteSnapshotService } from "../transport/routing/route-model";
30
+ import { RoutingEngine } from "../transport/routing/router";
31
+ import { RedisAdmissionController } from "../transport/routing/admission";
32
+ import { ApiKeyAdmissionService } from "../security/admission/service";
33
+ import { InMemoryAdmissionCounterStore } from "../security/admission/in-memory-store";
34
+ import { RedisAdmissionCounterStore } from "../security/admission/redis-store";
35
+ import { sweepLeases } from "../security/admission/lease-sweep";
36
+ import { DrizzleApiKeyStore } from "../persistence/stores/api-key-store";
37
+ import { UsageFlushBuffer } from "../persistence/usage-flush";
38
+ import { setPendingUsageSource } from "../security/api-key-auth";
39
+ import { HealthEventFlushBuffer, setHealthEventBuffer } from "../persistence/health-event-flush";
40
+ import { subscribePeerSettingsRevisions } from "../persistence/settings-revision";
41
+ import { deleteExpiredSessions } from "../console/auth/session-service";
42
+ import { InMemoryIpAbuseStore, IpAbuseProtectionService, RedisIpAbuseStore } from "../security/abuse";
43
+ import { checkReadiness } from "../persistence/readiness";
44
+ import { ProxyRequestPreparer } from "../transport/request/preparer";
45
+ import { DrizzleNetworkPoolLoader } from "../network/pool/loader";
46
+ import {
47
+ PoolAgentResolver,
48
+ ValidatedNetworkBindingFactory,
49
+ } from "../network/pool/resolver";
50
+ import { NetworkPoolSelector } from "../network/pool/selector";
51
+ import { ScheduledTaskRegistry } from "../workers/scheduler";
52
+ import { quotaRefreshSweep } from "../workers/quota-refresh-sweep";
53
+ import { markProviderApiKeyCredential } from "../console/quota/account-quota-shared";
54
+ import { dailyCheckinEgressForPass } from "../workers/daily-checkin";
55
+ import {
56
+ buddyAdoptTask,
57
+ buddyActivityReportTask,
58
+ buddyLotteryTask,
59
+ buddyRedeemTask,
60
+ buddyStreakTask,
61
+ buddyTravelTask,
62
+ buddyTrialTask,
63
+ syncBuddyUsage,
64
+ } from "../workers/buddy-growth-tasks";
65
+ import { createAccountSecretResolver } from "../providers/operations/provider-credential-service";
66
+ import { quotaCacheSize } from "../console/quota/cache";
67
+ import { modelCatalogCacheSize } from "../providers/operations/model-catalog-cache";
68
+ import { preferencesReaderFor } from "../persistence/tenant-preferences";
69
+ import { sweepExpiredCooldowns } from "../providers/operations/account-health-service";
70
+ import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
71
+ import { TelemetryPayloadCapture } from "../observability/payload-capture";
72
+ import { sweepExpiredPoolCooldowns } from "../network/pool-health-machine";
73
+ import { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
74
+ import { RuntimeMetricsSampler } from "../observability/runtime-metrics";
75
+ import {
76
+ resolveIpRateLimit,
77
+ } from "../config/security";
78
+ import { resolveSsrfPolicy, resolveTrustedProxyBoundary } from "../config/network";
79
+ import type { TrustedProxyBoundary } from "../config/network";
80
+ import { resolveTelemetryRetentionDays } from "../config/telemetry";
81
+ import { resolveConcurrencyTolerance } from "../config/resources";
82
+ import { CONFIG_SPEC, readInt, readList } from "../config/spec";
83
+ import type { ProviderAdapter } from "../providers/provider-contracts";
84
+ import type { ProviderRegistry } from "../providers/provider-registry";
85
+ import type { ByokUpstreamHost } from "../providers/operations/provider-catalog-service";
86
+ import type { ReadinessCheckResult } from "../persistence/readiness";
87
+ import { log } from "../observability/logger";
88
+ import { refreshProviderClientVersions } from "../providers/operations/client-versions";
89
+
90
+
91
+ export interface ProductionDeps {
92
+ db: CartethyiaDatabase;
93
+ redis: RedisClient | undefined;
94
+ snapshotService: InMemoryRouteSnapshotService;
95
+ /** Live routing admission snapshot scoped to one provider/tenant for console reads. */
96
+ readRoutingAccountInflight: (
97
+ providerId: string,
98
+ tenantId: string | null,
99
+ ) => Promise<readonly { accountId: string; inflight: number }[]>;
100
+ /** On-demand adapter resolution keeps heavy providers out of the boot path. */
101
+ resolveProviderAdapter: (providerId: string) => Promise<ProviderAdapter | undefined>;
102
+ bundledModelCatalog: BundledProviderCatalog;
103
+ /** Live lookup of a provider's SSRF-validated upstream host. */
104
+ byokUpstreamHosts: { readonly get: (providerId: string) => ByokUpstreamHost | undefined };
105
+ proxyPreparer: ProxyRequestPreparer;
106
+ networkBindingFactory: ValidatedNetworkBindingFactory;
107
+ ipAbuseProtection: IpAbuseProtectionService;
108
+ readiness: () => Promise<ReadinessCheckResult>;
109
+ scheduledTasks: ScheduledTaskRegistry;
110
+ poolAgentResolver: PoolAgentResolver;
111
+ poolSelector: NetworkPoolSelector;
112
+ telemetryBuffer: TelemetryBatchBuffer;
113
+ usageFlush: UsageFlushBuffer;
114
+ healthEventFlush: HealthEventFlushBuffer;
115
+ trustedProxyBoundary: TrustedProxyBoundary;
116
+ providerRegistry: ProviderRegistry;
117
+ resolveOAuthRefresher: (providerId: string) => Promise<OAuthTokenRefresher | undefined>;
118
+ oauthRefreshService: OAuthRefreshService;
119
+ admissionService: ApiKeyAdmissionService;
120
+ /** Graduated strikes for repeated invalid-model requests. */
121
+ /** Memory sampler; in-flight source is bound once the request store exists. */
122
+ runtimeMetricsSampler: RuntimeMetricsSampler;
123
+ }
124
+
125
+ /**
126
+ * Pause between accounts in the OAuth refresh sweep. The token endpoints
127
+ * rate-limit a burst of refreshes even at low concurrency.
128
+ */
129
+ const OAUTH_REFRESH_INTER_ITEM_DELAY_MS = 1_500;
130
+
131
+ /**
132
+ * Client identities are refreshed out of band so provider dispatch never waits
133
+ * on npm/provider version endpoints. Resolver TTLs deduplicate the sources.
134
+ */
135
+ // Matched to the resolver's own 30 min cache TTL: a shorter interval makes
136
+ // every second pass a pure cache read that cannot discover anything new.
137
+ const CLIENT_VERSION_MONITOR_INTERVAL_MS = 30 * 60_000;
138
+ const CLIENT_VERSION_INITIAL_DELAY_MS = 10_000;
139
+
140
+ /** Bound on the boot-time Redis probe: a start must fail, not hang. */
141
+ const REDIS_PROBE_TIMEOUT_MS = 2_000;
142
+
143
+ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
144
+ const { promise: timeout, reject } = Promise.withResolvers<never>();
145
+ const timer = setTimeout(() => reject(new Error("timeout")), ms);
146
+ try {
147
+ return await Promise.race([promise, timeout]);
148
+ } finally {
149
+ clearTimeout(timer);
150
+ }
151
+ }
152
+
153
+ /** Bounded Redis reachability probe: fails rather than hangs. */
154
+ async function redisIsReachable(): Promise<boolean> {
155
+ const client = resolveRedisClient();
156
+ if (!client) return false;
157
+ try {
158
+ const result = await withTimeout(client.ping(), REDIS_PROBE_TIMEOUT_MS);
159
+ return result === "PONG";
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Refuses to start a multi-process deployment whose shared state is not
167
+ * actually shared.
168
+ *
169
+ * Every process shares one port via `SO_REUSEPORT`, so the kernel balances
170
+ * across them — and each process keeps its own in-memory admission counters,
171
+ * quota cache, and cooldown map. Those are only correct when they live in
172
+ * Redis. Starting anyway does not fail loudly; it enforces every per-key limit
173
+ * N times over, which then reads as "the limit did not work".
174
+ *
175
+ * Redis is checked by PING with a hard bound, not merely for being configured:
176
+ * a configured-but-unreachable Redis is the failure mode that matters, and an
177
+ * unbounded connect at boot would hang the start instead of failing it.
178
+ */
179
+ async function assertMultiProcessPreconditions(
180
+ topology: ProcessTopology,
181
+ redisReachable: () => Promise<boolean>,
182
+ ): Promise<void> {
183
+ if (!topology.multiProcess) return;
184
+ if (process.platform !== "linux") {
185
+ throw Object.assign(
186
+ new Error(
187
+ `CARTETHYIA_PROCESS_COUNT=${topology.count} requires Linux (SO_REUSEPORT); ` +
188
+ `this process is on ${process.platform}`,
189
+ ),
190
+ { code: "multi_process_unsupported_platform" },
191
+ );
192
+ }
193
+ if (resolveDbMode() !== "full") {
194
+ throw Object.assign(
195
+ new Error(
196
+ `CARTETHYIA_PROCESS_COUNT=${topology.count} requires CARTETHYIA_DB_MODE=full (PostgreSQL); ` +
197
+ "two processes on one SQLite file corrupt it — there is no cross-process lock in the driver",
198
+ ),
199
+ { code: "multi_process_requires_postgres" },
200
+ );
201
+ }
202
+ const reachable = await redisReachable();
203
+ if (!reachable) {
204
+ throw Object.assign(
205
+ new Error(
206
+ `CARTETHYIA_PROCESS_COUNT=${topology.count} requires a REACHABLE Redis: admission counters, ` +
207
+ "quotas, and cooldowns are per-process without one, so every per-key limit would be " +
208
+ `enforced ${topology.count} times over`,
209
+ ),
210
+ { code: "multi_process_requires_redis" },
211
+ );
212
+ }
213
+ }
214
+
215
+ export async function buildProductionDeps(): Promise<ProductionDeps> {
216
+ const startupStartedAt = performance.now();
217
+ const startupMemoryBefore = process.memoryUsage();
218
+ // Resolved first and validated before any connection is opened: a
219
+ // half-configured multi-process deploy that starts anyway looks healthy
220
+ // until a per-key limit is enforced N times over.
221
+ const topology = resolveProcessTopology();
222
+ await assertMultiProcessPreconditions(topology, redisIsReachable);
223
+ const handle = await measureStartupPhase("database-connect", bootDatabase);
224
+ const db = handle.db;
225
+ // One rule, no mode flag: REDIS_URL set → shared client, unset → memory.
226
+ const redis = resolveRedisClient();
227
+ const ssrfPolicy = resolveSsrfPolicy();
228
+ await measureStartupPhase("database-migrations", async () => {
229
+ await ensureMigrated();
230
+ // Pool sizing is an external-server concern; the embedded backend has no
231
+ // connection budget to fit into.
232
+ if (handle.kind === "pg") await assertPoolFitsServerCapacity(poolMaxFromEnv(), topology.count);
233
+ });
234
+ await measureStartupPhase("provider-seed", () => seedBundledProviders(db));
235
+ // Boot-only: retire global rows for providers the bundle has dropped, so a
236
+ // retired provider cannot linger as a card the console can no longer delete.
237
+ // Kept off the test harness path, which shares one database across suites and
238
+ // installs its own global fixtures.
239
+ await measureStartupPhase("retired-provider-cleanup", () => retireUnbundledProviders(db));
240
+ const registry = createDefaultProviderRegistry();
241
+ // Live view, not a snapshot: custom providers registered after boot (or
242
+ // edited from the console) must resolve their SSRF binding immediately.
243
+ const byokUpstreamHosts = liveProviderUpstreamHosts(registry);
244
+ await measureStartupPhase("byok-registration", () =>
245
+ registerByokProviders(registry, db, ssrfPolicy),
246
+ );
247
+ const bundledCatalog = await measureStartupPhase("bundled-model-catalog", () =>
248
+ bundledModelCatalog(registry),
249
+ );
250
+ await measureStartupPhase("bundled-model-seed", () =>
251
+ seedBundledModels(db, bundledCatalog.modelsByProvider),
252
+ );
253
+ // The boot seed above is unconditional, so record it: the snapshot builder's
254
+ // first rebuild would otherwise re-seed an identical catalog.
255
+ markCatalogSeeded();
256
+ const snapshotService = new InMemoryRouteSnapshotService(
257
+ createDatabaseSnapshotBuilder(db, () => refreshActiveProviderCatalogs(db, registry)),
258
+ );
259
+ const routingEngine = new RoutingEngine(
260
+ redis ? new RedisAdmissionController(redis) : undefined,
261
+ redis,
262
+ );
263
+ const readRoutingAccountInflight = async (
264
+ providerId: string,
265
+ tenantId: string | null,
266
+ ): Promise<readonly { accountId: string; inflight: number }[]> => {
267
+ if (providerId === "" || tenantId === null) return [];
268
+ const accounts = await new DrizzleProviderCatalogStore(db, {
269
+ telemetryBuffer,
270
+ bundledModelCatalog: bundledCatalog,
271
+ providerRegistry: registry,
272
+ outboundFetchFor: async () => ({ fetch: networkBindingFactory.fetch(undefined, tenantId) }),
273
+ snapshotInvalidator: snapshotService,
274
+ }).listAccounts(tenantId, providerId);
275
+ const owned = new Set(accounts.map((account) => account.id));
276
+ // Scoped read: only the provider's own candidates for this tenant are
277
+ // queried, so the console read costs one exact MGET over the accounts it
278
+ // is about to report on — not a fleet-wide `KEYS` scan whose cost is the
279
+ // size of every bucket every process ever wrote.
280
+ const routeSnapshot = await snapshotService.getSnapshot();
281
+ const ownedCandidates = routeSnapshot.candidates.filter(
282
+ (candidate) =>
283
+ candidate.provider_id === providerId &&
284
+ candidate.provider_account_id !== undefined &&
285
+ owned.has(candidate.provider_account_id),
286
+ );
287
+ const snapshot = await routingEngine.accountInflightSnapshot(ownedCandidates);
288
+ const totals = new Map<string, number>();
289
+ for (const [bucket, count] of snapshot) {
290
+ const accountId = bucket.slice(bucket.lastIndexOf(":") + 1);
291
+ if (!owned.has(accountId)) continue;
292
+ totals.set(accountId, (totals.get(accountId) ?? 0) + count);
293
+ }
294
+ return [...totals].map(([accountId, inflight]) => ({ accountId, inflight }));
295
+ };
296
+ // Peer processes' settings mutations arrive here. Subscribed once for the
297
+ // process: the revision counter is process-global, and the handler only
298
+ // moves it forward.
299
+ subscribePeerSettingsRevisions();
300
+ const usageFlush = new UsageFlushBuffer(db);
301
+ // Auth reads lifetime usage to seed the admission counter. The persisted
302
+ // value lags by up to one flush interval, so hand it the in-flight deltas
303
+ // too — otherwise the seed is systematically under what has actually been
304
+ // spent, worst exactly when the key is busiest.
305
+ setPendingUsageSource((apiKeyId) => usageFlush.pendingFor(apiKeyId));
306
+ // Health audit rows are append-only and read by nobody in dispatch, so they
307
+ // are buffered out of the request. The status write they accompany stays
308
+ // synchronous — routing reads it.
309
+ const healthEventFlush = new HealthEventFlushBuffer(db);
310
+ setHealthEventBuffer(healthEventFlush);
311
+ const admissionStore = redis
312
+ ? new RedisAdmissionCounterStore(redis)
313
+ : new InMemoryAdmissionCounterStore();
314
+ const admissionService = new ApiKeyAdmissionService(
315
+ admissionStore,
316
+ undefined,
317
+ // The per-tenant concurrency cap is a `console_settings` preference, read
318
+ // on every admitted request. Reading it directly cost one Postgres round
319
+ // trip per request (multiplied by each failover candidate); the shared
320
+ // revision-keyed reader answers from memory and converges the moment an
321
+ // operator saves, because `DrizzleRuntimeSettingsStore.update` bumps the
322
+ // revision every cached key embeds.
323
+ (tenantId) =>
324
+ preferencesReaderFor(db)
325
+ .readPreferences(tenantId)
326
+ .then((prefs) => prefs?.tenantConcurrencyLimit ?? null),
327
+ ({ apiKeyId, delta }) => {
328
+ // Coalesced: one UPDATE per key per flush instead of one per request.
329
+ // The admission counter is the live total; this only persists it.
330
+ usageFlush.enqueue(apiKeyId, delta);
331
+ return Promise.resolve();
332
+ },
333
+ // Fresh lifetime reader — invoked by the store only when it is about to
334
+ // seed a missing `admission:lifetime:<id>` counter, so the ≤3s-stale auth
335
+ // snapshot value cannot freeze a low baseline in place. Family total: the
336
+ // row plus every child (share recipients admit under the parent id).
337
+ async (apiKeyId) => {
338
+ const store = new DrizzleApiKeyStore(db);
339
+ const row = await store.findActiveById(apiKeyId);
340
+ if (!row) return undefined;
341
+ const children = await store.sumChildrenConsumed(apiKeyId);
342
+ return (row.lifetimeTokensConsumed ?? 0) + children;
343
+ },
344
+ // Slack above every key's concurrency limit. A tenant preference an
345
+ // operator sets in the runtime settings modal, falling back to the
346
+ // deployment default so an unset row does not silently lose the allowance.
347
+ (tenantId) =>
348
+ preferencesReaderFor(db)
349
+ .readPreferences(tenantId)
350
+ .then((prefs) => prefs?.concurrencyTolerance ?? resolveConcurrencyTolerance()),
351
+ );
352
+ const ipStore = redis
353
+ ? new RedisIpAbuseStore(redis)
354
+ : new InMemoryIpAbuseStore();
355
+ const ipAbuseProtection = new IpAbuseProtectionService(ipStore, undefined, {
356
+ maxRequestsPerWindow: resolveIpRateLimit(),
357
+ });
358
+
359
+ const readiness = () => checkReadiness(db, redis, resolveRedisBackend(redis));
360
+ // One list covers every periodic task: a small deployment can cut the
361
+ // heaviest sweeps (quota refresh, OAuth refresh, client-version polling)
362
+ // without the codebase growing a flag per task.
363
+ // Leases coordinate the SHARED sweeps across processes. Absent Redis there is
364
+ // nothing to coordinate with, and the registry's local one-at-a-time rule is
365
+ // the correct (and only) guard.
366
+ const scheduledTasks = new ScheduledTaskRegistry(
367
+ readList("CARTETHYIA_DISABLED_TASKS", CONFIG_SPEC.CARTETHYIA_DISABLED_TASKS) ?? [],
368
+ {
369
+ ...(redis ? { lease: createRedisMaintenanceLeaseStore(redis) } : {}),
370
+ },
371
+ );
372
+ if (redis) {
373
+ scheduledTasks.register({
374
+ name: "lease-sweep",
375
+ intervalMs: 30_000,
376
+ run: () => sweepLeases(redis),
377
+ });
378
+ }
379
+ scheduledTasks.register({
380
+ name: "account-health-sweep",
381
+ intervalMs: 30_000,
382
+ // Recovers cooldowns deployment-wide and invalidates the shared snapshot.
383
+ shared: true,
384
+ run: async () => {
385
+ const recovered = await sweepExpiredCooldowns(db);
386
+ const poolRecovered = await sweepExpiredPoolCooldowns(db);
387
+ if (recovered > 0 || poolRecovered > 0) await snapshotService.invalidate();
388
+ },
389
+ });
390
+ const payloadCapture = new TelemetryPayloadCapture(db);
391
+ const telemetryStore = new DrizzleTelemetryStore(db);
392
+ scheduledTasks.register({
393
+ name: "telemetry-payload-cleanup",
394
+ intervalMs: 15 * 60_000,
395
+ // Deletes payload frames for every tenant: two passes over the same
396
+ // batches is duplicated work, not speedup.
397
+ shared: true,
398
+ run: () => payloadCapture.cleanupExpired(),
399
+ });
400
+ scheduledTasks.register({
401
+ name: "console-session-sweep",
402
+ // Sessions live 48 h, so an hourly sweep deleted nothing 47 times out of
403
+ // 48 — each pass a full scan of the session table for rows that cannot yet
404
+ // be expired. Half the session lifetime is the point where a pass can
405
+ // actually find work, and still leaves a full grace period after it.
406
+ intervalMs: 12 * 60 * 60_000,
407
+ shared: true,
408
+ run: () => deleteExpiredSessions(db),
409
+ });
410
+ scheduledTasks.register({
411
+ name: "telemetry-retention",
412
+ intervalMs: 6 * 60 * 60_000,
413
+ // Retention deletes rows and files for the whole deployment; a second
414
+ // concurrent pass would churn the same batches.
415
+ shared: true,
416
+ run: () =>
417
+ telemetryStore.pruneTelemetry(
418
+ new Date(Date.now() - resolveTelemetryRetentionDays() * 24 * 60 * 60_000),
419
+ ),
420
+ });
421
+ const telemetryBuffer = new TelemetryBatchBuffer(db);
422
+ const poolSelector = new NetworkPoolSelector(redis);
423
+ const poolAgentResolver = new PoolAgentResolver(
424
+ new DrizzleNetworkPoolLoader(db),
425
+ { ssrfPolicy, db },
426
+ );
427
+ const networkBindingFactory = new ValidatedNetworkBindingFactory(
428
+ ssrfPolicy,
429
+ undefined,
430
+ poolAgentResolver,
431
+ );
432
+ // Runtime gauges (memory + bounded-collection sizes) sampled on the shared
433
+ // maintenance cadence. The sampler reads live sizes through closures so the
434
+ // observability layer never holds direct references to routing/pool state.
435
+ const runtimeMetricsSampler = new RuntimeMetricsSampler({
436
+ routingRoundRobinEntries: () => routingEngine.roundRobinEntries(),
437
+ ipAbuseKeys: () => (ipStore instanceof InMemoryIpAbuseStore ? ipStore.keyCount() : 0),
438
+ quotaCacheEntries: () => quotaCacheSize(),
439
+ modelCatalogEntries: () => modelCatalogCacheSize(),
440
+ // Late-bound via bindInFlight once the request state store exists.
441
+ inFlight: () => 0,
442
+ // The buffer is built below; the closure keeps the sampler from holding a
443
+ // reference during construction.
444
+ telemetryBacklog: () => telemetryBuffer?.snapshot(),
445
+ });
446
+ // One sample NOW, not on the first tick. The headroom guard reads
447
+ // `memorySnapshot()` on every gateway request and treats `undefined` as
448
+ // "unknown" — so without this the guard would spend its first interval
449
+ // (seconds, at the configured cadence) unable to shed at all, which is
450
+ // exactly the window a cold start is most likely to be short of memory.
451
+ runtimeMetricsSampler.sample();
452
+ scheduledTasks.register({
453
+ name: "runtime-metrics",
454
+ intervalMs: readInt(
455
+ "CARTETHYIA_RUNTIME_METRICS_INTERVAL_MS",
456
+ CONFIG_SPEC.CARTETHYIA_RUNTIME_METRICS_INTERVAL_MS,
457
+ ),
458
+ run: () => runtimeMetricsSampler.sample(),
459
+ });
460
+ scheduledTasks.register({
461
+ name: "pool-agent-sweep",
462
+ // `resolveAgent` already self-sweeps every 30 s, so a 1 min timer adds a
463
+ // second pass that almost always finds nothing to release. 5 min is well
464
+ // inside the idle timeout this exists to enforce, and outside the path
465
+ // that already runs.
466
+ intervalMs: 5 * 60_000,
467
+ run: () => poolAgentResolver.sweepIdleAgents(),
468
+ });
469
+ const oauthRefreshService = new OAuthRefreshService(db);
470
+ // Converge accounts parked on a dead refresh grant: an account whose stored
471
+ // access token is still usable becomes a static token (and returns to
472
+ // rotation) instead of staying disabled for re-auth.
473
+ await measureStartupPhase("static-token-reconciliation", () =>
474
+ reconcileStaticTokenAccounts(db),
475
+ );
476
+ // Fills in the identity a batch import deliberately skipped, so a paste of
477
+ // many credentials is one instant write and the email/label arrive shortly
478
+ // after rather than blocking the import. Purely CPU on an already-stored
479
+ // credential — no upstream call — so it runs often and cheaply.
480
+ scheduledTasks.register({
481
+ name: "account-enrichment-sweep",
482
+ // Converges via `identity_checked_at`: once every stored credential has
483
+ // been parsed, the listing query returns nothing — a 30 s timer would run
484
+ // that empty query forever, two million times a year. 10 min is still
485
+ // instant for the case that matters (a batch import, where the label and
486
+ // email show up on the next tick) and costs nothing when idle.
487
+ intervalMs: 10 * 60_000,
488
+ run: () => accountIdentitySweep({ db }),
489
+ });
490
+ scheduledTasks.register({
491
+ name: "oauth-refresh-sweep",
492
+ intervalMs: 60_000,
493
+ run: () =>
494
+ oauthRefreshSweep({
495
+ loadDueAccounts: () => loadDueOAuthAccounts(db),
496
+ refreshService: oauthRefreshService,
497
+ resolveRefresher: (providerId) => registry.resolveRefresher(providerId),
498
+ // Sequential with a pause between accounts: the OAuth token endpoints
499
+ // rate-limit a burst of refreshes, and a pass over a handful of
500
+ // accounts must not look like one. Per-account outcomes are already
501
+ // pushed to the Console Log ring by `OAuthRefreshService`; this only
502
+ // covers an error that escaped before reaching it.
503
+ interItemDelayMs: OAUTH_REFRESH_INTER_ITEM_DELAY_MS,
504
+ onAccountError: (accountId, providerId, error) => {
505
+ log.error(
506
+ `[oauth-refresh] account=${accountId} provider=${providerId} failed:`,
507
+ error as Error,
508
+ );
509
+ pushStructuredConsoleLog("error", "OAuth refresh sweep: account failed", {
510
+ event: "token_refresh",
511
+ accountId,
512
+ providerId,
513
+ errorCode: "refresh_sweep_failed",
514
+ });
515
+ },
516
+ }),
517
+ });
518
+
519
+ // Client versions are monitor-only metadata; let the listener settle before
520
+ // starting their external fetches. The scheduled task keeps the cache warm.
521
+ scheduledTasks.register({
522
+ name: "client-version-monitor",
523
+ intervalMs: CLIENT_VERSION_MONITOR_INTERVAL_MS,
524
+ initialDelayMs: CLIENT_VERSION_INITIAL_DELAY_MS,
525
+ run: refreshProviderClientVersions,
526
+ });
527
+
528
+ const quotaResolveCredential = createAccountSecretResolver({
529
+ db,
530
+ resolveRefresher: (providerId) => registry.resolveRefresher(providerId),
531
+ refreshService: oauthRefreshService,
532
+ });
533
+ // Keeps every account's cached quota warm so opening the Quota page is a
534
+ // cache read. Without this the only refreshes are user-triggered, so the
535
+ // first open after an expiry always blocks on the provider.
536
+ //
537
+ // It also carries the daily check-in ride-along: each account refreshed
538
+ // here gets its once-per-day WorkBuddy/CodeBuddy credit grant claimed in
539
+ // the same pass (one attempt per account per day, guarded by a day
540
+ // marker), so no second timer duplicates the credential resolution.
541
+ //
542
+ // Registered in **every** mode, not only when Redis is present: gating it on
543
+ // Redis would cost Lite proactive quota warming and the check-in ride-along
544
+ // above that claims the once-per-day WorkBuddy/CodeBuddy credit grant. The
545
+ // whole sweep chain accepts `RedisClient | undefined` and falls back to the
546
+ // in-process TTL cache and an in-process day marker, so Redis is an
547
+ // optimization here rather than a precondition — which is exactly what
548
+ // Lite is for.
549
+ scheduledTasks.register({
550
+ name: "quota-refresh-sweep",
551
+ intervalMs: 60_000,
552
+ // Staggered off the oauth/lease/health ticks so the three sweeps that
553
+ // strike providerAccounts do not land together every minute.
554
+ initialDelayMs: 20_000,
555
+ run: () =>
556
+ quotaRefreshSweep({
557
+ db,
558
+ redis,
559
+ providerRegistry: registry,
560
+ resolveCredential: quotaResolveCredential,
561
+ snapshotInvalidator: snapshotService,
562
+ // Without this the sweep hands each collector the raw stored secret.
563
+ // For `cline` that means an `sk_...` key reaching the OAuth branch,
564
+ // which prepends `workos:` and is refused with "please make sure
565
+ // you're using the latest version of Cline and re-authenticate" — so
566
+ // every sweep cools a working account with `quota_fetch_failed` while
567
+ // the same account refreshes fine from the console, which binds the
568
+ // same marker through `createQuotaRefreshDeps`.
569
+ markApiKeyCredential: markProviderApiKeyCredential,
570
+ // Below the 60 s interval: a budget longer than the tick guarantees
571
+ // skip/burst under load instead of steady progress.
572
+ passBudgetMs: 45_000,
573
+ // The check-in ride-along rotates egress per account: each account
574
+ // gets the next active pool in its tenant's rotation so check-ins
575
+ // spread across IPs instead of sharing one direct egress. No pool
576
+ // (or an unreadable pool table) falls back to direct egress, and a
577
+ // tenant never borrows another tenant's pool.
578
+ checkinFetcherFor: dailyCheckinEgressForPass({ db, networkBindingFactory }),
579
+ // Per-account lines and the pass summary are emitted by the sweep
580
+ // itself at `info` level, so they survive the production LOG_LEVEL.
581
+ // `onTick` is left for tests.
582
+ }),
583
+ });
584
+ // The buddy growth/credit tasks: one per upstream capability, each
585
+ // independently switchable with `CARTETHYIA_DISABLED_TASKS`. Every one of
586
+ // them is a credit gain the account is entitled to, and none of them can mark
587
+ // an account unhealthy — a growth endpoint that 404s is a display miss, not a
588
+ // dispatch failure.
589
+ //
590
+ // Daily, not hourly: each claim is once per account per day and is
591
+ // ledger-guarded, so at an hourly cadence 23 of every 24 passes are a full
592
+ // scan of active accounts plus a ledger read per account that resolves to
593
+ // nothing. Once a day is the shortest interval where a pass can still find
594
+ // work, which is the only reason to run at all.
595
+ const buddyTaskDeps = {
596
+ db,
597
+ resolveCredential: quotaResolveCredential,
598
+ fetcher: fetch,
599
+ // Spread across accounts so a pass does not look like a burst to the
600
+ // upstream's risk gate.
601
+ interAccountDelayMs: 800,
602
+ };
603
+ scheduledTasks.register({
604
+ name: "buddy-streak",
605
+ intervalMs: 24 * 60 * 60_000,
606
+ initialDelayMs: 5 * 60_000,
607
+ run: () => buddyStreakTask(buddyTaskDeps),
608
+ });
609
+ scheduledTasks.register({
610
+ name: "buddy-redeem",
611
+ intervalMs: 24 * 60 * 60_000,
612
+ initialDelayMs: 8 * 60_000,
613
+ run: () => buddyRedeemTask(buddyTaskDeps),
614
+ });
615
+ scheduledTasks.register({
616
+ name: "buddy-lottery",
617
+ intervalMs: 24 * 60 * 60_000,
618
+ initialDelayMs: 11 * 60_000,
619
+ run: () => buddyLotteryTask(buddyTaskDeps),
620
+ });
621
+ scheduledTasks.register({
622
+ name: "buddy-travel",
623
+ intervalMs: 24 * 60 * 60_000,
624
+ initialDelayMs: 14 * 60_000,
625
+ run: () => buddyTravelTask(buddyTaskDeps),
626
+ });
627
+ scheduledTasks.register({
628
+ name: "buddy-adopt",
629
+ intervalMs: 24 * 60 * 60_000,
630
+ initialDelayMs: 17 * 60_000,
631
+ run: () => buddyAdoptTask(buddyTaskDeps),
632
+ });
633
+ scheduledTasks.register({
634
+ name: "buddy-trial",
635
+ intervalMs: 24 * 60 * 60_000,
636
+ initialDelayMs: 20 * 60_000,
637
+ run: () => buddyTrialTask(buddyTaskDeps),
638
+ });
639
+ // Growth-activity report: the `/v2/report` batch that lights the login streak
640
+ // and scores the daily growth event. Once a day with its own ledger — it used
641
+ // to ride the quota sweep with no marker of its own, so its eight POSTs ran
642
+ // every minute.
643
+ scheduledTasks.register({
644
+ name: "buddy-activity-report",
645
+ intervalMs: 24 * 60 * 60_000,
646
+ initialDelayMs: 15 * 60 * 60_000,
647
+ run: () => buddyActivityReportTask(buddyTaskDeps),
648
+ });
649
+ // Real credit deductions, stored so the console renders a figure the upstream
650
+ // actually reported. Six-hourly: the upstream's own window is 30 days, so a
651
+ // day's number does not change intraday.
652
+ scheduledTasks.register({
653
+ name: "buddy-usage-sync",
654
+ intervalMs: 6 * 60 * 60_000,
655
+ initialDelayMs: 23 * 60_000,
656
+ run: () => syncBuddyUsage(buddyTaskDeps),
657
+ });
658
+ // The scheduled tasks are registered here but started by the caller once the
659
+ // listener is actually serving (`main.ts`). Starting them inside this builder
660
+ // would let the first tick — the lease sweep, the health sweep — run against
661
+ // a process that has not begun accepting traffic yet.
662
+ const deps: ProductionDeps = {
663
+ db,
664
+ redis,
665
+ snapshotService,
666
+ readRoutingAccountInflight,
667
+ resolveProviderAdapter: (providerId) => registry.resolve(providerId),
668
+ bundledModelCatalog: bundledCatalog,
669
+ byokUpstreamHosts,
670
+ proxyPreparer: new ProxyRequestPreparer({
671
+ snapshotService,
672
+ routingEngine,
673
+ admissionService,
674
+ }),
675
+ networkBindingFactory,
676
+ ipAbuseProtection,
677
+ readiness,
678
+ scheduledTasks,
679
+ poolAgentResolver,
680
+ telemetryBuffer,
681
+ usageFlush,
682
+ healthEventFlush,
683
+ poolSelector,
684
+ admissionService,
685
+ trustedProxyBoundary: resolveTrustedProxyBoundary(),
686
+ providerRegistry: registry,
687
+ resolveOAuthRefresher: (providerId) => registry.resolveRefresher(providerId),
688
+ oauthRefreshService,
689
+ runtimeMetricsSampler,
690
+ };
691
+ const startupMemoryAfter = process.memoryUsage();
692
+ log.info(
693
+ `[startup] phase=dependencies-ready elapsed_ms=${Math.round(performance.now() - startupStartedAt)} ` +
694
+ `rss_mb=${toMiB(startupMemoryAfter.rss)} ` +
695
+ `rss_delta_mb=${toMiB(startupMemoryAfter.rss - startupMemoryBefore.rss)} ` +
696
+ `heap_used_mb=${toMiB(startupMemoryAfter.heapUsed)} ` +
697
+ `heap_delta_mb=${toMiB(startupMemoryAfter.heapUsed - startupMemoryBefore.heapUsed)}`,
698
+ );
699
+ return deps;
700
+ }
701
+
702
+ async function measureStartupPhase<T>(name: string, operation: () => Promise<T>): Promise<T> {
703
+ const startedAt = performance.now();
704
+ const before = process.memoryUsage();
705
+ log.info(
706
+ `[startup] phase=${name} status=started rss_mb=${toMiB(before.rss)} heap_used_mb=${toMiB(before.heapUsed)}`,
707
+ );
708
+ try {
709
+ const result = await operation();
710
+ const after = process.memoryUsage();
711
+ log.info(
712
+ `[startup] phase=${name} status=completed elapsed_ms=${Math.round(performance.now() - startedAt)} ` +
713
+ `rss_mb=${toMiB(after.rss)} rss_delta_mb=${toMiB(after.rss - before.rss)} ` +
714
+ `heap_used_mb=${toMiB(after.heapUsed)} heap_delta_mb=${toMiB(after.heapUsed - before.heapUsed)} ` +
715
+ `external_mb=${toMiB(after.external)}`,
716
+ );
717
+ return result;
718
+ } catch (error) {
719
+ log.error(
720
+ `[startup] phase=${name} status=failed elapsed_ms=${Math.round(performance.now() - startedAt)}`,
721
+ error instanceof Error ? error : new Error(String(error)),
722
+ );
723
+ throw error;
724
+ }
725
+ }
726
+
727
+ function toMiB(bytes: number): string {
728
+ return (bytes / 1024 / 1024).toFixed(1);
729
+ }