@cruxy/cli 1.1.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.
@@ -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 ───────────────────────────────────────────
@@ -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 `summarize` tier honestly.
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())
@@ -6,8 +6,8 @@ export const ProviderSchema = z.enum(["cruxy", "openai", "custom"]);
6
6
  export const ModelConfigSchema = z
7
7
  .object({
8
8
  provider: ProviderSchema.default("cruxy"),
9
- // Cruxy tiers: "kavi" | "vaani" | "mira", or "auto" (shimmed to a default
10
- // tier client-side until the gateway ships server-side routing).
9
+ // Cruxy tiers: "kavi" | "vaani" | "mira", or "auto" (the v2 gateway routes
10
+ // "auto" server-side; the served tier comes back in the response routing).
11
11
  model: z.string().default("auto"),
12
12
  maxTokens: z.number().int().positive().default(8192),
13
13
  temperature: z.number().min(0).max(2).default(0),
@@ -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
- // Cache counters are conditionally spread so an unreported field stays
49
- // absent from the persisted entry (undefined 0) only the Anthropic
50
- // dev path ever populates them.
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
  }
@@ -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
- const body = JSON.stringify({ version: USAGE_FILE_VERSION, runs: pruned }, null, 2);
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
  }
@@ -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
- /** The routing tier (C.30) this request ran on; absent when routing is inert. */
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
- .strict();
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
- .strict();
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
- .strict();
122
+ .passthrough();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.1.0",
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.3.0"
39
+ "@cruxy/sdk": "0.4.1"
40
40
  },
41
41
  "optionalDependencies": {
42
42
  "better-sqlite3": "^12.11.1"