@cruxy/cli 1.2.0 → 1.3.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/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +41 -2
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +185 -72
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +374 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +755 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +27 -3
- package/dist/components/select.js +3 -3
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +198 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +385 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +63 -0
- package/dist/tui/index.js +10 -0
- package/dist/tui/layout.js +269 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +776 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/usage/collect.js +21 -3
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/store.js +7 -1
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +73 -4
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +50 -4
- package/package.json +2 -2
- package/dist/usage/cost.js +0 -29
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the environment can host the full-viewport TUI.
|
|
3
|
+
*
|
|
4
|
+
* Three requirements, and they are exactly the axes capability detection now
|
|
5
|
+
* resolves — no re-probing of `process.stdin` or `TERM` here:
|
|
6
|
+
*
|
|
7
|
+
* - `interactive` (stdin is a terminal AND the output takes cursor control):
|
|
8
|
+
* the TUI both reads keys and repaints, so it needs both halves. This is the
|
|
9
|
+
* flag that makes `echo hi | cruxy` fall through to the plain path.
|
|
10
|
+
* - not `screenReader`: a live full-screen region is precisely what
|
|
11
|
+
* screen-reader mode exists to avoid (U.11). Announce-friendly linear output
|
|
12
|
+
* wins over the shell, always.
|
|
13
|
+
*
|
|
14
|
+
* Capability only. WHETHER a given invocation wants a TUI is an entry-mode
|
|
15
|
+
* decision (bare `cruxy` does; `cruxy run "task"` does not) and lives with the
|
|
16
|
+
* caller — see `createRenderer`'s `tui` option.
|
|
17
|
+
*/
|
|
18
|
+
export function supportsTui(caps) {
|
|
19
|
+
return caps.interactive && !caps.screenReader;
|
|
20
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Host tool versions for the rail (P4 track 5).
|
|
4
|
+
*
|
|
5
|
+
* THE COST IS THE DESIGN. Measured warm on a fast Mac: pnpm 584ms, docker
|
|
6
|
+
* 130ms, tsc 108ms, git 34ms — ~911ms serially, and still ~584ms in parallel
|
|
7
|
+
* because pnpm alone dominates. That is half a second of dead terminal to
|
|
8
|
+
* populate a decorative panel, and it is the optimistic case: Windows process
|
|
9
|
+
* creation is far slower, which this repo has already paid for in CI.
|
|
10
|
+
*
|
|
11
|
+
* So nothing here runs at startup, nothing is awaited, and nothing blocks a
|
|
12
|
+
* paint. {@link ToolVersions.start} fires the probes and returns immediately;
|
|
13
|
+
* every row renders "…" until its OWN probe lands, independently of the others.
|
|
14
|
+
* A slow pnpm cannot delay a fast git.
|
|
15
|
+
*
|
|
16
|
+
* The shape is `sandbox/detect.ts`'s, which already solved this correctly:
|
|
17
|
+
* memoized for the process, a hard timeout, ENOENT treated as an answer rather
|
|
18
|
+
* than a throw, and never rejecting. One deliberate difference — that module
|
|
19
|
+
* probes `docker version --format {{.Server.Version}}`, which requires a LIVE
|
|
20
|
+
* DAEMON and reports a stopped Docker as absent. This panel is reporting what
|
|
21
|
+
* is INSTALLED, so it asks the client (`docker --version`), which answers with
|
|
22
|
+
* the daemon down.
|
|
23
|
+
*/
|
|
24
|
+
/** Hard ceiling on one probe; a hung binary must never wedge the panel. */
|
|
25
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
26
|
+
/**
|
|
27
|
+
* Pull a semantic version out of a tool's `--version` line.
|
|
28
|
+
*
|
|
29
|
+
* Every one of these prints something different — "git version 2.50.1 (Apple
|
|
30
|
+
* Git-155)", "Docker version 29.6.2, build dfc4efb", "Version 5.9.3", a bare
|
|
31
|
+
* "10.33.2" — so the first `x.y.z` in the output is both the simplest rule and
|
|
32
|
+
* the most robust one. `null` when there is no version-shaped thing at all,
|
|
33
|
+
* which is honest: something answered, but not with a version.
|
|
34
|
+
*/
|
|
35
|
+
export function parseVersion(output) {
|
|
36
|
+
return /\d+\.\d+\.\d+(?:[-+][\w.]+)?/.exec(output)?.[0] ?? null;
|
|
37
|
+
}
|
|
38
|
+
/** Default probe: spawn, capture, and treat any failure as "not installed". */
|
|
39
|
+
const spawnProbe = (bin, args) => new Promise((resolve) => {
|
|
40
|
+
try {
|
|
41
|
+
execFile(bin, [...args], { encoding: "utf8", timeout: PROBE_TIMEOUT_MS, windowsHide: true }, (err, stdout, stderr) => {
|
|
42
|
+
// Some tools print their version to stderr; a non-zero exit with a
|
|
43
|
+
// parseable version still tells us the tool is there.
|
|
44
|
+
const text = `${stdout ?? ""}${stderr ?? ""}`;
|
|
45
|
+
if (err && text.trim() === "")
|
|
46
|
+
resolve(null);
|
|
47
|
+
else
|
|
48
|
+
resolve(text);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// execFile can throw synchronously on a malformed binary path.
|
|
53
|
+
resolve(null);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
/**
|
|
57
|
+
* The tools reported, in display order.
|
|
58
|
+
*
|
|
59
|
+
* Node is NOT here: `process.versions.node` is the running interpreter's own
|
|
60
|
+
* version, already in memory. Spawning `node --version` to learn something the
|
|
61
|
+
* process knows about itself would be the most obviously wasteful 55ms in the
|
|
62
|
+
* list — and it would answer for whichever node is on PATH, which need not be
|
|
63
|
+
* the one executing this code.
|
|
64
|
+
*/
|
|
65
|
+
const TOOLS = [
|
|
66
|
+
{ name: "tsc", bin: "tsc", args: ["--version"] },
|
|
67
|
+
{ name: "pnpm", bin: "pnpm", args: ["--version"] },
|
|
68
|
+
{ name: "git", bin: "git", args: ["--version"] },
|
|
69
|
+
// The CLIENT, not `docker version --format {{.Server.Version}}`: this panel
|
|
70
|
+
// reports what is installed, and a stopped daemon does not uninstall Docker.
|
|
71
|
+
{ name: "docker", bin: "docker", args: ["--version"] },
|
|
72
|
+
];
|
|
73
|
+
export class ToolVersions {
|
|
74
|
+
probe;
|
|
75
|
+
versions = new Map();
|
|
76
|
+
started = false;
|
|
77
|
+
onSettled;
|
|
78
|
+
constructor(probe = spawnProbe) {
|
|
79
|
+
this.probe = probe;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Every row in display order — a pure map read, safe on the paint path.
|
|
83
|
+
*
|
|
84
|
+
* Node is resolved here rather than probed: free, exact, and it means the
|
|
85
|
+
* panel is never completely empty even on the very first frame.
|
|
86
|
+
*/
|
|
87
|
+
current() {
|
|
88
|
+
return [
|
|
89
|
+
{ name: "node", version: process.versions.node },
|
|
90
|
+
...TOOLS.map((t) => ({
|
|
91
|
+
name: t.name,
|
|
92
|
+
version: this.versions.get(t.name),
|
|
93
|
+
})),
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Begin probing. Idempotent and memoized: the first call starts every probe,
|
|
98
|
+
* later calls do nothing, so a panel that repaints 30 times a second cannot
|
|
99
|
+
* re-spawn anything.
|
|
100
|
+
*
|
|
101
|
+
* Returns immediately. Each probe settles on its own and calls `onSettled`,
|
|
102
|
+
* so a fast git appears while a slow pnpm is still running — the rows are
|
|
103
|
+
* independent, and the panel fills in rather than arriving all at once.
|
|
104
|
+
*/
|
|
105
|
+
start(onSettled) {
|
|
106
|
+
if (onSettled)
|
|
107
|
+
this.onSettled = onSettled;
|
|
108
|
+
if (this.started)
|
|
109
|
+
return;
|
|
110
|
+
this.started = true;
|
|
111
|
+
for (const tool of TOOLS) {
|
|
112
|
+
void this.run(tool);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async run(tool) {
|
|
116
|
+
let version = null;
|
|
117
|
+
try {
|
|
118
|
+
const output = await this.probe(tool.bin, tool.args);
|
|
119
|
+
version = output === null ? null : parseVersion(output);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// A probe that rejects is "not installed" for display purposes; it must
|
|
123
|
+
// never surface as an unhandled rejection from a decorative panel.
|
|
124
|
+
version = null;
|
|
125
|
+
}
|
|
126
|
+
this.versions.set(tool.name, version);
|
|
127
|
+
this.onSettled?.();
|
|
128
|
+
}
|
|
129
|
+
}
|
package/dist/usage/collect.js
CHANGED
|
@@ -45,15 +45,33 @@ export class UsageCollector {
|
|
|
45
45
|
tier: req.tier,
|
|
46
46
|
inputTokens: u?.input_tokens,
|
|
47
47
|
outputTokens: u?.output_tokens,
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
48
|
+
// Every additive field below is conditionally spread so an unreported one
|
|
49
|
+
// stays ABSENT from the persisted entry rather than landing as a 0 that
|
|
50
|
+
// reads like a measurement (undefined ≠ 0). The reasons absence happens
|
|
51
|
+
// differ per field — a non-caching provider, a gateway too old to send
|
|
52
|
+
// billable input, a request the server quoted no cost for — but the entry
|
|
53
|
+
// records the same thing for all of them: nothing.
|
|
51
54
|
...(u?.cache_read_input_tokens !== undefined
|
|
52
55
|
? { cacheReadTokens: u.cache_read_input_tokens }
|
|
53
56
|
: {}),
|
|
54
57
|
...(u?.cache_creation_input_tokens !== undefined
|
|
55
58
|
? { cacheCreationTokens: u.cache_creation_input_tokens }
|
|
56
59
|
: {}),
|
|
60
|
+
...(u?.billable_input_tokens !== undefined
|
|
61
|
+
? { billableInputTokens: u.billable_input_tokens }
|
|
62
|
+
: {}),
|
|
63
|
+
// The server's own cost, persisted verbatim — and now the ONLY source of
|
|
64
|
+
// the cost `cruxy usage` shows; the user-configured price table it used to
|
|
65
|
+
// compete with is retired. Amount and currency travel together: a figure
|
|
66
|
+
// without its unit is not a cost, and the summary refuses to add two
|
|
67
|
+
// currencies rather than invent a rate. A recorded 0 is a real 0; see
|
|
68
|
+
// UsageEntry.costAmount for why that must never be read as "unpriced".
|
|
69
|
+
...(u?.cost !== undefined
|
|
70
|
+
? { costAmount: u.cost.amount, costCurrency: u.cost.currency }
|
|
71
|
+
: {}),
|
|
72
|
+
...(req.routingMode !== undefined
|
|
73
|
+
? { routingMode: req.routingMode }
|
|
74
|
+
: {}),
|
|
57
75
|
at: this.now(),
|
|
58
76
|
});
|
|
59
77
|
}
|
package/dist/usage/index.js
CHANGED
|
@@ -7,9 +7,17 @@
|
|
|
7
7
|
* `@cruxy/sdk` import below is a TYPE-only import (`Usage`), erased at build. A
|
|
8
8
|
* future opt-in remote report would be a new, clearly-named seam — this build
|
|
9
9
|
* ships nothing that sends. Asserted by the runtime + static no-phone-home tests.
|
|
10
|
+
*
|
|
11
|
+
* The guarantee survived this module gaining SERVER-SOURCED figures (the
|
|
12
|
+
* gateway's cost, and weighted tokens in the gateway's own metering unit)
|
|
13
|
+
* because none of them is fetched here: cost rides in on the chat response the
|
|
14
|
+
* agent loop was already making, and the weighted multipliers are a compiled-in
|
|
15
|
+
* constant. Reading a number the server sent is not the same as asking it for
|
|
16
|
+
* one — the second would be the thing this promise forbids.
|
|
10
17
|
*/
|
|
11
18
|
export * from "./types.js";
|
|
12
19
|
export { UsageCollector, accumulateCacheTokens, } from "./collect.js";
|
|
13
|
-
export {
|
|
20
|
+
export { TIER_MULTIPLIERS, multiplierForTier, weightedFor, } from "./weighted.js";
|
|
14
21
|
export { loadUsage, appendRun, usageStorePath } from "./store.js";
|
|
15
|
-
export { summarizeRuns, renderSummary, formatCost
|
|
22
|
+
export { summarizeRuns, renderSummary, formatCost } from "./summary.js";
|
|
23
|
+
export { runCountLabel, selectRuns, usageReport, } from "./report.js";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { renderSummary, summarizeRuns } from "./summary.js";
|
|
2
|
+
/** Narrow the retained runs to `scope`, newest-last order preserved. */
|
|
3
|
+
export function selectRuns(runs, scope = {}) {
|
|
4
|
+
let selected = [...runs];
|
|
5
|
+
if (scope.sessionId !== undefined) {
|
|
6
|
+
selected = selected.filter((r) => r.sessionId === scope.sessionId);
|
|
7
|
+
}
|
|
8
|
+
if (scope.last !== undefined) {
|
|
9
|
+
// Guarded, not `slice(-last)` alone: `slice(-0)` is `slice(0)` and returns
|
|
10
|
+
// EVERYTHING, so a zero would quietly widen the scope to the whole store —
|
|
11
|
+
// the exact opposite of what it asks for.
|
|
12
|
+
selected = scope.last <= 0 ? [] : selected.slice(-scope.last);
|
|
13
|
+
}
|
|
14
|
+
return selected;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Say what the weighted figure IS, every time it is shown.
|
|
18
|
+
*
|
|
19
|
+
* The number is meaningless-to-dangerous without its scope: a reader who assumes
|
|
20
|
+
* it covers their whole account, or reads it as a remaining balance, has been
|
|
21
|
+
* misled by a figure that is itself correct. These lines are the only thing
|
|
22
|
+
* standing between the two readings, which is why they are not optional and not
|
|
23
|
+
* abbreviated for the in-session surface.
|
|
24
|
+
*/
|
|
25
|
+
function weightedNote(summary, t) {
|
|
26
|
+
if (summary.totalWeightedTokens !== undefined) {
|
|
27
|
+
return [
|
|
28
|
+
t.muted("weighted = (billable input + output) × tier weight — weighted tokens used by\n" +
|
|
29
|
+
"this CLI, over the runs shown. Not a balance or a limit: it cannot see your\n" +
|
|
30
|
+
"plan, or what cruxy on the web or desktop used from the same account."),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
// Absent rather than zero, and the reasons are unguessable from a blank line
|
|
34
|
+
// — so name them instead of leaving the figure silently missing.
|
|
35
|
+
return [
|
|
36
|
+
t.muted([
|
|
37
|
+
"weighted tokens unavailable for these runs — a request can only be weighed",
|
|
38
|
+
"when it records a tier and the gateway's billable input count. Runs from an",
|
|
39
|
+
"older cruxy, or against a non-cruxy provider, carry neither.",
|
|
40
|
+
].join("\n")),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The full usage report for `runs`, as lines to print.
|
|
45
|
+
*
|
|
46
|
+
* Empty `runs` is a first-class outcome, not an early-return the caller has to
|
|
47
|
+
* remember: it renders the "nothing yet" answer plus, when tracking is off, the
|
|
48
|
+
* reason — because "you have run nothing" and "nothing is being recorded" are
|
|
49
|
+
* different facts with the same blank output.
|
|
50
|
+
*/
|
|
51
|
+
export function usageReport(runs, t, opts) {
|
|
52
|
+
if (runs.length === 0) {
|
|
53
|
+
const lines = [t.muted("no usage recorded yet")];
|
|
54
|
+
if (!opts.trackingEnabled) {
|
|
55
|
+
lines.push(t.muted("usage tracking is off (usage.enabled = false)"));
|
|
56
|
+
}
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
const summary = summarizeRuns(runs);
|
|
60
|
+
const lines = [
|
|
61
|
+
t.heading(`usage — ${opts.scopeLabel}`),
|
|
62
|
+
renderSummary(summary, t),
|
|
63
|
+
...weightedNote(summary, t),
|
|
64
|
+
];
|
|
65
|
+
if (opts.legacyPriceConfig) {
|
|
66
|
+
lines.push(t.muted([
|
|
67
|
+
"note: usage.prices / usage.currency are no longer used. Cost is now the",
|
|
68
|
+
"gateway's own figure. You can remove them from your config.",
|
|
69
|
+
].join("\n")));
|
|
70
|
+
}
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
/** `3 runs` / `1 run` — the countable half of a scope label. */
|
|
74
|
+
export function runCountLabel(n) {
|
|
75
|
+
return `${n} run${n === 1 ? "" : "s"}`;
|
|
76
|
+
}
|
package/dist/usage/store.js
CHANGED
|
@@ -73,7 +73,13 @@ export function appendRun(record, opts = { retention: 50 }) {
|
|
|
73
73
|
const pruned = runs.slice(-retention); // keep the newest `retention` runs
|
|
74
74
|
try {
|
|
75
75
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
76
|
-
|
|
76
|
+
// Spread the loaded file first so any key a NEWER cruxy wrote at the top
|
|
77
|
+
// level survives this rewrite, the same way unknown keys inside runs and
|
|
78
|
+
// entries do (see the forward-compatibility note in types.ts). Tolerating a
|
|
79
|
+
// newer file on read but flattening it on the next append would still lose
|
|
80
|
+
// the data, just one write later. version/runs are re-stated after the
|
|
81
|
+
// spread so this build's own values always win.
|
|
82
|
+
const body = JSON.stringify({ ...loaded.data, version: USAGE_FILE_VERSION, runs: pruned }, null, 2);
|
|
77
83
|
writeFileSync(file, body, { mode: 0o600 });
|
|
78
84
|
return {};
|
|
79
85
|
}
|
package/dist/usage/summary.js
CHANGED
|
@@ -1,14 +1,38 @@
|
|
|
1
1
|
import { formatTokens } from "../render/state.js";
|
|
2
|
-
import {
|
|
2
|
+
import { weightedFor } from "./weighted.js";
|
|
3
|
+
/**
|
|
4
|
+
* Usage aggregation + rendering (C.22). Aggregation sums only KNOWN figures and
|
|
5
|
+
* counts the requests that reported none separately, so a total is never
|
|
6
|
+
* inflated by a fabricated zero — and the renderer ALWAYS surfaces those counts,
|
|
7
|
+
* so a total can never be misread as complete while requests are silently
|
|
8
|
+
* excluded. That rule now applies on three independent axes, because a request
|
|
9
|
+
* can report tokens but no cost, or a cost but nothing weighable:
|
|
10
|
+
* `requestsWithoutUsage`, `requestsWithoutCost`, `requestsWithoutWeight`.
|
|
11
|
+
*
|
|
12
|
+
* Both derived figures come from the server: cost is the gateway's own quote,
|
|
13
|
+
* weighted tokens are the gateway's own metering formula. Neither is derived
|
|
14
|
+
* from a user-configured price — there is no longer such a thing here.
|
|
15
|
+
*
|
|
16
|
+
* Only tier names ever reach the output (U.8 gag). Pure — no I/O, no network.
|
|
17
|
+
*/
|
|
3
18
|
/** Aggregate one or more run records into a {@link UsageSummary}. */
|
|
4
|
-
export function summarizeRuns(runs
|
|
19
|
+
export function summarizeRuns(runs) {
|
|
5
20
|
const byTier = new Map();
|
|
6
21
|
let totalInputTokens;
|
|
7
22
|
let totalOutputTokens;
|
|
8
23
|
let totalCacheReadTokens;
|
|
9
24
|
let totalCacheCreationTokens;
|
|
25
|
+
let totalBillableInputTokens;
|
|
26
|
+
let totalWeightedTokens;
|
|
27
|
+
let totalCost;
|
|
10
28
|
let requests = 0;
|
|
11
29
|
let requestsWithoutUsage = 0;
|
|
30
|
+
let requestsWithoutWeight = 0;
|
|
31
|
+
let requestsWithoutCost = 0;
|
|
32
|
+
// Every distinct currency the gateway stated. Summing across two of them would
|
|
33
|
+
// require an exchange rate we don't have and would never be told, so the set
|
|
34
|
+
// is kept and a >1 outcome withholds the total rather than guessing.
|
|
35
|
+
const currencies = new Set();
|
|
12
36
|
const addKnown = (acc, v) => (v === undefined ? acc : (acc ?? 0) + v);
|
|
13
37
|
for (const run of runs) {
|
|
14
38
|
for (const e of run.entries) {
|
|
@@ -16,10 +40,24 @@ export function summarizeRuns(runs, opts) {
|
|
|
16
40
|
const known = e.inputTokens !== undefined || e.outputTokens !== undefined;
|
|
17
41
|
if (!known)
|
|
18
42
|
requestsWithoutUsage++;
|
|
43
|
+
// Weighed per REQUEST, never from the tier's summed totals: the multiplier
|
|
44
|
+
// is per-tier, but a tier's bucket can mix weighable and unweighable
|
|
45
|
+
// requests, and multiplying a sum that includes the latter would silently
|
|
46
|
+
// weigh tokens that were never eligible.
|
|
47
|
+
const weighted = weightedFor(e.tier, e.billableInputTokens, e.outputTokens);
|
|
48
|
+
if (weighted === undefined)
|
|
49
|
+
requestsWithoutWeight++;
|
|
50
|
+
if (e.costAmount === undefined)
|
|
51
|
+
requestsWithoutCost++;
|
|
52
|
+
else if (e.costCurrency !== undefined)
|
|
53
|
+
currencies.add(e.costCurrency);
|
|
19
54
|
totalInputTokens = addKnown(totalInputTokens, e.inputTokens);
|
|
20
55
|
totalOutputTokens = addKnown(totalOutputTokens, e.outputTokens);
|
|
21
56
|
totalCacheReadTokens = addKnown(totalCacheReadTokens, e.cacheReadTokens);
|
|
22
57
|
totalCacheCreationTokens = addKnown(totalCacheCreationTokens, e.cacheCreationTokens);
|
|
58
|
+
totalBillableInputTokens = addKnown(totalBillableInputTokens, e.billableInputTokens);
|
|
59
|
+
totalWeightedTokens = addKnown(totalWeightedTokens, weighted);
|
|
60
|
+
totalCost = addKnown(totalCost, e.costAmount);
|
|
23
61
|
// Per-tier attribution only for entries that carry a tier. Untiered
|
|
24
62
|
// requests (routing inert) still count toward totals — the total stays
|
|
25
63
|
// honest — but there is no tier label to bucket them under.
|
|
@@ -27,42 +65,53 @@ export function summarizeRuns(runs, opts) {
|
|
|
27
65
|
const b = byTier.get(e.tier) ?? {
|
|
28
66
|
requests: 0,
|
|
29
67
|
requestsWithoutUsage: 0,
|
|
68
|
+
requestsWithoutWeight: 0,
|
|
30
69
|
};
|
|
31
70
|
b.requests++;
|
|
32
71
|
if (!known)
|
|
33
72
|
b.requestsWithoutUsage++;
|
|
73
|
+
if (weighted === undefined)
|
|
74
|
+
b.requestsWithoutWeight++;
|
|
34
75
|
b.inputTokens = addKnown(b.inputTokens, e.inputTokens);
|
|
35
76
|
b.outputTokens = addKnown(b.outputTokens, e.outputTokens);
|
|
36
77
|
b.cacheReadTokens = addKnown(b.cacheReadTokens, e.cacheReadTokens);
|
|
37
78
|
b.cacheCreationTokens = addKnown(b.cacheCreationTokens, e.cacheCreationTokens);
|
|
79
|
+
b.billableInputTokens = addKnown(b.billableInputTokens, e.billableInputTokens);
|
|
80
|
+
b.weightedTokens = addKnown(b.weightedTokens, weighted);
|
|
81
|
+
b.cost = addKnown(b.cost, e.costAmount);
|
|
38
82
|
byTier.set(e.tier, b);
|
|
39
83
|
}
|
|
40
84
|
}
|
|
41
85
|
}
|
|
86
|
+
const costMixedCurrency = currencies.size > 1;
|
|
42
87
|
const perTier = [...byTier.entries()].map(([tier, b]) => ({
|
|
43
88
|
tier,
|
|
44
89
|
inputTokens: b.inputTokens,
|
|
45
90
|
outputTokens: b.outputTokens,
|
|
46
91
|
cacheReadTokens: b.cacheReadTokens,
|
|
47
92
|
cacheCreationTokens: b.cacheCreationTokens,
|
|
93
|
+
billableInputTokens: b.billableInputTokens,
|
|
94
|
+
weightedTokens: b.weightedTokens,
|
|
48
95
|
requests: b.requests,
|
|
49
96
|
requestsWithoutUsage: b.requestsWithoutUsage,
|
|
50
|
-
|
|
97
|
+
requestsWithoutWeight: b.requestsWithoutWeight,
|
|
98
|
+
// Withheld under mixed currencies for the same reason the total is: a tier's
|
|
99
|
+
// requests are not guaranteed to share a currency either.
|
|
100
|
+
cost: costMixedCurrency ? undefined : b.cost,
|
|
51
101
|
}));
|
|
52
|
-
const costs = perTier
|
|
53
|
-
.map((p) => p.cost)
|
|
54
|
-
.filter((c) => c !== undefined);
|
|
55
|
-
const priced = costs.length > 0;
|
|
56
|
-
const totalCost = priced ? costs.reduce((a, c) => a + c, 0) : undefined;
|
|
57
102
|
return {
|
|
58
103
|
perTier,
|
|
59
104
|
totalInputTokens,
|
|
60
105
|
totalOutputTokens,
|
|
61
106
|
totalCacheReadTokens,
|
|
62
107
|
totalCacheCreationTokens,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
108
|
+
totalBillableInputTokens,
|
|
109
|
+
totalWeightedTokens,
|
|
110
|
+
requestsWithoutWeight,
|
|
111
|
+
totalCost: costMixedCurrency ? undefined : totalCost,
|
|
112
|
+
costCurrency: costMixedCurrency ? undefined : [...currencies][0],
|
|
113
|
+
costMixedCurrency,
|
|
114
|
+
requestsWithoutCost,
|
|
66
115
|
requestsWithoutUsage,
|
|
67
116
|
requests,
|
|
68
117
|
runCount: runs.length,
|
|
@@ -70,15 +119,27 @@ export function summarizeRuns(runs, opts) {
|
|
|
70
119
|
}
|
|
71
120
|
/**
|
|
72
121
|
* Format a cost figure honestly: enough precision for small per-run costs,
|
|
73
|
-
* trailing zeros trimmed.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
122
|
+
* trailing zeros trimmed. The currency is the gateway's own code (e.g. "usd"),
|
|
123
|
+
* so it is appended as a UNIT (`0.0021 USD`) rather than prefixed as a symbol —
|
|
124
|
+
* a code reads as a suffix, and cruxy no longer has a user-configured symbol to
|
|
125
|
+
* prefix with. An absent currency renders the bare number rather than assuming
|
|
126
|
+
* one. Callers pass a cost only when the gateway actually quoted it.
|
|
76
127
|
*/
|
|
77
128
|
export function formatCost(n, currency) {
|
|
78
129
|
const abs = Math.abs(n);
|
|
79
130
|
const decimals = abs >= 1 ? 2 : abs >= 0.01 ? 4 : 6;
|
|
80
131
|
const trimmed = n.toFixed(decimals).replace(/\.?0+$/, "");
|
|
81
|
-
|
|
132
|
+
const amount = trimmed === "" || trimmed === "-0" ? "0" : trimmed;
|
|
133
|
+
return currency ? `${amount} ${currency.toUpperCase()}` : amount;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Weighted tokens at status-line width. Rounded to a whole token because the
|
|
137
|
+
* multipliers are fractional (3.25×, 3.8×) and a `812.5` would imply the meter
|
|
138
|
+
* counts half-tokens; the unrounded value stays on the summary for anything that
|
|
139
|
+
* needs the exact arithmetic.
|
|
140
|
+
*/
|
|
141
|
+
function formatWeighted(n) {
|
|
142
|
+
return formatTokens(Math.round(n));
|
|
82
143
|
}
|
|
83
144
|
/** `↑1.2k ↓340` (worded `up 1.2k down 340` under a screen reader). */
|
|
84
145
|
function tokenText(input, output, t) {
|
|
@@ -98,7 +159,9 @@ export function renderSummary(summary, t) {
|
|
|
98
159
|
for (const p of summary.perTier) {
|
|
99
160
|
const known = p.inputTokens !== undefined || p.outputTokens !== undefined;
|
|
100
161
|
if (known) {
|
|
101
|
-
const cost = p.cost !== undefined
|
|
162
|
+
const cost = p.cost !== undefined
|
|
163
|
+
? ` ${formatCost(p.cost, summary.costCurrency)}`
|
|
164
|
+
: "";
|
|
102
165
|
parts.push(`${p.tier} ${tokenText(p.inputTokens, p.outputTokens, t)}${t.muted(cost)}`);
|
|
103
166
|
}
|
|
104
167
|
else {
|
|
@@ -111,11 +174,19 @@ export function renderSummary(summary, t) {
|
|
|
111
174
|
const totalKnown = summary.totalInputTokens !== undefined ||
|
|
112
175
|
summary.totalOutputTokens !== undefined;
|
|
113
176
|
const totalCost = summary.totalCost !== undefined
|
|
114
|
-
? ` ${formatCost(summary.totalCost, summary.
|
|
177
|
+
? ` ${formatCost(summary.totalCost, summary.costCurrency)}`
|
|
115
178
|
: "";
|
|
116
179
|
parts.push(totalKnown
|
|
117
180
|
? `${t.strong("total")} ${tokenText(summary.totalInputTokens, summary.totalOutputTokens, t)}${t.muted(totalCost)}`
|
|
118
181
|
: `${t.strong("total")} —`);
|
|
182
|
+
// Weighted tokens — the gateway's own metering unit, computed locally from
|
|
183
|
+
// billable input + output. Shown only when at least one request could actually
|
|
184
|
+
// be weighed; the label stays "weighted", never anything implying a balance
|
|
185
|
+
// (`cruxy usage` carries the fuller wording). A reported 0 IS shown, same rule
|
|
186
|
+
// as `cached ↻0`: it is a measurement, not an absence.
|
|
187
|
+
if (summary.totalWeightedTokens !== undefined) {
|
|
188
|
+
parts.push(t.muted(`weighted ${formatWeighted(summary.totalWeightedTokens)}`));
|
|
189
|
+
}
|
|
119
190
|
// Cache reads (phase-A caching, Anthropic dev path): shown ONLY when the
|
|
120
191
|
// provider actually reported cache usage — `undefined` on the cruxy gateway
|
|
121
192
|
// and every OpenAI-compat path, so normal users never see this segment. A
|
|
@@ -130,6 +201,24 @@ export function renderSummary(summary, t) {
|
|
|
130
201
|
if (summary.requestsWithoutUsage > 0) {
|
|
131
202
|
parts.push(t.warning(unreported(summary.requestsWithoutUsage)));
|
|
132
203
|
}
|
|
204
|
+
// Coverage for the two derived figures. Each is emitted only when it says
|
|
205
|
+
// something the line doesn't already: `requestsWithoutUsage` above already
|
|
206
|
+
// explains requests that reported nothing at all, so these fire only for the
|
|
207
|
+
// EXTRA shortfall — requests that did report tokens but still couldn't be
|
|
208
|
+
// weighed (unknown tier / no billable input) or weren't quoted a cost.
|
|
209
|
+
const weightShortfall = summary.requestsWithoutWeight - summary.requestsWithoutUsage;
|
|
210
|
+
if (summary.totalWeightedTokens !== undefined && weightShortfall > 0) {
|
|
211
|
+
parts.push(t.muted(`${weightShortfall} not weighted`));
|
|
212
|
+
}
|
|
213
|
+
const costShortfall = summary.requestsWithoutCost - summary.requestsWithoutUsage;
|
|
214
|
+
if (summary.totalCost !== undefined && costShortfall > 0) {
|
|
215
|
+
parts.push(t.muted(`${costShortfall} without cost`));
|
|
216
|
+
}
|
|
217
|
+
// Two gateways quoting two currencies: refuse to add them, and say why rather
|
|
218
|
+
// than just dropping the cost silently.
|
|
219
|
+
if (summary.costMixedCurrency) {
|
|
220
|
+
parts.push(t.warning("cost omitted: mixed currencies"));
|
|
221
|
+
}
|
|
133
222
|
return parts.join(sep);
|
|
134
223
|
}
|
|
135
224
|
/** `2 requests: usage not reported`. */
|
package/dist/usage/types.js
CHANGED
|
@@ -8,6 +8,32 @@ import { z } from "zod";
|
|
|
8
8
|
*/
|
|
9
9
|
/** Bump when the on-disk usage file shape changes (enables future migration). */
|
|
10
10
|
export const USAGE_FILE_VERSION = 1;
|
|
11
|
+
/**
|
|
12
|
+
* FORWARD COMPATIBILITY, and why every schema below is `.passthrough()` rather
|
|
13
|
+
* than `.strict()`.
|
|
14
|
+
*
|
|
15
|
+
* `loadUsage` discards the ENTIRE file on any parse failure (see store.ts) — it
|
|
16
|
+
* cannot do otherwise, because a file it can't validate is a file it can't
|
|
17
|
+
* safely append to. Combined with `.strict()`, that made the store hostile to
|
|
18
|
+
* its own future: the moment a newer cruxy wrote a field an older one didn't
|
|
19
|
+
* know, the older binary rejected the whole file and the user silently lost
|
|
20
|
+
* every run of history. Downgrading a CLI, or running two versions against one
|
|
21
|
+
* home directory, is ordinary — losing accounting for it is not acceptable.
|
|
22
|
+
*
|
|
23
|
+
* A version bump alone does NOT fix this: the old binary is already shipped and
|
|
24
|
+
* still can't parse the new file, whatever the number says. The fix has to be
|
|
25
|
+
* in the schema that old binaries already run, so it is made here, once, BEFORE
|
|
26
|
+
* any new field is persisted — that ordering is the whole point.
|
|
27
|
+
*
|
|
28
|
+
* `.passthrough()` also PRESERVES unknown keys rather than stripping them, so an
|
|
29
|
+
* old CLI that loads, prunes and rewrites the file hands a newer CLI's fields
|
|
30
|
+
* back intact. `.strip()` (zod's default) would tolerate the read and then
|
|
31
|
+
* quietly erase them on the next write — tolerant to parse, lossy in practice.
|
|
32
|
+
*
|
|
33
|
+
* The tradeoff accepted: a typo'd key is no longer a parse error. That is worth
|
|
34
|
+
* it — this is a local, additive, append-only telemetry file, and every field
|
|
35
|
+
* the code reads is still fully validated.
|
|
36
|
+
*/
|
|
11
37
|
/**
|
|
12
38
|
* One model request's usage. A request that reported no usage keeps
|
|
13
39
|
* `inputTokens`/`outputTokens` as `undefined` — the honest "unknown", never
|
|
@@ -16,7 +42,13 @@ export const USAGE_FILE_VERSION = 1;
|
|
|
16
42
|
*/
|
|
17
43
|
export const UsageEntrySchema = z
|
|
18
44
|
.object({
|
|
19
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* The tier this request ACTUALLY ran on. Preferentially the tier the gateway
|
|
47
|
+
* reported serving (v2 `routing.tier`, which resolves `auto` and reflects a
|
|
48
|
+
* budget downgrade); the client-side routing choice (C.30) only when the
|
|
49
|
+
* gateway said nothing. Absent when neither is known — e.g. a non-cruxy
|
|
50
|
+
* provider with routing inert.
|
|
51
|
+
*/
|
|
20
52
|
tier: z.string().optional(),
|
|
21
53
|
/** Provider-reported prompt tokens; `undefined` ⇔ no usage was reported. */
|
|
22
54
|
inputTokens: z.number().int().nonnegative().optional(),
|
|
@@ -31,10 +63,47 @@ export const UsageEntrySchema = z
|
|
|
31
63
|
cacheReadTokens: z.number().int().nonnegative().optional(),
|
|
32
64
|
/** Prompt-cache tokens WRITTEN this request (the ~1.25× write premium); same `undefined`-≠-0 rule. */
|
|
33
65
|
cacheCreationTokens: z.number().int().nonnegative().optional(),
|
|
66
|
+
/**
|
|
67
|
+
* Tokens the gateway actually METERED as fresh input — cache-read tokens
|
|
68
|
+
* excluded, which is what makes the caching win legible. This is the input
|
|
69
|
+
* term of the weighted-pool formula `(billable_input + output) × multiplier`
|
|
70
|
+
* that weighted.ts computes, and `inputTokens` is NEVER an acceptable
|
|
71
|
+
* stand-in for it: substituting a count that includes cache reads overstates
|
|
72
|
+
* precisely the requests caching made cheaper. Reported by the cruxy v2
|
|
73
|
+
* wire on every request INCLUDING as a literal `0`, so `undefined` here means
|
|
74
|
+
* a gateway too old to send it or a provider that never does — unknown, not
|
|
75
|
+
* zero. Never zero-filled.
|
|
76
|
+
*/
|
|
77
|
+
billableInputTokens: z.number().int().nonnegative().optional(),
|
|
78
|
+
/**
|
|
79
|
+
* The gateway's OWN computed cost for this request (`usage.cost.amount`) and
|
|
80
|
+
* its currency — the same figure persisted to the server-side ledger, so
|
|
81
|
+
* client and ledger can never disagree. Recorded verbatim; nothing here is
|
|
82
|
+
* derived from a user-configured price.
|
|
83
|
+
*
|
|
84
|
+
* TWO TRAPS, do not "optimize" either away:
|
|
85
|
+
* - `costAmount === 0` is a LEGITIMATE cost (a request that billed nothing),
|
|
86
|
+
* NOT a signal that the tier is unpriced. Absence is the only unknown.
|
|
87
|
+
* - server-side, an unrecognized model falls through to the mira price rather
|
|
88
|
+
* than to no price at all, so a cost is always a real quote for SOME tier —
|
|
89
|
+
* you cannot infer "unpriced" from the amount under any circumstances.
|
|
90
|
+
* Read `costAmount !== undefined` and nothing else.
|
|
91
|
+
*/
|
|
92
|
+
costAmount: z.number().nonnegative().optional(),
|
|
93
|
+
/** Currency of {@link costAmount} as the gateway stated it (e.g. "usd"); absent with it. */
|
|
94
|
+
costCurrency: z.string().optional(),
|
|
95
|
+
/**
|
|
96
|
+
* How the served tier was chosen, verbatim from the gateway's `routing.mode`
|
|
97
|
+
* (`explicit` | `auto` | `auto_degraded`). This is what makes a client/server
|
|
98
|
+
* tier disagreement explainable after the fact rather than silent: when a
|
|
99
|
+
* run configured `kavi` but the entry reads `tier: "mira"`, the mode says
|
|
100
|
+
* whether that was routing working as asked or a budget downgrade.
|
|
101
|
+
*/
|
|
102
|
+
routingMode: z.string().optional(),
|
|
34
103
|
/** ISO-8601 timestamp the request completed. */
|
|
35
104
|
at: z.string(),
|
|
36
105
|
})
|
|
37
|
-
.
|
|
106
|
+
.passthrough();
|
|
38
107
|
/** One run's usage: an ordered list of per-request entries. */
|
|
39
108
|
export const UsageRecordSchema = z
|
|
40
109
|
.object({
|
|
@@ -46,11 +115,11 @@ export const UsageRecordSchema = z
|
|
|
46
115
|
startedAt: z.string(),
|
|
47
116
|
entries: z.array(UsageEntrySchema),
|
|
48
117
|
})
|
|
49
|
-
.
|
|
118
|
+
.passthrough();
|
|
50
119
|
/** The persisted store: a bounded, newest-last list of run records. */
|
|
51
120
|
export const UsageFileSchema = z
|
|
52
121
|
.object({
|
|
53
122
|
version: z.literal(USAGE_FILE_VERSION),
|
|
54
123
|
runs: z.array(UsageRecordSchema),
|
|
55
124
|
})
|
|
56
|
-
.
|
|
125
|
+
.passthrough();
|