metergraph-cli 0.2.2 → 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 +49 -5
- package/bin/metergraph.js +1 -1
- package/package.json +1 -1
- package/src/agent-key.js +6 -3
- package/src/args.js +218 -22
- package/src/auth-oauth.js +58 -5
- package/src/cli.js +98 -10
- package/src/constants.js +41 -0
- package/src/experiments-contract.js +462 -0
- package/src/experiments-output.js +189 -0
- package/src/experiments.js +397 -0
- package/src/output.js +112 -1
- 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 +23 -3
- package/src/read.js +15 -1
- package/src/setup-deployment.js +7 -0
- package/src/setup-interactive.js +341 -0
- package/src/transport.js +5 -3
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { agentKeySource, keySession, keyTarget, readAgentKey } from "./agent-key.js";
|
|
2
|
+
import { readBinding, resolveProject } from "./auth-binding.js";
|
|
3
|
+
import { Stop, openStore, readCredential, resolveConfigDir } from "./auth-store.js";
|
|
4
|
+
import { DEFAULT_ORIGIN, EXPERIMENT_PATHS, EXPERIMENT_START_SCOPE, METADATA_SCOPE, READ_MAX_BYTES } from "./constants.js";
|
|
5
|
+
import { AGENT_URL_ENV, classifyEnvironment, handoffPages } from "./environment.js";
|
|
6
|
+
import {
|
|
7
|
+
experimentEstimate,
|
|
8
|
+
experimentRefusal,
|
|
9
|
+
experimentStarted,
|
|
10
|
+
experimentSummary,
|
|
11
|
+
experimentsList,
|
|
12
|
+
} from "./experiments-contract.js";
|
|
13
|
+
import { parseJsonObject } from "./http.js";
|
|
14
|
+
import { parseOrigin } from "./origin.js";
|
|
15
|
+
import { holdsKnown } from "./read.js";
|
|
16
|
+
import { deadline, failureOf, send, trapSignals } from "./transport.js";
|
|
17
|
+
|
|
18
|
+
// Experiments: replay a workload's recent production calls on 1 to 3
|
|
19
|
+
// treatments and read the built-in statistics per arm against production.
|
|
20
|
+
//
|
|
21
|
+
// list, estimate and get are Metadata reads. runRead (read.js) establishes
|
|
22
|
+
// their session exactly as for every other read command, a saved grant or a
|
|
23
|
+
// Metadata key, and calls experimentRead with it. They send only these
|
|
24
|
+
// requests and print only validated fields; no input, output or tracked
|
|
25
|
+
// field value is ever requested.
|
|
26
|
+
//
|
|
27
|
+
// start is the only command that changes a workspace. It needs a separate
|
|
28
|
+
// coding-agent key with Debug access and the agent:analysis-run grant, from
|
|
29
|
+
// --agent-token-file or METERGRAPH_AGENT_TOKEN, verified for exactly that
|
|
30
|
+
// (auth-oauth.js checkStartWorkspace and checkStartCapabilities) before it is
|
|
31
|
+
// used for this one POST. A saved sign in is Metadata only and never starts
|
|
32
|
+
// one. Each start creates a new experiment, so a start that succeeded is not
|
|
33
|
+
// retried.
|
|
34
|
+
|
|
35
|
+
const CAPABILITIES = Object.freeze({ list: "experiments", estimate: "experiment_estimate", get: "experiment" });
|
|
36
|
+
const RETRY_AFTER = /^[0-9]{1,5}$/;
|
|
37
|
+
// The service's refusal reasons with their own fixed message here; any other
|
|
38
|
+
// is reported as experiment_refused with its code kept in data.refusal.
|
|
39
|
+
export const REFUSAL_REASONS = Object.freeze([
|
|
40
|
+
"over_budget",
|
|
41
|
+
"no_replayable_calls",
|
|
42
|
+
"experiments_in_progress",
|
|
43
|
+
"daily_budget_exhausted",
|
|
44
|
+
"route_unpriced",
|
|
45
|
+
"invalid_reasoning_effort",
|
|
46
|
+
"invalid_settings",
|
|
47
|
+
"invalid_sample",
|
|
48
|
+
"invalid_treatment",
|
|
49
|
+
"invalid_treatments",
|
|
50
|
+
"invalid_tracked_fields",
|
|
51
|
+
"invalid_request",
|
|
52
|
+
]);
|
|
53
|
+
export const POLL_AFTER_SECONDS = 15;
|
|
54
|
+
|
|
55
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
56
|
+
|
|
57
|
+
// A Metadata capability this grant can read: listed, available and plainly
|
|
58
|
+
// metadata only.
|
|
59
|
+
function readable(entry) {
|
|
60
|
+
return isObject(entry) && entry.available === true && entry.content === false && entry.mutates === false &&
|
|
61
|
+
entry.external_calls === false && entry.privacy_class === "metadata" && entry.required_scope === METADATA_SCOPE;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function retryAfterOf(response) {
|
|
65
|
+
const header = response.headers["retry-after"];
|
|
66
|
+
const seconds = typeof header === "string" && RETRY_AFTER.test(header.trim()) ? Number(header.trim()) : null;
|
|
67
|
+
return seconds !== null && seconds <= 86400 ? seconds : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One answer from an experiments endpoint as { ok: true, body } or a failure
|
|
71
|
+
// with fixed tokens. A refusal keeps the service's code, reason and numbers.
|
|
72
|
+
function answer(response, expected) {
|
|
73
|
+
if (response.kind === "response") {
|
|
74
|
+
const { status } = response;
|
|
75
|
+
if (status === 401) return { ok: false, outcome: "login_required", reason: "access_revoked" };
|
|
76
|
+
if (status === 403) return { ok: false, outcome: "permission_denied", reason: "forbidden" };
|
|
77
|
+
if (status === 429) return { ok: false, outcome: "rate_limited", reason: "rate_limited", retryAfter: retryAfterOf(response) };
|
|
78
|
+
if (status === 501) return { ok: false, outcome: "capability_unavailable", reason: "experiments_unavailable" };
|
|
79
|
+
if (status === 404 || status === 409 || status === 422) {
|
|
80
|
+
const refusal = experimentRefusal(parseJsonObject(response));
|
|
81
|
+
if (refusal === null) return { ok: false, outcome: "unsupported", reason: "request_rejected" };
|
|
82
|
+
const named = refusal.code === "not_found" ? "experiment_not_found" : refusal.reason;
|
|
83
|
+
const reason = named === "experiment_not_found" || REFUSAL_REASONS.includes(named) ? named : "experiment_refused";
|
|
84
|
+
return { ok: false, outcome: "refused", reason, refusal };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const failure = failureOf(response);
|
|
88
|
+
if (failure !== null) return { ok: false, ...failure };
|
|
89
|
+
if (response.status !== expected) return { ok: false, outcome: "unsupported", reason: "unexpected_status" };
|
|
90
|
+
const body = parseJsonObject(response);
|
|
91
|
+
if (body === null) return { ok: false, outcome: "verification_failed", reason: "experiment_response_invalid" };
|
|
92
|
+
return { ok: true, body };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The list, estimate or summary read for runRead, with its verified session.
|
|
96
|
+
export async function experimentRead(session, options, ctx, documents, signal, notices) {
|
|
97
|
+
if (!readable(documents.capabilities?.agent?.[CAPABILITIES[options.action]])) {
|
|
98
|
+
return { ok: false, outcome: "capability_unavailable", reason: "experiments_unavailable" };
|
|
99
|
+
}
|
|
100
|
+
const request = options.experiment;
|
|
101
|
+
const common = { signal, maxBytes: READ_MAX_BYTES, bearer: session.accessToken };
|
|
102
|
+
let response;
|
|
103
|
+
let validate;
|
|
104
|
+
if (options.action === "list") {
|
|
105
|
+
const query = new URLSearchParams();
|
|
106
|
+
if (request.workloadId !== null) query.set("workload_id", request.workloadId);
|
|
107
|
+
query.set("limit", String(request.limit));
|
|
108
|
+
response = await send(session.origin, EXPERIMENT_PATHS.list, { ...common, query });
|
|
109
|
+
validate = (body) => experimentsList(body, ctx, request, notices);
|
|
110
|
+
} else if (options.action === "estimate") {
|
|
111
|
+
response = await send(session.origin, EXPERIMENT_PATHS.estimate, { ...common, method: "POST", json: request.request });
|
|
112
|
+
validate = (body) => experimentEstimate(body, ctx, request.request, notices);
|
|
113
|
+
} else {
|
|
114
|
+
// The ID is a validated UUID, so the path is still a fixed shape.
|
|
115
|
+
response = await send(session.origin, `${EXPERIMENT_PATHS.list}/${request.id}`, common);
|
|
116
|
+
validate = (body) => experimentSummary(body, ctx, request, notices);
|
|
117
|
+
}
|
|
118
|
+
const read = answer(response, 200);
|
|
119
|
+
if (!read.ok) {
|
|
120
|
+
if (read.reason === "access_revoked" && ctx.credential === "agent_key") read.reason = "agent_key_rejected";
|
|
121
|
+
return read;
|
|
122
|
+
}
|
|
123
|
+
return validate(read.body);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// What an agent does next with an experiments result, or undefined for the
|
|
127
|
+
// read commands' shared handling (keys, sign in, workspace, rate limits).
|
|
128
|
+
export function experimentNextAction(outcome, ctx, options, report) {
|
|
129
|
+
if (options.command !== "experiments") return undefined;
|
|
130
|
+
const origin = ctx.origin;
|
|
131
|
+
if (outcome === "ok" && options.action === "estimate") {
|
|
132
|
+
return {
|
|
133
|
+
kind: report.blocking_reasons.length > 0 ? "change_experiment" : "start_experiment",
|
|
134
|
+
message: report.blocking_reasons.length > 0
|
|
135
|
+
? "A start now would be refused: this estimate is above the experiment budget or the workspace's daily " +
|
|
136
|
+
"cap. Sample fewer calls, drop a treatment or lower max_tokens, then estimate again."
|
|
137
|
+
: 'Run the same command as "metergraph experiments start" to start it. A start needs a coding-agent key ' +
|
|
138
|
+
"with Debug access and Allow analysis starts, in --agent-token-file or METERGRAPH_AGENT_TOKEN; " +
|
|
139
|
+
"or start it in the dashboard.",
|
|
140
|
+
url: report.new_experiment_url,
|
|
141
|
+
start_granted: report.start_granted,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (outcome === "ok" && options.action === "get") return pollOrOpen(report.experiment);
|
|
145
|
+
if (outcome === "refused" && ctx.reason === "experiment_not_found") {
|
|
146
|
+
return {
|
|
147
|
+
kind: "list_experiments",
|
|
148
|
+
message: "No experiment with this ID is visible to this credential's workspace. List them to find it.",
|
|
149
|
+
command: "metergraph experiments list --json",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (outcome === "refused") return refusedAction(ctx.reason);
|
|
153
|
+
if (outcome === "capability_unavailable") {
|
|
154
|
+
return {
|
|
155
|
+
kind: "use_dashboard",
|
|
156
|
+
message:
|
|
157
|
+
"This deployment does not offer experiments to this credential: the feature may be off for it. " +
|
|
158
|
+
"Nothing was read or started.",
|
|
159
|
+
url: origin === null ? null : `${origin}/#experiments`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function pollOrOpen(experiment, { started = false } = {}) {
|
|
166
|
+
if (!experiment.terminal) {
|
|
167
|
+
return {
|
|
168
|
+
kind: "poll_experiment",
|
|
169
|
+
message: started
|
|
170
|
+
? `The experiment is ${experiment.status}. Poll it in about ${POLL_AFTER_SECONDS} seconds with a Metadata key ` +
|
|
171
|
+
"or this project's sign in; the start key is not used for reads."
|
|
172
|
+
: `The experiment is ${experiment.status}. Run the same command again in about ${POLL_AFTER_SECONDS} seconds.`,
|
|
173
|
+
command: `metergraph experiments get --id ${experiment.id} --json`,
|
|
174
|
+
poll_after_seconds: POLL_AFTER_SECONDS,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (experiment.status === "failed") {
|
|
178
|
+
return {
|
|
179
|
+
kind: "open_experiment",
|
|
180
|
+
message:
|
|
181
|
+
"The experiment failed; its error_code says why. Statistics cover only the cases that finished. " +
|
|
182
|
+
"Inputs, outputs and tracked field values are in the dashboard.",
|
|
183
|
+
url: experiment.app_url,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
kind: "open_experiment",
|
|
188
|
+
message:
|
|
189
|
+
"The statistics show how each treatment differs from production on the same calls; they do not judge it. " +
|
|
190
|
+
"Inputs, outputs and tracked field values are in the dashboard.",
|
|
191
|
+
url: experiment.app_url,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function refusedAction(reason) {
|
|
196
|
+
if (reason === "experiments_in_progress" || reason === "daily_budget_exhausted") {
|
|
197
|
+
return {
|
|
198
|
+
kind: "retry_later",
|
|
199
|
+
message: reason === "experiments_in_progress"
|
|
200
|
+
? "Two experiments are already running in this workspace. Start this one when one finishes."
|
|
201
|
+
: "This workspace has used its daily experiment budget. Try again later.",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
kind: "change_experiment",
|
|
206
|
+
message:
|
|
207
|
+
"Change the request and estimate it again: sample fewer calls, drop or change a treatment, lower max_tokens " +
|
|
208
|
+
"or pick a workload with captured calls. Nothing was started.",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// experiments start: one POST with a start key, then the experiment to poll.
|
|
213
|
+
export async function runExperimentStart(options) {
|
|
214
|
+
const env = options.env ?? process.env;
|
|
215
|
+
const ctx = {
|
|
216
|
+
environment: options.environment ?? classifyEnvironment(env),
|
|
217
|
+
origin: null,
|
|
218
|
+
workspaceId: null,
|
|
219
|
+
intendedWorkspace: null,
|
|
220
|
+
profile: null,
|
|
221
|
+
authenticated: false,
|
|
222
|
+
scopes: [],
|
|
223
|
+
reason: null,
|
|
224
|
+
// Where the Keys page is before any origin is resolved.
|
|
225
|
+
keysOrigin: options.origin ?? (typeof env[AGENT_URL_ENV] === "string" ? parseOrigin(env[AGENT_URL_ENV]) : null) ??
|
|
226
|
+
DEFAULT_ORIGIN,
|
|
227
|
+
};
|
|
228
|
+
const notices = new Set();
|
|
229
|
+
const trap = trapSignals();
|
|
230
|
+
const limit = deadline(options.timeoutMs, trap.signal);
|
|
231
|
+
let known = [];
|
|
232
|
+
const end = (outcome, reason, report = null) => {
|
|
233
|
+
ctx.reason = reason;
|
|
234
|
+
const data = startData(ctx, notices, outcome, report);
|
|
235
|
+
if (holdsKnown(data, known)) {
|
|
236
|
+
return { outcome: "verification_failed", reason: "credential_in_metadata_response",
|
|
237
|
+
data: startData({ ...ctx, reason: "credential_in_metadata_response" }, new Set(), "verification_failed", null) };
|
|
238
|
+
}
|
|
239
|
+
return { outcome, reason, data };
|
|
240
|
+
};
|
|
241
|
+
const settle = (result) => {
|
|
242
|
+
const interrupted =
|
|
243
|
+
(result.outcome === "authorization_failed" && result.reason === "cancelled") ||
|
|
244
|
+
(result.outcome === "connection_failed" && result.reason === "timeout");
|
|
245
|
+
if (interrupted && limit.timedOut()) return { outcome: "connection_failed", reason: "timeout" };
|
|
246
|
+
if (interrupted && trap.signal.aborted) return { outcome: "cancelled", reason: "cancelled" };
|
|
247
|
+
return result;
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
const root = resolveProject(options.project);
|
|
251
|
+
const binding = readBinding(root)?.binding ?? null;
|
|
252
|
+
const source = agentKeySource(options, env);
|
|
253
|
+
if (source === null) return end("permission_denied", "experiment_start_key_required");
|
|
254
|
+
// As for reads: a binding's non-hosted origin is confirmed by a saved
|
|
255
|
+
// sign in for it on this machine, or by --url or METERGRAPH_URL.
|
|
256
|
+
let originConfirmed = false;
|
|
257
|
+
if (binding !== null) {
|
|
258
|
+
try {
|
|
259
|
+
const store = openStore(resolveConfigDir(options.configDir), { create: false });
|
|
260
|
+
const record = store === null ? null : readCredential(store, binding.credential_slot);
|
|
261
|
+
originConfirmed = record !== null && record.origin === binding.origin;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (!(error instanceof Stop)) throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (source.kind === "env") {
|
|
267
|
+
const shape = readAgentKey(source, env);
|
|
268
|
+
if (!shape.ok) return end(shape.outcome, shape.reason);
|
|
269
|
+
}
|
|
270
|
+
const target = keyTarget({ binding, options, env, originConfirmed });
|
|
271
|
+
if (!target.ok) return end(target.outcome, target.reason);
|
|
272
|
+
Object.assign(ctx, {
|
|
273
|
+
origin: target.origin, workspaceId: target.workspaceId, intendedWorkspace: target.workspaceId, profile: target.profile,
|
|
274
|
+
});
|
|
275
|
+
const session = await keySession({ target, source, env, signal: limit.signal, access: "experiment_start" });
|
|
276
|
+
if (!session.ok) {
|
|
277
|
+
const settled = settle(session);
|
|
278
|
+
const reason = settled.reason === "agent_key_insufficient_scope" ? "experiment_start_not_granted" : settled.reason;
|
|
279
|
+
return end(settled.outcome, reason);
|
|
280
|
+
}
|
|
281
|
+
ctx.profile = session.session.profile;
|
|
282
|
+
if (ctx.workspaceId === null) {
|
|
283
|
+
ctx.workspaceId = session.session.workspaceId;
|
|
284
|
+
notices.add("workspace_not_pinned");
|
|
285
|
+
}
|
|
286
|
+
known = session.knownCredentials;
|
|
287
|
+
ctx.authenticated = true;
|
|
288
|
+
ctx.scopes = session.session.scopes;
|
|
289
|
+
const request = options.experiment.request;
|
|
290
|
+
// The start has its own deadline: the service samples, prices and
|
|
291
|
+
// launches within the request, and an answer cut short is ambiguous.
|
|
292
|
+
const post = deadline(options.timeoutMs, trap.signal);
|
|
293
|
+
const response = await send(ctx.origin, EXPERIMENT_PATHS.start, {
|
|
294
|
+
method: "POST", json: request, signal: post.signal, maxBytes: READ_MAX_BYTES, bearer: session.session.accessToken,
|
|
295
|
+
});
|
|
296
|
+
const started = answer(response, 202);
|
|
297
|
+
if (!started.ok) {
|
|
298
|
+
if (unconfirmed(response, started)) return end("unconfirmed", "experiment_start_unconfirmed");
|
|
299
|
+
if (started.reason === "access_revoked") started.reason = "agent_key_rejected";
|
|
300
|
+
if (started.outcome === "permission_denied") started.reason = "experiment_start_not_granted";
|
|
301
|
+
if (started.reason === "timeout" && post.timedOut()) return end("connection_failed", "timeout");
|
|
302
|
+
if (trap.signal.aborted) return end("cancelled", "cancelled");
|
|
303
|
+
return end(started.outcome, started.reason, started.refusal ? { refusal: started.refusal } : null);
|
|
304
|
+
}
|
|
305
|
+
// Accepted: whatever its answer says from here, an experiment exists.
|
|
306
|
+
const value = experimentStarted(started.body, ctx, request, notices);
|
|
307
|
+
if (!value.ok) return end("unconfirmed", "experiment_start_unconfirmed");
|
|
308
|
+
if (value.value.experiment.status === "failed") return end("unhealthy", "experiment_launch_failed", value.value);
|
|
309
|
+
return end("ok", null, value.value);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (error instanceof Stop) {
|
|
312
|
+
const settled = settle(error);
|
|
313
|
+
return end(settled.outcome, settled.reason);
|
|
314
|
+
}
|
|
315
|
+
throw error;
|
|
316
|
+
} finally {
|
|
317
|
+
trap.release();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A start whose request was handed to the network and then failed, or that
|
|
322
|
+
// the service answered with a server error, may have been recorded.
|
|
323
|
+
function unconfirmed(response, failure) {
|
|
324
|
+
if (response.kind === "error") return response.sent === true;
|
|
325
|
+
if (response.status >= 200 && response.status < 300) return true;
|
|
326
|
+
if (failure.outcome === "unhealthy" && failure.reason === "server_error") return true;
|
|
327
|
+
if (response.status === 503) {
|
|
328
|
+
const body = parseJsonObject(response);
|
|
329
|
+
// A managed-workflow refusal is decided before anything is recorded.
|
|
330
|
+
return !(isObject(body?.error) && body.error.code === "managed_features_disabled");
|
|
331
|
+
}
|
|
332
|
+
return failure.outcome === "unsupported" && failure.reason === "response_too_large";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function startData(ctx, notices, outcome, report) {
|
|
336
|
+
return {
|
|
337
|
+
origin: ctx.origin,
|
|
338
|
+
workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
|
|
339
|
+
deployment_profile: ctx.profile,
|
|
340
|
+
environment: ctx.environment.kind,
|
|
341
|
+
credential: "agent_key",
|
|
342
|
+
authenticated: ctx.authenticated,
|
|
343
|
+
scopes: ctx.scopes,
|
|
344
|
+
result: outcome === "ok" || ctx.reason === "experiment_launch_failed" ? report : null,
|
|
345
|
+
refusal: outcome === "refused" ? report?.refusal ?? null : null,
|
|
346
|
+
notices: [...notices].sort(),
|
|
347
|
+
next_action: startNextAction(outcome, ctx, report),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function startNextAction(outcome, ctx, report) {
|
|
352
|
+
const keys = handoffPages(ctx.origin ?? ctx.keysOrigin).keys_url;
|
|
353
|
+
if (outcome === "ok") return pollOrOpen({ ...report.experiment, app_url: report.app_url }, { started: true });
|
|
354
|
+
if (outcome === "unconfirmed") {
|
|
355
|
+
return {
|
|
356
|
+
kind: "check_before_retry",
|
|
357
|
+
message:
|
|
358
|
+
"The start request reached the service, but its answer was not confirmed: an experiment may exist and " +
|
|
359
|
+
"may be running. List the newest experiments with a Metadata key or this project's sign in before " +
|
|
360
|
+
"starting again; each start creates a new experiment.",
|
|
361
|
+
command: "metergraph experiments list --json",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (ctx.reason === "experiment_launch_failed") {
|
|
365
|
+
return {
|
|
366
|
+
kind: "start_again_later",
|
|
367
|
+
message:
|
|
368
|
+
"The service recorded the experiment but could not launch its runner, so it failed and nothing ran. " +
|
|
369
|
+
"Start it again later.",
|
|
370
|
+
url: report.app_url,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (outcome === "refused") return refusedAction(ctx.reason);
|
|
374
|
+
if (["experiment_start_key_required", "experiment_start_not_granted", "experiment_start_unavailable"].includes(ctx.reason)) {
|
|
375
|
+
return {
|
|
376
|
+
kind: "use_experiment_start_key",
|
|
377
|
+
url: keys,
|
|
378
|
+
message:
|
|
379
|
+
"Starting an experiment needs a coding-agent key with Debug access and Allow analysis starts, which only " +
|
|
380
|
+
"a workspace owner can grant on the Keys page. Pass it with --agent-token-file or METERGRAPH_AGENT_TOKEN; " +
|
|
381
|
+
"do not ask for it in chat. A start replays the sampled calls' captured prompts on each treatment's " +
|
|
382
|
+
"provider within the server's budget. Or start the experiment in the dashboard.",
|
|
383
|
+
required_scope: EXPERIMENT_START_SCOPE,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (ctx.reason === "agent_key_rejected") {
|
|
387
|
+
return { kind: "replace_agent_key", url: keys, message: "The service refused this key: it is unknown, expired or revoked." };
|
|
388
|
+
}
|
|
389
|
+
if (ctx.reason === "workspace_context_mismatch") {
|
|
390
|
+
return { kind: "use_workspace_key", url: keys, message: "This key belongs to a different workspace than this project or --workspace names." };
|
|
391
|
+
}
|
|
392
|
+
if (outcome === "rate_limited") return { kind: "retry_later", message: "Wait, then run the same command again." };
|
|
393
|
+
if (outcome === "capability_unavailable") {
|
|
394
|
+
return { kind: "use_dashboard", message: "This deployment does not offer experiments to this key. Nothing was started.", url: null };
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
package/src/output.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PRICING_OPTIONS } from "./pricing-args.js";
|
|
1
2
|
import {
|
|
2
3
|
CONNECTION_GUIDE_URL,
|
|
3
4
|
DEFAULT_ORIGIN,
|
|
@@ -232,6 +233,37 @@ const READ_BASE_OPTIONS = [
|
|
|
232
233
|
"Without it, METERGRAPH_AGENT_TOKEN is used when this project has no saved sign in on this machine.",
|
|
233
234
|
},
|
|
234
235
|
];
|
|
236
|
+
const EXPERIMENTS_USAGE = [
|
|
237
|
+
"metergraph experiments list [--workload ID] [--limit N] [read options] [--json]",
|
|
238
|
+
"metergraph experiments estimate --workload ID --treatment SPEC [--treatment SPEC]... [--last-n N] " +
|
|
239
|
+
"[--sample-model MODEL] [--track PATH]... [read options] [--json]",
|
|
240
|
+
"metergraph experiments start (same as estimate) [--agent-token-file PATH] [--json]",
|
|
241
|
+
"metergraph experiments get --id UUID [read options] [--json]",
|
|
242
|
+
];
|
|
243
|
+
const EXPERIMENTS_OPTIONS = [
|
|
244
|
+
{ name: "--workload", value: "ID", summary: "The workload (route) whose recent production calls are replayed. Required for estimate and start; a filter for list." },
|
|
245
|
+
{
|
|
246
|
+
name: "--treatment",
|
|
247
|
+
value: "SPEC",
|
|
248
|
+
summary:
|
|
249
|
+
"1 to 3 times. PROVIDER/MODEL, optionally followed by ,label=NAME ,temperature=0..2 ,max_tokens=1..32000 " +
|
|
250
|
+
"or ,reasoning_effort=none|minimal|low|medium|high|xhigh|max. Unset settings follow production.",
|
|
251
|
+
},
|
|
252
|
+
{ name: "--last-n", value: "N", summary: "Replay the last N replayable calls, 10 to 40. Default 20." },
|
|
253
|
+
{ name: "--sample-model", value: "MODEL", summary: "Only production calls made with this exact model, for a single-model control." },
|
|
254
|
+
{ name: "--track", value: "PATH", summary: "Up to 10 times. A JSON path such as $.priority whose values the dashboard compares." },
|
|
255
|
+
{ name: "--id", value: "UUID", summary: "The experiment to read (get)." },
|
|
256
|
+
{ name: "--limit", value: "N", summary: "Experiments to list, 1 to 50. Default 20." },
|
|
257
|
+
...READ_BASE_OPTIONS.filter((option) => option.name !== "--agent-token-file"),
|
|
258
|
+
{
|
|
259
|
+
name: "--agent-token-file",
|
|
260
|
+
value: "PATH",
|
|
261
|
+
summary:
|
|
262
|
+
"Private (0600) file holding a coding-agent key. For list, estimate and get: a key with Metadata access. " +
|
|
263
|
+
"For start: a key with Debug access and Allow analysis starts, used for that one request.",
|
|
264
|
+
},
|
|
265
|
+
JSON_OPTION,
|
|
266
|
+
];
|
|
235
267
|
const DAYS_OPTION = {
|
|
236
268
|
name: "--days",
|
|
237
269
|
value: "N",
|
|
@@ -424,6 +456,7 @@ export function helpData(topic) {
|
|
|
424
456
|
"metergraph --help [--json]",
|
|
425
457
|
"metergraph --version [--json]",
|
|
426
458
|
"metergraph doctor [--url ORIGIN] [--timeout-ms N] [--json]",
|
|
459
|
+
"metergraph pricing models|lookup|calculate|cost|history|market [options] [--json]",
|
|
427
460
|
...SKILL_USAGE,
|
|
428
461
|
...SKILLS_USAGE,
|
|
429
462
|
LOGIN_USAGE,
|
|
@@ -431,8 +464,12 @@ export function helpData(topic) {
|
|
|
431
464
|
SETUP_USAGE,
|
|
432
465
|
VERIFY_USAGE,
|
|
433
466
|
...READ_HELP.map((entry) => entry.usage),
|
|
467
|
+
...EXPERIMENTS_USAGE,
|
|
434
468
|
],
|
|
435
469
|
commands: [
|
|
470
|
+
{ name: "pricing", summary: "Public model discovery, lookup and calculation. Current market board; historical charts in local reference mode.",
|
|
471
|
+
options: [...PRICING_OPTIONS.map(name => ({ name, value: "VALUE", summary: "See metergraph help pricing and docs/pricing.md." })),
|
|
472
|
+
{ name: "--batch", value: null, summary: "Use batch pricing for cost." }, JSON_OPTION] },
|
|
436
473
|
{
|
|
437
474
|
name: "doctor",
|
|
438
475
|
summary:
|
|
@@ -488,7 +525,7 @@ export function helpData(topic) {
|
|
|
488
525
|
name: "setup",
|
|
489
526
|
summary: "Guide browser sign in and workspace choice, approve an ingest-only key, and install the selected client skill. Does not verify application traffic.",
|
|
490
527
|
options: [
|
|
491
|
-
{ name: "--runtime", value: "RUNTIME", summary: "Required. local only; other runtimes get a handoff." },
|
|
528
|
+
{ name: "--runtime", value: "RUNTIME", summary: "Required unless a person runs setup at a terminal (then local). local only; other runtimes get a handoff." },
|
|
492
529
|
{ name: "--url", value: "ORIGIN", summary: "Deployment origin. Defaults to an existing project binding, else the hosted origin." },
|
|
493
530
|
{ name: "--workspace", value: "UUID", summary: "Expected workspace; the browser must approve this exact workspace." },
|
|
494
531
|
{ name: "--project", value: "DIR", summary: "Project directory. Default: current directory." },
|
|
@@ -514,6 +551,14 @@ export function helpData(topic) {
|
|
|
514
551
|
summary: "Poll Metadata for one exact trace in an explicit invocation window. Never sends application traffic.",
|
|
515
552
|
options: VERIFY_OPTIONS,
|
|
516
553
|
},
|
|
554
|
+
{
|
|
555
|
+
name: "experiments",
|
|
556
|
+
summary:
|
|
557
|
+
"Estimate, start, list and read experiments: a workload's recent production calls replayed on 1 to 3 " +
|
|
558
|
+
"treatments, with built-in statistics per arm next to production. list, estimate and get are Metadata " +
|
|
559
|
+
"reads; start needs a key with Debug access and Allow analysis starts and spends within the server's budget.",
|
|
560
|
+
options: EXPERIMENTS_OPTIONS,
|
|
561
|
+
},
|
|
517
562
|
...READ_HELP.map(({ name, summary, options, examples }) => ({
|
|
518
563
|
name,
|
|
519
564
|
summary,
|
|
@@ -535,6 +580,43 @@ export function helpData(topic) {
|
|
|
535
580
|
|
|
536
581
|
export function helpText(topic, style = PLAIN) {
|
|
537
582
|
const lines = [];
|
|
583
|
+
if (topic === "pricing") {
|
|
584
|
+
return `Usage: metergraph pricing models|lookup|calculate|cost|history|market [options] [--json]
|
|
585
|
+
|
|
586
|
+
Public catalog prices. No account, project or token is required.
|
|
587
|
+
models [--model ID] [--provider ID | --channel ID] [--region ID] [--limit 1..100]
|
|
588
|
+
lookup --model ID [--provider ID | --channel ID] [--region ID] [--at YYYY-MM-DD]
|
|
589
|
+
calculate (or cost) Same selectors; --input-tokens N --output-tokens N [--batch]
|
|
590
|
+
--cache-read-tokens N; --trace-format anthropic.messages|openai.responses|
|
|
591
|
+
openai.chat_completions|otel.gen_ai. Native format inferred for direct
|
|
592
|
+
Anthropic/OpenAI; otherwise otel.gen_ai. Only supplied counts are sent.
|
|
593
|
+
Anthropic also accepts --cache-write-tokens N or
|
|
594
|
+
--cache-write-5m-tokens N / --cache-write-1h-tokens N.
|
|
595
|
+
market --channel ID [--region ID] [--limit 1..100] (current rates, no history)
|
|
596
|
+
history --model ID --channel ID [--region ID] [--format table|chart|html]
|
|
597
|
+
Requires --api reference --url LOOPBACK_ORIGIN; no public history API.
|
|
598
|
+
|
|
599
|
+
All actions: --url ORIGIN --timeout-ms 100..30000 --json --api public|reference
|
|
600
|
+
Default API: public; default origin: https://app.metergraph.dev.
|
|
601
|
+
--at selects UTC midnight; without it public lookup/calculation use server time.
|
|
602
|
+
Models and public market discovery are current-only. Filters on models run locally.
|
|
603
|
+
History/market region defaults to global. Omitted input/output counts stay unknown;
|
|
604
|
+
partial costs are known subtotals, not complete costs. OpenAI cache counts must
|
|
605
|
+
not exceed total input. --json cannot be combined with --format.
|
|
606
|
+
|
|
607
|
+
Examples:
|
|
608
|
+
metergraph pricing models --limit 10
|
|
609
|
+
metergraph pricing lookup --model gpt-5 --provider openai --region global --json
|
|
610
|
+
metergraph pricing calculate --model claude-sonnet-5 --provider anthropic --input-tokens 1000 --cache-read-tokens 800 --output-tokens 200
|
|
611
|
+
metergraph pricing market --channel openai-api --limit 5
|
|
612
|
+
metergraph pricing history --model openai/gpt-5.6-terra --channel openai-api --api reference --url http://127.0.0.1:8765 --format html > history.html
|
|
613
|
+
|
|
614
|
+
Public API: GET /v1/pricing/models; POST /v1/pricing/lookup and /calculate.
|
|
615
|
+
No credentials, traces, prompts or responses are loaded from project files.
|
|
616
|
+
The CLI constructs metadata-only provider-format traces from the supplied counts.
|
|
617
|
+
See docs/pricing.md for the supported contract, limitations and reference previews.
|
|
618
|
+
`;
|
|
619
|
+
}
|
|
538
620
|
if (topic === "doctor") {
|
|
539
621
|
lines.push(
|
|
540
622
|
"Usage: metergraph doctor [--url ORIGIN] [--timeout-ms N] [--json]",
|
|
@@ -560,6 +642,8 @@ export function helpText(topic, style = PLAIN) {
|
|
|
560
642
|
"--skip-skill is explicit. Non-hosted setup requires an explicit origin, workspace and",
|
|
561
643
|
"operator prerequisite attestation; OSS remains an operator handoff. No application",
|
|
562
644
|
"traffic is claimed until an exact instrumented invocation is separately verified.",
|
|
645
|
+
"", "At a terminal without --json, setup asks for any of these it needs that are",
|
|
646
|
+
"missing, prints the full command, then runs it. Scripts and agents get no questions.",
|
|
563
647
|
"", "Options:");
|
|
564
648
|
lines.push(...optionLines(helpData(null).commands.find((entry) => entry.name === "setup").options, 25));
|
|
565
649
|
lines.push("", ...AGENT_APPROVAL_HELP);
|
|
@@ -599,6 +683,30 @@ export function helpText(topic, style = PLAIN) {
|
|
|
599
683
|
"Opening requires a server link that selects the verified workspace; older links get a manual handoff.",
|
|
600
684
|
"", "Options:");
|
|
601
685
|
lines.push(...optionLines(VERIFY_OPTIONS, 22));
|
|
686
|
+
} else if (topic === "experiments") {
|
|
687
|
+
lines.push(
|
|
688
|
+
"Usage:",
|
|
689
|
+
...EXPERIMENTS_USAGE.map((usage) => ` ${usage}`),
|
|
690
|
+
"",
|
|
691
|
+
"An experiment replays a workload's last 10 to 40 replayable production calls on 1 to 3 treatments",
|
|
692
|
+
"(a provider, model and settings) and compares each with what production returned on the same calls:",
|
|
693
|
+
"cost per call, p50 latency and output tokens, output length, JSON parse rate, same top-level keys,",
|
|
694
|
+
"empty or refused and cut-off rates. The statistics show differences; they do not judge them.",
|
|
695
|
+
"",
|
|
696
|
+
"estimate prices it against the server's budget and daily cap and writes nothing. start takes the",
|
|
697
|
+
"same arguments, so it starts exactly what was priced; poll it with get until it is terminal.",
|
|
698
|
+
"Each start creates a new experiment, so a start that succeeded is never retried.",
|
|
699
|
+
"",
|
|
700
|
+
"list, estimate and get use this project's saved Metadata grant or a Metadata key, as the read",
|
|
701
|
+
"commands do, and never read inputs, outputs or tracked field values; those are in the dashboard.",
|
|
702
|
+
"start needs a coding-agent key with Debug access and Allow analysis starts, which only a workspace",
|
|
703
|
+
"owner can grant, in --agent-token-file or METERGRAPH_AGENT_TOKEN. The CLI checks the key has exactly",
|
|
704
|
+
"that before it sends the one start request, and never uses it for anything else. The service then",
|
|
705
|
+
"replays the sampled calls' captured prompts on each treatment's provider within its budget.",
|
|
706
|
+
"",
|
|
707
|
+
"Options:",
|
|
708
|
+
);
|
|
709
|
+
lines.push(...optionLines(EXPERIMENTS_OPTIONS, 22));
|
|
602
710
|
} else if (READ_HELP.some((entry) => entry.name === topic)) {
|
|
603
711
|
const entry = READ_HELP.find((candidate) => candidate.name === topic);
|
|
604
712
|
lines.push(
|
|
@@ -699,6 +807,8 @@ export function helpText(topic, style = PLAIN) {
|
|
|
699
807
|
" metergraph usage [options] Daily usage per route, Metadata only",
|
|
700
808
|
" metergraph routes [options] Routes and evaluation contract versions",
|
|
701
809
|
" metergraph traces [options] One page of trace metadata",
|
|
810
|
+
" metergraph experiments list|estimate|start|get Replay a workload on other models",
|
|
811
|
+
" metergraph pricing <action> Public lookup, cost, history and market board",
|
|
702
812
|
"",
|
|
703
813
|
'Run "metergraph help doctor" for doctor options.',
|
|
704
814
|
'Run "metergraph help skill" for skill options.',
|
|
@@ -706,6 +816,7 @@ export function helpText(topic, style = PLAIN) {
|
|
|
706
816
|
'Run "metergraph help login" or "metergraph help logout" for sign in options.',
|
|
707
817
|
'Run "metergraph help verify" for exact-trace verification options.',
|
|
708
818
|
'Run "metergraph help COMMAND" for status, context, capabilities, usage, routes or traces.',
|
|
819
|
+
'Run "metergraph help experiments" for experiment options.',
|
|
709
820
|
"",
|
|
710
821
|
...AGENT_PATH_HELP,
|
|
711
822
|
);
|