@cruxy/cli 1.3.0 → 1.5.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.
@@ -59,18 +59,35 @@ function rejectProjectScopeHeaders(obj, file) {
59
59
  }
60
60
  }
61
61
  }
62
+ /**
63
+ * Which environment variable overrides which config path — a TABLE rather than
64
+ * a run of `if`s, because two surfaces need it read in opposite directions:
65
+ * this file turns variables into a layer, and the settings view turns a layer
66
+ * back into "which variable is doing this". A second hand-written copy of the
67
+ * mapping would drift on the first variable added, and the copy that drifts is
68
+ * the one telling the user where the value came from.
69
+ */
70
+ export const ENV_OVERRIDES = [
71
+ { envVar: "CRUXY_MODEL", path: "model.model" },
72
+ { envVar: "CRUXY_PROVIDER", path: "model.provider" },
73
+ { envVar: "CRUXY_LOG_LEVEL", path: "logLevel" },
74
+ ];
62
75
  /** Overrides sourced from environment variables. */
63
76
  function envOverrides() {
64
77
  const out = {};
65
- const model = {};
66
- if (process.env.CRUXY_MODEL)
67
- model.model = process.env.CRUXY_MODEL;
68
- if (process.env.CRUXY_PROVIDER)
69
- model.provider = process.env.CRUXY_PROVIDER;
70
- if (Object.keys(model).length)
71
- out.model = model;
72
- if (process.env.CRUXY_LOG_LEVEL)
73
- out.logLevel = process.env.CRUXY_LOG_LEVEL;
78
+ for (const { envVar, path } of ENV_OVERRIDES) {
79
+ const value = process.env[envVar];
80
+ if (!value)
81
+ continue;
82
+ const keys = path.split(".");
83
+ let cursor = out;
84
+ for (const key of keys.slice(0, -1)) {
85
+ if (!isPlainObject(cursor[key]))
86
+ cursor[key] = {};
87
+ cursor = cursor[key];
88
+ }
89
+ cursor[keys[keys.length - 1]] = value;
90
+ }
74
91
  return out;
75
92
  }
76
93
  /**
@@ -83,15 +100,23 @@ export function loadConfig(opts = {}) {
83
100
  project: null,
84
101
  explicit: null,
85
102
  };
103
+ const layers = {
104
+ global: null,
105
+ project: null,
106
+ explicit: null,
107
+ env: {},
108
+ };
86
109
  let merged = {};
87
110
  const gPath = globalConfigPath();
88
111
  if (existsSync(gPath)) {
89
- merged = deepMerge(merged, readJsonFile(gPath));
112
+ layers.global = readJsonFile(gPath);
113
+ merged = deepMerge(merged, layers.global);
90
114
  sources.global = gPath;
91
115
  }
92
116
  if (opts.configPath) {
93
117
  const obj = readJsonFile(opts.configPath);
94
118
  rejectProjectScopeHeaders(obj, opts.configPath);
119
+ layers.explicit = obj;
95
120
  merged = deepMerge(merged, obj);
96
121
  sources.explicit = opts.configPath;
97
122
  }
@@ -100,11 +125,13 @@ export function loadConfig(opts = {}) {
100
125
  if (pPath) {
101
126
  const obj = readJsonFile(pPath);
102
127
  rejectProjectScopeHeaders(obj, pPath);
128
+ layers.project = obj;
103
129
  merged = deepMerge(merged, obj);
104
130
  sources.project = pPath;
105
131
  }
106
132
  }
107
- merged = deepMerge(merged, envOverrides());
133
+ layers.env = envOverrides();
134
+ merged = deepMerge(merged, layers.env);
108
135
  const result = CruxyConfigSchema.safeParse(merged);
109
136
  if (!result.success) {
110
137
  const issues = result.error.issues
@@ -112,7 +139,7 @@ export function loadConfig(opts = {}) {
112
139
  .join("\n");
113
140
  throw configInvalid(issues, sources.project ?? sources.global ?? undefined);
114
141
  }
115
- return { config: result.data, sources };
142
+ return { config: result.data, sources, layers };
116
143
  }
117
144
  /** Resolve a dot-path (e.g. "model.temperature") against a config object. */
118
145
  export function getPath(obj, path) {
@@ -176,12 +203,15 @@ export function resolveApiKey(provider) {
176
203
  }
177
204
  /** The API key from the environment for `provider`, or `undefined`. */
178
205
  function envApiKey(provider) {
179
- switch (provider) {
180
- case "cruxy":
181
- return process.env.CRUXY_API_KEY;
182
- case "openai":
183
- return process.env.OPENAI_API_KEY;
184
- default:
185
- return process.env.CRUXY_API_KEY;
186
- }
206
+ return process.env[apiKeyEnvVar(provider)];
207
+ }
208
+ /**
209
+ * The environment variable a provider's API key is read from — the NAME only,
210
+ * never the value, so a surface can say where a key would come from without
211
+ * ever holding one. Exported so nothing has to restate this mapping: a second
212
+ * copy would drift the moment a provider is added, and the copy that drifts is
213
+ * always the one telling the user where to put their key.
214
+ */
215
+ export function apiKeyEnvVar(provider) {
216
+ return provider === "openai" ? "OPENAI_API_KEY" : "CRUXY_API_KEY";
187
217
  }
@@ -1,4 +1,4 @@
1
- import { ApiError, AuthError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
1
+ import { ApiError, AuthError, BudgetExhaustedError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
2
2
  import { scrubModelNames } from "../brand/index.js";
3
3
  import { CruxyError, ErrorCode } from "./types.js";
4
4
  /**
@@ -223,14 +223,92 @@ export function apiOverloaded(underlying) {
223
223
  underlying,
224
224
  });
225
225
  }
226
+ /**
227
+ * A wait, in the largest unit that stays honest: "4h", "12m", "18d".
228
+ *
229
+ * `apiRateLimit` renders seconds because a rate-limit wait IS seconds. A budget
230
+ * window is hours or days, and "~14400s" is a number a human has to do
231
+ * arithmetic on at the moment they are least inclined to.
232
+ */
233
+ function humanWait(ms) {
234
+ const minutes = Math.round(ms / 60_000);
235
+ if (minutes < 60)
236
+ return `${Math.max(1, minutes)}m`;
237
+ const hours = Math.round(minutes / 60);
238
+ return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`;
239
+ }
240
+ /**
241
+ * The weighted-pool gate refused the request (429 `budget_exhausted`).
242
+ *
243
+ * THIS IS THE POOL, AND ONLY THE POOL. The gateway spends separate codes on the
244
+ * things a user actually can pay their way out of — `credits_exhausted`,
245
+ * `key_spend_cap_exceeded`, `workspace_spend_cap_exceeded` — and none of them
246
+ * arrive here. So the advice this used to give ("top up or raise your budget,
247
+ * then retry") was wrong in both halves: there is nothing to top up on a
248
+ * subscription pool, and "retry" against a window that refills on a clock is an
249
+ * instruction to hammer a gate that will refuse again.
250
+ *
251
+ * That wording was harmless while it was unreachable from this CLI. cruxy-ai/api#174
252
+ * makes a CLI login mint a subscription credential, so it is now the message a
253
+ * blocked user actually reads — see cruxy-ai/cli#212.
254
+ *
255
+ * The error already carries everything needed to say something true, and all of
256
+ * it used to be discarded:
257
+ *
258
+ * - `miraAvailable` — mira is exempt from the pool kill, so on most denials
259
+ * there IS a way to keep working. It goes first: it is the only step that
260
+ * unblocks someone now rather than telling them when to come back.
261
+ * - `window` — "monthly" and "burst" are different waits (a calendar rollover
262
+ * versus a trailing sum sliding back under its cap), so the wait is named.
263
+ * - `resetAt` / `retryAfterMs` — WHEN, so the wait is a time rather than a
264
+ * vague "later". The window length is deliberately not asserted here; the
265
+ * server sends the recovery instant and this reports that.
266
+ */
226
267
  export function budgetExhausted(underlying) {
268
+ const err = underlying instanceof BudgetExhaustedError ? underlying : undefined;
269
+ const waitMs = err?.retryAfterMs ??
270
+ (err?.resetAt !== undefined ? msUntil(err.resetAt) : undefined);
271
+ const nextSteps = [];
272
+ // Mira stays available on an exhausted pool (it is the always-available
273
+ // floor), so when the gateway says so, the first step is the one that gets
274
+ // the user working again instead of waiting.
275
+ if (err?.miraAvailable) {
276
+ nextSteps.push("switch to the mira tier, which stays available: `/model mira`");
277
+ }
278
+ const window = err?.window === "burst" ? "burst" : err?.window;
279
+ const windowPhrase = window ? `your ${window} budget window` : "your budget";
280
+ nextSteps.push(waitMs !== undefined && waitMs > 0
281
+ ? `wait ~${humanWait(waitMs)} — ${windowPhrase} recovers then`
282
+ : `wait for ${windowPhrase} to recover`);
283
+ // Only where it is true. A subscription pool is not something a user can add
284
+ // to; a plan change is the only lever, and it is a different action from
285
+ // "top up" with a different place to do it.
286
+ nextSteps.push("or move to a higher plan for a larger allowance");
227
287
  return new CruxyError({
228
288
  code: ErrorCode.BudgetExhausted,
229
- title: "your Cruxy budget is exhausted",
289
+ title: window
290
+ ? `your ${window} Cruxy budget is exhausted`
291
+ : "your Cruxy budget is exhausted",
230
292
  cause: scrubbedMessageOf(underlying),
231
- nextSteps: ["top up or raise your budget, then retry"],
293
+ nextSteps,
232
294
  underlying,
233
- });
295
+ meta: err
296
+ ? {
297
+ ...(err.window !== undefined ? { window: err.window } : {}),
298
+ ...(err.resetAt !== undefined ? { resetAt: err.resetAt } : {}),
299
+ ...(err.miraAvailable !== undefined
300
+ ? { miraAvailable: err.miraAvailable }
301
+ : {}),
302
+ }
303
+ : undefined,
304
+ });
305
+ }
306
+ /** Milliseconds until an ISO instant, or `undefined` if it is absent/unparseable. */
307
+ function msUntil(iso) {
308
+ const at = Date.parse(iso);
309
+ if (Number.isNaN(at))
310
+ return undefined;
311
+ return Math.max(0, at - Date.now());
234
312
  }
235
313
  // ── filesystem (exit 7) ───────────────────────────────────────────────────────
236
314
  export function fileNotFound(path, underlying) {
@@ -1309,12 +1387,14 @@ export function classifyProviderError(underlying) {
1309
1387
  return apiOverloaded(underlying);
1310
1388
  if (underlying instanceof NetworkError)
1311
1389
  return gatewayUnreachable(underlying);
1312
- if (underlying instanceof ApiError) {
1313
- // BudgetExhaustedError isn't exported by the SDK; match by name.
1314
- if (underlying.name === "BudgetExhaustedError") {
1315
- return budgetExhausted(underlying);
1316
- }
1317
- return apiError(underlying);
1390
+ // Before the `ApiError` base it extends, like every other subclass here. The
1391
+ // name check this replaces was a workaround for an export that exists — the
1392
+ // SDK's barrel has always re-exported the class — and it silently stopped
1393
+ // matching anything the moment a bundler minified the constructor name.
1394
+ if (underlying instanceof BudgetExhaustedError) {
1395
+ return budgetExhausted(underlying);
1318
1396
  }
1397
+ if (underlying instanceof ApiError)
1398
+ return apiError(underlying);
1319
1399
  return null;
1320
1400
  }
@@ -0,0 +1,100 @@
1
+ import { reduceLimits } from "./reduce.js";
2
+ /**
3
+ * How long a probe's answer is treated as current. The pool moves when the user
4
+ * spends, and the surfaces that spend it (this CLI, web chat, desktop) do so in
5
+ * turns — so a per-turn refresh is the natural cadence and this floor exists
6
+ * only to coalesce the several triggers a single turn can produce.
7
+ */
8
+ const MIN_INTERVAL_MS = 15_000;
9
+ export class LimitsCache {
10
+ probe;
11
+ now;
12
+ minIntervalMs;
13
+ state;
14
+ /** The refresh in flight, so concurrent triggers share one request. */
15
+ inFlight = null;
16
+ lastAttemptAt = 0;
17
+ constructor(
18
+ /**
19
+ * Absent when there is no credential to ask with. That is not a failure to
20
+ * report later — it is knowable now, costs no request to determine, and the
21
+ * panel's answer ("sign in") is the same either way.
22
+ */
23
+ probe, opts = {}) {
24
+ this.probe = probe;
25
+ this.now = opts.now ?? Date.now;
26
+ this.minIntervalMs = opts.minIntervalMs ?? MIN_INTERVAL_MS;
27
+ this.state = probe
28
+ ? { status: "pending" }
29
+ : { status: "error", reason: "unauthenticated" };
30
+ }
31
+ /** The last settled state — a field read, safe from the paint path. */
32
+ current() {
33
+ return this.state;
34
+ }
35
+ /** True once a reading has been obtained, whatever has happened since. */
36
+ hasReading() {
37
+ return this.state.status === "ready";
38
+ }
39
+ /**
40
+ * Re-read the limits, off the paint path. Coalesces concurrent callers onto
41
+ * one request and declines to re-probe within {@link minIntervalMs} of the
42
+ * last attempt.
43
+ *
44
+ * Never rejects: a refresh is a background probe for a status panel, and a
45
+ * caller that has to `.catch` a status update will eventually forget to.
46
+ */
47
+ async refresh() {
48
+ if (!this.probe)
49
+ return;
50
+ if (this.inFlight)
51
+ return this.inFlight;
52
+ const at = this.now();
53
+ // The floor applies only once something is on screen. Before the first
54
+ // answer there is nothing to protect, and rate-limiting our way to a blank
55
+ // panel would be the floor working against its own purpose.
56
+ if (this.state.status === "ready" &&
57
+ at - this.lastAttemptAt < this.minIntervalMs) {
58
+ return;
59
+ }
60
+ this.lastAttemptAt = at;
61
+ this.inFlight = this.run(this.probe);
62
+ try {
63
+ await this.inFlight;
64
+ }
65
+ finally {
66
+ this.inFlight = null;
67
+ }
68
+ }
69
+ async run(probe) {
70
+ try {
71
+ const res = await probe();
72
+ this.state = { status: "ready", reading: reduceLimits(res, this.now()) };
73
+ }
74
+ catch (err) {
75
+ // Keep a good reading rather than blanking a panel that was correct a
76
+ // moment ago; only report an error when there is nothing else to say.
77
+ if (this.state.status === "ready")
78
+ return;
79
+ this.state = { status: "error", reason: classify(err) };
80
+ }
81
+ }
82
+ }
83
+ /**
84
+ * Why the probe failed, in the three distinctions a user can act on.
85
+ *
86
+ * Matched on the SDK's error class names rather than `instanceof`, so this stays
87
+ * a pure classification with no import of the transport into a module the TUI
88
+ * loads. A 404/501 is the interesting case: it means a gateway that does not
89
+ * serve `/limits` at all — an older deployment, or a base URL pointed somewhere
90
+ * else entirely — and telling that user "you are offline" would send them
91
+ * debugging a network that is working fine.
92
+ */
93
+ function classify(err) {
94
+ const e = err;
95
+ if (e?.name === "AuthError")
96
+ return "unauthenticated";
97
+ if (e?.status === 404 || e?.status === 501)
98
+ return "unsupported";
99
+ return "unreachable";
100
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Limits (P9): the gateway's own account of what this credential may spend, and
3
+ * how much of it is left.
4
+ *
5
+ * Three files, one direction: `reduce.ts` turns the wire response into the
6
+ * closed {@link LimitsReading} union, `cache.ts` owns the network read and the
7
+ * staleness rules, and `types.ts` holds the union both sides agree on. The
8
+ * rendering lives in `tui/limits-panel.ts` — nothing here formats anything.
9
+ */
10
+ export { LimitsCache } from "./cache.js";
11
+ export { reduceLimits, bindingWindow, usedFraction } from "./reduce.js";
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Wire response → {@link LimitsReading}: one place where "which absence means
3
+ * what" is decided (P9).
4
+ *
5
+ * The reduction is driven by `bucket` and NOTHING else. Not by how the user
6
+ * logged in, not by whether a key looks like `cxy_live_`, not by which config
7
+ * fields are set — every one of those was a plausible inference and every one of
8
+ * them is now wrong, because the gateway changed which bucket a CLI login mints
9
+ * into (cruxy-ai/api#174) without changing anything a client could see locally.
10
+ * The endpoint is the answer to that question; asking anything else is guessing.
11
+ */
12
+ /** A cap is only a denominator when it is a positive, finite number. */
13
+ function usableCap(cap) {
14
+ return Number.isFinite(cap) && cap > 0;
15
+ }
16
+ /**
17
+ * A weighted-token window, or `undefined` when it cannot serve as a fraction.
18
+ *
19
+ * A cap of 0 is dropped rather than rendered as "100% used": zero is what an
20
+ * unprovisioned or misconfigured plan reports, and dividing by it produces
21
+ * either a crash or a full red bar, neither of which is a true statement about
22
+ * what the user may spend.
23
+ */
24
+ function toWindow(w) {
25
+ if (!w || !usableCap(w.cap))
26
+ return undefined;
27
+ return {
28
+ cap: w.cap,
29
+ used: w.used,
30
+ remaining: w.remaining,
31
+ ...(w.state !== undefined ? { state: w.state } : {}),
32
+ ...(w.resets_at !== undefined ? { resetsAt: w.resets_at } : {}),
33
+ };
34
+ }
35
+ function toSpendCap(c) {
36
+ if (!c || !usableCap(c.cap))
37
+ return undefined;
38
+ return {
39
+ cap: c.cap,
40
+ spent: c.spent,
41
+ remaining: c.remaining,
42
+ ...(c.resets_at !== undefined ? { resetsAt: c.resets_at } : {}),
43
+ };
44
+ }
45
+ function toRateWindow(w) {
46
+ return {
47
+ limit: w.limit,
48
+ remaining: w.remaining,
49
+ windowSeconds: w.window_seconds,
50
+ ...(w.resets_at !== undefined ? { resetsAt: w.resets_at } : {}),
51
+ };
52
+ }
53
+ /**
54
+ * The budget shape for this credential.
55
+ *
56
+ * Ordered by bucket, and each branch answers only for its own bucket — a
57
+ * `subscription` response that somehow also carried `credits` does not get to
58
+ * fall through into the headless branch. The server decides which section is
59
+ * authoritative by telling us the bucket; honouring that literally is what keeps
60
+ * this reduction and the gate in agreement.
61
+ */
62
+ function reduceBudget(res) {
63
+ switch (res.bucket) {
64
+ case "subscription": {
65
+ const pool = res.token_pool;
66
+ // No section at all: gated by something we were not told about.
67
+ if (!pool)
68
+ return { kind: "unknown" };
69
+ // Enterprise. The one place "no numbers" is a complete answer.
70
+ if (!pool.enforced)
71
+ return { kind: "unenforced" };
72
+ const monthly = toWindow(pool.monthly);
73
+ const burst = toWindow(pool.burst);
74
+ // Enforced, but nothing readable to enforce against. NOT "unenforced":
75
+ // the pool exists and will stop this user, we just cannot say when.
76
+ if (!monthly && !burst)
77
+ return { kind: "unknown" };
78
+ return {
79
+ kind: "pool",
80
+ unit: pool.unit ?? "weighted_tokens",
81
+ ...(pool.state !== undefined ? { state: pool.state } : {}),
82
+ ...(monthly ? { monthly } : {}),
83
+ ...(burst ? { burst } : {}),
84
+ ...(pool.mira && usableCap(pool.mira.cap)
85
+ ? {
86
+ mira: {
87
+ cap: pool.mira.cap,
88
+ used: pool.mira.used,
89
+ remaining: pool.mira.remaining,
90
+ state: pool.mira.state,
91
+ window: pool.mira.window,
92
+ ...(pool.mira.resets_at !== undefined
93
+ ? { resetsAt: pool.mira.resets_at }
94
+ : {}),
95
+ },
96
+ }
97
+ : {}),
98
+ blockedModels: pool.blocked_models ?? [],
99
+ };
100
+ }
101
+ case "apikey":
102
+ // `metered: true` is the server's positive statement that there is no pool
103
+ // to report. Its absence on an apikey bucket means this build is reading a
104
+ // response it does not fully understand, so it says so.
105
+ return res.metered === true ? { kind: "metered" } : { kind: "unknown" };
106
+ case "headless": {
107
+ const c = res.credits;
108
+ if (!c)
109
+ return { kind: "unknown" };
110
+ return {
111
+ kind: "credits",
112
+ plan: c.plan,
113
+ granted: c.granted,
114
+ used: c.used,
115
+ remaining: c.remaining,
116
+ ...(c.resets_at !== undefined ? { resetsAt: c.resets_at } : {}),
117
+ };
118
+ }
119
+ default:
120
+ // A bucket added after this build. The tier and the rate limits below are
121
+ // still real and still reported; only the budget shape is unreadable.
122
+ return { kind: "unknown" };
123
+ }
124
+ }
125
+ /** Reduce a wire response to the reading the panel consumes. */
126
+ export function reduceLimits(res, readAt = Date.now()) {
127
+ const chat = res.rate_limits?.chat;
128
+ const keySpendCap = toSpendCap(res.key_spend_cap);
129
+ const workspaceSpendCap = toSpendCap(res.workspace_spend_cap);
130
+ const rate = chat
131
+ ? {
132
+ perKey: toRateWindow(chat.per_key),
133
+ perOrg: toRateWindow(chat.per_org),
134
+ maxConcurrentRequests: chat.max_concurrent_requests,
135
+ }
136
+ : undefined;
137
+ return {
138
+ tier: res.tier,
139
+ bucket: res.bucket,
140
+ budget: reduceBudget(res),
141
+ ...(rate ? { chat: rate } : {}),
142
+ ...(keySpendCap ? { keySpendCap } : {}),
143
+ ...(workspaceSpendCap ? { workspaceSpendCap } : {}),
144
+ readAt,
145
+ };
146
+ }
147
+ /**
148
+ * The window that will stop this user FIRST — the one the gate itself calls
149
+ * binding: whichever of monthly/burst has the smaller REMAINING fraction
150
+ * (`internal/budget/decide.go`).
151
+ *
152
+ * This is what earns the single bar the rail has room for. Drawing the month
153
+ * because it is the bigger number would routinely show a comfortable 6% while
154
+ * the trailing-12h window — a quarter of the month's cap on every self-serve
155
+ * tier — is the one about to refuse the next request.
156
+ */
157
+ export function bindingWindow(monthly, burst) {
158
+ if (!monthly)
159
+ return burst ? { window: burst, name: "burst" } : undefined;
160
+ if (!burst)
161
+ return { window: monthly, name: "month" };
162
+ const frac = (w) => w.remaining / w.cap;
163
+ return frac(burst) < frac(monthly)
164
+ ? { window: burst, name: "burst" }
165
+ : { window: monthly, name: "month" };
166
+ }
167
+ /** The fraction of a cap consumed, clamped to [0,1] for display. */
168
+ export function usedFraction(w) {
169
+ if (!usableCap(w.cap))
170
+ return undefined;
171
+ return Math.min(1, Math.max(0, w.used / w.cap));
172
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The CLI's reading of `GET /api/v1/limits` (P9).
3
+ *
4
+ * THE WIRE SHAPE IS NOT THE RENDER SHAPE, and the gap between them is the whole
5
+ * reason this module exists. On the wire a section is present or absent and a
6
+ * reader must know which absence means what: no `token_pool` on an `apikey` is
7
+ * normal, no `monthly` inside an enforced pool is a gateway this build cannot
8
+ * read, and `{enforced: false}` is a definite statement that there is no cap —
9
+ * three absences with three different meanings, one of which must never be shown
10
+ * as another.
11
+ *
12
+ * So the reduction resolves them ONCE, into a closed union where each variant
13
+ * carries exactly the numbers that variant may legitimately state. A renderer
14
+ * handed a {@link Unenforced} has no cap to divide by because the type has no
15
+ * cap in it — the honesty is structural, not a rule someone has to remember at
16
+ * the call site.
17
+ *
18
+ * WHAT THIS ADDS TO `usage/weighted.ts`, which computes the same unit locally:
19
+ * that module is explicit that it can state consumption and never headroom,
20
+ * because a cap needs the user's plan and what every OTHER surface (web chat,
21
+ * desktop, phone) has already spent — "neither of which is on this machine".
22
+ * This is the other half arriving over the wire: the denominator, already
23
+ * inclusive of every surface, from the meter itself.
24
+ */
25
+ export {};