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,254 +1,269 @@
1
- /**
2
- * Periodic runtime gauges: process memory plus bounded-collection sizes.
3
- *
4
- * Sampling is driven by `ScheduledTaskRegistry` (unref'd interval) rather than
5
- * a dedicated timer so it shares the process's existing maintenance lifecycle
6
- * and stops with it. Map/collection sizes are read through closures supplied at
7
- * composition time; this module owns no references to the routing or pool
8
- * layers, keeping the observability boundary one-directional.
9
- */
10
- import { readFileSync } from "node:fs";
11
- import { metrics } from "./metrics";
12
- import { trackMemoryUsage } from "./performance-metrics";
13
- import { log } from "./logger";
14
- import { resolveMemoryLimitOverrideBytes } from "../config/timeouts";
15
- import { CONFIG_SPEC, readFlag } from "../config/spec";
16
- import type { TelemetryBacklogSnapshot } from "./telemetry-buffer";
17
- /**
18
- * The memory figures a shed decision needs.
19
- *
20
- * Published once per sample instead of re-measured per request:
21
- * `process.memoryUsage()` is not free, and the headroom guard runs on every
22
- * request. A snapshot up to one sample interval old is the right trade — the
23
- * guard is protecting against a spike that arrives over seconds, not one
24
- * request's worth of allocation.
25
- */
26
- export interface RuntimeMemorySnapshot {
27
- readonly rssBytes: number;
28
- /** The resolved limit; `undefined` when none could be discovered. */
29
- readonly limitBytes: number | undefined;
30
- readonly sampledAtMs: number;
31
- }
32
-
33
- /** Live collection sizes sampled alongside process memory. */
34
- export interface RuntimeMetricSources {
35
- readonly routingRoundRobinEntries: () => { readonly combo: number; readonly provider: number };
36
- /**
37
- * Tracked keys in the in-memory IP abuse store, or 0 when the store is
38
- * Redis-backed. Unlike the routing maps this one is fed by client-supplied
39
- * identities, so its size is the leak signal to watch.
40
- */
41
- readonly ipAbuseKeys: () => number;
42
- /**
43
- * The telemetry backlog's current shape, or `undefined` when no buffer is
44
- * wired (tests, and the console-only shell).
45
- */
46
- readonly telemetryBacklog?: () => TelemetryBacklogSnapshot | undefined;
47
- /** Tracked keys in the in-process quota cache tier (0 when Redis-only paths are idle). */
48
- readonly quotaCacheEntries: () => number;
49
-
50
- /**
51
- * Entries in the per-provider model-catalog cache. Bounded, but a count that
52
- * sits at the cap means every rebuild is evicting something it needs.
53
- */
54
- readonly modelCatalogEntries: () => number;
55
- /**
56
- * Live in-flight request count. Zero means the gateway is idle, so a
57
- * synchronous full collection is safe (no request to stall).
58
- */
59
- readonly inFlight: () => number;
60
- }
61
-
62
- /** Cooldown between memory-pressure warnings so a sustained high-water mark does not flood logs. */
63
- const PRESSURE_LOG_WINDOW_MS = 60_000;
64
- /** Cooldown between proactive garbage collections while pressure persists. */
65
- const PRESSURE_GC_WINDOW_MS = 60_000;
66
- /** Fraction of the resolved limit at which memory pressure is reported. */
67
- const PRESSURE_THRESHOLD = 0.8;
68
- /**
69
- * Fallback limit used when neither an override nor a cgroup limit is
70
- * discoverable (native development, macOS, Windows). Without it the pressure
71
- * check bails and `Bun.gc` never runs, so an idle process holds its startup
72
- * peak forever. 512 MiB keeps reclaim active without assuming a container.
73
- */
74
- const DEFAULT_MEMORY_LIMIT_BYTES = 512 * 1024 * 1024;
75
- /** Cooldown between idle full collections. */
76
- const IDLE_GC_WINDOW_MS = 5 * 60_000;
77
- /** RSS retained above the post-collection baseline before another idle collection runs. */
78
- const IDLE_GC_MIN_GROWTH_BYTES = 32 * 1024 * 1024;
79
-
80
- /**
81
- * Resolves the effective memory limit for the process.
82
- *
83
- * Precedence: an explicit `CARTETHYIA_MEMORY_LIMIT_BYTES` override, then the
84
- * cgroup v2/v1 limit when running in a container. Falls back to
85
- * {@link DEFAULT_MEMORY_LIMIT_BYTES} when no limit is discoverable (native
86
- * development, macOS, Windows) so pressure-based reclaim still runs.
87
- */
88
- export function resolveMemoryLimitBytes(): number | undefined {
89
- const override = resolveMemoryLimitOverrideBytes();
90
- if (override !== undefined) return override;
91
- if (process.platform !== "linux") return DEFAULT_MEMORY_LIMIT_BYTES;
92
- for (const path of [
93
- "/sys/fs/cgroup/memory.max",
94
- "/sys/fs/cgroup/memory/memory.limit_in_bytes",
95
- ]) {
96
- try {
97
- const content = readFileSync(path, "utf8").trim();
98
- if (content === "max") continue;
99
- const value = Number(content);
100
- // The v1 unlimited sentinel is a huge finite number; treat it as unknown.
101
- if (Number.isInteger(value) && value > 0 && value < 2 ** 53) return value;
102
- } catch {
103
- // File absent (cgroup v1 vs v2) or unreadable — try the next source.
104
- }
105
- }
106
- return DEFAULT_MEMORY_LIMIT_BYTES;
107
- }
108
-
109
- /** Samples process memory and bounded-collection sizes into the metric registry. */
110
- export class RuntimeMetricsSampler {
111
- private readonly limitBytes: number | undefined;
112
- private readonly gcOnPressure: boolean;
113
- private lastPressureLogAt = 0;
114
- private lastGcAt = 0;
115
- private lastIdleGcAt = 0;
116
- private rssAtLastGc = 0;
117
- private inFlightSource: () => number = () => 0;
118
- /**
119
- * The latest snapshot, published once per sample. Stays undefined until the
120
- * first sample runs; consumers must treat that as "unknown", not as zero.
121
- */
122
- private snapshot: RuntimeMemorySnapshot | undefined;
123
- /** The effective concurrency ceiling, published alongside the gauges. */
124
- private concurrencyLimit: number | undefined;
125
-
126
- /**
127
- * Binds the live in-flight count after the request state store exists. The
128
- * sampler is constructed before that store, so the source is late-bound.
129
- */
130
- bindInFlight(source: () => number): void {
131
- this.inFlightSource = source;
132
- }
133
-
134
- /**
135
- * The last sampled memory state, for consumers on a hot path.
136
- *
137
- * Undefined before the first sample: a guard that read zero as "plenty of
138
- * room" would disable itself exactly when it matters, so callers must fall
139
- * back to their own judgement when this is absent.
140
- */
141
- memorySnapshot(): RuntimeMemorySnapshot | undefined {
142
- return this.snapshot;
143
- }
144
-
145
- /**
146
- * Publishes the effective concurrency ceiling.
147
- *
148
- * Late-bound like `bindInFlight`: the ceiling is derived from the memory
149
- * limit, which is known here, but the guard that owns it is constructed in
150
- * the transport layer.
151
- */
152
- bindConcurrencyLimit(limit: number): void {
153
- this.concurrencyLimit = limit;
154
- }
155
-
156
- constructor(
157
- private readonly sources: RuntimeMetricSources,
158
- limitBytes: number | undefined = resolveMemoryLimitBytes(),
159
- // Off by default: a forced collection is not how memory is meant to come
160
- // back. The synchronous form (`Bun.gc(true)`) stops the shared event loop
161
- // outright, so every concurrent request stalls inside it — which reads as
162
- // a latency spike with no upstream cause. And it hides the actual defect:
163
- // a process that only returns memory when something forces it to is a
164
- // process that is still holding it. Bounded caches, released streams, and
165
- // batched writers reclaim naturally; this is opt-in for an operator who
166
- // has measured that they need it.
167
- gcOnPressure = readFlag("CARTETHYIA_FORCED_GC", CONFIG_SPEC.CARTETHYIA_FORCED_GC),
168
- ) {
169
- this.limitBytes = limitBytes;
170
- this.gcOnPressure = gcOnPressure;
171
- }
172
-
173
- sample(): void {
174
- const memory = process.memoryUsage();
175
- metrics.cartethyia_memory_rss_bytes.set(memory.rss);
176
- metrics.cartethyia_memory_heap_used_bytes.set(memory.heapUsed);
177
- metrics.cartethyia_memory_heap_total_bytes.set(memory.heapTotal);
178
- metrics.cartethyia_memory_limit_bytes.set(this.limitBytes ?? 0);
179
- this.snapshot = {
180
- rssBytes: memory.rss,
181
- limitBytes: this.limitBytes,
182
- sampledAtMs: Date.now(),
183
- };
184
- trackMemoryUsage("rss", memory.rss);
185
- trackMemoryUsage("heap_used", memory.heapUsed);
186
- trackMemoryUsage("heap_total", memory.heapTotal);
187
- trackMemoryUsage("external", memory.external);
188
-
189
- const roundRobin = this.sources.routingRoundRobinEntries();
190
- metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.combo, { scope: "combo" });
191
- metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.provider, { scope: "provider" });
192
- metrics.cartethyia_ip_abuse_keys.set(this.sources.ipAbuseKeys());
193
- metrics.cartethyia_quota_cache_entries.set(this.sources.quotaCacheEntries());
194
- metrics.cartethyia_model_catalog_entries.set(this.sources.modelCatalogEntries());
195
- metrics.cartethyia_process_in_flight.set(this.inFlightSource());
196
- const backlog = this.sources.telemetryBacklog?.();
197
- if (backlog !== undefined) {
198
- metrics.cartethyia_telemetry_backlog_items.set(backlog.items);
199
- metrics.cartethyia_telemetry_backlog_bytes.set(backlog.bytes);
200
- }
201
- if (this.concurrencyLimit !== undefined) {
202
- metrics.cartethyia_process_concurrency_limit.set(this.concurrencyLimit);
203
- }
204
-
205
- this.checkPressure(memory.rss);
206
- this.checkIdleReclaim(memory.rss);
207
- }
208
-
209
- private checkPressure(rss: number): void {
210
- if (this.limitBytes === undefined || rss <= this.limitBytes * PRESSURE_THRESHOLD) return;
211
- const now = Date.now();
212
- if (this.gcOnPressure && now - this.lastGcAt >= PRESSURE_GC_WINDOW_MS) {
213
- this.lastGcAt = now;
214
- // Asynchronous GC: reclaims before the limit is reached without stalling
215
- // in-flight requests on the shared event loop. A synchronous full
216
- // collection here would add a latency spike to every concurrent request.
217
- Bun.gc(false);
218
- }
219
- if (now - this.lastPressureLogAt < PRESSURE_LOG_WINDOW_MS) return;
220
- this.lastPressureLogAt = now;
221
- log.warn(
222
- `[memory] RSS ${(rss / 1024 / 1024).toFixed(1)}MiB exceeds 80% of the ` +
223
- `${(this.limitBytes / 1024 / 1024).toFixed(1)}MiB limit`,
224
- );
225
- }
226
-
227
- /**
228
- * Reclaims memory while the gateway is idle.
229
- *
230
- * Triggered on RSS growth rather than heap growth: this process holds most of
231
- * its footprint off-heap (adapters, TLS contexts, buffers), and `heapUsed`
232
- * barely moves when those are allocated — a heap-based gate never opens, which
233
- * is why an idle gateway held its startup peak indefinitely.
234
- *
235
- * A synchronous full collection is safe here because no request is in flight to
236
- * stall, and it is the only form that actually returns pages to the OS; the
237
- * async form used under pressure does not.
238
- *
239
- * Not gated on `gcOnPressure` the way `checkPressure` is. The opt-in exists
240
- * because a synchronous collection stalls concurrent requests, and this path
241
- * only runs when there is nothing in flight to stall — so the reason the flag
242
- * is off does not apply to it. Gating it meant a gateway with no traffic at
243
- * all never reclaimed anything and held its startup peak for hours.
244
- */
245
- private checkIdleReclaim(rss: number): void {
246
- if (this.inFlightSource() > 0) return;
247
- const now = Date.now();
248
- if (now - this.lastIdleGcAt < IDLE_GC_WINDOW_MS) return;
249
- if (this.rssAtLastGc !== 0 && rss - this.rssAtLastGc < IDLE_GC_MIN_GROWTH_BYTES) return;
250
- this.lastIdleGcAt = now;
251
- Bun.gc(true);
252
- this.rssAtLastGc = process.memoryUsage().rss;
253
- }
254
- }
1
+ /**
2
+ * Periodic runtime gauges: process memory plus bounded-collection sizes.
3
+ *
4
+ * Sampling is driven by `ScheduledTaskRegistry` (unref'd interval) rather than
5
+ * a dedicated timer so it shares the process's existing maintenance lifecycle
6
+ * and stops with it. Map/collection sizes are read through closures supplied at
7
+ * composition time; this module owns no references to the routing or pool
8
+ * layers, keeping the observability boundary one-directional.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import os from "node:os";
12
+ import { metrics } from "./metrics";
13
+ import { trackMemoryUsage } from "./performance-metrics";
14
+ import { log } from "./logger";
15
+ import { resolveMemoryLimitOverrideBytes } from "../config/timeouts";
16
+ import { CONFIG_SPEC, readFlag } from "../config/spec";
17
+ import type { TelemetryBacklogSnapshot } from "./telemetry-buffer";
18
+ /**
19
+ * The memory figures a shed decision needs.
20
+ *
21
+ * Published once per sample instead of re-measured per request:
22
+ * `process.memoryUsage()` is not free, and the headroom guard runs on every
23
+ * request. A snapshot up to one sample interval old is the right trade — the
24
+ * guard is protecting against a spike that arrives over seconds, not one
25
+ * request's worth of allocation.
26
+ */
27
+ export interface RuntimeMemorySnapshot {
28
+ readonly rssBytes: number;
29
+ /** The resolved limit; `undefined` when none could be discovered. */
30
+ readonly limitBytes: number | undefined;
31
+ readonly sampledAtMs: number;
32
+ }
33
+
34
+ /** Live collection sizes sampled alongside process memory. */
35
+ export interface RuntimeMetricSources {
36
+ readonly routingRoundRobinEntries: () => { readonly combo: number; readonly provider: number };
37
+ /**
38
+ * Tracked keys in the in-memory IP abuse store, or 0 when the store is
39
+ * Redis-backed. Unlike the routing maps this one is fed by client-supplied
40
+ * identities, so its size is the leak signal to watch.
41
+ */
42
+ readonly ipAbuseKeys: () => number;
43
+ /**
44
+ * The telemetry backlog's current shape, or `undefined` when no buffer is
45
+ * wired (tests, and the console-only shell).
46
+ */
47
+ readonly telemetryBacklog?: () => TelemetryBacklogSnapshot | undefined;
48
+ /** Tracked keys in the in-process quota cache tier (0 when Redis-only paths are idle). */
49
+ readonly quotaCacheEntries: () => number;
50
+
51
+ /**
52
+ * Entries in the per-provider model-catalog cache. Bounded, but a count that
53
+ * sits at the cap means every rebuild is evicting something it needs.
54
+ */
55
+ readonly modelCatalogEntries: () => number;
56
+ /**
57
+ * Live in-flight request count. Zero means the gateway is idle, so a
58
+ * synchronous full collection is safe (no request to stall).
59
+ */
60
+ readonly inFlight: () => number;
61
+ }
62
+
63
+ /** Cooldown between memory-pressure warnings so a sustained high-water mark does not flood logs. */
64
+ const PRESSURE_LOG_WINDOW_MS = 60_000;
65
+ /** Cooldown between proactive garbage collections while pressure persists. */
66
+ const PRESSURE_GC_WINDOW_MS = 60_000;
67
+ /** Fraction of the resolved limit at which memory pressure is reported. */
68
+ const PRESSURE_THRESHOLD = 0.8;
69
+ /**
70
+ * Fallback limit when neither an override nor a cgroup limit is discoverable
71
+ * (native development, macOS, Windows).
72
+ *
73
+ * A fixed 512 MiB made every machine without a cgroup look like a 512 MiB
74
+ * container. On a desktop running the gateway natively that is far below what
75
+ * the machine has, so the pressure check fired against a number that described
76
+ * nothing — "RSS 430 MiB exceeds 80% of the 512 MiB limit" on a machine with
77
+ * gigabytes free — and the concurrency cap derived from it admitted a handful
78
+ * of requests while every tenant-level setting sat unreachable above it.
79
+ *
80
+ * The host's real memory is the honest stand-in. It is what the process can
81
+ * actually grow into here, and it is what "no container limit" means.
82
+ */
83
+ function defaultMemoryLimitBytes(): number {
84
+ try {
85
+ const total = os.totalmem();
86
+ if (Number.isFinite(total) && total > 0) return total;
87
+ } catch {
88
+ // Platform without totalmem; fall through to the last-resort constant.
89
+ }
90
+ return LAST_RESORT_MEMORY_LIMIT_BYTES;
91
+ }
92
+
93
+ /**
94
+ * Used only when the host's memory cannot be read at all. Deliberately large
95
+ * so an unmeasurable host is not treated as a small container.
96
+ */
97
+ const LAST_RESORT_MEMORY_LIMIT_BYTES = 4 * 1024 * 1024 * 1024;
98
+ /** Cooldown between idle full collections. */
99
+ const IDLE_GC_WINDOW_MS = 5 * 60_000;
100
+ /** RSS retained above the post-collection baseline before another idle collection runs. */
101
+ const IDLE_GC_MIN_GROWTH_BYTES = 32 * 1024 * 1024;
102
+
103
+ /**
104
+ * Resolves the effective memory limit for the process.
105
+ *
106
+ * Precedence: an explicit `CARTETHYIA_MEMORY_LIMIT_BYTES` override, then the
107
+ * cgroup v2/v1 limit when running in a container. Falls back to
108
+ * {@link DEFAULT_MEMORY_LIMIT_BYTES} when no limit is discoverable (native
109
+ * development, macOS, Windows) so pressure-based reclaim still runs.
110
+ */
111
+ export function resolveMemoryLimitBytes(): number | undefined {
112
+ const override = resolveMemoryLimitOverrideBytes();
113
+ if (override !== undefined) return override;
114
+ if (process.platform !== "linux") return defaultMemoryLimitBytes();
115
+ for (const path of [
116
+ "/sys/fs/cgroup/memory.max",
117
+ "/sys/fs/cgroup/memory/memory.limit_in_bytes",
118
+ ]) {
119
+ try {
120
+ const content = readFileSync(path, "utf8").trim();
121
+ if (content === "max") continue;
122
+ const value = Number(content);
123
+ // The v1 unlimited sentinel is a huge finite number; treat it as unknown.
124
+ if (Number.isInteger(value) && value > 0 && value < 2 ** 53) return value;
125
+ } catch {
126
+ // File absent (cgroup v1 vs v2) or unreadable — try the next source.
127
+ }
128
+ }
129
+ return defaultMemoryLimitBytes();
130
+ }
131
+
132
+ /** Samples process memory and bounded-collection sizes into the metric registry. */
133
+ export class RuntimeMetricsSampler {
134
+ private readonly limitBytes: number | undefined;
135
+ private readonly gcOnPressure: boolean;
136
+ private lastPressureLogAt = 0;
137
+ private lastGcAt = 0;
138
+ private lastIdleGcAt = 0;
139
+ private rssAtLastGc = 0;
140
+ private inFlightSource: () => number = () => 0;
141
+ /**
142
+ * The latest snapshot, published once per sample. Stays undefined until the
143
+ * first sample runs; consumers must treat that as "unknown", not as zero.
144
+ */
145
+ private snapshot: RuntimeMemorySnapshot | undefined;
146
+
147
+ /**
148
+ * Binds the live in-flight count after the request state store exists. The
149
+ * sampler is constructed before that store, so the source is late-bound.
150
+ */
151
+ bindInFlight(source: () => number): void {
152
+ this.inFlightSource = source;
153
+ }
154
+
155
+ /**
156
+ * The last sampled memory state, for consumers on a hot path.
157
+ *
158
+ * Undefined before the first sample: a guard that read zero as "plenty of
159
+ * room" would disable itself exactly when it matters, so callers must fall
160
+ * back to their own judgement when this is absent.
161
+ */
162
+ memorySnapshot(): RuntimeMemorySnapshot | undefined {
163
+ return this.snapshot;
164
+ }
165
+
166
+ /**
167
+ * Publishes the effective concurrency ceiling.
168
+ *
169
+ * Late-bound like `bindInFlight`: the ceiling is derived from the memory
170
+ * limit, which is known here, but the guard that owns it is constructed in
171
+ * the transport layer.
172
+ */
173
+
174
+ constructor(
175
+ private readonly sources: RuntimeMetricSources,
176
+ limitBytes: number | undefined = resolveMemoryLimitBytes(),
177
+ // Off by default: a forced collection is not how memory is meant to come
178
+ // back. The synchronous form (`Bun.gc(true)`) stops the shared event loop
179
+ // outright, so every concurrent request stalls inside it — which reads as
180
+ // a latency spike with no upstream cause. And it hides the actual defect:
181
+ // a process that only returns memory when something forces it to is a
182
+ // process that is still holding it. Bounded caches, released streams, and
183
+ // batched writers reclaim naturally; this is opt-in for an operator who
184
+ // has measured that they need it.
185
+ gcOnPressure = readFlag("CARTETHYIA_FORCED_GC", CONFIG_SPEC.CARTETHYIA_FORCED_GC),
186
+ ) {
187
+ this.limitBytes = limitBytes;
188
+ this.gcOnPressure = gcOnPressure;
189
+ }
190
+
191
+ sample(): void {
192
+ const memory = process.memoryUsage();
193
+ metrics.cartethyia_memory_rss_bytes.set(memory.rss);
194
+ metrics.cartethyia_memory_heap_used_bytes.set(memory.heapUsed);
195
+ metrics.cartethyia_memory_heap_total_bytes.set(memory.heapTotal);
196
+ metrics.cartethyia_memory_limit_bytes.set(this.limitBytes ?? 0);
197
+ this.snapshot = {
198
+ rssBytes: memory.rss,
199
+ limitBytes: this.limitBytes,
200
+ sampledAtMs: Date.now(),
201
+ };
202
+ trackMemoryUsage("rss", memory.rss);
203
+ trackMemoryUsage("heap_used", memory.heapUsed);
204
+ trackMemoryUsage("heap_total", memory.heapTotal);
205
+ trackMemoryUsage("external", memory.external);
206
+
207
+ const roundRobin = this.sources.routingRoundRobinEntries();
208
+ metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.combo, { scope: "combo" });
209
+ metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.provider, { scope: "provider" });
210
+ metrics.cartethyia_ip_abuse_keys.set(this.sources.ipAbuseKeys());
211
+ metrics.cartethyia_quota_cache_entries.set(this.sources.quotaCacheEntries());
212
+ metrics.cartethyia_model_catalog_entries.set(this.sources.modelCatalogEntries());
213
+ metrics.cartethyia_process_in_flight.set(this.inFlightSource());
214
+ const backlog = this.sources.telemetryBacklog?.();
215
+ if (backlog !== undefined) {
216
+ metrics.cartethyia_telemetry_backlog_items.set(backlog.items);
217
+ metrics.cartethyia_telemetry_backlog_bytes.set(backlog.bytes);
218
+ }
219
+
220
+ this.checkPressure(memory.rss);
221
+ this.checkIdleReclaim(memory.rss);
222
+ }
223
+
224
+ private checkPressure(rss: number): void {
225
+ if (this.limitBytes === undefined || rss <= this.limitBytes * PRESSURE_THRESHOLD) return;
226
+ const now = Date.now();
227
+ if (this.gcOnPressure && now - this.lastGcAt >= PRESSURE_GC_WINDOW_MS) {
228
+ this.lastGcAt = now;
229
+ // Asynchronous GC: reclaims before the limit is reached without stalling
230
+ // in-flight requests on the shared event loop. A synchronous full
231
+ // collection here would add a latency spike to every concurrent request.
232
+ Bun.gc(false);
233
+ }
234
+ if (now - this.lastPressureLogAt < PRESSURE_LOG_WINDOW_MS) return;
235
+ this.lastPressureLogAt = now;
236
+ log.warn(
237
+ `[memory] RSS ${(rss / 1024 / 1024).toFixed(1)}MiB exceeds 80% of the ` +
238
+ `${(this.limitBytes / 1024 / 1024).toFixed(1)}MiB limit`,
239
+ );
240
+ }
241
+
242
+ /**
243
+ * Reclaims memory while the gateway is idle.
244
+ *
245
+ * Triggered on RSS growth rather than heap growth: this process holds most of
246
+ * its footprint off-heap (adapters, TLS contexts, buffers), and `heapUsed`
247
+ * barely moves when those are allocated — a heap-based gate never opens, which
248
+ * is why an idle gateway held its startup peak indefinitely.
249
+ *
250
+ * A synchronous full collection is safe here because no request is in flight to
251
+ * stall, and it is the only form that actually returns pages to the OS; the
252
+ * async form used under pressure does not.
253
+ *
254
+ * Not gated on `gcOnPressure` the way `checkPressure` is. The opt-in exists
255
+ * because a synchronous collection stalls concurrent requests, and this path
256
+ * only runs when there is nothing in flight to stall — so the reason the flag
257
+ * is off does not apply to it. Gating it meant a gateway with no traffic at
258
+ * all never reclaimed anything and held its startup peak for hours.
259
+ */
260
+ private checkIdleReclaim(rss: number): void {
261
+ if (this.inFlightSource() > 0) return;
262
+ const now = Date.now();
263
+ if (now - this.lastIdleGcAt < IDLE_GC_WINDOW_MS) return;
264
+ if (this.rssAtLastGc !== 0 && rss - this.rssAtLastGc < IDLE_GC_MIN_GROWTH_BYTES) return;
265
+ this.lastIdleGcAt = now;
266
+ Bun.gc(true);
267
+ this.rssAtLastGc = process.memoryUsage().rss;
268
+ }
269
+ }