@cruxy/cli 1.2.0 → 1.2.1
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/loop.js +21 -1
- package/dist/agent/session.js +14 -3
- package/dist/usage/collect.js +21 -3
- package/dist/usage/store.js +7 -1
- package/dist/usage/types.js +70 -4
- package/package.json +2 -2
package/dist/agent/loop.js
CHANGED
|
@@ -116,6 +116,12 @@ async function driveLoop(args, renderer, routed) {
|
|
|
116
116
|
// true and is recorded as a real 0.
|
|
117
117
|
let sawUsage = false;
|
|
118
118
|
const reqUsage = { input_tokens: 0, output_tokens: 0 };
|
|
119
|
+
// The served tier arrives on the stream's OPENING frame, but usage is only
|
|
120
|
+
// reported once the stream has closed — so it is held here across the whole
|
|
121
|
+
// request, exactly like `sawUsage`. Absent for backends that report no
|
|
122
|
+
// routing, which is why the fallback below still exists.
|
|
123
|
+
let servedTier;
|
|
124
|
+
let routingMode;
|
|
119
125
|
// Live progress while waiting on the model; dismissed by the first delta.
|
|
120
126
|
// Token context is whatever the loop has actually accumulated (U.4): zero
|
|
121
127
|
// on the first turn → no figure shown, never a fabricated number.
|
|
@@ -133,6 +139,10 @@ async function driveLoop(args, renderer, routed) {
|
|
|
133
139
|
...(routed ? { model: routed.model } : {}),
|
|
134
140
|
})) {
|
|
135
141
|
switch (ev.type) {
|
|
142
|
+
case "routing":
|
|
143
|
+
servedTier = ev.routing.tier;
|
|
144
|
+
routingMode = ev.routing.mode;
|
|
145
|
+
break;
|
|
136
146
|
case "text_delta":
|
|
137
147
|
turnText += ev.text;
|
|
138
148
|
renderer?.write(ev.text);
|
|
@@ -178,8 +188,18 @@ async function driveLoop(args, renderer, routed) {
|
|
|
178
188
|
// event arrived, or `undefined` (unknown) when the provider reported none. A
|
|
179
189
|
// stream that threw above never reaches here, so failed requests aren't
|
|
180
190
|
// recorded with a misleading zero.
|
|
191
|
+
// THE BACKEND WINS on tier. `routed?.tier` is what this run ASKED for;
|
|
192
|
+
// `servedTier` is what the gateway says actually ran, which is not the same
|
|
193
|
+
// claim — a "auto" request is resolved server-side, and any request can be
|
|
194
|
+
// downgraded under budget pressure. Attributing tokens to the tier we asked
|
|
195
|
+
// for would misreport exactly the case worth knowing about. The client
|
|
196
|
+
// choice remains the fallback for backends that report no routing at all.
|
|
197
|
+
// `routingMode` travels alongside so a difference between the two is
|
|
198
|
+
// explainable after the fact ("auto_degraded") rather than an unexplained
|
|
199
|
+
// tier nobody selected.
|
|
181
200
|
args.onRequestUsage?.({
|
|
182
|
-
tier: routed?.tier,
|
|
201
|
+
tier: servedTier ?? routed?.tier,
|
|
202
|
+
...(routingMode !== undefined ? { routingMode } : {}),
|
|
183
203
|
usage: sawUsage ? { ...reqUsage } : undefined,
|
|
184
204
|
});
|
|
185
205
|
// ── Record the assistant turn ───────────────────────────────────────────
|
package/dist/agent/session.js
CHANGED
|
@@ -331,14 +331,22 @@ export class Session {
|
|
|
331
331
|
? resolveTaskModel(this.args.router, "summarize")
|
|
332
332
|
: null;
|
|
333
333
|
// Per-request usage capture for telemetry (C.22), same honesty pivot as the
|
|
334
|
-
// main loop: unknown unless a usage event actually arrives.
|
|
334
|
+
// main loop: unknown unless a usage event actually arrives. The served tier
|
|
335
|
+
// is held across the stream for the same reason it is in the loop — it
|
|
336
|
+
// arrives on the opening frame, and is reported after the close.
|
|
335
337
|
let sawUsage = false;
|
|
338
|
+
let servedTier;
|
|
339
|
+
let routingMode;
|
|
336
340
|
for await (const ev of this.args.provider.stream({
|
|
337
341
|
system: SUMMARY_SYSTEM,
|
|
338
342
|
messages: [{ role: "user", content: transcript }],
|
|
339
343
|
...(routed ? { model: routed.model } : {}),
|
|
340
344
|
})) {
|
|
341
345
|
switch (ev.type) {
|
|
346
|
+
case "routing":
|
|
347
|
+
servedTier = ev.routing.tier;
|
|
348
|
+
routingMode = ev.routing.mode;
|
|
349
|
+
break;
|
|
342
350
|
case "text_delta":
|
|
343
351
|
text += ev.text;
|
|
344
352
|
break;
|
|
@@ -354,9 +362,12 @@ export class Session {
|
|
|
354
362
|
break;
|
|
355
363
|
}
|
|
356
364
|
}
|
|
357
|
-
// Attribute this compaction request to the
|
|
365
|
+
// Attribute this compaction request to the tier that served it — the
|
|
366
|
+
// gateway's answer over the `summarize` tier this run asked for, same
|
|
367
|
+
// precedence and same reasoning as the main loop.
|
|
358
368
|
onRequestUsage?.({
|
|
359
|
-
tier: routed?.tier,
|
|
369
|
+
tier: servedTier ?? routed?.tier,
|
|
370
|
+
...(routingMode !== undefined ? { routingMode } : {}),
|
|
360
371
|
usage: sawUsage ? { ...usage } : undefined,
|
|
361
372
|
});
|
|
362
373
|
if (!text.trim())
|
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 NOT yet surfaced (the
|
|
64
|
+
// summary's cost line still comes from the user's configured price table —
|
|
65
|
+
// reconciling the two is a deliberate follow-up, not a mechanical merge).
|
|
66
|
+
// Amount and currency travel together: a figure without its unit is not a
|
|
67
|
+
// cost. A recorded 0 is a real 0; see UsageEntry.costAmount for why that
|
|
68
|
+
// 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/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/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,44 @@ 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 (the weighted pool
|
|
69
|
+
* meters `(billable_input + output) × multiplier`). Reported by the cruxy v2
|
|
70
|
+
* wire on every request INCLUDING as a literal `0`, so `undefined` here means
|
|
71
|
+
* a gateway too old to send it or a provider that never does — unknown, not
|
|
72
|
+
* zero. Never zero-filled.
|
|
73
|
+
*/
|
|
74
|
+
billableInputTokens: z.number().int().nonnegative().optional(),
|
|
75
|
+
/**
|
|
76
|
+
* The gateway's OWN computed cost for this request (`usage.cost.amount`) and
|
|
77
|
+
* its currency — the same figure persisted to the server-side ledger, so
|
|
78
|
+
* client and ledger can never disagree. Recorded verbatim; nothing here is
|
|
79
|
+
* derived from a user-configured price.
|
|
80
|
+
*
|
|
81
|
+
* TWO TRAPS, do not "optimize" either away:
|
|
82
|
+
* - `costAmount === 0` is a LEGITIMATE cost (a request that billed nothing),
|
|
83
|
+
* NOT a signal that the tier is unpriced. Absence is the only unknown.
|
|
84
|
+
* - server-side, an unrecognized model falls through to the mira price rather
|
|
85
|
+
* than to no price at all, so a cost is always a real quote for SOME tier —
|
|
86
|
+
* you cannot infer "unpriced" from the amount under any circumstances.
|
|
87
|
+
* Read `costAmount !== undefined` and nothing else.
|
|
88
|
+
*/
|
|
89
|
+
costAmount: z.number().nonnegative().optional(),
|
|
90
|
+
/** Currency of {@link costAmount} as the gateway stated it (e.g. "usd"); absent with it. */
|
|
91
|
+
costCurrency: z.string().optional(),
|
|
92
|
+
/**
|
|
93
|
+
* How the served tier was chosen, verbatim from the gateway's `routing.mode`
|
|
94
|
+
* (`explicit` | `auto` | `auto_degraded`). This is what makes a client/server
|
|
95
|
+
* tier disagreement explainable after the fact rather than silent: when a
|
|
96
|
+
* run configured `kavi` but the entry reads `tier: "mira"`, the mode says
|
|
97
|
+
* whether that was routing working as asked or a budget downgrade.
|
|
98
|
+
*/
|
|
99
|
+
routingMode: z.string().optional(),
|
|
34
100
|
/** ISO-8601 timestamp the request completed. */
|
|
35
101
|
at: z.string(),
|
|
36
102
|
})
|
|
37
|
-
.
|
|
103
|
+
.passthrough();
|
|
38
104
|
/** One run's usage: an ordered list of per-request entries. */
|
|
39
105
|
export const UsageRecordSchema = z
|
|
40
106
|
.object({
|
|
@@ -46,11 +112,11 @@ export const UsageRecordSchema = z
|
|
|
46
112
|
startedAt: z.string(),
|
|
47
113
|
entries: z.array(UsageEntrySchema),
|
|
48
114
|
})
|
|
49
|
-
.
|
|
115
|
+
.passthrough();
|
|
50
116
|
/** The persisted store: a bounded, newest-last list of run records. */
|
|
51
117
|
export const UsageFileSchema = z
|
|
52
118
|
.object({
|
|
53
119
|
version: z.literal(USAGE_FILE_VERSION),
|
|
54
120
|
runs: z.array(UsageRecordSchema),
|
|
55
121
|
})
|
|
56
|
-
.
|
|
122
|
+
.passthrough();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cruxy/cli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "an agentic coding CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"undici": "^6.21.0",
|
|
37
37
|
"zod": "^3.23.8",
|
|
38
38
|
"zod-to-json-schema": "^3.23.5",
|
|
39
|
-
"@cruxy/sdk": "0.4.
|
|
39
|
+
"@cruxy/sdk": "0.4.1"
|
|
40
40
|
},
|
|
41
41
|
"optionalDependencies": {
|
|
42
42
|
"better-sqlite3": "^12.11.1"
|