@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.
- package/dist/agent/status.js +96 -0
- package/dist/cli/commands/run.js +54 -3
- package/dist/cli/session-commands.js +16 -49
- package/dist/components/keys.js +38 -0
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- 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/diff.js +7 -1
- package/dist/render/status-view.js +58 -6
- package/dist/theme/tokens.js +7 -0
- package/dist/tui/app.js +125 -2
- package/dist/tui/disk-status.js +47 -0
- package/dist/tui/git-status.js +46 -1
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +8 -2
- package/dist/tui/layout.js +46 -0
- package/dist/tui/limits-panel.js +255 -0
- package/dist/tui/overview.js +61 -0
- package/dist/tui/panels.js +2 -0
- package/dist/tui/renderer.js +431 -20
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/views.js +66 -0
- package/dist/utils/disk.js +95 -0
- package/dist/utils/git.js +113 -0
- package/package.json +2 -2
package/dist/config/manager.js
CHANGED
|
@@ -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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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:
|
|
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 {};
|