@cruxy/cli 1.2.1 → 1.4.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 +20 -1
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +171 -69
- package/dist/agent/status.js +56 -0
- 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 +401 -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 +717 -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 +65 -3
- package/dist/components/select.js +3 -3
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- 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 +204 -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 +508 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +108 -0
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +15 -0
- package/dist/tui/layout.js +314 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/overview.js +49 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +1121 -0
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/tui/views.js +66 -0
- package/dist/usage/collect.js +6 -6
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +5 -2
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +163 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** The built-in view: the streaming conversation, and the default selection. */
|
|
2
|
+
export const CONVERSATION_VIEW = "conversation";
|
|
3
|
+
/**
|
|
4
|
+
* The selectable views, conversation first.
|
|
5
|
+
*
|
|
6
|
+
* Conversation is prepended here rather than registered by a caller because it
|
|
7
|
+
* is the one view the renderer already owns the content for (the scrollback
|
|
8
|
+
* buffer) and the one that must always exist: it is the default, and it is
|
|
9
|
+
* unclosable, so nothing may remove it from the list a user can get back to.
|
|
10
|
+
*/
|
|
11
|
+
export function viewOrder(sources) {
|
|
12
|
+
return [CONVERSATION_VIEW, ...sources.map((s) => s.id)];
|
|
13
|
+
}
|
|
14
|
+
/** Human label for an id — conversation's is built in, the rest self-describe. */
|
|
15
|
+
export function viewLabel(id, sources) {
|
|
16
|
+
if (id === CONVERSATION_VIEW)
|
|
17
|
+
return "conversation";
|
|
18
|
+
return sources.find((s) => s.id === id)?.label ?? id;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The id `steps` places after `from` in the nav order, wrapping.
|
|
22
|
+
*
|
|
23
|
+
* Wrapping (rather than stopping at the ends) because this is reached from a
|
|
24
|
+
* single cycling key: a ring the user can hold down to get anywhere beats two
|
|
25
|
+
* bindings that dead-end, and the list is short enough that overshooting costs
|
|
26
|
+
* one more press. Same reasoning as the mode ring in P5.
|
|
27
|
+
*/
|
|
28
|
+
export function cycleView(from, sources, steps) {
|
|
29
|
+
const order = viewOrder(sources);
|
|
30
|
+
const at = order.indexOf(from);
|
|
31
|
+
// An id that is no longer registered resolves to the conversation rather than
|
|
32
|
+
// to `order[-1]`: a view can be attached and re-attached, and the fallback has
|
|
33
|
+
// to be the one that always exists.
|
|
34
|
+
if (at < 0)
|
|
35
|
+
return CONVERSATION_VIEW;
|
|
36
|
+
const next = (at + (steps % order.length) + order.length) % order.length;
|
|
37
|
+
return order[next];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The sidebar's nav block: one row per view, the selected one marked.
|
|
41
|
+
*
|
|
42
|
+
* FOCUS IS DRAWN, not implied. When the sidebar holds the keyboard the heading
|
|
43
|
+
* says so and the selected row is highlighted rather than merely marked —
|
|
44
|
+
* otherwise "which keys go where" is invisible state, and the arrow keys
|
|
45
|
+
* silently mean two different things depending on something the screen never
|
|
46
|
+
* showed. The marker glyph stays in both states so a screen reader and a
|
|
47
|
+
* NO_COLOR terminal can still tell which row is current.
|
|
48
|
+
*/
|
|
49
|
+
export function navLines(theme, sources, selected, focused) {
|
|
50
|
+
// A WORD, not a glyph. This has to survive NO_COLOR, CRUXY_ASCII and a screen
|
|
51
|
+
// reader, and "focus" carries the meaning in all three where an arrow or a
|
|
52
|
+
// colour carries it in none. It also fits the 18-column sidebar.
|
|
53
|
+
const heading = focused ? "views (focus)" : "views";
|
|
54
|
+
const lines = [focused ? theme.accent(heading) : theme.strong(heading), ""];
|
|
55
|
+
for (const id of viewOrder(sources)) {
|
|
56
|
+
const label = viewLabel(id, sources);
|
|
57
|
+
const current = id === selected;
|
|
58
|
+
const row = `${current ? theme.glyph.pointer : " "} ${label}`;
|
|
59
|
+
if (!current) {
|
|
60
|
+
lines.push(theme.muted(row));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
lines.push(focused ? theme.accent(row) : theme.strong(row));
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
package/dist/usage/collect.js
CHANGED
|
@@ -60,12 +60,12 @@ export class UsageCollector {
|
|
|
60
60
|
...(u?.billable_input_tokens !== undefined
|
|
61
61
|
? { billableInputTokens: u.billable_input_tokens }
|
|
62
62
|
: {}),
|
|
63
|
-
// The server's own cost, persisted verbatim and
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// must never be read as "unpriced".
|
|
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
69
|
...(u?.cost !== undefined
|
|
70
70
|
? { costAmount: u.cost.amount, costCurrency: u.cost.currency }
|
|
71
71
|
: {}),
|
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/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
|
@@ -65,8 +65,11 @@ export const UsageEntrySchema = z
|
|
|
65
65
|
cacheCreationTokens: z.number().int().nonnegative().optional(),
|
|
66
66
|
/**
|
|
67
67
|
* Tokens the gateway actually METERED as fresh input — cache-read tokens
|
|
68
|
-
* excluded, which is what makes the caching win legible
|
|
69
|
-
*
|
|
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
|
|
70
73
|
* wire on every request INCLUDING as a literal `0`, so `undefined` here means
|
|
71
74
|
* a gateway too old to send it or a provider that never does — unknown, not
|
|
72
75
|
* zero. Never zero-filled.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weighted tokens (C.22) — the unit the cruxy gateway actually meters a
|
|
3
|
+
* subscriber in.
|
|
4
|
+
*
|
|
5
|
+
* A subscriber is not billed per token and not billed in dollars: the gateway
|
|
6
|
+
* counts `(billable_input + output) × multiplier` per request and checks that
|
|
7
|
+
* against two windows. This module computes THAT number, locally, from figures
|
|
8
|
+
* already on disk — so `cruxy usage` can state consumption in the same unit the
|
|
9
|
+
* meter uses instead of a currency nobody is charged in.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS IS NOT: it is not headroom, and it must never be rendered as such.
|
|
12
|
+
* A cap needs the user's plan, and how much of that plan every OTHER surface
|
|
13
|
+
* (web chat, desktop, phone) has already spent — neither of which is on this
|
|
14
|
+
* machine. All this module can honestly say is "this is what the runs in this
|
|
15
|
+
* CLI's own store weigh". Anything phrased as remaining/left/available would be
|
|
16
|
+
* a number this process cannot possibly know.
|
|
17
|
+
*
|
|
18
|
+
* No network: the multipliers are a compiled-in constant, not a fetched one.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Weighted-token multipliers, keyed by tier — mirroring `multipliers` in the
|
|
22
|
+
* gateway's `internal/budget/config.go`, which is the source of truth.
|
|
23
|
+
*
|
|
24
|
+
* MIRRORED, THEREFORE DRIFTABLE. Nothing checks these against the server at
|
|
25
|
+
* runtime (that would be a network call this module is forbidden to make), so a
|
|
26
|
+
* gateway-side reweighting silently makes an old CLI's arithmetic stale. That
|
|
27
|
+
* is the accepted cost of computing offline, and it is why every rendering of
|
|
28
|
+
* this figure is framed as what THIS CLI reckons it used — never as an
|
|
29
|
+
* authoritative balance. If they drift, the server is right and this is wrong.
|
|
30
|
+
*
|
|
31
|
+
* Only the chat tiers are listed. The gateway also weighs its embed/guard/rerank
|
|
32
|
+
* models at 0.1×, but the CLI's chat path cannot produce a request on one, so
|
|
33
|
+
* including them would model traffic that never reaches this store.
|
|
34
|
+
*/
|
|
35
|
+
export const TIER_MULTIPLIERS = {
|
|
36
|
+
mira: 1.0,
|
|
37
|
+
vaani: 3.25,
|
|
38
|
+
kavi: 3.8,
|
|
39
|
+
};
|
|
40
|
+
/** The multiplier for a tier, or `undefined` when the tier isn't a known one. */
|
|
41
|
+
export function multiplierForTier(tier) {
|
|
42
|
+
return TIER_MULTIPLIERS[tier];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Weighted tokens for one request's figures, or `undefined` when the request
|
|
46
|
+
* cannot be weighed honestly.
|
|
47
|
+
*
|
|
48
|
+
* BOTH terms are required, and this is the whole discipline of the function.
|
|
49
|
+
* The meter's formula has two addends; substituting 0 for an unknown one does
|
|
50
|
+
* not produce an approximate answer, it produces a confidently understated one —
|
|
51
|
+
* and understating consumption is the direction that flatters, so it is exactly
|
|
52
|
+
* the error that would go unnoticed. `costFor` could price one known side
|
|
53
|
+
* because the raw token counts stayed visible beside it; here the weighted
|
|
54
|
+
* figure IS the deliverable, so a partial one is worse than none. Callers count
|
|
55
|
+
* the `undefined`s and surface them instead.
|
|
56
|
+
*
|
|
57
|
+
* Three ways a request is unweighable, all of them real and all treated alike:
|
|
58
|
+
* - no tier, or a tier this build doesn't know (a non-cruxy provider — those
|
|
59
|
+
* tokens never touched the weighted pool, so weighing them would be fiction);
|
|
60
|
+
* - no `billable_input_tokens` (a gateway too old to send it). Note that
|
|
61
|
+
* `inputTokens` is NOT a fallback: it includes cache-read tokens, which the
|
|
62
|
+
* meter excludes, so it would overstate every cached request — the precise
|
|
63
|
+
* inverse of the caching win the field exists to show;
|
|
64
|
+
* - no output count.
|
|
65
|
+
*
|
|
66
|
+
* A reported `0` on either side is a real measurement and weighs in as 0.
|
|
67
|
+
*/
|
|
68
|
+
export function weightedFor(tier, billableInputTokens, outputTokens) {
|
|
69
|
+
if (tier === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
const multiplier = multiplierForTier(tier);
|
|
72
|
+
if (multiplier === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (billableInputTokens === undefined || outputTokens === undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
return (billableInputTokens + outputTokens) * multiplier;
|
|
77
|
+
}
|