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.
- package/README.md +60 -11
- package/assets/skill/SKILL.md +52 -3
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +13 -13
- package/assets/skills/metergraph-analyze/SKILL.md +62 -4
- package/assets/skills/metergraph-investigate/SKILL.md +222 -35
- package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
- package/assets/skills/metergraph-report/SKILL.md +19 -2
- package/bin/metergraph.js +1 -1
- package/package.json +1 -1
- package/src/agent-key.js +160 -0
- package/src/approval-handoff.js +5 -0
- package/src/args.js +572 -17
- package/src/auth-browser.js +5 -19
- package/src/auth-callback.js +24 -15
- package/src/auth-login.js +55 -12
- package/src/auth-oauth.js +77 -7
- package/src/callback-page.js +58 -0
- package/src/cli.js +190 -45
- package/src/constants.js +73 -3
- package/src/environment.js +169 -0
- package/src/experiments-contract.js +462 -0
- package/src/experiments-output.js +189 -0
- package/src/experiments.js +397 -0
- package/src/origin.js +6 -0
- package/src/output.js +437 -131
- package/src/pricing-args.js +163 -0
- package/src/pricing-output.js +206 -0
- package/src/pricing-reference.js +195 -0
- package/src/pricing.js +307 -0
- package/src/read-contract.js +976 -16
- package/src/read-output.js +449 -30
- package/src/read.js +312 -60
- package/src/setup-deployment.js +11 -3
- package/src/setup-interactive.js +341 -0
- package/src/setup-refusal.js +172 -0
- package/src/setup-state.js +4 -2
- package/src/setup.js +227 -53
- package/src/skill-bundle.js +2 -2
- package/src/style.js +101 -0
- package/src/transport.js +13 -7
- package/src/usage-aggregates.js +197 -0
- 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([
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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) {
|