metergraph-cli 0.2.1 → 0.2.3

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.
Files changed (43) hide show
  1. package/README.md +60 -11
  2. package/assets/skill/SKILL.md +52 -3
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +13 -13
  5. package/assets/skills/metergraph-analyze/SKILL.md +62 -4
  6. package/assets/skills/metergraph-investigate/SKILL.md +222 -35
  7. package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
  8. package/assets/skills/metergraph-report/SKILL.md +19 -2
  9. package/bin/metergraph.js +1 -1
  10. package/package.json +1 -1
  11. package/src/agent-key.js +160 -0
  12. package/src/approval-handoff.js +5 -0
  13. package/src/args.js +572 -17
  14. package/src/auth-browser.js +5 -19
  15. package/src/auth-callback.js +24 -15
  16. package/src/auth-login.js +55 -12
  17. package/src/auth-oauth.js +77 -7
  18. package/src/callback-page.js +58 -0
  19. package/src/cli.js +190 -45
  20. package/src/constants.js +73 -3
  21. package/src/environment.js +169 -0
  22. package/src/experiments-contract.js +462 -0
  23. package/src/experiments-output.js +189 -0
  24. package/src/experiments.js +397 -0
  25. package/src/origin.js +6 -0
  26. package/src/output.js +437 -131
  27. package/src/pricing-args.js +163 -0
  28. package/src/pricing-output.js +206 -0
  29. package/src/pricing-reference.js +195 -0
  30. package/src/pricing.js +307 -0
  31. package/src/read-contract.js +976 -16
  32. package/src/read-output.js +449 -30
  33. package/src/read.js +312 -60
  34. package/src/setup-deployment.js +11 -3
  35. package/src/setup-interactive.js +341 -0
  36. package/src/setup-refusal.js +172 -0
  37. package/src/setup-state.js +4 -2
  38. package/src/setup.js +227 -53
  39. package/src/skill-bundle.js +2 -2
  40. package/src/style.js +101 -0
  41. package/src/transport.js +13 -7
  42. package/src/usage-aggregates.js +197 -0
  43. package/src/verify-output.js +21 -11
package/src/args.js CHANGED
@@ -1,7 +1,16 @@
1
+ import { parsePricing, PRICING_ACTIONS, PRICING_OPTIONS } from "./pricing-args.js";
1
2
  import { parseRepository } from "./repository-identity.js";
2
3
  import {
3
4
  DEFAULT_ORIGIN,
4
5
  DEFAULT_TIMEOUT_MS,
6
+ EXPERIMENT_LAST_N,
7
+ EXPERIMENT_LIST_DEFAULT_LIMIT,
8
+ EXPERIMENT_LIST_MAX_LIMIT,
9
+ EXPERIMENT_MAX_TOKENS,
10
+ EXPERIMENT_MAX_TRACKED_FIELDS,
11
+ EXPERIMENT_MAX_TREATMENTS,
12
+ EXPERIMENT_REASONING_EFFORTS,
13
+ EXPERIMENT_TEXT_LIMITS,
5
14
  HANDOFF_SKILL_CLIENTS,
6
15
  HANDOFF_SKILL_RUNTIMES,
7
16
  HANDOFF_LOGIN_RUNTIMES,
@@ -21,30 +30,60 @@ import {
21
30
  SKILL_CLIENTS,
22
31
  SKILL_RUNTIMES,
23
32
  TRACES_DEFAULT_LIMIT,
33
+ MAX_METADATA_FILTERS,
34
+ MAX_METADATA_KEY_LENGTH,
35
+ MAX_QUERY_LENGTH,
36
+ MAX_METADATA_VALUE_LENGTH,
37
+ USAGE_COMPARISONS,
38
+ USAGE_DEFAULT_GROUP_BY,
39
+ USAGE_DIMENSIONS,
40
+ USAGE_MAX_FILTER_VALUES,
41
+ USAGE_SORTS,
24
42
  } from "./constants.js";
25
43
  import { parseOrigin } from "./origin.js";
26
- import { isCursor, isSafeFilter } from "./read-contract.js";
44
+ import { groupedRequest, hasUnsafeText, isCursor, isSafeFilter, readQuery } from "./read-contract.js";
27
45
 
28
46
  export const READ_COMMANDS = Object.freeze(["status", "context", "capabilities", "usage", "routes", "traces"]);
29
- const COMMANDS = new Set(["doctor", "help", "skill", "skills", "login", "logout", "setup", "verify", ...READ_COMMANDS]);
30
- const HELP_TOPICS = new Set(["doctor", "skill", "skills", "login", "logout", "setup", "verify", ...READ_COMMANDS]);
47
+ const COMMANDS = new Set(["pricing", "doctor", "help", "skill", "skills", "login", "logout", "setup", "verify", "experiments", ...READ_COMMANDS]);
48
+ const HELP_TOPICS = new Set(["pricing", "doctor", "skill", "skills", "login", "logout", "setup", "verify", "experiments", ...READ_COMMANDS]);
49
+ export const EXPERIMENT_ACTIONS = Object.freeze(["list", "estimate", "start", "get"]);
31
50
  const SKILL_ACTIONS = new Set(["install", "update"]);
32
51
  const SKILLS_ACTIONS = new Set(["install", "update", "list"]);
33
52
  const READ_BASE = ["--project", "--config-dir", "--timeout-ms"];
53
+ // The read commands may name where an agent key is sent and which workspace
54
+ // it must be for, and a private file holding the key. The key itself is
55
+ // never an argument.
56
+ const READ_KEY = ["--url", "--workspace", "--agent-token-file"];
34
57
  const OPTIONS = {
58
+ pricing: new Set(PRICING_OPTIONS),
35
59
  doctor: new Set(["--url", "--timeout-ms"]),
36
60
  skill: new Set(["--client", "--runtime", "--project"]),
37
61
  skills: new Set(["--client", "--runtime", "--project"]),
38
62
  login: new Set(["--runtime", "--url", "--workspace", "--project", "--config-dir", "--timeout-ms"]),
39
63
  setup: new Set(["--runtime", "--url", "--workspace", "--project", "--config-dir", "--env-file", "--client", "--timeout-ms", "--deployment", "--agent-token-file", "--repository"]),
40
64
  logout: new Set(["--project", "--config-dir"]),
41
- status: new Set(READ_BASE),
42
- context: new Set(READ_BASE),
43
- capabilities: new Set(READ_BASE),
44
- usage: new Set([...READ_BASE, "--days", "--limit"]),
45
- routes: new Set([...READ_BASE, "--limit"]),
46
- traces: new Set([...READ_BASE, "--days", "--limit", "--route", "--status", "--cursor"]),
65
+ status: new Set([...READ_BASE, ...READ_KEY]),
66
+ context: new Set([...READ_BASE, ...READ_KEY]),
67
+ capabilities: new Set([...READ_BASE, ...READ_KEY]),
68
+ usage: new Set([
69
+ ...READ_BASE, ...READ_KEY, "--days", "--limit", "--metadata", "--metadata-json", "--group-by-metadata",
70
+ "--group-by", "--route", "--provider", "--model", "--environment", "--since", "--until", "--sort", "--compare",
71
+ ]),
72
+ routes: new Set([...READ_BASE, ...READ_KEY, "--limit"]),
73
+ traces: new Set([
74
+ ...READ_BASE, ...READ_KEY, "--days", "--limit", "--route", "--status", "--cursor", "--metadata", "--metadata-json",
75
+ ]),
47
76
  verify: new Set([...READ_BASE, "--trace-id", "--request-id", "--since", "--until", "--source", "--days", "--poll-ms", "--max-attempts"]),
77
+ experiments: new Set([
78
+ ...READ_BASE, ...READ_KEY, "--workload", "--limit", "--treatment", "--last-n", "--sample-model", "--track", "--id",
79
+ ]),
80
+ };
81
+ // Which experiments options each action takes, beyond READ_BASE and READ_KEY.
82
+ const EXPERIMENT_OPTIONS = {
83
+ list: new Set(["--workload", "--limit"]),
84
+ estimate: new Set(["--workload", "--treatment", "--last-n", "--sample-model", "--track"]),
85
+ start: new Set(["--workload", "--treatment", "--last-n", "--sample-model", "--track"]),
86
+ get: new Set(["--id"]),
48
87
  };
49
88
  // Requests the read commands recognize and refuse, so a script gets an
50
89
  // explicit unsupported result instead of an unknown argument or, worse, a
@@ -63,23 +102,37 @@ const REFUSED = {
63
102
  };
64
103
  const REFUSED_MESSAGES = {
65
104
  environment_selector_unsupported:
66
- "--environment is not supported: the service's agent access contract has no environment selector, " +
67
- "so the CLI never sends one. Results cover the bound workspace. No request was made.",
105
+ "--environment is supported only by usage, which sends it to the grouped usage read. This command has no " +
106
+ "environment selector, so the CLI never sends one. Results cover the bound workspace. No request was made.",
68
107
  workload_filter_unsupported:
69
108
  "--workload is not supported by this CLI version: it cannot verify from the returned traces that a " +
70
109
  "workload filter was applied, so it never sends one. No request was made.",
71
110
  time_range_unsupported:
72
- "--since and --until are not supported. Use --days N (1 to 90) for a window relative to now. No request was made.",
111
+ "--since and --until are supported only by usage. Use --days N (1 to 90) for a window relative to now. " +
112
+ "No request was made.",
73
113
  query_unsupported:
74
114
  "Free-form queries are not supported. Use the fixed options of this command. No request was made.",
75
115
  content_access_unsupported:
76
116
  "Read commands return Metadata only. They never read retained content, debug data or replays. No request was made.",
77
117
  };
118
+ // Options that may be given more than once; their values are kept in order.
119
+ const REPEATABLE = new Set(["--metadata"]);
120
+ const EXPERIMENT_REPEATABLE = new Set(["--treatment", "--track"]);
121
+ // usage takes several values for each of its exact-match filters.
122
+ const USAGE_REPEATABLE = new Set(["--route", "--provider", "--model", "--environment"]);
123
+ // usage reads a time range and environments itself, through grouped usage.
124
+ const USAGE_ACCEPTS = new Set(["--environment", "--since", "--until"]);
125
+ // Options that select grouped usage (GET /v1/agent/usage/aggregates).
126
+ const USAGE_GROUPED = [
127
+ "--group-by", "--group-by-metadata", "--route", "--provider", "--model", "--environment", "--since", "--until", "--sort", "--compare",
128
+ ];
78
129
  // Options that take no value.
79
130
  const FLAGS = {
131
+ pricing: new Set(["--batch"]),
80
132
  login: new Set(["--signup", "--no-browser", "--reconnect"]),
81
133
  setup: new Set(["--no-browser", "--repair", "--signup", "--reconnect", "--skip-skill", "--confirm-prerequisites", "--no-repository"]),
82
134
  verify: new Set(["--open", "--no-browser"]),
135
+ usage: new Set(["--today"]),
83
136
  };
84
137
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
85
138
 
@@ -117,7 +170,7 @@ export function parseArgs(argv) {
117
170
 
118
171
  const fail = (code, message, outcome = "invalid_input") => ({
119
172
  ok: false,
120
- command: (command === "skill" || command === "skills") && action !== null
173
+ command: (command === "skill" || command === "skills" || command === "experiments") && action !== null
121
174
  ? `${command} ${action}`
122
175
  : command ?? (version ? "version" : null),
123
176
  json,
@@ -144,6 +197,10 @@ export function parseArgs(argv) {
144
197
  topic = arg;
145
198
  continue;
146
199
  }
200
+ if (command === "pricing" && action === null && PRICING_ACTIONS.includes(arg)) {
201
+ action = arg;
202
+ continue;
203
+ }
147
204
  if (command === "skill" && action === null && SKILL_ACTIONS.has(arg)) {
148
205
  action = arg;
149
206
  continue;
@@ -152,11 +209,15 @@ export function parseArgs(argv) {
152
209
  action = arg;
153
210
  continue;
154
211
  }
212
+ if (command === "experiments" && action === null && EXPERIMENT_ACTIONS.includes(arg)) {
213
+ action = arg;
214
+ continue;
215
+ }
155
216
 
156
217
  if (READ_COMMANDS.includes(command)) {
157
218
  const equals = arg.indexOf("=");
158
219
  const name = equals === -1 ? arg : arg.slice(0, equals);
159
- if (Object.hasOwn(REFUSED, name)) {
220
+ if (Object.hasOwn(REFUSED, name) && !(command === "usage" && USAGE_ACCEPTS.has(name))) {
160
221
  const { reason } = REFUSED[name];
161
222
  return fail(reason, REFUSED_MESSAGES[reason], "unsupported");
162
223
  }
@@ -182,6 +243,11 @@ export function parseArgs(argv) {
182
243
  if (value === undefined) {
183
244
  return fail("missing_value", `${name} requires a value.`);
184
245
  }
246
+ if (REPEATABLE.has(name) || (command === "usage" && USAGE_REPEATABLE.has(name)) ||
247
+ (command === "experiments" && EXPERIMENT_REPEATABLE.has(name))) {
248
+ values[name] = [...(values[name] ?? []), value];
249
+ continue;
250
+ }
185
251
  if (values[name] !== undefined) {
186
252
  return fail("duplicate_option", `${name} may be given only once.`);
187
253
  }
@@ -208,6 +274,12 @@ export function parseArgs(argv) {
208
274
  `Unknown skills subcommand at argument ${position}. Use install, update or list.`,
209
275
  );
210
276
  }
277
+ if (command === "experiments" && action === null && !arg.startsWith("-")) {
278
+ return fail(
279
+ "unknown_subcommand",
280
+ `Unknown experiments subcommand at argument ${position}. Use list, estimate, start or get.`,
281
+ );
282
+ }
211
283
  return fail(
212
284
  "unknown_argument",
213
285
  `Unrecognized argument at position ${position}. Run "metergraph --help" for usage.`,
@@ -218,12 +290,14 @@ export function parseArgs(argv) {
218
290
  if (command === null || command === "help") {
219
291
  return { ok: true, command: "help", topic, json };
220
292
  }
293
+ if (command === "pricing") return parsePricing(action, values, flags, json, fail);
221
294
  if (command === "skill") return parseSkill(action, values, json, fail);
222
295
  if (command === "skills") return parseSkills(action, values, json, fail);
223
296
  if (command === "login") return parseLogin(values, flags, json, fail);
224
297
  if (command === "setup") return parseSetup(values, flags, json, fail);
225
298
  if (command === "verify") return parseVerify(values, flags, json, fail);
226
- if (READ_COMMANDS.includes(command)) return parseRead(command, values, json, fail);
299
+ if (command === "experiments") return parseExperiments(action, values, json, fail);
300
+ if (READ_COMMANDS.includes(command)) return parseRead(command, values, flags, json, fail);
227
301
  if (command === "logout") {
228
302
  const paths = parsePaths(values, fail);
229
303
  if (!paths.ok) return paths;
@@ -246,6 +320,177 @@ export function parseArgs(argv) {
246
320
  return { ok: true, command: "doctor", origin, timeoutMs, json };
247
321
  }
248
322
 
323
+ // --url, --workspace and --agent-token-file, shared by the read commands and
324
+ // experiments: { ok: true, origin, workspace, agentTokenFile } or a failure.
325
+ function parseKeyTarget(values, fail) {
326
+ let origin = null;
327
+ if (values["--url"] !== undefined) {
328
+ origin = parseOrigin(values["--url"]);
329
+ if (origin === null) return fail("invalid_url", INVALID_URL);
330
+ }
331
+ const rawWorkspace = values["--workspace"];
332
+ if (rawWorkspace !== undefined && !UUID.test(rawWorkspace)) {
333
+ return fail("invalid_workspace", "--workspace must be a workspace ID in UUID form.");
334
+ }
335
+ const agentTokenFile = values["--agent-token-file"] ?? null;
336
+ if (agentTokenFile !== null && (agentTokenFile === "" || agentTokenFile.includes("\0"))) {
337
+ return fail("invalid_agent_token_file", "--agent-token-file must name a private absolute file.");
338
+ }
339
+ return {
340
+ ok: true,
341
+ origin,
342
+ workspace: rawWorkspace === undefined ? null : rawWorkspace.toLowerCase(),
343
+ agentTokenFile,
344
+ };
345
+ }
346
+
347
+ // experiments list | estimate | start | get. estimate and start take the same
348
+ // request, so an agent starts exactly what it priced: the workload, 1 to 3
349
+ // --treatment PROVIDER/MODEL[,label=..][,temperature=..][,max_tokens=..]
350
+ // [,reasoning_effort=..], and optionally --last-n, --sample-model and up to ten
351
+ // --track JSON paths. The service validates every request in full; these
352
+ // checks only refuse what it would refuse before anything is sent.
353
+ function parseExperiments(action, values, json, fail) {
354
+ if (action === null) {
355
+ return fail(
356
+ "missing_subcommand",
357
+ 'experiments requires a subcommand: list, estimate, start or get. Run "metergraph help experiments" for usage.',
358
+ );
359
+ }
360
+ for (const name of Object.keys(values)) {
361
+ if (!READ_BASE.includes(name) && !READ_KEY.includes(name) && !EXPERIMENT_OPTIONS[action].has(name)) {
362
+ return fail("unknown_argument", `experiments ${action} does not take ${name}. Run "metergraph help experiments" for usage.`);
363
+ }
364
+ }
365
+ const paths = parsePaths(values, fail);
366
+ if (!paths.ok) return paths;
367
+ const rawTimeout = values["--timeout-ms"];
368
+ const timeoutMs = rawTimeout === undefined
369
+ ? READ_DEFAULT_TIMEOUT_MS
370
+ : parseTimeout(rawTimeout, READ_MIN_TIMEOUT_MS, READ_MAX_TIMEOUT_MS);
371
+ if (timeoutMs === null) {
372
+ return fail("invalid_timeout", `--timeout-ms must be a whole number from ${READ_MIN_TIMEOUT_MS} to ${READ_MAX_TIMEOUT_MS}.`);
373
+ }
374
+ const target = parseKeyTarget(values, fail);
375
+ if (!target.ok) return target;
376
+ delete target.ok;
377
+ const parsed = {
378
+ ok: true, command: "experiments", action, ...target, project: paths.project, configDir: paths.configDir,
379
+ timeoutMs, json, experiment: { id: null, workloadId: null, limit: null, request: null },
380
+ };
381
+ const workload = values["--workload"];
382
+ if (workload !== undefined && !experimentText(workload, EXPERIMENT_TEXT_LIMITS.workload)) {
383
+ return fail("invalid_workload", `--workload must be 1 to ${EXPERIMENT_TEXT_LIMITS.workload} characters without control characters.`);
384
+ }
385
+ if (action === "get") {
386
+ const id = values["--id"];
387
+ if (id === undefined || !UUID.test(id)) return fail("invalid_experiment_id", "--id must be an experiment ID in UUID form.");
388
+ parsed.experiment.id = id.toLowerCase();
389
+ return parsed;
390
+ }
391
+ if (action === "list") {
392
+ const limit = values["--limit"] === undefined
393
+ ? EXPERIMENT_LIST_DEFAULT_LIMIT
394
+ : parseBounded(values["--limit"], 1, EXPERIMENT_LIST_MAX_LIMIT);
395
+ if (limit === null) return fail("invalid_limit", `--limit must be a whole number from 1 to ${EXPERIMENT_LIST_MAX_LIMIT}.`);
396
+ // The exact query list sends, so an over-long one fails here and not after sign in.
397
+ const query = new URLSearchParams({ ...(workload === undefined ? {} : { workload_id: workload }), limit: String(limit) });
398
+ if (query.toString().length > MAX_QUERY_LENGTH) {
399
+ return fail("query_too_long", "--workload is too long to send in one request once encoded.");
400
+ }
401
+ parsed.experiment.workloadId = workload ?? null;
402
+ parsed.experiment.limit = limit;
403
+ return parsed;
404
+ }
405
+ if (workload === undefined) return fail("missing_workload", "--workload is required: the workload (route) whose calls to replay.");
406
+ const specs = values["--treatment"] ?? [];
407
+ if (specs.length < 1 || specs.length > EXPERIMENT_MAX_TREATMENTS) {
408
+ return fail("invalid_treatments", `Give 1 to ${EXPERIMENT_MAX_TREATMENTS} --treatment values.`);
409
+ }
410
+ const treatments = [];
411
+ for (const spec of specs) {
412
+ const treatment = parseTreatment(spec);
413
+ if (treatment === null) {
414
+ return fail(
415
+ "invalid_treatment",
416
+ "--treatment must be PROVIDER/MODEL, optionally followed by ,label=NAME ,temperature=0..2 " +
417
+ `,max_tokens=1..${EXPERIMENT_MAX_TOKENS} or ,reasoning_effort=${EXPERIMENT_REASONING_EFFORTS.join("|")}.`,
418
+ );
419
+ }
420
+ treatments.push(treatment);
421
+ }
422
+ const labels = treatments.map((treatment) => treatment.label).filter((label) => label !== undefined);
423
+ if (new Set(labels.map((label) => label.toLowerCase())).size !== labels.length ||
424
+ labels.some((label) => label.toLowerCase() === "control")) {
425
+ return fail("invalid_treatments", 'Treatment labels must be unique and cannot be "control".');
426
+ }
427
+ const sample = { last_n: EXPERIMENT_LAST_N.default };
428
+ if (values["--last-n"] !== undefined) {
429
+ sample.last_n = parseBounded(values["--last-n"], EXPERIMENT_LAST_N.min, EXPERIMENT_LAST_N.max);
430
+ if (sample.last_n === null) {
431
+ return fail("invalid_last_n", `--last-n must be a whole number from ${EXPERIMENT_LAST_N.min} to ${EXPERIMENT_LAST_N.max}.`);
432
+ }
433
+ }
434
+ if (values["--sample-model"] !== undefined) {
435
+ if (!experimentText(values["--sample-model"], EXPERIMENT_TEXT_LIMITS.model)) {
436
+ return fail("invalid_sample_model", `--sample-model must be 1 to ${EXPERIMENT_TEXT_LIMITS.model} characters without control characters.`);
437
+ }
438
+ sample.model = values["--sample-model"];
439
+ }
440
+ const tracked = values["--track"] ?? [];
441
+ if (tracked.length > EXPERIMENT_MAX_TRACKED_FIELDS ||
442
+ tracked.some((field) => !experimentText(field, EXPERIMENT_TEXT_LIMITS.path) || !field.startsWith("$"))) {
443
+ return fail("invalid_tracked_fields", `Give at most ${EXPERIMENT_MAX_TRACKED_FIELDS} --track JSON paths, each starting with $.`);
444
+ }
445
+ parsed.experiment.request = { workload_id: workload, sample, treatments, tracked_fields: [...new Set(tracked)] };
446
+ return parsed;
447
+ }
448
+
449
+ function experimentText(value, max) {
450
+ return typeof value === "string" && value.trim() === value && value.length > 0 && value.length <= max && !hasUnsafeText(value);
451
+ }
452
+
453
+ const TREATMENT_KEYS = new Set(["label", "temperature", "max_tokens", "reasoning_effort"]);
454
+
455
+ // PROVIDER/MODEL[,key=value...] as a treatment, or null. The model may itself
456
+ // contain "/" (a gateway ID such as anthropic/claude-haiku-4.5).
457
+ function parseTreatment(spec) {
458
+ const [head, ...options] = spec.split(",");
459
+ const slash = head.indexOf("/");
460
+ if (slash <= 0) return null;
461
+ const provider = head.slice(0, slash);
462
+ const model = head.slice(slash + 1);
463
+ if (!experimentText(provider, EXPERIMENT_TEXT_LIMITS.provider) || !experimentText(model, EXPERIMENT_TEXT_LIMITS.model)) {
464
+ return null;
465
+ }
466
+ const treatment = { provider, model };
467
+ const settings = {};
468
+ for (const option of options) {
469
+ const equals = option.indexOf("=");
470
+ const key = option.slice(0, equals);
471
+ const value = option.slice(equals + 1);
472
+ if (equals <= 0 || !TREATMENT_KEYS.has(key) || Object.hasOwn(settings, key) || (key === "label" && treatment.label !== undefined)) {
473
+ return null;
474
+ }
475
+ if (key === "label") {
476
+ if (!experimentText(value, EXPERIMENT_TEXT_LIMITS.label)) return null;
477
+ treatment.label = value;
478
+ } else if (key === "temperature") {
479
+ if (!/^(?:[0-1](?:\.[0-9]{1,3})?|2(?:\.0{1,3})?)$/.test(value)) return null;
480
+ settings.temperature = Number(value);
481
+ } else if (key === "max_tokens") {
482
+ const tokens = /^[0-9]{1,5}$/.test(value) ? Number(value) : 0;
483
+ if (tokens < 1 || tokens > EXPERIMENT_MAX_TOKENS) return null;
484
+ settings.max_tokens = tokens;
485
+ } else {
486
+ if (!EXPERIMENT_REASONING_EFFORTS.includes(value)) return null;
487
+ settings.reasoning_effort = value;
488
+ }
489
+ }
490
+ if (Object.keys(settings).length > 0) treatment.settings = settings;
491
+ return treatment;
492
+ }
493
+
249
494
  function parseVerify(values, flags, json, fail) {
250
495
  const paths = parsePaths(values, fail);
251
496
  if (!paths.ok) return paths;
@@ -452,7 +697,7 @@ function parseSetup(values, flags, json, fail) {
452
697
  // Read commands take the project and config directory, one total timeout and,
453
698
  // where the endpoint supports them, bounded days, limit and trace filters.
454
699
  // Out of range values fail here; nothing is clamped.
455
- function parseRead(command, values, json, fail) {
700
+ function parseRead(command, values, flags, json, fail) {
456
701
  const paths = parsePaths(values, fail);
457
702
  if (!paths.ok) return paths;
458
703
  const rawTimeout = values["--timeout-ms"];
@@ -479,6 +724,14 @@ function parseRead(command, values, json, fail) {
479
724
  limit = values["--limit"] === undefined ? fallback : parseBounded(values["--limit"], 1, READ_MAX_LIMIT);
480
725
  if (limit === null) return fail("invalid_limit", `--limit must be a whole number from 1 to ${READ_MAX_LIMIT}.`);
481
726
  }
727
+ // Where to read and with which key: the same for every read, grouped usage
728
+ // included.
729
+ const target = parseKeyTarget(values, fail);
730
+ if (!target.ok) return target;
731
+ delete target.ok;
732
+ if (command === "usage" && (USAGE_GROUPED.some((name) => values[name] !== undefined) || flags.has("--today"))) {
733
+ return parseGroupedUsage(values, flags, json, fail, { paths, timeoutMs, limit, target });
734
+ }
482
735
  const route = values["--route"] ?? null;
483
736
  if (route !== null && !isSafeFilter(route)) {
484
737
  return fail("invalid_route", "--route must be 1 to 256 characters without control characters.");
@@ -494,9 +747,12 @@ function parseRead(command, values, json, fail) {
494
747
  "--cursor must be the next_cursor value from an earlier traces result, at most 512 printable characters.",
495
748
  );
496
749
  }
497
- return {
750
+ const metadata = parseMetadata(values, fail);
751
+ if (!metadata.ok) return metadata;
752
+ const parsed = {
498
753
  ok: true,
499
754
  command,
755
+ ...target,
500
756
  project: paths.project,
501
757
  configDir: paths.configDir,
502
758
  timeoutMs,
@@ -505,8 +761,307 @@ function parseRead(command, values, json, fail) {
505
761
  route,
506
762
  status,
507
763
  cursor,
764
+ metadata: metadata.filters,
765
+ groupByMetadata: null,
508
766
  json,
509
767
  };
768
+ // Measure the exact query the command will send (URLSearchParams encoding,
769
+ // every parameter), so an over-long one fails here and not after sign in.
770
+ const query = readQuery(parsed);
771
+ if (query !== null && query.toString().length > MAX_QUERY_LENGTH) {
772
+ return fail(
773
+ "query_too_long",
774
+ "The filters are too long to send in one request. Use fewer or shorter metadata values, or a shorter route or cursor.",
775
+ );
776
+ }
777
+ return parsed;
778
+ }
779
+
780
+ // Grouped usage: server-side groups by provider, model, route and UTC day,
781
+ // with exact filters and a UTC window. The window is either a preset of whole
782
+ // UTC days (--days N or --today: the current UTC day so far and the N - 1
783
+ // whole UTC days before it) or an explicit --since/--until, never both.
784
+ // Ranking other than by cost and the prior-period comparison are computed
785
+ // from the returned groups and say when those groups are not all of them.
786
+ function parseGroupedUsage(values, flags, json, fail, { paths, timeoutMs, limit, target }) {
787
+ // A "metadata.<key>" entry takes the rest of the value as the key, so a key
788
+ // may contain commas and dots; it must come last. --group-by-metadata KEY
789
+ // is the same dimension, appended last.
790
+ let groupBy = values["--group-by"] === undefined && values["--group-by-metadata"] !== undefined
791
+ ? []
792
+ : [...USAGE_DEFAULT_GROUP_BY];
793
+ if (values["--group-by"] !== undefined) {
794
+ const raw = values["--group-by"];
795
+ const at = raw.startsWith(METADATA_PREFIX) ? 0 : raw.indexOf(`,${METADATA_PREFIX}`);
796
+ const plain = at === -1 ? raw : raw.slice(0, Math.max(0, at));
797
+ const metadataPart = at === -1 ? null : raw.slice(at === 0 ? 0 : at + 1);
798
+ const parts = plain === "" ? [] : plain.split(",").map((part) => part.trim());
799
+ if (
800
+ (parts.length === 0 && metadataPart === null) ||
801
+ !parts.every((part) => USAGE_DIMENSIONS.includes(part)) ||
802
+ new Set(parts).size !== parts.length
803
+ ) {
804
+ return fail(
805
+ "invalid_group_by",
806
+ `--group-by must list distinct dimensions from ${USAGE_DIMENSIONS.join(", ")}, comma separated, ` +
807
+ "optionally ending with metadata.KEY.",
808
+ );
809
+ }
810
+ groupBy = metadataPart === null ? parts : [...parts, metadataPart];
811
+ }
812
+ if (values["--group-by-metadata"] !== undefined) {
813
+ if (groupBy.some((dimension) => dimension.startsWith(METADATA_PREFIX))) {
814
+ return fail("too_many_metadata_dimensions", "Group by at most one metadata key.");
815
+ }
816
+ groupBy = [...groupBy, `${METADATA_PREFIX}${values["--group-by-metadata"]}`];
817
+ }
818
+ const metadataDimension = groupBy.find((dimension) => dimension.startsWith(METADATA_PREFIX)) ?? null;
819
+ if (metadataDimension !== null) {
820
+ const problem = metadataKeyProblem(metadataDimension.slice(METADATA_PREFIX.length));
821
+ if (problem !== null) return fail(problem.code, `A metadata grouping key ${problem.message}`);
822
+ }
823
+ const filters = {};
824
+ for (const [option, key] of [["--route", "route"], ["--provider", "provider"], ["--model", "model"], ["--environment", "environment"]]) {
825
+ const list = values[option] ?? [];
826
+ if (list.length > USAGE_MAX_FILTER_VALUES) {
827
+ return fail("too_many_filter_values", `${option} may be given at most ${USAGE_MAX_FILTER_VALUES} times.`);
828
+ }
829
+ if (!list.every((value) => isSafeFilter(value) && value.trim() !== "")) {
830
+ return fail(`invalid_${key}`, `${option} must be 1 to 256 characters without control characters.`);
831
+ }
832
+ filters[key] = list.length === 0 ? null : [...new Set(list)];
833
+ }
834
+ const today = flags.has("--today");
835
+ const rawSince = values["--since"];
836
+ const rawUntil = values["--until"];
837
+ const explicit = rawSince !== undefined || rawUntil !== undefined;
838
+ if ([today, explicit, values["--days"] !== undefined].filter(Boolean).length > 1) {
839
+ return fail("window_conflict", "Use one window: --days N, --today, or --since with an optional --until.");
840
+ }
841
+ let days = null;
842
+ let since = null;
843
+ let until = null;
844
+ if (explicit) {
845
+ if (rawSince === undefined) return fail("invalid_window", "--until needs --since.");
846
+ since = utcInstant(rawSince);
847
+ until = rawUntil === undefined ? null : utcInstant(rawUntil);
848
+ if (since === null || (rawUntil !== undefined && until === null)) {
849
+ return fail(
850
+ "invalid_window",
851
+ "--since and --until must be a UTC date (YYYY-MM-DD, meaning 00:00 UTC) or an ISO 8601 timestamp with Z or an offset.",
852
+ );
853
+ }
854
+ const end = until === null ? Date.now() : Date.parse(until);
855
+ if (Date.parse(since) >= end) return fail("invalid_window", "--since must be before --until (or before now).");
856
+ if (end - Date.parse(since) > READ_MAX_DAYS * DAY_MS) {
857
+ return fail("invalid_window", `The window may span at most ${READ_MAX_DAYS} days.`);
858
+ }
859
+ } else {
860
+ days = today ? 1 : values["--days"] === undefined ? READ_DEFAULT_DAYS : parseBounded(values["--days"], 1, READ_MAX_DAYS);
861
+ if (days === null) return fail("invalid_days", `--days must be a whole number from 1 to ${READ_MAX_DAYS}.`);
862
+ }
863
+ const sort = values["--sort"] ?? (groupBy.length === 1 && groupBy[0] === "day" ? "day" : "cost");
864
+ if (!USAGE_SORTS.includes(sort)) return fail("invalid_sort", `--sort must be one of ${USAGE_SORTS.join(", ")}.`);
865
+ if (sort === "day" && !groupBy.includes("day")) {
866
+ return fail("invalid_sort", "--sort day needs day in --group-by.");
867
+ }
868
+ const compare = values["--compare"] ?? null;
869
+ if (compare !== null && !USAGE_COMPARISONS.includes(compare)) {
870
+ return fail("invalid_compare", `--compare must be ${USAGE_COMPARISONS.join(" or ")}.`);
871
+ }
872
+ if (compare !== null && groupBy.includes("day")) {
873
+ return fail(
874
+ "compare_with_day_grouping",
875
+ "--compare matches groups across two windows, and a UTC day is never in both. " +
876
+ "For a trend, use --group-by day without --compare.",
877
+ );
878
+ }
879
+ const metadata = parseMetadata(values, fail);
880
+ if (!metadata.ok) return metadata;
881
+ const parsed = {
882
+ ok: true,
883
+ command: "usage",
884
+ ...target,
885
+ project: paths.project,
886
+ configDir: paths.configDir,
887
+ timeoutMs,
888
+ days,
889
+ limit,
890
+ route: null,
891
+ status: null,
892
+ cursor: null,
893
+ metadata: metadata.filters,
894
+ groupByMetadata: metadataDimension === null ? null : metadataDimension.slice(METADATA_PREFIX.length),
895
+ grouped: { groupBy, filters, since, until, today, sort, compare },
896
+ json,
897
+ };
898
+ // Measure the exact query the read sends, and for --compare the prior
899
+ // window's query, which carries two timestamps.
900
+ const longest = [readQuery(parsed)];
901
+ if (compare !== null) {
902
+ const stamp = "0000-00-00T00:00:00.000Z";
903
+ longest.push(readQuery(parsed, { ...groupedRequest(parsed), days: null, since: stamp, until: stamp }));
904
+ }
905
+ if (longest.some((query) => query.toString().length > MAX_QUERY_LENGTH)) {
906
+ return fail(
907
+ "query_too_long",
908
+ "The filters are too long to send in one request. Use fewer or shorter --route, --provider, --model, " +
909
+ "--environment or --metadata values.",
910
+ );
911
+ }
912
+ return parsed;
913
+ }
914
+
915
+ const DAY_MS = 24 * 60 * 60 * 1000;
916
+ const METADATA_PREFIX = "metadata.";
917
+ const UTC_DATE = /^\d{4}-\d{2}-\d{2}$/;
918
+ const ZONED_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?(?:Z|[+-]\d{2}:\d{2})$/;
919
+
920
+ // A window bound as a UTC ISO 8601 string, or null. A date alone is 00:00
921
+ // UTC; a timestamp must carry Z or an offset, so a local time is never read
922
+ // as UTC by accident.
923
+ function utcInstant(raw) {
924
+ if (typeof raw !== "string" || raw.length > 40) return null;
925
+ const text = UTC_DATE.test(raw) ? `${raw}T00:00:00Z` : ZONED_TIME.test(raw) ? raw : null;
926
+ if (text === null) return null;
927
+ const parsed = Date.parse(text);
928
+ if (!Number.isFinite(parsed)) return null;
929
+ // Reject calendar values the parser would roll over, such as 2026-02-30 or
930
+ // 2026-09-31T12:00:00Z, rather than read a different instant.
931
+ const [year, month, day] = text.slice(0, 10).split("-").map(Number);
932
+ const calendar = new Date(Date.UTC(year, month - 1, day));
933
+ if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day) {
934
+ return null;
935
+ }
936
+ const time = /T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(text);
937
+ if (time && (Number(time[1]) > 23 || Number(time[2]) > 59 || Number(time[3] ?? 0) > 59)) return null;
938
+ const offset = /([+-])(\d{2}):(\d{2})$/.exec(text);
939
+ if (offset && (Number(offset[2]) > 23 || Number(offset[3]) > 59)) return null;
940
+ return new Date(parsed).toISOString();
941
+ }
942
+
943
+ // Exact customer-metadata filters. --metadata KEY=VALUE splits at the first
944
+ // "=", so a value may contain "=" and a key may not; --metadata-json takes one
945
+ // JSON object for keys with "=" or values that are awkward to quote. Values
946
+ // are opaque strings: nothing is trimmed, case folded or split on a prefix.
947
+ // Returns filters sorted by key, or null when none were given.
948
+ function parseMetadata(values, fail) {
949
+ const pairs = values["--metadata"] ?? [];
950
+ const raw = values["--metadata-json"];
951
+ if (pairs.length > 0 && raw !== undefined) {
952
+ return fail("metadata_conflict", "Use --metadata or --metadata-json, not both.");
953
+ }
954
+ const entries = [];
955
+ if (raw !== undefined) {
956
+ let parsed;
957
+ try {
958
+ parsed = JSON.parse(raw);
959
+ } catch {
960
+ return fail("invalid_metadata", '--metadata-json must be a JSON object such as {"prompt_id":"p-1"}.');
961
+ }
962
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
963
+ return fail("invalid_metadata", '--metadata-json must be a JSON object such as {"prompt_id":"p-1"}.');
964
+ }
965
+ if (duplicateJsonKey(raw)) {
966
+ return fail("duplicate_metadata_key", "A metadata key may be given only once.");
967
+ }
968
+ entries.push(...Object.entries(parsed));
969
+ } else {
970
+ for (const pair of pairs) {
971
+ const equals = pair.indexOf("=");
972
+ if (equals <= 0) {
973
+ return fail(
974
+ "invalid_metadata",
975
+ "--metadata must be KEY=VALUE with a non-empty key. Use --metadata-json for a key that contains \"=\".",
976
+ );
977
+ }
978
+ entries.push([pair.slice(0, equals), pair.slice(equals + 1)]);
979
+ }
980
+ }
981
+ if (entries.length === 0) return { ok: true, filters: null };
982
+ if (entries.length > MAX_METADATA_FILTERS) {
983
+ return fail("too_many_metadata_filters", `At most ${MAX_METADATA_FILTERS} metadata keys may be given.`);
984
+ }
985
+ const seen = new Set();
986
+ for (const [key, value] of entries) {
987
+ const problem = metadataKeyProblem(key);
988
+ if (problem !== null) return fail(problem.code, `A metadata key ${problem.message}`);
989
+ if (seen.has(key)) return fail("duplicate_metadata_key", "A metadata key may be given only once.");
990
+ seen.add(key);
991
+ if (typeof value !== "string") {
992
+ return fail("invalid_metadata", "Metadata values must be strings; they are matched as exact opaque text.");
993
+ }
994
+ if (value.length > MAX_METADATA_VALUE_LENGTH || value.includes("\0")) {
995
+ return fail(
996
+ "invalid_metadata",
997
+ `A metadata value must be at most ${MAX_METADATA_VALUE_LENGTH} characters without NUL.`,
998
+ );
999
+ }
1000
+ }
1001
+ const filters = entries
1002
+ .map(([key, value]) => ({ key, value }))
1003
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
1004
+ return { ok: true, filters };
1005
+ }
1006
+
1007
+ // JSON.parse keeps the last of two equal keys; the service refuses them, so
1008
+ // the CLI refuses them first instead of silently sending one. Scans the
1009
+ // already valid JSON text for the top-level object's keys.
1010
+ function duplicateJsonKey(raw) {
1011
+ const keys = new Set();
1012
+ let depth = 0;
1013
+ for (let index = 0; index < raw.length; index += 1) {
1014
+ const char = raw[index];
1015
+ if (char === "{" || char === "[") depth += 1;
1016
+ else if (char === "}" || char === "]") depth -= 1;
1017
+ else if (char === '"') {
1018
+ let end = index + 1;
1019
+ while (raw[end] !== '"') end += raw[end] === "\\" ? 2 : 1;
1020
+ const token = raw.slice(index, end + 1);
1021
+ let next = end + 1;
1022
+ while (/\s/.test(raw[next] ?? "")) next += 1;
1023
+ if (depth === 1 && raw[next] === ":") {
1024
+ const key = JSON.parse(token);
1025
+ if (keys.has(key)) return true;
1026
+ keys.add(key);
1027
+ }
1028
+ index = end;
1029
+ }
1030
+ }
1031
+ return false;
1032
+ }
1033
+
1034
+ // Matches the service's rule: lowercased, everything but letters and digits
1035
+ // removed, the key must not end in one of these.
1036
+ const SENSITIVE_KEY_SUFFIXES = Object.freeze([
1037
+ "accesskey", "apikey", "auth", "authorization", "bearer", "clientsecret", "cookie",
1038
+ "credential", "credentials", "encryptionkey", "jwt", "passwd", "password", "privatekey",
1039
+ "secret", "secretkey", "sessionkey", "signingkey", "token",
1040
+ ]);
1041
+
1042
+ function metadataKeyProblem(key) {
1043
+ // Keys are printed back (filter and grouping lines), so control and
1044
+ // formatting characters that could rewrite terminal or log output are
1045
+ // refused, as they are for every other printed name.
1046
+ if (
1047
+ typeof key !== "string" ||
1048
+ key.length === 0 ||
1049
+ key.length > MAX_METADATA_KEY_LENGTH ||
1050
+ hasUnsafeText(key)
1051
+ ) {
1052
+ return {
1053
+ code: "invalid_metadata_key",
1054
+ message: `must be 1 to ${MAX_METADATA_KEY_LENGTH} characters without control or formatting characters.`,
1055
+ };
1056
+ }
1057
+ const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
1058
+ if (SENSITIVE_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) {
1059
+ return {
1060
+ code: "metadata_key_ineligible",
1061
+ message: "looks like a credential name and cannot be used for filtering or grouping.",
1062
+ };
1063
+ }
1064
+ return null;
510
1065
  }
511
1066
 
512
1067
  function parseBounded(raw, min, max) {