@danceiny/gotry 0.2.0-rc.31 → 0.2.0-rc.33

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 (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Resource limits for the child process trees that run model-driven code on a replica
3
+ * (docs/rfc/aaas-rfc.md §4.8, threat S-17).
4
+ *
5
+ * The tenant boundary of the state layer says nothing about CPU and memory: every tenant on a replica shares them,
6
+ * so one runaway planner child could take the replica, and every tenant on it, down with it. This module bounds
7
+ * what one child tree can take before the operating system's OOM killer decides for us. It is a ceiling for a
8
+ * runaway, not a budget and not isolation: a tree that stays under its limits still shares the machine.
9
+ *
10
+ * The governor samples the process table (`ps`), finds the tree that starts at a watched root, and kills the
11
+ * whole tree the moment it is over a limit. The root is identified by its parent as well as by its pid, so a pid
12
+ * that the operating system has reused for an unrelated process is never sampled and never killed. Sampling is
13
+ * read-only, one `ps` per tick for every watched tree, and a failed sample kills nothing.
14
+ *
15
+ * Linux places the worker in a user-systemd scope and Darwin in its own process group, so neither a group id nor a
16
+ * cgroup is assumed here: the tree is the root and its descendants by parent pid, which both platforms report.
17
+ * A child that deliberately re-parents itself away leaves the tree; the planner children cannot start processes
18
+ * (their shell, file and subprocess plugins are disabled), and the provider-owned range cleanup of
19
+ * `dsh-subprocess-local` remains the primary teardown. This is the second line.
20
+ */
21
+ import { execFile } from 'node:child_process';
22
+ /** The process table cannot be read or does not show this process: the sampler would govern nothing. */
23
+ export class ProcessTableUnavailableError extends Error {
24
+ constructor(cause) {
25
+ super('the process table cannot be read, so planner process trees cannot be governed', { cause });
26
+ this.name = 'ProcessTableUnavailableError';
27
+ }
28
+ }
29
+ const DEFAULT_INTERVAL_MS = 1_000;
30
+ const DEFAULT_MAX_BLIND_TICKS = 10;
31
+ const DEFAULT_TABLE_TIMEOUT_MS = 5_000;
32
+ const PS_TIMEOUT_MS = 3_000;
33
+ const PS_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
34
+ /** `[DD-][HH:]MM:SS[.ss]`: Linux prints `HH:MM:SS` and `DD-HH:MM:SS`, Darwin `M:SS.ss` and `H:MM:SS.ss`. NaN for anything else. */
35
+ export function parseCpuSeconds(text) {
36
+ const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(text.trim());
37
+ if (!match)
38
+ return Number.NaN;
39
+ const [, days, hours, minutes, seconds] = match;
40
+ return (Number(days ?? 0) * 24 + Number(hours ?? 0)) * 3_600 + Number(minutes) * 60 + Number(seconds);
41
+ }
42
+ /** Parses the output of `ps` with `PS_ARGS` (rss in KiB). Lines that do not parse are dropped, never guessed. */
43
+ export function parsePsTable(stdout) {
44
+ const rows = [];
45
+ for (const line of stdout.split('\n')) {
46
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
47
+ if (!match)
48
+ continue;
49
+ const cpuSeconds = parseCpuSeconds(match[4]);
50
+ if (Number.isNaN(cpuSeconds))
51
+ continue;
52
+ rows.push({ pid: Number(match[1]), ppid: Number(match[2]), rssBytes: Number(match[3]) * 1024, cpuSeconds });
53
+ }
54
+ return rows;
55
+ }
56
+ /**
57
+ * `-A` selects every process on procps (Linux) and on BSD (Darwin) alike; the BSD-style `-ax` that the cleanup diagnostics
58
+ * use on Darwin makes procps warn on stderr. `pid=` and the like suppress the header. `rss` is in KiB on both.
59
+ */
60
+ export const PS_ARGS = ['-A', '-o', 'pid=,ppid=,rss=,time='];
61
+ export function readPsTable() {
62
+ return new Promise((resolve, reject) => {
63
+ execFile('/bin/ps', [...PS_ARGS], {
64
+ encoding: 'utf8', timeout: PS_TIMEOUT_MS, maxBuffer: PS_MAX_BUFFER_BYTES,
65
+ env: { PATH: '/usr/bin:/bin', LC_ALL: 'C' },
66
+ }, (error, stdout) => (error ? reject(error) : resolve(parsePsTable(stdout))));
67
+ });
68
+ }
69
+ /**
70
+ * The root and its descendants, or `null` when the root is not there or is not a child of the expected parent.
71
+ * Pure: the suites feed crafted tables.
72
+ */
73
+ export function treeOf(rows, target) {
74
+ const byPid = new Map();
75
+ const children = new Map();
76
+ for (const row of rows) {
77
+ byPid.set(row.pid, row);
78
+ const siblings = children.get(row.ppid);
79
+ if (siblings)
80
+ siblings.push(row);
81
+ else
82
+ children.set(row.ppid, [row]);
83
+ }
84
+ const root = byPid.get(target.rootPid);
85
+ if (!root || root.ppid !== target.parentPid)
86
+ return null;
87
+ const pids = [];
88
+ let rssBytes = 0;
89
+ let cpuSeconds = 0;
90
+ const seen = new Set();
91
+ const queue = [root];
92
+ for (let row = queue.shift(); row; row = queue.shift()) {
93
+ if (seen.has(row.pid))
94
+ continue;
95
+ seen.add(row.pid);
96
+ pids.push(row.pid);
97
+ rssBytes += row.rssBytes;
98
+ cpuSeconds += row.cpuSeconds;
99
+ for (const child of children.get(row.pid) ?? [])
100
+ queue.push(child);
101
+ }
102
+ return { pids, rssBytes, cpuSeconds };
103
+ }
104
+ function withTimeout(promise, ms) {
105
+ return new Promise((resolve, reject) => {
106
+ const timer = setTimeout(() => reject(new Error('the process table did not answer in time')), ms);
107
+ promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
108
+ });
109
+ }
110
+ function breachOf(sample, limits) {
111
+ if (limits.maxRssBytes !== undefined && sample.rssBytes > limits.maxRssBytes) {
112
+ return { kind: 'memory', limit: limits.maxRssBytes, observed: sample.rssBytes };
113
+ }
114
+ if (limits.maxCpuSeconds !== undefined && sample.cpuSeconds > limits.maxCpuSeconds) {
115
+ return { kind: 'cpu', limit: limits.maxCpuSeconds, observed: sample.cpuSeconds };
116
+ }
117
+ return null;
118
+ }
119
+ function assertPositive(name, value) {
120
+ if (value !== undefined && (!Number.isFinite(value) || value <= 0))
121
+ throw new RangeError(`${name} must be a positive number`);
122
+ }
123
+ function killEach(pids) {
124
+ for (const pid of pids) {
125
+ try {
126
+ process.kill(pid, 'SIGKILL');
127
+ }
128
+ catch { /* already gone */ }
129
+ }
130
+ }
131
+ export class ProcessTreeGovernor {
132
+ intervalMs;
133
+ maxBlindTicks;
134
+ tableTimeoutMs;
135
+ table;
136
+ kill;
137
+ onFault;
138
+ watches = new Map();
139
+ nextId = 1;
140
+ timer;
141
+ ticking = false;
142
+ sampleFailures = 0;
143
+ constructor(options = {}) {
144
+ this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
145
+ assertPositive('intervalMs', this.intervalMs);
146
+ this.maxBlindTicks = options.maxBlindTicks ?? DEFAULT_MAX_BLIND_TICKS;
147
+ assertPositive('maxBlindTicks', this.maxBlindTicks);
148
+ this.tableTimeoutMs = options.tableTimeoutMs ?? DEFAULT_TABLE_TIMEOUT_MS;
149
+ assertPositive('tableTimeoutMs', this.tableTimeoutMs);
150
+ this.table = options.table ?? readPsTable;
151
+ this.kill = options.kill ?? killEach;
152
+ this.onFault = options.onFault;
153
+ }
154
+ /** Trees under watch now. */
155
+ get size() {
156
+ return this.watches.size;
157
+ }
158
+ /** Samples that failed since construction. A failed sample kills nothing. */
159
+ get failedSamples() {
160
+ return this.sampleFailures;
161
+ }
162
+ /**
163
+ * Watches one tree. `onBreach` runs once, before the tree is killed, so the owner can record why. The returned
164
+ * function stops the watch and is safe to call after a breach. With no limit given nothing is watched.
165
+ */
166
+ watch(target, limits, onBreach) {
167
+ if (!Number.isSafeInteger(target.rootPid) || target.rootPid <= 0)
168
+ throw new RangeError('rootPid must be a positive integer');
169
+ if (!Number.isSafeInteger(target.parentPid) || target.parentPid <= 0)
170
+ throw new RangeError('parentPid must be a positive integer');
171
+ assertPositive('maxRssBytes', limits.maxRssBytes);
172
+ assertPositive('maxCpuSeconds', limits.maxCpuSeconds);
173
+ if (limits.maxRssBytes === undefined && limits.maxCpuSeconds === undefined)
174
+ return () => undefined;
175
+ const id = this.nextId++;
176
+ this.watches.set(id, { target, limits, onBreach, blind: 0 });
177
+ this.startTimer();
178
+ return () => {
179
+ this.watches.delete(id);
180
+ if (this.watches.size === 0)
181
+ this.stopTimer();
182
+ };
183
+ }
184
+ /**
185
+ * Proves the sampler can see: the table reads, and it shows this very process. A service that cannot see its own
186
+ * process table would run its children ungoverned and silently (for example where `ps` is a stripped-down build),
187
+ * so a deployment checks this once before the first tree starts and refuses to run children without it.
188
+ */
189
+ async probe() {
190
+ let rows;
191
+ try {
192
+ rows = await withTimeout(this.table(), this.tableTimeoutMs);
193
+ }
194
+ catch (error) {
195
+ throw new ProcessTableUnavailableError(error);
196
+ }
197
+ const self = rows.find((row) => row.pid === process.pid);
198
+ if (!self || self.rssBytes <= 0)
199
+ throw new ProcessTableUnavailableError();
200
+ }
201
+ /** One sampling pass. The timer calls it; the suites call it directly to stay deterministic. */
202
+ async tick() {
203
+ if (this.ticking || this.watches.size === 0)
204
+ return;
205
+ this.ticking = true;
206
+ try {
207
+ let rows = null;
208
+ try {
209
+ rows = await withTimeout(this.table(), this.tableTimeoutMs);
210
+ }
211
+ catch (error) {
212
+ this.sampleFailures += 1;
213
+ this.report(error);
214
+ }
215
+ for (const [id, watch] of [...this.watches]) {
216
+ const sample = rows ? treeOf(rows, watch.target) : null;
217
+ if (!sample) {
218
+ // A tree that cannot be seen is not a tree within its limits. A few such ticks happen (a worker exiting between
219
+ // two samples); a run of them means the sampler governs nothing, and failing open would hide it.
220
+ watch.blind += 1;
221
+ if (watch.blind >= this.maxBlindTicks)
222
+ this.end(id, watch, { kind: 'blind', limit: this.maxBlindTicks, observed: watch.blind }, []);
223
+ continue;
224
+ }
225
+ watch.blind = 0;
226
+ const breach = breachOf(sample, watch.limits);
227
+ if (breach)
228
+ this.end(id, watch, breach, sample.pids);
229
+ }
230
+ }
231
+ finally {
232
+ this.ticking = false;
233
+ if (this.watches.size === 0)
234
+ this.stopTimer();
235
+ }
236
+ }
237
+ close() {
238
+ this.watches.clear();
239
+ this.stopTimer();
240
+ }
241
+ /** The watch is over: the owner is told why, and whatever was seen of the tree is killed. */
242
+ end(id, watch, breach, pids) {
243
+ this.watches.delete(id);
244
+ try {
245
+ watch.onBreach(breach);
246
+ }
247
+ catch (error) {
248
+ this.report(error);
249
+ }
250
+ finally {
251
+ this.kill(pids);
252
+ }
253
+ }
254
+ report(error) {
255
+ try {
256
+ this.onFault?.(error);
257
+ }
258
+ catch { /* reporting must never stop the governor */ }
259
+ }
260
+ startTimer() {
261
+ if (this.timer)
262
+ return;
263
+ this.timer = setInterval(() => { this.tick().catch((error) => this.report(error)); }, this.intervalMs);
264
+ // The governor must not keep the process alive: a watched child cannot outlive its owner's own shutdown.
265
+ this.timer.unref();
266
+ }
267
+ stopTimer() {
268
+ if (!this.timer)
269
+ return;
270
+ clearInterval(this.timer);
271
+ this.timer = undefined;
272
+ }
273
+ }
274
+ let shared;
275
+ /** The governor every managed port shares by default: one `ps` per tick, however many trees are watched. */
276
+ export function sharedProcessTreeGovernor() {
277
+ shared ??= new ProcessTreeGovernor({
278
+ onFault: (error) => { console.error('[process-governor] fault:', error instanceof Error ? error.message : String(error)); },
279
+ });
280
+ return shared;
281
+ }
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Quotas and metering per tenant and per organization (docs/rfc/aaas-rfc.md §4.4, threats S-10 and S-11).
3
+ *
4
+ * One tenant must not be able to move another tenant's latency budget, and one organization must not be able to
5
+ * move another organization's. The meter therefore counts at two scopes (the ledger tenant and its organization) and
6
+ * a global concurrency cap, and refuses with a typed `QuotaExceededError` (HTTP 429) before the expensive work: a
7
+ * refused turn is turned away after authentication and before the request body is read, the lease is acquired or a
8
+ * planner is started.
9
+ *
10
+ * Dimensions: concurrent turns, turn rate, LLM tokens and cost (priced by the frozen ADR-20 table, in the same code
11
+ * the nightly evidence uses), bridge job rate, and ledger size. Windows are rolling, driven by an injected
12
+ * monotonic clock. The LLM budget is checked before a turn and charged after it, so it can be overshot by the
13
+ * turns in flight: a soft cap, by design. A planner invocation that reports no usage (or zero tokens) is charged the
14
+ * configured worst case instead of nothing, so a silent or zero-answering feed cannot make the budget unreachable.
15
+ *
16
+ * State is per replica. Tenant-affine routing gives a tenant one owner, so tenant quotas hold fleet-wide; an
17
+ * organization's quota is per replica until a shared counter is chosen (A3).
18
+ */
19
+ import { assertModelPriced, priceMeasuredUsage } from '../llm-price-table.js';
20
+ import { TenantStateError } from '../tenant-state/errors.js';
21
+ import { assertOrganizationId } from '../tenant-state/ids.js';
22
+ import { assertSeamTenant, seamTenantKey } from './seam.js';
23
+ export const QUOTA_DIMENSIONS = ['concurrent_turns', 'turn_rate', 'llm_tokens', 'llm_cost', 'bridge_jobs', 'ledger_size'];
24
+ /** A turn that found every slot taken has no better hint than "soon". */
25
+ const CONCURRENCY_RETRY_AFTER_MS = 1_000;
26
+ /** A full ledger frees up only by an operator action, so the hint is long. */
27
+ const LEDGER_RETRY_AFTER_MS = 3_600_000;
28
+ const MICRO = 1_000_000;
29
+ const SWEEP_EVERY = 1_000;
30
+ /** Refused by a quota. Carries the narrowest scope that refused, the dimension and a retry hint; no personal data. */
31
+ export class QuotaExceededError extends TenantStateError {
32
+ scope;
33
+ dimension;
34
+ limit;
35
+ retryAfterMs;
36
+ constructor(scope, dimension, limit, retryAfterMs) {
37
+ super('GOTRY_QUOTA_EXCEEDED', `${scope} quota exceeded: ${dimension}`);
38
+ this.scope = scope;
39
+ this.dimension = dimension;
40
+ this.limit = limit;
41
+ this.retryAfterMs = Math.max(1, Math.ceil(retryAfterMs));
42
+ }
43
+ /** Whole seconds for a `Retry-After` header, at least one. */
44
+ get retryAfterSeconds() {
45
+ return Math.max(1, Math.ceil(this.retryAfterMs / 1_000));
46
+ }
47
+ }
48
+ /**
49
+ * Starting values, not a decision: they are conservative and unreviewed, and a deployment sets its own.
50
+ * Each tenant cap sits below its organization's, and each organization's below the global one, so that no single
51
+ * tenant can fill an organization and no single organization can fill the service.
52
+ */
53
+ export const DEFAULT_QUOTA_POLICY = Object.freeze({
54
+ tenant: {
55
+ concurrentTurns: 2,
56
+ turns: { limit: 30, windowMs: 60_000 },
57
+ llm: { tokens: 400_000, costUsd: 2, windowMs: 3_600_000 },
58
+ bridgeJobs: { limit: 12, windowMs: 60_000 },
59
+ ledgerBytes: 64 * 1024 * 1024,
60
+ },
61
+ organization: {
62
+ concurrentTurns: 16,
63
+ turns: { limit: 600, windowMs: 60_000 },
64
+ llm: { tokens: 20_000_000, costUsd: 100, windowMs: 3_600_000 },
65
+ bridgeJobs: { limit: 120, windowMs: 60_000 },
66
+ ledgerBytes: 8 * 1024 * 1024 * 1024,
67
+ },
68
+ globalConcurrentTurns: 64,
69
+ unprovenInvocation: { tokens: 50_000, costUsd: 0.25 },
70
+ });
71
+ /** A rolling window of amounts: the sum of what was added in the last `windowMs`. */
72
+ class RollingSum {
73
+ entries = [];
74
+ head = 0;
75
+ total = 0;
76
+ windowMs;
77
+ constructor(windowMs) {
78
+ this.windowMs = windowMs;
79
+ }
80
+ prune(now) {
81
+ const horizon = now - this.windowMs;
82
+ while (this.head < this.entries.length && this.entries[this.head][0] <= horizon) {
83
+ this.total -= this.entries[this.head][1];
84
+ this.head += 1;
85
+ }
86
+ if (this.head > 1_024 && this.head * 2 > this.entries.length) {
87
+ this.entries.splice(0, this.head);
88
+ this.head = 0;
89
+ }
90
+ }
91
+ sum(now) {
92
+ this.prune(now);
93
+ return this.total;
94
+ }
95
+ add(now, amount) {
96
+ this.prune(now);
97
+ this.entries.push([now, amount]);
98
+ this.total += amount;
99
+ }
100
+ /** Milliseconds until the sum falls strictly below `limit` if nothing more is added; 0 when it already is. */
101
+ msUntilBelow(now, limit) {
102
+ this.prune(now);
103
+ let remaining = this.total;
104
+ if (remaining < limit)
105
+ return 0;
106
+ for (let index = this.head; index < this.entries.length; index += 1) {
107
+ remaining -= this.entries[index][1];
108
+ if (remaining < limit)
109
+ return Math.max(1, this.entries[index][0] + this.windowMs - now);
110
+ }
111
+ return this.windowMs;
112
+ }
113
+ idle(now) {
114
+ this.prune(now);
115
+ return this.head === this.entries.length;
116
+ }
117
+ }
118
+ class Account {
119
+ active = 0;
120
+ turns;
121
+ tokens;
122
+ costMicro;
123
+ bridge;
124
+ ledgerBytes = 0;
125
+ rejected = { concurrent_turns: 0, turn_rate: 0, llm_tokens: 0, llm_cost: 0, bridge_jobs: 0, ledger_size: 0 };
126
+ constructor(limits) {
127
+ this.turns = new RollingSum(limits.turns.windowMs);
128
+ this.tokens = new RollingSum(limits.llm.windowMs);
129
+ this.costMicro = new RollingSum(limits.llm.windowMs);
130
+ this.bridge = new RollingSum(limits.bridgeJobs.windowMs);
131
+ }
132
+ /** No live turn and nothing left in any window. The ledger size is not part of it: the sweep takes it out of the organization total. */
133
+ idle(now) {
134
+ return this.active === 0 && this.turns.idle(now) && this.tokens.idle(now) && this.costMicro.idle(now) && this.bridge.idle(now);
135
+ }
136
+ }
137
+ function positiveInteger(name, value) {
138
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1)
139
+ throw new RangeError(`${name} must be a positive integer`);
140
+ }
141
+ function positiveNumber(name, value) {
142
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
143
+ throw new RangeError(`${name} must be a positive number`);
144
+ }
145
+ function validateLimits(scope, limits) {
146
+ positiveInteger(`${scope}.concurrentTurns`, limits.concurrentTurns);
147
+ positiveInteger(`${scope}.turns.limit`, limits.turns.limit);
148
+ positiveInteger(`${scope}.turns.windowMs`, limits.turns.windowMs);
149
+ positiveInteger(`${scope}.llm.tokens`, limits.llm.tokens);
150
+ positiveNumber(`${scope}.llm.costUsd`, limits.llm.costUsd);
151
+ positiveInteger(`${scope}.llm.windowMs`, limits.llm.windowMs);
152
+ positiveInteger(`${scope}.bridgeJobs.limit`, limits.bridgeJobs.limit);
153
+ positiveInteger(`${scope}.bridgeJobs.windowMs`, limits.bridgeJobs.windowMs);
154
+ positiveInteger(`${scope}.ledgerBytes`, limits.ledgerBytes);
155
+ }
156
+ function validatePolicy(policy) {
157
+ validateLimits('tenant', policy.tenant);
158
+ validateLimits('organization', policy.organization);
159
+ positiveInteger('globalConcurrentTurns', policy.globalConcurrentTurns);
160
+ positiveInteger('unprovenInvocation.tokens', policy.unprovenInvocation.tokens);
161
+ positiveNumber('unprovenInvocation.costUsd', policy.unprovenInvocation.costUsd);
162
+ // A tenant cap above its organization's can never bind, and an organization above the global one lets it starve the rest.
163
+ if (policy.tenant.concurrentTurns > policy.organization.concurrentTurns)
164
+ throw new RangeError('tenant.concurrentTurns must not exceed organization.concurrentTurns');
165
+ if (policy.organization.concurrentTurns > policy.globalConcurrentTurns)
166
+ throw new RangeError('organization.concurrentTurns must not exceed globalConcurrentTurns');
167
+ }
168
+ export class TenantQuotas {
169
+ policy;
170
+ priceTable;
171
+ clock;
172
+ tenants = new Map();
173
+ organizations = new Map();
174
+ globalActive = 0;
175
+ admissions = 0;
176
+ constructor(options) {
177
+ validatePolicy(options.policy);
178
+ assertModelPriced(options.model, options.priceTable);
179
+ this.policy = options.policy;
180
+ this.priceTable = options.priceTable;
181
+ this.clock = options.clock ?? (() => performance.now());
182
+ }
183
+ /**
184
+ * Admits one turn or refuses it. Checks run narrowest scope first (tenant, organization, global) so a hot tenant is
185
+ * told it hit its own limit, and an innocent one only ever sees an organization limit that the sum really reached.
186
+ * Nothing is reserved unless every check passes.
187
+ */
188
+ admitTurn(tenant) {
189
+ const seam = assertSeamTenant(tenant);
190
+ const now = this.clock();
191
+ // Every attempt counts toward the sweep, so a flood of refused attempts cannot outrun it.
192
+ this.maybeSweep(now);
193
+ const key = seamTenantKey(seam);
194
+ // A tenant is registered only when a turn is admitted: a fresh tenant that is refused leaves nothing behind.
195
+ const known = this.tenants.get(key);
196
+ const tenantAccount = known ?? new Account(this.policy.tenant);
197
+ const organizationAccount = this.organizationAccount(seam.organization);
198
+ const { policy } = this;
199
+ this.refuseIfOver(tenantAccount, 'tenant', 'concurrent_turns', tenantAccount.active, policy.tenant.concurrentTurns, CONCURRENCY_RETRY_AFTER_MS);
200
+ this.refuseIfOverRolling(tenantAccount, 'tenant', 'turn_rate', tenantAccount.turns, now, policy.tenant.turns.limit);
201
+ this.refuseIfOverRolling(tenantAccount, 'tenant', 'llm_tokens', tenantAccount.tokens, now, policy.tenant.llm.tokens);
202
+ this.refuseIfOverRolling(tenantAccount, 'tenant', 'llm_cost', tenantAccount.costMicro, now, Math.round(policy.tenant.llm.costUsd * MICRO), MICRO);
203
+ this.refuseIfOver(organizationAccount, 'organization', 'concurrent_turns', organizationAccount.active, policy.organization.concurrentTurns, CONCURRENCY_RETRY_AFTER_MS);
204
+ this.refuseIfOverRolling(organizationAccount, 'organization', 'turn_rate', organizationAccount.turns, now, policy.organization.turns.limit);
205
+ this.refuseIfOverRolling(organizationAccount, 'organization', 'llm_tokens', organizationAccount.tokens, now, policy.organization.llm.tokens);
206
+ this.refuseIfOverRolling(organizationAccount, 'organization', 'llm_cost', organizationAccount.costMicro, now, Math.round(policy.organization.llm.costUsd * MICRO), MICRO);
207
+ this.refuseIfOver(organizationAccount, 'global', 'concurrent_turns', this.globalActive, policy.globalConcurrentTurns, CONCURRENCY_RETRY_AFTER_MS);
208
+ if (!known)
209
+ this.tenants.set(key, tenantAccount);
210
+ tenantAccount.active += 1;
211
+ organizationAccount.active += 1;
212
+ this.globalActive += 1;
213
+ tenantAccount.turns.add(now, 1);
214
+ organizationAccount.turns.add(now, 1);
215
+ let released = false;
216
+ return {
217
+ release: () => {
218
+ if (released)
219
+ return;
220
+ released = true;
221
+ tenantAccount.active -= 1;
222
+ organizationAccount.active -= 1;
223
+ this.globalActive -= 1;
224
+ },
225
+ };
226
+ }
227
+ /**
228
+ * Charges measured usage, priced by the frozen table. Throws for an unpriced model, malformed counts or a report of zero
229
+ * tokens (no completion costs nothing, so a relay that answers zeros is not measuring); the caller then charges the worst case.
230
+ */
231
+ recordLlmUsage(tenant, model, usage) {
232
+ const seam = assertSeamTenant(tenant);
233
+ const priced = priceMeasuredUsage(model, usage, this.priceTable);
234
+ if (priced.tokens === 0)
235
+ throw new RangeError('a usage report of zero tokens proves nothing');
236
+ this.charge(seam, priced.tokens, Math.round(priced.costUsd * MICRO));
237
+ }
238
+ /** Charges one planner invocation that reported no usable usage: the configured worst case. */
239
+ chargeUnprovenInvocation(tenant) {
240
+ const seam = assertSeamTenant(tenant);
241
+ this.charge(seam, this.policy.unprovenInvocation.tokens, Math.round(this.policy.unprovenInvocation.costUsd * MICRO));
242
+ }
243
+ /** Counts one bridge job for a tenant and its organization, or refuses it. */
244
+ admitBridgeJob(tenant) {
245
+ const seam = assertSeamTenant(tenant);
246
+ const now = this.clock();
247
+ const tenantAccount = this.tenantAccount(seamTenantKey(seam));
248
+ const organizationAccount = this.organizationAccount(seam.organization);
249
+ this.refuseIfOverRolling(tenantAccount, 'tenant', 'bridge_jobs', tenantAccount.bridge, now, this.policy.tenant.bridgeJobs.limit);
250
+ this.refuseIfOverRolling(organizationAccount, 'organization', 'bridge_jobs', organizationAccount.bridge, now, this.policy.organization.bridgeJobs.limit);
251
+ tenantAccount.bridge.add(now, 1);
252
+ organizationAccount.bridge.add(now, 1);
253
+ }
254
+ /** The same for a caller that is known only by organization, such as the extension lane's HTTP surface. */
255
+ admitOrganizationBridgeJob(organization) {
256
+ const now = this.clock();
257
+ const organizationAccount = this.organizationAccount(assertOrganizationId(organization));
258
+ this.refuseIfOverRolling(organizationAccount, 'organization', 'bridge_jobs', organizationAccount.bridge, now, this.policy.organization.bridgeJobs.limit);
259
+ organizationAccount.bridge.add(now, 1);
260
+ }
261
+ /**
262
+ * Records the size of a tenant's ledger and refuses while it is at its limit, or while the ledgers of the
263
+ * organization's tenants served here add up to the organization's limit.
264
+ */
265
+ observeLedgerBytes(tenant, bytes) {
266
+ const seam = assertSeamTenant(tenant);
267
+ if (!Number.isSafeInteger(bytes) || bytes < 0)
268
+ throw new RangeError('bytes must be a non-negative integer');
269
+ const tenantAccount = this.tenantAccount(seamTenantKey(seam));
270
+ const organizationAccount = this.organizationAccount(seam.organization);
271
+ organizationAccount.ledgerBytes += bytes - tenantAccount.ledgerBytes;
272
+ tenantAccount.ledgerBytes = bytes;
273
+ this.refuseIfOver(tenantAccount, 'tenant', 'ledger_size', bytes, this.policy.tenant.ledgerBytes, LEDGER_RETRY_AFTER_MS);
274
+ this.refuseIfOver(organizationAccount, 'organization', 'ledger_size', organizationAccount.ledgerBytes, this.policy.organization.ledgerBytes, LEDGER_RETRY_AFTER_MS);
275
+ }
276
+ /** Forgets a tenant's counters (after a forget, or when its ledger leaves this replica). */
277
+ forgetTenant(tenant) {
278
+ const seam = assertSeamTenant(tenant);
279
+ const key = seamTenantKey(seam);
280
+ const account = this.tenants.get(key);
281
+ if (!account)
282
+ return;
283
+ const organizationAccount = this.organizations.get(seam.organization);
284
+ if (organizationAccount)
285
+ organizationAccount.ledgerBytes -= account.ledgerBytes;
286
+ this.tenants.delete(key);
287
+ }
288
+ /** Counters keyed by `<organization>/<hmac tenant id>`: nothing that names a person. */
289
+ snapshot() {
290
+ const now = this.clock();
291
+ const describe = (key, account) => ({
292
+ key,
293
+ activeTurns: account.active,
294
+ turnsInWindow: account.turns.sum(now),
295
+ llmTokens: account.tokens.sum(now),
296
+ llmCostUsd: account.costMicro.sum(now) / MICRO,
297
+ bridgeJobs: account.bridge.sum(now),
298
+ ledgerBytes: account.ledgerBytes,
299
+ rejected: { ...account.rejected },
300
+ });
301
+ return {
302
+ globalActiveTurns: this.globalActive,
303
+ tenants: [...this.tenants].map(([key, account]) => describe(key, account)),
304
+ organizations: [...this.organizations].map(([key, account]) => describe(key, account)),
305
+ };
306
+ }
307
+ charge(seam, tokens, costMicro) {
308
+ const now = this.clock();
309
+ for (const account of [this.tenantAccount(seamTenantKey(seam)), this.organizationAccount(seam.organization)]) {
310
+ account.tokens.add(now, tokens);
311
+ account.costMicro.add(now, costMicro);
312
+ }
313
+ }
314
+ tenantAccount(key) {
315
+ let account = this.tenants.get(key);
316
+ if (!account) {
317
+ account = new Account(this.policy.tenant);
318
+ this.tenants.set(key, account);
319
+ }
320
+ return account;
321
+ }
322
+ organizationAccount(organization) {
323
+ let account = this.organizations.get(organization);
324
+ if (!account) {
325
+ account = new Account(this.policy.organization);
326
+ this.organizations.set(organization, account);
327
+ }
328
+ return account;
329
+ }
330
+ /** Refuses when `used` has reached `limit`. */
331
+ refuseIfOver(account, scope, dimension, used, limit, retryAfterMs) {
332
+ if (used < limit)
333
+ return;
334
+ account.rejected[dimension] += 1;
335
+ throw new QuotaExceededError(scope, dimension, limit, retryAfterMs);
336
+ }
337
+ refuseIfOverRolling(account, scope, dimension, window, now, limit, unit = 1) {
338
+ if (window.sum(now) < limit)
339
+ return;
340
+ account.rejected[dimension] += 1;
341
+ throw new QuotaExceededError(scope, dimension, limit / unit, window.msUntilBelow(now, limit));
342
+ }
343
+ maybeSweep(now) {
344
+ this.admissions += 1;
345
+ if (this.admissions % SWEEP_EVERY !== 0)
346
+ return;
347
+ for (const [key, account] of this.tenants) {
348
+ if (!account.idle(now))
349
+ continue;
350
+ this.tenants.delete(key);
351
+ // The tenant's ledger leaves the organization's total with its account; its next turn observes the size again.
352
+ const organizationAccount = this.organizations.get(key.slice(0, key.indexOf('/')));
353
+ if (organizationAccount)
354
+ organizationAccount.ledgerBytes -= account.ledgerBytes;
355
+ }
356
+ for (const [key, account] of this.organizations)
357
+ if (account.idle(now) && account.ledgerBytes === 0)
358
+ this.organizations.delete(key);
359
+ }
360
+ }