@cruxy/cli 1.4.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.
- package/dist/agent/status.js +41 -1
- package/dist/cli/commands/run.js +27 -3
- package/dist/cli/session-commands.js +6 -1
- package/dist/errors/constructors.js +90 -10
- package/dist/limits/cache.js +100 -0
- package/dist/limits/index.js +11 -0
- package/dist/limits/reduce.js +172 -0
- package/dist/limits/types.js +25 -0
- package/dist/onboarding/detect.js +95 -9
- package/dist/onboarding/types.js +29 -1
- package/dist/render/status-view.js +52 -0
- package/dist/theme/tokens.js +7 -0
- package/dist/tui/disk-status.js +47 -0
- package/dist/tui/index.js +1 -0
- package/dist/tui/layout.js +1 -0
- package/dist/tui/limits-panel.js +255 -0
- package/dist/tui/overview.js +16 -4
- package/dist/tui/panels.js +2 -0
- package/dist/tui/renderer.js +66 -0
- package/dist/utils/disk.js +95 -0
- package/package.json +2 -2
package/dist/agent/status.js
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
import { readContext } from "./context.js";
|
|
2
2
|
import { modeDescription } from "./mode.js";
|
|
3
|
+
import { globalDir } from "../config/paths.js";
|
|
4
|
+
import { GLOBAL_DIR_NAME } from "../constants.js";
|
|
5
|
+
/**
|
|
6
|
+
* Where the disk figures come from: every declared root, plus `~/.cruxy`.
|
|
7
|
+
*
|
|
8
|
+
* The home directory is in the list because it is the one that fills up
|
|
9
|
+
* invisibly. A root's usage is the user's own files, which they know about;
|
|
10
|
+
* `~/.cruxy` accumulates session transcripts, the usage store and — the big one
|
|
11
|
+
* — a checkpoint shadow copy per run. Reporting only the roots would leave the
|
|
12
|
+
* cruxy-specific half of "why is this disk full" out of cruxy's own status
|
|
13
|
+
* screen.
|
|
14
|
+
*/
|
|
15
|
+
function diskLocations(roots, disk) {
|
|
16
|
+
const locations = [];
|
|
17
|
+
for (const root of roots) {
|
|
18
|
+
const capacity = disk(root.absPath);
|
|
19
|
+
if (capacity)
|
|
20
|
+
locations.push({ label: root.name, capacity });
|
|
21
|
+
}
|
|
22
|
+
const home = disk(globalDir());
|
|
23
|
+
// Labelled `~/.cruxy` rather than the resolved path: the row is 40 columns of
|
|
24
|
+
// numbers already, and the literal home path adds width without adding a fact
|
|
25
|
+
// — `/status` prints every root's real path a few lines further down anyway.
|
|
26
|
+
if (home)
|
|
27
|
+
locations.push({ label: `~/${GLOBAL_DIR_NAME}`, capacity: home });
|
|
28
|
+
return locations;
|
|
29
|
+
}
|
|
3
30
|
/** Count real user turns: `role: "user"` with STRING content — tool results
|
|
4
31
|
* are also role "user" but carry blocks, so this counts what the human said. */
|
|
5
32
|
function userTurns(session) {
|
|
@@ -13,7 +40,14 @@ export function buildSessionStatus(session, git,
|
|
|
13
40
|
* on the stream's routing frame — so `/status`, which has no renderer, omits
|
|
14
41
|
* it and shows the configured value alone.
|
|
15
42
|
*/
|
|
16
|
-
servedTier
|
|
43
|
+
servedTier,
|
|
44
|
+
/**
|
|
45
|
+
* Free space where cruxy writes. Omitted rather than defaulted to a live
|
|
46
|
+
* probe: a caller that doesn't supply one gets no disk rows, which is the
|
|
47
|
+
* only safe default for a function whose whole contract is that it never
|
|
48
|
+
* touches the world itself.
|
|
49
|
+
*/
|
|
50
|
+
disk) {
|
|
17
51
|
const toolCtx = session.toolContext;
|
|
18
52
|
const config = toolCtx.config;
|
|
19
53
|
const mode = session.getMode();
|
|
@@ -29,6 +63,9 @@ servedTier) {
|
|
|
29
63
|
};
|
|
30
64
|
});
|
|
31
65
|
const jobs = session.jobs?.list();
|
|
66
|
+
const locations = disk
|
|
67
|
+
? diskLocations(toolCtx.workspace.roots(), disk)
|
|
68
|
+
: undefined;
|
|
32
69
|
return {
|
|
33
70
|
sessionId: session.sessionId,
|
|
34
71
|
turns: userTurns(session),
|
|
@@ -42,6 +79,9 @@ servedTier) {
|
|
|
42
79
|
sandboxEnabled: Boolean(toolCtx.sandbox),
|
|
43
80
|
...(toolCtx.sandbox ? { sandboxRuntime: toolCtx.sandbox.runtimeName } : {}),
|
|
44
81
|
checkpoints: Boolean(toolCtx.checkpointsActive),
|
|
82
|
+
// Absent when nothing could be read, not an empty array — same rule as the
|
|
83
|
+
// rest of this object: an omitted field is a fact we don't have.
|
|
84
|
+
...(locations && locations.length > 0 ? { disk: locations } : {}),
|
|
45
85
|
...(jobs
|
|
46
86
|
? {
|
|
47
87
|
jobs: {
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { LimitsClient } from "@cruxy/sdk";
|
|
4
|
+
import { LimitsCache } from "../../limits/index.js";
|
|
3
5
|
import { logger } from "../../utils/logger.js";
|
|
4
6
|
import { SessionLog, listSessions, resumeById, resumePicker, shortId, } from "../../session/index.js";
|
|
5
7
|
/** Sessions shown in the TUI sidebar — the same depth as the resume picker. */
|
|
6
8
|
const SIDEBAR_SESSIONS = 10;
|
|
7
|
-
import { loadConfig, resolveApiKey } from "../../config/index.js";
|
|
9
|
+
import { globalDir, loadConfig, resolveApiKey } from "../../config/index.js";
|
|
8
10
|
import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
|
|
9
11
|
import { createRenderer } from "../../render/index.js";
|
|
10
12
|
import { themeForColor } from "../../theme/index.js";
|
|
@@ -14,7 +16,7 @@ import { SandboxService } from "../../sandbox/index.js";
|
|
|
14
16
|
import { buildHooksService, buildHooksRouter } from "../../hooks/index.js";
|
|
15
17
|
import { DEFAULT_MODE, } from "../../agent/index.js";
|
|
16
18
|
import { runInteractive } from "../repl.js";
|
|
17
|
-
import { ContextGauge, createKeyLease, createGitView, createOverviewView, createSettingsView, createTasksView, runTui, TuiRenderer, WorkspaceGitCache, } from "../../tui/index.js";
|
|
19
|
+
import { ContextGauge, createKeyLease, createGitView, createOverviewView, createSettingsView, createTasksView, runTui, TuiRenderer, WorkspaceDiskCache, WorkspaceGitCache, } from "../../tui/index.js";
|
|
18
20
|
import { buildAgentSession } from "../session-factory.js";
|
|
19
21
|
import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
|
|
20
22
|
import { resetLspServices } from "../../lsp/index.js";
|
|
@@ -340,6 +342,21 @@ export async function executeRun(promptParts, opts) {
|
|
|
340
342
|
// provider, where the constructor's static value stands and `/model` declines.
|
|
341
343
|
if (session.model)
|
|
342
344
|
tui?.attachModel(session.model);
|
|
345
|
+
// The limits rail panel (P9) — the account's headroom, read from the gateway
|
|
346
|
+
// that enforces it.
|
|
347
|
+
//
|
|
348
|
+
// ONLY ON THE CRUXY PROVIDER, and only with a key. `/limits` is a cruxy
|
|
349
|
+
// gateway endpoint; a bring-your-own-provider session has no such notion, and
|
|
350
|
+
// pointing this at someone else's base URL would be a request to a stranger.
|
|
351
|
+
// The cache is still attached without a key so the panel can say "not signed
|
|
352
|
+
// in" — a fact worth stating, and one that costs no request to establish.
|
|
353
|
+
const limitsKey = config.model.provider === "cruxy" ? apiKey : undefined;
|
|
354
|
+
tui?.attachLimits(new LimitsCache(limitsKey === undefined
|
|
355
|
+
? undefined
|
|
356
|
+
: (signal) => new LimitsClient({
|
|
357
|
+
apiKey: limitsKey,
|
|
358
|
+
gatewayUrl: config.cruxy.gatewayUrl,
|
|
359
|
+
}).read(signal)));
|
|
343
360
|
// The main-pane views (P7). Registered here — the one place that has both the
|
|
344
361
|
// renderer and the session — and after the session exists, because a view
|
|
345
362
|
// reads its live state. Each root gets its own cached git probe: writes fan
|
|
@@ -350,8 +367,15 @@ export async function executeRun(promptParts, opts) {
|
|
|
350
367
|
// a second instance would double the subprocesses to hold two copies of an
|
|
351
368
|
// answer that must agree anyway.
|
|
352
369
|
const workspaceGit = new WorkspaceGitCache(session.toolContext.workspace.roots().map((r) => r.absPath));
|
|
370
|
+
// Every root, plus `~/.cruxy` — the two kinds of place a run fills up. The
|
|
371
|
+
// home directory is the one nobody watches: checkpoints shadow-copy the
|
|
372
|
+
// tree once per run, and they accumulate there rather than in the repo.
|
|
373
|
+
const workspaceDisk = new WorkspaceDiskCache([
|
|
374
|
+
...session.toolContext.workspace.roots().map((r) => r.absPath),
|
|
375
|
+
globalDir(),
|
|
376
|
+
]);
|
|
353
377
|
tui.attachViews([
|
|
354
|
-
createOverviewView(session, workspaceGit, () => tui.servedTier()),
|
|
378
|
+
createOverviewView(session, workspaceGit, () => tui.servedTier(), workspaceDisk),
|
|
355
379
|
createGitView(() => session.toolContext.workspace.roots(), workspaceGit),
|
|
356
380
|
// Registered even when background jobs are disabled — the view says so,
|
|
357
381
|
// and a nav whose rows appear and vanish with config is worse than one
|
|
@@ -9,6 +9,7 @@ import { contextReportLines } from "../render/context-view.js";
|
|
|
9
9
|
import { renderUnifiedDiff } from "../render/index.js";
|
|
10
10
|
import { sessionStatusLines } from "../render/status-view.js";
|
|
11
11
|
import { defaultExportName, exportMarkdown } from "../session/index.js";
|
|
12
|
+
import { readDiskCapacitySync } from "../utils/disk.js";
|
|
12
13
|
import { getGitInfo } from "../utils/git.js";
|
|
13
14
|
import { currentBranch, diffAgainst, hasChanges } from "../vcs/git.js";
|
|
14
15
|
import { MODEL_CHOICES, describeModelChoice, parseModelChoice, } from "../routing/index.js";
|
|
@@ -373,7 +374,11 @@ function handleStatus(ctx) {
|
|
|
373
374
|
//
|
|
374
375
|
// Everything else comes from the shared builder, so this and the Overview
|
|
375
376
|
// view cannot drift: they differ in exactly this argument and nowhere else.
|
|
376
|
-
const status = buildSessionStatus(session, (absPath) => getGitInfo(absPath)
|
|
377
|
+
const status = buildSessionStatus(session, (absPath) => getGitInfo(absPath), undefined,
|
|
378
|
+
// Synchronous here for the same reason git is: the user asked. This one is
|
|
379
|
+
// a single syscall rather than two subprocesses, so it costs microseconds
|
|
380
|
+
// on any disk that is answering at all.
|
|
381
|
+
(path) => readDiskCapacitySync(path));
|
|
377
382
|
for (const line of sessionStatusLines(status, out.theme)) {
|
|
378
383
|
out.print(out.fit(line));
|
|
379
384
|
}
|
|
@@ -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:
|
|
289
|
+
title: window
|
|
290
|
+
? `your ${window} Cruxy budget is exhausted`
|
|
291
|
+
: "your Cruxy budget is exhausted",
|
|
230
292
|
cause: scrubbedMessageOf(underlying),
|
|
231
|
-
nextSteps
|
|
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
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
return
|
|
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 {};
|