@data-club/ai-hub-server 0.0.1
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.
- package/dist/auth/argon2.d.ts +72 -0
- package/dist/auth/argon2.d.ts.map +1 -0
- package/dist/auth/argon2.js +145 -0
- package/dist/auth/argon2.js.map +1 -0
- package/dist/auth/encryption.d.ts +52 -0
- package/dist/auth/encryption.d.ts.map +1 -0
- package/dist/auth/encryption.js +98 -0
- package/dist/auth/encryption.js.map +1 -0
- package/dist/auth/jwt.d.ts +133 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/jwt.js +147 -0
- package/dist/auth/jwt.js.map +1 -0
- package/dist/auth/middleware.d.ts +101 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +161 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/passwordPolicy.d.ts +72 -0
- package/dist/auth/passwordPolicy.d.ts.map +1 -0
- package/dist/auth/passwordPolicy.js +88 -0
- package/dist/auth/passwordPolicy.js.map +1 -0
- package/dist/auth/roleRules.d.ts +200 -0
- package/dist/auth/roleRules.d.ts.map +1 -0
- package/dist/auth/roleRules.js +365 -0
- package/dist/auth/roleRules.js.map +1 -0
- package/dist/auth/userView.d.ts +55 -0
- package/dist/auth/userView.d.ts.map +1 -0
- package/dist/auth/userView.js +77 -0
- package/dist/auth/userView.js.map +1 -0
- package/dist/bootstrap.d.ts +104 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +160 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/db/connection.d.ts +39 -0
- package/dist/db/connection.d.ts.map +1 -0
- package/dist/db/connection.js +62 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/migrate.d.ts +31 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +197 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema.d.ts +1902 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +662 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/seed.d.ts +92 -0
- package/dist/db/seed.d.ts.map +1 -0
- package/dist/db/seed.js +160 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/handlers/fetch.d.ts +89 -0
- package/dist/handlers/fetch.d.ts.map +1 -0
- package/dist/handlers/fetch.js +173 -0
- package/dist/handlers/fetch.js.map +1 -0
- package/dist/handlers/nextjs.d.ts +40 -0
- package/dist/handlers/nextjs.d.ts.map +1 -0
- package/dist/handlers/nextjs.js +42 -0
- package/dist/handlers/nextjs.js.map +1 -0
- package/dist/handlers/responses.d.ts +46 -0
- package/dist/handlers/responses.d.ts.map +1 -0
- package/dist/handlers/responses.js +74 -0
- package/dist/handlers/responses.js.map +1 -0
- package/dist/handlers/routeTable.d.ts +21 -0
- package/dist/handlers/routeTable.d.ts.map +1 -0
- package/dist/handlers/routeTable.js +133 -0
- package/dist/handlers/routeTable.js.map +1 -0
- package/dist/handlers/router.d.ts +37 -0
- package/dist/handlers/router.d.ts.map +1 -0
- package/dist/handlers/router.js +82 -0
- package/dist/handlers/router.js.map +1 -0
- package/dist/handlers/sse.d.ts +38 -0
- package/dist/handlers/sse.d.ts.map +1 -0
- package/dist/handlers/sse.js +52 -0
- package/dist/handlers/sse.js.map +1 -0
- package/dist/handlers/types.d.ts +96 -0
- package/dist/handlers/types.d.ts.map +1 -0
- package/dist/handlers/types.js +14 -0
- package/dist/handlers/types.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/letta/client.d.ts +355 -0
- package/dist/letta/client.d.ts.map +1 -0
- package/dist/letta/client.js +529 -0
- package/dist/letta/client.js.map +1 -0
- package/dist/letta/eventTranslator.d.ts +166 -0
- package/dist/letta/eventTranslator.d.ts.map +1 -0
- package/dist/letta/eventTranslator.js +532 -0
- package/dist/letta/eventTranslator.js.map +1 -0
- package/dist/letta/index.d.ts +13 -0
- package/dist/letta/index.d.ts.map +1 -0
- package/dist/letta/index.js +12 -0
- package/dist/letta/index.js.map +1 -0
- package/dist/letta/modelSettings.d.ts +176 -0
- package/dist/letta/modelSettings.d.ts.map +1 -0
- package/dist/letta/modelSettings.js +314 -0
- package/dist/letta/modelSettings.js.map +1 -0
- package/dist/letta/promptWrapping.d.ts +141 -0
- package/dist/letta/promptWrapping.d.ts.map +1 -0
- package/dist/letta/promptWrapping.js +213 -0
- package/dist/letta/promptWrapping.js.map +1 -0
- package/dist/letta/sandbox.d.ts +100 -0
- package/dist/letta/sandbox.d.ts.map +1 -0
- package/dist/letta/sandbox.js +151 -0
- package/dist/letta/sandbox.js.map +1 -0
- package/dist/letta/settingsFileSeed.d.ts +146 -0
- package/dist/letta/settingsFileSeed.d.ts.map +1 -0
- package/dist/letta/settingsFileSeed.js +177 -0
- package/dist/letta/settingsFileSeed.js.map +1 -0
- package/dist/letta/subprocessThrottle.d.ts +151 -0
- package/dist/letta/subprocessThrottle.d.ts.map +1 -0
- package/dist/letta/subprocessThrottle.js +195 -0
- package/dist/letta/subprocessThrottle.js.map +1 -0
- package/dist/letta/tpmRecovery.d.ts +202 -0
- package/dist/letta/tpmRecovery.d.ts.map +1 -0
- package/dist/letta/tpmRecovery.js +314 -0
- package/dist/letta/tpmRecovery.js.map +1 -0
- package/dist/letta/types.d.ts +54 -0
- package/dist/letta/types.d.ts.map +1 -0
- package/dist/letta/types.js +16 -0
- package/dist/letta/types.js.map +1 -0
- package/dist/observability/logger.d.ts +48 -0
- package/dist/observability/logger.d.ts.map +1 -0
- package/dist/observability/logger.js +81 -0
- package/dist/observability/logger.js.map +1 -0
- package/dist/observability/metrics.d.ts +53 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +92 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/routes/admin/agents.d.ts +63 -0
- package/dist/routes/admin/agents.d.ts.map +1 -0
- package/dist/routes/admin/agents.js +461 -0
- package/dist/routes/admin/agents.js.map +1 -0
- package/dist/routes/admin/rotatingMessages.d.ts +51 -0
- package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
- package/dist/routes/admin/rotatingMessages.js +292 -0
- package/dist/routes/admin/rotatingMessages.js.map +1 -0
- package/dist/routes/admin/sessions.d.ts +61 -0
- package/dist/routes/admin/sessions.d.ts.map +1 -0
- package/dist/routes/admin/sessions.js +232 -0
- package/dist/routes/admin/sessions.js.map +1 -0
- package/dist/routes/admin/settings.d.ts +110 -0
- package/dist/routes/admin/settings.d.ts.map +1 -0
- package/dist/routes/admin/settings.js +401 -0
- package/dist/routes/admin/settings.js.map +1 -0
- package/dist/routes/admin/users.d.ts +115 -0
- package/dist/routes/admin/users.d.ts.map +1 -0
- package/dist/routes/admin/users.js +374 -0
- package/dist/routes/admin/users.js.map +1 -0
- package/dist/routes/agents.d.ts +30 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +55 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/auth.d.ts +121 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +422 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +97 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +484 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/featureToggles.d.ts +34 -0
- package/dist/routes/featureToggles.d.ts.map +1 -0
- package/dist/routes/featureToggles.js +47 -0
- package/dist/routes/featureToggles.js.map +1 -0
- package/dist/routes/health.d.ts +24 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +37 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/metrics.d.ts +35 -0
- package/dist/routes/metrics.d.ts.map +1 -0
- package/dist/routes/metrics.js +106 -0
- package/dist/routes/metrics.js.map +1 -0
- package/dist/routes/sessions.d.ts +161 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +1501 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/stubs.d.ts +25 -0
- package/dist/routes/stubs.d.ts.map +1 -0
- package/dist/routes/stubs.js +25 -0
- package/dist/routes/stubs.js.map +1 -0
- package/dist/state/cron.d.ts +58 -0
- package/dist/state/cron.d.ts.map +1 -0
- package/dist/state/cron.js +160 -0
- package/dist/state/cron.js.map +1 -0
- package/dist/state/rotationRunner.d.ts +96 -0
- package/dist/state/rotationRunner.d.ts.map +1 -0
- package/dist/state/rotationRunner.js +246 -0
- package/dist/state/rotationRunner.js.map +1 -0
- package/dist/state/rotationScheduler.d.ts +76 -0
- package/dist/state/rotationScheduler.d.ts.map +1 -0
- package/dist/state/rotationScheduler.js +124 -0
- package/dist/state/rotationScheduler.js.map +1 -0
- package/dist/state/turnRegistry.d.ts +95 -0
- package/dist/state/turnRegistry.d.ts.map +1 -0
- package/dist/state/turnRegistry.js +149 -0
- package/dist/state/turnRegistry.js.map +1 -0
- package/dist/state/turnRunner.d.ts +134 -0
- package/dist/state/turnRunner.d.ts.map +1 -0
- package/dist/state/turnRunner.js +476 -0
- package/dist/state/turnRunner.js.map +1 -0
- package/package.json +47 -0
- package/src/auth/argon2.ts +152 -0
- package/src/auth/encryption.ts +110 -0
- package/src/auth/jwt.ts +217 -0
- package/src/auth/middleware.ts +250 -0
- package/src/auth/passwordPolicy.ts +99 -0
- package/src/auth/roleRules.ts +497 -0
- package/src/auth/userView.ts +88 -0
- package/src/bootstrap.ts +254 -0
- package/src/db/connection.ts +75 -0
- package/src/db/migrate.ts +212 -0
- package/src/db/schema.ts +750 -0
- package/src/db/seed.ts +236 -0
- package/src/handlers/fetch.ts +251 -0
- package/src/handlers/nextjs.ts +49 -0
- package/src/handlers/responses.ts +95 -0
- package/src/handlers/routeTable.ts +188 -0
- package/src/handlers/router.ts +96 -0
- package/src/handlers/sse.ts +55 -0
- package/src/handlers/types.ts +104 -0
- package/src/index.ts +60 -0
- package/src/letta/client.ts +860 -0
- package/src/letta/eventTranslator.ts +667 -0
- package/src/letta/index.ts +46 -0
- package/src/letta/lettaCliShim.mjs +40 -0
- package/src/letta/sandbox.ts +167 -0
- package/src/letta/settingsFileSeed.ts +254 -0
- package/src/letta/subprocessThrottle.ts +251 -0
- package/src/letta/types.ts +65 -0
- package/src/observability/logger.ts +103 -0
- package/src/observability/metrics.ts +114 -0
- package/src/routes/admin/agents.ts +574 -0
- package/src/routes/admin/rotatingMessages.ts +351 -0
- package/src/routes/admin/sessions.ts +283 -0
- package/src/routes/admin/settings.ts +463 -0
- package/src/routes/admin/users.ts +455 -0
- package/src/routes/agents.ts +66 -0
- package/src/routes/auth.ts +513 -0
- package/src/routes/chat.ts +534 -0
- package/src/routes/featureToggles.ts +52 -0
- package/src/routes/health.ts +44 -0
- package/src/routes/metrics.ts +111 -0
- package/src/routes/sessions.ts +1878 -0
- package/src/state/cron.ts +179 -0
- package/src/state/rotationRunner.ts +326 -0
- package/src/state/rotationScheduler.ts +164 -0
- package/src/state/turnRegistry.ts +210 -0
- package/src/state/turnRunner.ts +588 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic RAM-based concurrent-spawn throttle for Letta Code subprocesses.
|
|
3
|
+
*
|
|
4
|
+
* Every time we're about to spawn a Letta Code CLI subprocess (which happens
|
|
5
|
+
* once per chat turn — see `letta/client.ts` → `streamUserMessage`), we call
|
|
6
|
+
* `acquire()` first. If the host's *available* RAM has dropped below a
|
|
7
|
+
* configured threshold OR we've already got the configured hard-cap of live
|
|
8
|
+
* subprocesses, `acquire()` throws `ServerOverloadedError`. The chat route
|
|
9
|
+
* catches that and returns HTTP 503 with the `server_overloaded` SSE error
|
|
10
|
+
* event so the React client can surface a "server is busy" banner.
|
|
11
|
+
*
|
|
12
|
+
* **Why MemAvailable, not MemFree.** Linux uses unused RAM as page cache;
|
|
13
|
+
* `os.freemem()` / `/proc/meminfo`'s `MemFree` line reports memory that's
|
|
14
|
+
* *literally* unallocated, which on any healthy server is misleadingly low
|
|
15
|
+
* (often ~5% even when ~50% is actually allocatable without swapping).
|
|
16
|
+
* `/proc/meminfo`'s `MemAvailable` line is the kernel's own estimate of
|
|
17
|
+
* "memory that could be allocated without entering swap" — that's what we
|
|
18
|
+
* compare against the threshold.
|
|
19
|
+
*
|
|
20
|
+
* **Why two gates and not one.** The RAM gate is the primary throttle and
|
|
21
|
+
* adapts to whatever EC2 instance the deployment runs on. The hard-cap is a
|
|
22
|
+
* circuit breaker: if our `/proc/meminfo` math is ever wrong (kernel reports
|
|
23
|
+
* stale numbers under load, container cgroup limits trip earlier than host
|
|
24
|
+
* MemAvailable suggests, …), an unbounded subprocess pool would still be
|
|
25
|
+
* able to OOM-kill the box. The hard-cap is the floor on how bad it can get.
|
|
26
|
+
*
|
|
27
|
+
* @see ../../docs/operations/env-vars.md
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
import { totalmem } from "node:os";
|
|
32
|
+
|
|
33
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
34
|
+
/* Config */
|
|
35
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
36
|
+
|
|
37
|
+
/** Default minimum percentage of total RAM that must be available before we'll
|
|
38
|
+
* spawn a new subprocess. Overridable via env. */
|
|
39
|
+
export const DEFAULT_RAM_THRESHOLD_PCT = 20;
|
|
40
|
+
/** Default hard cap on concurrent live subprocesses. Overridable via env. */
|
|
41
|
+
export const DEFAULT_HARD_CAP = 100;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolved throttle config. Pulled from env at construction time; tests pass
|
|
45
|
+
* an explicit one to bypass env reading.
|
|
46
|
+
*/
|
|
47
|
+
export interface ThrottleConfig {
|
|
48
|
+
/** Minimum % of total RAM that must remain available to accept a spawn. */
|
|
49
|
+
ramThresholdPct: number;
|
|
50
|
+
/** Maximum concurrent live subprocesses, regardless of RAM. */
|
|
51
|
+
hardCap: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reads throttle config from the process environment, with defaults.
|
|
56
|
+
*
|
|
57
|
+
* Env vars:
|
|
58
|
+
* - `AI_HUB_SUBPROCESS_RAM_THRESHOLD_PCT` — integer 0..100. Defaults to 20.
|
|
59
|
+
* A value <= 0 disables the RAM gate (every acquire passes the RAM check).
|
|
60
|
+
* A value >= 100 means every acquire fails the RAM check (useful for
|
|
61
|
+
* testing the overload path).
|
|
62
|
+
* - `AI_HUB_SUBPROCESS_HARD_CAP` — positive integer. Defaults to 100.
|
|
63
|
+
*
|
|
64
|
+
* @returns the parsed config bundle.
|
|
65
|
+
*/
|
|
66
|
+
export function readThrottleConfigFromEnv(): ThrottleConfig {
|
|
67
|
+
const rawPct = process.env.AI_HUB_SUBPROCESS_RAM_THRESHOLD_PCT;
|
|
68
|
+
const rawCap = process.env.AI_HUB_SUBPROCESS_HARD_CAP;
|
|
69
|
+
return {
|
|
70
|
+
ramThresholdPct: parseIntInRange(rawPct, 0, 100, DEFAULT_RAM_THRESHOLD_PCT),
|
|
71
|
+
hardCap: parseIntInRange(rawCap, 1, 10_000, DEFAULT_HARD_CAP),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseIntInRange(raw: string | undefined, min: number, max: number, fallback: number): number {
|
|
76
|
+
if (raw === undefined || raw === "") return fallback;
|
|
77
|
+
const parsed = Number.parseInt(raw, 10);
|
|
78
|
+
if (!Number.isFinite(parsed) || parsed < min || parsed > max) return fallback;
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
83
|
+
/* Memory reader */
|
|
84
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reader for the host's MemAvailable + MemTotal. Returns kilobytes (matches
|
|
88
|
+
* `/proc/meminfo` units). Tests inject a fake reader so they don't depend on
|
|
89
|
+
* the host's actual memory state.
|
|
90
|
+
*/
|
|
91
|
+
export interface MemReader {
|
|
92
|
+
/** @returns `{availableKb, totalKb}` snapshot at the moment of call. */
|
|
93
|
+
read(): { availableKb: number; totalKb: number };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Default reader — parses `/proc/meminfo` directly. Falls back to
|
|
98
|
+
* `os.totalmem()` for `totalKb` when /proc is unreadable (non-Linux), and
|
|
99
|
+
* returns `MemTotal` as `availableKb` in that case so the RAM gate is
|
|
100
|
+
* effectively disabled there (the hard-cap still applies).
|
|
101
|
+
*
|
|
102
|
+
* This file lives at a stable kernel-exposed path on every Linux host
|
|
103
|
+
* (including in Docker without any special mounts) so no privileges are
|
|
104
|
+
* needed.
|
|
105
|
+
*
|
|
106
|
+
* @see https://www.kernel.org/doc/Documentation/filesystems/proc.txt — the
|
|
107
|
+
* meminfo section documents MemAvailable's semantics ("estimate of how
|
|
108
|
+
* much memory is available for starting new applications, without
|
|
109
|
+
* swapping").
|
|
110
|
+
*/
|
|
111
|
+
export const procMeminfoReader: MemReader = {
|
|
112
|
+
read() {
|
|
113
|
+
try {
|
|
114
|
+
const raw = readFileSync("/proc/meminfo", "utf8");
|
|
115
|
+
const total = raw.match(/^MemTotal:\s+(\d+)\s+kB/m);
|
|
116
|
+
const available = raw.match(/^MemAvailable:\s+(\d+)\s+kB/m);
|
|
117
|
+
if (total && available) {
|
|
118
|
+
return { totalKb: Number(total[1]), availableKb: Number(available[1]) };
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Fall through to the os.totalmem fallback.
|
|
122
|
+
}
|
|
123
|
+
const totalKb = Math.round(totalmem() / 1024);
|
|
124
|
+
return { totalKb, availableKb: totalKb };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
129
|
+
/* Throttle */
|
|
130
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Typed error thrown by `SubprocessThrottle.acquire()` when the host is too
|
|
134
|
+
* loaded to accept another subprocess. The chat route catches this and
|
|
135
|
+
* returns HTTP 503 with an `error {server_overloaded}` SSE event.
|
|
136
|
+
*/
|
|
137
|
+
export class ServerOverloadedError extends Error {
|
|
138
|
+
/** Stable error code for the SSE wire protocol's `error.code` field. */
|
|
139
|
+
readonly code = "server_overloaded" as const;
|
|
140
|
+
/** Which gate tripped — useful for logs + the eventual operator playbook. */
|
|
141
|
+
readonly gate: "ram" | "hard_cap";
|
|
142
|
+
/** Snapshot of the trigger at the moment we rejected. */
|
|
143
|
+
readonly detail: {
|
|
144
|
+
/** MemAvailable as a fraction of MemTotal at the time of rejection. */
|
|
145
|
+
availablePct: number;
|
|
146
|
+
/** Live subprocess count at the time of rejection. */
|
|
147
|
+
liveCount: number;
|
|
148
|
+
/** Configured RAM threshold (percent). */
|
|
149
|
+
ramThresholdPct: number;
|
|
150
|
+
/** Configured hard cap. */
|
|
151
|
+
hardCap: number;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
constructor(gate: "ram" | "hard_cap", detail: ServerOverloadedError["detail"]) {
|
|
155
|
+
super(
|
|
156
|
+
gate === "ram"
|
|
157
|
+
? `Server overloaded: only ${detail.availablePct.toFixed(1)}% RAM available (< ${detail.ramThresholdPct}% required).`
|
|
158
|
+
: `Server overloaded: ${detail.liveCount} live subprocesses (>= ${detail.hardCap} hard cap).`,
|
|
159
|
+
);
|
|
160
|
+
this.name = "ServerOverloadedError";
|
|
161
|
+
this.gate = gate;
|
|
162
|
+
this.detail = detail;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A leased slot in the throttle's live-subprocess counter. Returned by
|
|
168
|
+
* `acquire()`; callers must call `release()` exactly once (use try/finally).
|
|
169
|
+
*/
|
|
170
|
+
export interface ThrottleLease {
|
|
171
|
+
/** Releases the slot back to the throttle. Idempotent — calling twice
|
|
172
|
+
* is a no-op. */
|
|
173
|
+
release(): void;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The throttle.
|
|
178
|
+
*
|
|
179
|
+
* Construct one per ai-hub-server process (it's stateful — the live-count
|
|
180
|
+
* counter is in-memory). Production wires this into `AIHubServerContext` via
|
|
181
|
+
* `createFetchHandler`; tests pass a fake `MemReader` to drive the RAM gate
|
|
182
|
+
* deterministically.
|
|
183
|
+
*/
|
|
184
|
+
export class SubprocessThrottle {
|
|
185
|
+
readonly config: ThrottleConfig;
|
|
186
|
+
private readonly memReader: MemReader;
|
|
187
|
+
private liveCount = 0;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param config - resolved throttle config (use `readThrottleConfigFromEnv`).
|
|
191
|
+
* @param memReader - mem reader (defaults to /proc/meminfo).
|
|
192
|
+
*/
|
|
193
|
+
constructor(config: ThrottleConfig, memReader: MemReader = procMeminfoReader) {
|
|
194
|
+
this.config = config;
|
|
195
|
+
this.memReader = memReader;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Attempts to acquire a slot. Throws `ServerOverloadedError` synchronously
|
|
200
|
+
* if either gate trips; otherwise increments the live counter and returns
|
|
201
|
+
* a lease the caller must release when the subprocess exits.
|
|
202
|
+
*
|
|
203
|
+
* The acquire-then-spawn dance has a small race: between the RAM check
|
|
204
|
+
* here and the actual spawn, another concurrent acquire could land. That's
|
|
205
|
+
* acceptable for v1 — the next acquire will see the updated MemAvailable
|
|
206
|
+
* and the hard cap is the absolute backstop.
|
|
207
|
+
*
|
|
208
|
+
* @returns a lease the caller must `.release()` in finally.
|
|
209
|
+
* @throws `ServerOverloadedError` if either gate trips.
|
|
210
|
+
*/
|
|
211
|
+
acquire(): ThrottleLease {
|
|
212
|
+
// Hard cap check first — it's cheap and the more deterministic gate.
|
|
213
|
+
if (this.liveCount >= this.config.hardCap) {
|
|
214
|
+
throw new ServerOverloadedError("hard_cap", {
|
|
215
|
+
availablePct: 100, // unknown but irrelevant; the cap gate fired
|
|
216
|
+
liveCount: this.liveCount,
|
|
217
|
+
ramThresholdPct: this.config.ramThresholdPct,
|
|
218
|
+
hardCap: this.config.hardCap,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// RAM gate. ramThresholdPct=0 disables this gate entirely.
|
|
223
|
+
if (this.config.ramThresholdPct > 0) {
|
|
224
|
+
const { availableKb, totalKb } = this.memReader.read();
|
|
225
|
+
const availablePct = totalKb > 0 ? (availableKb / totalKb) * 100 : 100;
|
|
226
|
+
if (availablePct < this.config.ramThresholdPct) {
|
|
227
|
+
throw new ServerOverloadedError("ram", {
|
|
228
|
+
availablePct,
|
|
229
|
+
liveCount: this.liveCount,
|
|
230
|
+
ramThresholdPct: this.config.ramThresholdPct,
|
|
231
|
+
hardCap: this.config.hardCap,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.liveCount++;
|
|
237
|
+
let released = false;
|
|
238
|
+
return {
|
|
239
|
+
release: () => {
|
|
240
|
+
if (released) return;
|
|
241
|
+
released = true;
|
|
242
|
+
this.liveCount--;
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @returns the current live-subprocess count (for observability + tests). */
|
|
248
|
+
get currentLiveCount(): number {
|
|
249
|
+
return this.liveCount;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-shaped Letta types.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports only the SDK surfaces ai-hub uses, so the rest of the server
|
|
5
|
+
* code never has to traverse `@letta-ai/letta-client`'s deep namespace
|
|
6
|
+
* structure. **No raw HTTP, ever** — these are aliases over the SDK's own
|
|
7
|
+
* types, not parallel inventions (§6.4).
|
|
8
|
+
*
|
|
9
|
+
* If the SDK rev removes or renames one of these, the typechecker flags it
|
|
10
|
+
* at upgrade time and `letta/client.ts` is the single place to adapt.
|
|
11
|
+
*
|
|
12
|
+
* @see SPECIFICATIONS.md §6.4 — SDK-only access pattern
|
|
13
|
+
* @see SPECIFICATIONS.md §6.4.1 — first-class surfaces we depend on
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type Letta from "@letta-ai/letta-client";
|
|
17
|
+
import type {
|
|
18
|
+
AssistantMessage,
|
|
19
|
+
HiddenReasoningMessage,
|
|
20
|
+
Message as LettaSdkMessage,
|
|
21
|
+
ReasoningMessage,
|
|
22
|
+
ToolCallMessage,
|
|
23
|
+
UserMessage,
|
|
24
|
+
} from "@letta-ai/letta-client/resources/agents/messages.js";
|
|
25
|
+
import type { LettaStreamingResponse } from "@letta-ai/letta-client/resources/agents/messages.js";
|
|
26
|
+
import type { ToolReturnMessage } from "@letta-ai/letta-client/resources/tools.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The underlying SDK client. We `import type` everywhere except in
|
|
30
|
+
* `letta/client.ts` (where the constructor actually runs) so the rest of the
|
|
31
|
+
* codebase stays SDK-version-agnostic in shape.
|
|
32
|
+
*/
|
|
33
|
+
export type LettaSdk = Letta;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One streaming event from `client.conversations.messages.create({ streaming: true })`.
|
|
37
|
+
*
|
|
38
|
+
* The union spans every variant Letta emits in an SSE stream — assistant
|
|
39
|
+
* text, reasoning, tool calls, tool returns, errors, pings, stop reasons,
|
|
40
|
+
* usage stats. `letta/eventTranslator.ts` is the pure function from this
|
|
41
|
+
* union to ai-hub's wire-protocol events (§9).
|
|
42
|
+
*
|
|
43
|
+
* @see SPECIFICATIONS.md §9 — wire protocol
|
|
44
|
+
*/
|
|
45
|
+
export type LettaStreamEvent = LettaStreamingResponse;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Alias for `AssistantMessage` — used by the translator + tests. See
|
|
49
|
+
* `letta/eventTranslator.ts` for the streamed-delta vs. cumulative-content
|
|
50
|
+
* note.
|
|
51
|
+
*/
|
|
52
|
+
export type LettaAssistantMessage = AssistantMessage;
|
|
53
|
+
/** Alias for `ReasoningMessage` — see `eventTranslator.ts`. */
|
|
54
|
+
export type LettaReasoningMessage = ReasoningMessage;
|
|
55
|
+
/** Alias for `HiddenReasoningMessage` — see `eventTranslator.ts`. */
|
|
56
|
+
export type LettaHiddenReasoningMessage = HiddenReasoningMessage;
|
|
57
|
+
/** Alias for `ToolCallMessage` — see `eventTranslator.ts`. */
|
|
58
|
+
export type LettaToolCallMessage = ToolCallMessage;
|
|
59
|
+
/** Alias for `ToolReturnMessage` — see `eventTranslator.ts`. */
|
|
60
|
+
export type LettaToolReturnMessage = ToolReturnMessage;
|
|
61
|
+
/** Alias for `UserMessage` — only appears in history reads, not in streams. */
|
|
62
|
+
export type LettaUserMessage = UserMessage;
|
|
63
|
+
|
|
64
|
+
/** Full assembled Letta message (used by `messages.list`). */
|
|
65
|
+
export type LettaMessage = LettaSdkMessage;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logging via pino.
|
|
3
|
+
*
|
|
4
|
+
* Production: raw JSON to stdout, one line per record (§6.10 — twelve-factor
|
|
5
|
+
* pattern). Dev: piped through `pino-pretty` for human-readable output, gated
|
|
6
|
+
* on `NODE_ENV !== "production"` or `LOG_LEVEL=debug`.
|
|
7
|
+
*
|
|
8
|
+
* **Redaction is a load-bearing security control** (§13.3). pino's built-in
|
|
9
|
+
* `redact` option is the single point of truth — every log call goes through
|
|
10
|
+
* the configured logger, and a regression test asserts a known-secret value
|
|
11
|
+
* never appears in captured stdout.
|
|
12
|
+
*
|
|
13
|
+
* @see SPECIFICATIONS.md §6.10 — observability
|
|
14
|
+
* @see SPECIFICATIONS.md §13.3 — secrets
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { pino, type Logger as PinoLogger, type LoggerOptions } from "pino";
|
|
18
|
+
|
|
19
|
+
/** What the rest of the server consumes — keeps pino out of import surfaces. */
|
|
20
|
+
export type Logger = PinoLogger;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The redaction paths pino watches on every log record.
|
|
24
|
+
*
|
|
25
|
+
* The list explicitly covers the keys the spec calls out (`*_key`, `*_token`,
|
|
26
|
+
* `password`, `authorization`) plus their nested forms inside common places
|
|
27
|
+
* (`req.headers.authorization`, `body.password`, etc.). Adding a new
|
|
28
|
+
* sensitive field is a matter of appending a path here — there is no second
|
|
29
|
+
* redaction layer to keep in sync.
|
|
30
|
+
*/
|
|
31
|
+
const REDACT_PATHS: ReadonlyArray<string> = [
|
|
32
|
+
"password",
|
|
33
|
+
"current_password",
|
|
34
|
+
"new_password",
|
|
35
|
+
"passwordHash",
|
|
36
|
+
"password_hash",
|
|
37
|
+
"authorization",
|
|
38
|
+
"Authorization",
|
|
39
|
+
"headers.authorization",
|
|
40
|
+
"headers.Authorization",
|
|
41
|
+
"req.headers.authorization",
|
|
42
|
+
"req.headers.Authorization",
|
|
43
|
+
"request.headers.authorization",
|
|
44
|
+
"request.headers.Authorization",
|
|
45
|
+
"*.authorization",
|
|
46
|
+
"*.Authorization",
|
|
47
|
+
"service_token_signing_key",
|
|
48
|
+
"serviceTokenSigningKey",
|
|
49
|
+
"letta_api_key",
|
|
50
|
+
"lettaApiKey",
|
|
51
|
+
"metrics_scrape_token",
|
|
52
|
+
"metricsScrapeToken",
|
|
53
|
+
"encryption_key",
|
|
54
|
+
"encryptionKey",
|
|
55
|
+
"token",
|
|
56
|
+
"*.token",
|
|
57
|
+
"generated_password",
|
|
58
|
+
"*.generated_password",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Optional shape passed to `createLogger`. The dev-mode pretty-print stream
|
|
63
|
+
* is set up by the caller (the dev/server entrypoint) — `createLogger` itself
|
|
64
|
+
* stays format-agnostic so tests can pass a writable stream.
|
|
65
|
+
*/
|
|
66
|
+
export interface CreateLoggerOptions {
|
|
67
|
+
/** pino level (`fatal | error | warn | info | debug | trace`). Default `info`. */
|
|
68
|
+
level?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Optional destination. Tests pass a memory stream so they can assert on
|
|
71
|
+
* the captured output. Production passes nothing — pino writes to stdout
|
|
72
|
+
* by default.
|
|
73
|
+
*/
|
|
74
|
+
destination?: NodeJS.WritableStream;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Builds a pino logger with the project's redaction policy applied.
|
|
79
|
+
*
|
|
80
|
+
* @param opts - level + optional destination override.
|
|
81
|
+
* @returns the pino logger instance.
|
|
82
|
+
*/
|
|
83
|
+
export function createLogger(opts: CreateLoggerOptions = {}): Logger {
|
|
84
|
+
const config: LoggerOptions = {
|
|
85
|
+
level: opts.level ?? process.env.LOG_LEVEL ?? "info",
|
|
86
|
+
redact: {
|
|
87
|
+
paths: [...REDACT_PATHS],
|
|
88
|
+
censor: "[redacted]",
|
|
89
|
+
remove: false,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
return opts.destination ? pino(config, opts.destination) : pino(config);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The redaction-path list — exposed for tests so a new path added in
|
|
97
|
+
* `REDACT_PATHS` is automatically covered without test churn.
|
|
98
|
+
*
|
|
99
|
+
* @returns a fresh array of the redaction paths used by `createLogger`.
|
|
100
|
+
*/
|
|
101
|
+
export function getRedactPaths(): string[] {
|
|
102
|
+
return [...REDACT_PATHS];
|
|
103
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local Prometheus-shaped metrics registry.
|
|
3
|
+
*
|
|
4
|
+
* v1 keeps this tiny — three counters + two gauges — to avoid pulling
|
|
5
|
+
* `prom-client` for what is a five-series surface. The names + types
|
|
6
|
+
* match §6.10's contract verbatim so an operator's existing dashboards
|
|
7
|
+
* keep working when a future version swaps in a heavier library.
|
|
8
|
+
*
|
|
9
|
+
* Counters monotonically increment (and persist across the process'
|
|
10
|
+
* lifetime); gauges hold a single most-recent value. Step 5 wires the
|
|
11
|
+
* chat-side series; Step 6 fills in the TPM-recovery counters.
|
|
12
|
+
*
|
|
13
|
+
* @see SPECIFICATIONS.md §6.10 — observability / metrics
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A monotonic counter. */
|
|
17
|
+
export interface Counter {
|
|
18
|
+
/** Adds `n` (default 1) to the counter. */
|
|
19
|
+
inc(n?: number): void;
|
|
20
|
+
/** Reads the current value. */
|
|
21
|
+
value(): number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A single-value gauge. */
|
|
25
|
+
export interface Gauge {
|
|
26
|
+
/** Replaces the gauge's current value. */
|
|
27
|
+
set(value: number): void;
|
|
28
|
+
/** Reads the current value. */
|
|
29
|
+
value(): number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Internal counter factory. */
|
|
33
|
+
function counter(): Counter {
|
|
34
|
+
let v = 0;
|
|
35
|
+
return {
|
|
36
|
+
inc(n = 1) {
|
|
37
|
+
v += n;
|
|
38
|
+
},
|
|
39
|
+
value() {
|
|
40
|
+
return v;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Internal gauge factory. */
|
|
46
|
+
function gauge(): Gauge {
|
|
47
|
+
let v = 0;
|
|
48
|
+
return {
|
|
49
|
+
set(value) {
|
|
50
|
+
v = value;
|
|
51
|
+
},
|
|
52
|
+
value() {
|
|
53
|
+
return v;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Total `/chat` requests received. Bumped on every accepted POST. */
|
|
59
|
+
export const chatRequestsTotal: Counter = counter();
|
|
60
|
+
/** TPM recovery attempts started — populated by Step 6. */
|
|
61
|
+
export const tpmRecoveryAttemptsTotal: Counter = counter();
|
|
62
|
+
/** TPM recoveries that exhausted their budget — populated by Step 6. */
|
|
63
|
+
export const tpmExhaustedTotal: Counter = counter();
|
|
64
|
+
/** Most-recent `/chat` turn duration in ms. */
|
|
65
|
+
export const chatDurationMs: Gauge = gauge();
|
|
66
|
+
/** Most-recent Letta SDK call duration in ms. */
|
|
67
|
+
export const lettaRequestDurationMs: Gauge = gauge();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Renders the registry as a Prometheus-text v0.0.4 exposition.
|
|
71
|
+
*
|
|
72
|
+
* The route handler in `routes/metrics.ts` calls this after the bearer
|
|
73
|
+
* token gate passes.
|
|
74
|
+
*
|
|
75
|
+
* @returns the full response body.
|
|
76
|
+
*/
|
|
77
|
+
export function renderMetrics(): string {
|
|
78
|
+
return (
|
|
79
|
+
[
|
|
80
|
+
"# HELP chat_requests_total Total /chat requests received.",
|
|
81
|
+
"# TYPE chat_requests_total counter",
|
|
82
|
+
`chat_requests_total ${chatRequestsTotal.value()}`,
|
|
83
|
+
"",
|
|
84
|
+
"# HELP tpm_recovery_attempts_total TPM recovery attempts started.",
|
|
85
|
+
"# TYPE tpm_recovery_attempts_total counter",
|
|
86
|
+
`tpm_recovery_attempts_total ${tpmRecoveryAttemptsTotal.value()}`,
|
|
87
|
+
"",
|
|
88
|
+
"# HELP tpm_exhausted_total TPM recoveries that exhausted their budget.",
|
|
89
|
+
"# TYPE tpm_exhausted_total counter",
|
|
90
|
+
`tpm_exhausted_total ${tpmExhaustedTotal.value()}`,
|
|
91
|
+
"",
|
|
92
|
+
"# HELP chat_duration_ms Most-recent /chat turn duration in ms.",
|
|
93
|
+
"# TYPE chat_duration_ms gauge",
|
|
94
|
+
`chat_duration_ms ${chatDurationMs.value()}`,
|
|
95
|
+
"",
|
|
96
|
+
"# HELP letta_request_duration_ms Most-recent Letta SDK call duration in ms.",
|
|
97
|
+
"# TYPE letta_request_duration_ms gauge",
|
|
98
|
+
`letta_request_duration_ms ${lettaRequestDurationMs.value()}`,
|
|
99
|
+
"",
|
|
100
|
+
].join("\n")
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resets every series. **Test-only** — production never calls this.
|
|
106
|
+
* Without it, vitest's module-cache means counters carry across tests.
|
|
107
|
+
*/
|
|
108
|
+
export function _resetMetricsForTests(): void {
|
|
109
|
+
chatRequestsTotal.inc(-chatRequestsTotal.value());
|
|
110
|
+
tpmRecoveryAttemptsTotal.inc(-tpmRecoveryAttemptsTotal.value());
|
|
111
|
+
tpmExhaustedTotal.inc(-tpmExhaustedTotal.value());
|
|
112
|
+
chatDurationMs.set(0);
|
|
113
|
+
lettaRequestDurationMs.set(0);
|
|
114
|
+
}
|