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.
@@ -0,0 +1,462 @@
1
+ import { normalizeUuid } from "./auth-oauth.js";
2
+ import { EXPERIMENT_MAX_TRACKED_FIELDS, EXPERIMENT_MAX_TREATMENTS, EXPERIMENT_REASONING_EFFORTS } from "./constants.js";
3
+ import {
4
+ Invalid,
5
+ displayText,
6
+ hasUnsafeText,
7
+ isAmount,
8
+ isCount,
9
+ isObject,
10
+ isTimestamp,
11
+ isToken,
12
+ provenance,
13
+ rowObject,
14
+ run,
15
+ } from "./read-contract.js";
16
+
17
+ // Validators for the service's experiment documents (GET and POST
18
+ // /v1/agent/experiments...). They follow read-contract.js: each checks the
19
+ // document against the bound workspace and profile and the request this run
20
+ // sent, and returns a new object built only from fields it validated. The
21
+ // list, estimate, summary and start documents are Metadata: content_included
22
+ // must be false, and none carries an input, an output or a tracked field's
23
+ // value. Results are { ok: true, value } or { ok: false, outcome, reason }.
24
+
25
+ const STATUSES = Object.freeze(["queued", "running", "done", "failed"]);
26
+ const TERMINAL = Object.freeze(["done", "failed"]);
27
+ const BLOCKING = Object.freeze(["over_budget", "daily_budget_exhausted"]);
28
+ // The built-in statistics per arm, in the order they are shown. Rates are
29
+ // fractions from 0 to 1; any may be null when nothing returned.
30
+ export const STATISTICS = Object.freeze([
31
+ "cost_per_call_usd",
32
+ "latency_ms_p50",
33
+ "output_tokens_p50",
34
+ "output_chars_p50",
35
+ "json_parse_rate",
36
+ "same_keys_rate",
37
+ "empty_or_refused_rate",
38
+ "cut_off_rate",
39
+ ]);
40
+ const RATES = new Set(["json_parse_rate", "same_keys_rate", "empty_or_refused_rate", "cut_off_rate"]);
41
+ const MAX_LIST = 50;
42
+ const MAX_MAP = 50;
43
+ const MAX_LINK = 2048;
44
+
45
+ const fail = (reason) => {
46
+ throw new Invalid("verification_failed", reason);
47
+ };
48
+
49
+ function contentExcluded(body) {
50
+ if (body.content_included !== false) throw new Invalid("verification_failed", "content_access_granted");
51
+ }
52
+
53
+ function nullableText(value, notices, reason) {
54
+ return value === null || value === undefined ? null : displayText(value, notices, reason);
55
+ }
56
+
57
+ function nullableCount(value, reason) {
58
+ if (value === null || value === undefined) return null;
59
+ if (!isCount(value)) fail(reason);
60
+ return value;
61
+ }
62
+
63
+ function nullableTimestamp(value, reason) {
64
+ if (value === null || value === undefined) return null;
65
+ if (!isTimestamp(value)) fail(reason);
66
+ return value;
67
+ }
68
+
69
+ function amount(value, reason) {
70
+ if (!isAmount(value)) fail(reason);
71
+ return value;
72
+ }
73
+
74
+ function status(value, reason) {
75
+ if (!STATUSES.includes(value)) fail(reason);
76
+ return value;
77
+ }
78
+
79
+ function terminal(body, statusValue, reason) {
80
+ if (body.terminal !== TERMINAL.includes(statusValue)) fail(reason);
81
+ return body.terminal;
82
+ }
83
+
84
+ function errorCode(value, reason) {
85
+ if (value === null || value === undefined) return null;
86
+ if (!isToken(value)) fail(reason);
87
+ return value;
88
+ }
89
+
90
+ // Extra request fields a treatment may set from the service's allowlist,
91
+ // such as web_search_options.search_context_size: tokens only.
92
+ function extraParams(value, reason) {
93
+ if (!isObject(value) || Object.keys(value).length > MAX_MAP) fail(reason);
94
+ const result = {};
95
+ for (const [key, fields] of Object.entries(value)) {
96
+ if (!isToken(key) || !isObject(fields) || Object.keys(fields).length > MAX_MAP) fail(reason);
97
+ result[key] = {};
98
+ for (const [name, entry] of Object.entries(fields)) {
99
+ if (!isToken(name) || !isToken(entry)) fail(reason);
100
+ result[key][name] = entry;
101
+ }
102
+ }
103
+ return result;
104
+ }
105
+
106
+ // temperature, max_tokens, reasoning_effort and extra_params, as the service bounds them.
107
+ function settings(value, reason) {
108
+ if (value === undefined || value === null) return {};
109
+ if (!isObject(value)) fail(reason);
110
+ const result = {};
111
+ for (const [key, entry] of Object.entries(value)) {
112
+ if (key === "temperature" && typeof entry === "number" && entry >= 0 && entry <= 2) result.temperature = entry;
113
+ else if (key === "max_tokens" && Number.isSafeInteger(entry) && entry >= 1) result.max_tokens = entry;
114
+ else if (key === "reasoning_effort" && EXPERIMENT_REASONING_EFFORTS.includes(entry)) result.reasoning_effort = entry;
115
+ else if (key === "extra_params") result.extra_params = extraParams(entry, reason);
116
+ else fail(reason);
117
+ }
118
+ return result;
119
+ }
120
+
121
+ function armSetup(value, notices, reason) {
122
+ const arm = rowObject(value, reason);
123
+ return {
124
+ label: displayText(arm.label, notices, reason),
125
+ provider: displayText(arm.provider, notices, reason),
126
+ model: displayText(arm.model, notices, reason),
127
+ settings: settings(arm.settings, reason),
128
+ };
129
+ }
130
+
131
+ // A token-to-count map (production models seen, skip reasons, limitations).
132
+ function counts(value, notices, reason, { text = false } = {}) {
133
+ if (value === undefined || value === null) return {};
134
+ if (!isObject(value) || Object.keys(value).length > MAX_MAP) fail(reason);
135
+ const result = {};
136
+ for (const [key, entry] of Object.entries(value)) {
137
+ if (!isCount(entry)) fail(reason);
138
+ if (text) {
139
+ const name = displayText(key, notices, reason);
140
+ if (name !== null) result[name] = entry;
141
+ } else {
142
+ if (!isToken(key)) fail(reason);
143
+ result[key] = entry;
144
+ }
145
+ }
146
+ return result;
147
+ }
148
+
149
+ function statistics(value, reason) {
150
+ if (!isObject(value) || !isCount(value.cases) || !isCount(value.errors)) fail(reason);
151
+ const result = { cases: value.cases, errors: value.errors };
152
+ for (const name of STATISTICS) {
153
+ const entry = value[name];
154
+ if (entry === null || entry === undefined) {
155
+ result[name] = null;
156
+ continue;
157
+ }
158
+ if (!isAmount(entry) || (RATES.has(name) && entry > 1)) fail(reason);
159
+ result[name] = entry;
160
+ }
161
+ for (const name of ["temperature_dropped", "response_format_dropped", "temperature_ignored_with_reasoning"]) {
162
+ result[name] = value[name] === undefined ? 0 : nullableCount(value[name], reason) ?? 0;
163
+ }
164
+ return result;
165
+ }
166
+
167
+ function sample(value, notices, reason) {
168
+ if (!isObject(value)) fail(reason);
169
+ return {
170
+ last_n: nullableCount(value.last_n, reason),
171
+ model: nullableText(value.model, notices, reason),
172
+ cases: nullableCount(value.cases, reason),
173
+ };
174
+ }
175
+
176
+ function trackedFields(value, notices, reason) {
177
+ if (!Array.isArray(value) || value.length > EXPERIMENT_MAX_TRACKED_FIELDS) fail(reason);
178
+ return value.map((path) => displayText(path, notices, reason));
179
+ }
180
+
181
+ // A browser link to the Experiments page, kept only when it is on the bound
182
+ // origin, names exactly this experiment or workload and the bound workspace.
183
+ // Anything else is dropped with a notice, never printed or opened.
184
+ export function experimentLink(raw, ctx, expected, notices) {
185
+ if (raw === null || raw === undefined) return null;
186
+ const dropped = () => {
187
+ notices.add("experiment_link_omitted");
188
+ return null;
189
+ };
190
+ if (typeof raw !== "string" || raw.length > MAX_LINK || /[^\x21-\x7e]|\\/.test(raw)) return dropped();
191
+ try {
192
+ decodeURIComponent(raw);
193
+ } catch {
194
+ return dropped();
195
+ }
196
+ let url;
197
+ try {
198
+ url = new URL(raw);
199
+ } catch {
200
+ return dropped();
201
+ }
202
+ if (url.origin !== ctx.origin || url.username || url.password || url.pathname !== "/" || url.search ||
203
+ !(url.hash === "#experiments" || url.hash.startsWith("#experiments?"))) {
204
+ return dropped();
205
+ }
206
+ const query = new URLSearchParams(url.hash.slice("#experiments?".length));
207
+ const seen = new Set();
208
+ for (const [key, value] of query) {
209
+ if (!["workload", "id", "view", "workspace"].includes(key) || seen.has(key) || hasUnsafeText(value)) return dropped();
210
+ seen.add(key);
211
+ }
212
+ if (query.get("workspace") !== ctx.workspaceId) return dropped();
213
+ for (const key of ["workload", "id", "view"]) {
214
+ if ((query.get(key) ?? null) !== (expected[key] ?? null)) return dropped();
215
+ }
216
+ return raw;
217
+ }
218
+
219
+ // GET /v1/agent/experiments?workload_id=&limit=
220
+ export function experimentsList(body, ctx, request, notices) {
221
+ return run(() => {
222
+ const reason = "experiments_response_invalid";
223
+ const origin = provenance(body, ctx, reason);
224
+ contentExcluded(body);
225
+ if ((body.workload_id ?? null) !== request.workloadId) throw new Invalid("verification_failed", "filter_mismatch");
226
+ if (!isObject(body.page) || body.page.limit !== request.limit || typeof body.page.truncated !== "boolean") fail(reason);
227
+ if (!Array.isArray(body.experiments) || body.experiments.length > Math.min(request.limit, MAX_LIST)) fail(reason);
228
+ const experiments = body.experiments.map((value) => {
229
+ const row = rowObject(value, reason);
230
+ const id = normalizeUuid(row.id);
231
+ const state = status(row.status, reason);
232
+ const workload = displayText(row.workload_id, notices, reason);
233
+ if (id === null) fail(reason);
234
+ if (request.workloadId !== null && row.workload_id !== request.workloadId) {
235
+ throw new Invalid("verification_failed", "filter_not_applied");
236
+ }
237
+ if (!Array.isArray(row.arms) || row.arms.length > EXPERIMENT_MAX_TREATMENTS) fail(reason);
238
+ return {
239
+ id,
240
+ status: state,
241
+ terminal: terminal(row, state, reason),
242
+ workload_id: workload,
243
+ sample: sample(row.sample, notices, reason),
244
+ arms: row.arms.map((arm) => armSetup(arm, notices, reason)),
245
+ estimate_usd: amount(row.estimate_usd, reason),
246
+ budget_usd: amount(row.budget_usd, reason),
247
+ spent_usd: amount(row.spent_usd, reason),
248
+ error_code: errorCode(row.error_code, reason),
249
+ created_at: nullableTimestamp(row.created_at, reason),
250
+ finished_at: nullableTimestamp(row.finished_at, reason),
251
+ app_url: experimentLink(row.app_url, ctx, { workload: row.workload_id, id: row.id }, notices),
252
+ };
253
+ });
254
+ return { provenance: origin, workload_id: request.workloadId, page: { limit: request.limit, truncated: body.page.truncated }, experiments };
255
+ });
256
+ }
257
+
258
+ // The service echoes the request it read, with default labels and settings
259
+ // filled in. It must be the request this run sent.
260
+ function echoedRequest(value, sent, notices, reason) {
261
+ if (!isObject(value) || value.workload_id !== sent.workload_id || !isObject(value.sample)) {
262
+ throw new Invalid("verification_failed", "request_mismatch");
263
+ }
264
+ if (value.sample.last_n !== sent.sample.last_n || (value.sample.model ?? null) !== (sent.sample.model ?? null)) {
265
+ throw new Invalid("verification_failed", "request_mismatch");
266
+ }
267
+ if (!Array.isArray(value.treatments) || value.treatments.length !== sent.treatments.length) {
268
+ throw new Invalid("verification_failed", "request_mismatch");
269
+ }
270
+ const treatments = value.treatments.map((arm, index) => {
271
+ const mine = sent.treatments[index];
272
+ if (!isObject(arm) || arm.provider !== mine.provider || arm.model !== mine.model ||
273
+ (mine.label !== undefined && arm.label !== mine.label)) {
274
+ throw new Invalid("verification_failed", "request_mismatch");
275
+ }
276
+ const setup = armSetup(arm, notices, reason);
277
+ for (const [key, entry] of Object.entries(mine.settings ?? {})) {
278
+ if (setup.settings[key] !== entry) throw new Invalid("verification_failed", "request_mismatch");
279
+ }
280
+ return setup;
281
+ });
282
+ const tracked = trackedFields(value.tracked_fields, notices, reason);
283
+ if (JSON.stringify(value.tracked_fields) !== JSON.stringify(sent.tracked_fields)) {
284
+ throw new Invalid("verification_failed", "request_mismatch");
285
+ }
286
+ return {
287
+ workload_id: sent.workload_id,
288
+ sample: { last_n: sent.sample.last_n, ...(sent.sample.model ? { model: sent.sample.model } : {}) },
289
+ treatments,
290
+ tracked_fields: tracked,
291
+ };
292
+ }
293
+
294
+ function armEstimates(value, treatments, notices, reason) {
295
+ if (!Array.isArray(value) || value.length !== treatments.length) fail(reason);
296
+ return value.map((entry, index) => {
297
+ const arm = rowObject(entry, reason);
298
+ const treatment = treatments[index];
299
+ if (arm.label !== treatment.label || arm.gateway_model !== `${treatment.provider}/${treatment.model}`) fail(reason);
300
+ return {
301
+ label: displayText(arm.label, notices, reason),
302
+ gateway_model: displayText(arm.gateway_model, notices, reason),
303
+ estimate_usd: amount(arm.estimate_usd, reason),
304
+ };
305
+ });
306
+ }
307
+
308
+ function estimatedSample(value, notices, reason) {
309
+ return { ...sample(value, notices, reason), models_seen: counts(value.models_seen, notices, reason, { text: true }) };
310
+ }
311
+
312
+ // POST /v1/agent/experiments/estimate. Writes and spends nothing.
313
+ export function experimentEstimate(body, ctx, request, notices) {
314
+ return run(() => {
315
+ const reason = "experiment_estimate_invalid";
316
+ const origin = provenance(body, ctx, reason);
317
+ contentExcluded(body);
318
+ const echoed = echoedRequest(body.request, request, notices, reason);
319
+ const estimate = body.estimate;
320
+ if (!isObject(estimate)) fail(reason);
321
+ for (const flag of ["over_budget", "daily_budget_exhausted"]) {
322
+ if (typeof estimate[flag] !== "boolean") fail(reason);
323
+ }
324
+ if (!Array.isArray(body.blocking_reasons) || !body.blocking_reasons.every((entry) => BLOCKING.includes(entry))) fail(reason);
325
+ for (const flag of BLOCKING) {
326
+ if (body.blocking_reasons.includes(flag) !== estimate[flag]) fail(reason);
327
+ }
328
+ const start = body.start;
329
+ if (!isObject(start) || start.tool !== "metergraph_start_experiment" || typeof start.granted !== "boolean") fail(reason);
330
+ const links = isObject(body.metergraph_links) ? body.metergraph_links : {};
331
+ return {
332
+ provenance: origin,
333
+ request: echoed,
334
+ estimate: {
335
+ estimate_usd: amount(estimate.estimate_usd, reason),
336
+ budget_usd: amount(estimate.budget_usd, reason),
337
+ over_budget: estimate.over_budget,
338
+ daily_budget_usd: amount(estimate.daily_budget_usd, reason),
339
+ spent_24h_usd: amount(estimate.spent_24h_usd, reason),
340
+ daily_budget_exhausted: estimate.daily_budget_exhausted,
341
+ sample: estimatedSample(estimate.sample, notices, reason),
342
+ arms: armEstimates(estimate.arms, echoed.treatments, notices, reason),
343
+ },
344
+ blocking_reasons: [...body.blocking_reasons],
345
+ start_granted: start.granted,
346
+ new_experiment_url: experimentLink(links.new_experiment, ctx, { workload: request.workload_id, view: "new" }, notices),
347
+ };
348
+ });
349
+ }
350
+
351
+ // POST /v1/agent/experiments, 202. The only write this CLI makes.
352
+ export function experimentStarted(body, ctx, request, notices) {
353
+ return run(() => {
354
+ const reason = "experiment_start_invalid";
355
+ const origin = provenance(body, ctx, reason);
356
+ contentExcluded(body);
357
+ const echoed = echoedRequest(body.request, request, notices, reason);
358
+ const experiment = rowObject(body.experiment, reason);
359
+ const id = normalizeUuid(experiment.id);
360
+ if (id === null || experiment.workload_id !== request.workload_id) fail(reason);
361
+ const state = status(experiment.status, reason);
362
+ if (state === "failed" && !isToken(experiment.error_code)) fail(reason);
363
+ return {
364
+ provenance: origin,
365
+ request: echoed,
366
+ experiment: {
367
+ id,
368
+ status: state,
369
+ terminal: terminal(experiment, state, reason),
370
+ workload_id: request.workload_id,
371
+ error_code: errorCode(experiment.error_code, reason),
372
+ estimate_usd: amount(experiment.estimate_usd, reason),
373
+ budget_usd: amount(experiment.budget_usd, reason),
374
+ sample: estimatedSample(experiment.sample, notices, reason),
375
+ arms: armEstimates(experiment.arms, echoed.treatments, notices, reason),
376
+ },
377
+ app_url: experimentLink(body.app_url, ctx, { workload: request.workload_id, id: experiment.id }, notices),
378
+ };
379
+ });
380
+ }
381
+
382
+ // GET /v1/agent/experiments/<id>: status while it runs, then the built-in
383
+ // statistics per arm next to the production control over the same cases.
384
+ // They show differences; nothing here judges a treatment.
385
+ export function experimentSummary(body, ctx, request, notices) {
386
+ return run(() => {
387
+ const reason = "experiment_response_invalid";
388
+ const origin = provenance(body, ctx, reason);
389
+ contentExcluded(body);
390
+ const experiment = rowObject(body.experiment, reason);
391
+ if (normalizeUuid(experiment.id) !== request.id) fail(reason);
392
+ const state = status(experiment.status, reason);
393
+ const sampled = experiment.sample;
394
+ if (!isObject(sampled) || !isCount(sampled.replayed) || !isCount(sampled.not_replayable)) fail(reason);
395
+ const spend = experiment.spend;
396
+ if (!isObject(spend)) fail(reason);
397
+ const control = experiment.control;
398
+ if (!isObject(control)) fail(reason);
399
+ if (!Array.isArray(experiment.arms) || experiment.arms.length > EXPERIMENT_MAX_TREATMENTS) fail(reason);
400
+ const workload = displayText(experiment.workload_id, notices, reason);
401
+ return {
402
+ provenance: origin,
403
+ experiment: {
404
+ id: request.id,
405
+ status: state,
406
+ terminal: terminal(experiment, state, reason),
407
+ workload_id: workload,
408
+ error_code: errorCode(experiment.error_code, reason),
409
+ created_at: nullableTimestamp(experiment.created_at, reason),
410
+ started_at: nullableTimestamp(experiment.started_at, reason),
411
+ finished_at: nullableTimestamp(experiment.finished_at, reason),
412
+ sample: {
413
+ ...sample(sampled, notices, reason),
414
+ replayed: sampled.replayed,
415
+ not_replayable: sampled.not_replayable,
416
+ not_replayable_reasons: counts(sampled.not_replayable_reasons, notices, reason),
417
+ limitations: counts(sampled.limitations, notices, reason),
418
+ },
419
+ tracked_fields: trackedFields(experiment.tracked_fields, notices, reason),
420
+ spend: {
421
+ estimate_usd: amount(spend.estimate_usd, reason),
422
+ budget_usd: amount(spend.budget_usd, reason),
423
+ spent_usd: amount(spend.spent_usd, reason),
424
+ reserved_usd: amount(spend.reserved_usd, reason),
425
+ },
426
+ control: {
427
+ model: nullableText(control.model, notices, reason),
428
+ provider: nullableText(control.provider, notices, reason),
429
+ models_seen: counts(control.models_seen, notices, reason, { text: true }),
430
+ statistics: statistics(control.summary, reason),
431
+ },
432
+ arms: experiment.arms.map((value) => {
433
+ const arm = rowObject(value, reason);
434
+ if (typeof arm.temperature_ignored_with_reasoning !== "boolean") fail(reason);
435
+ return {
436
+ ...armSetup(arm, notices, reason),
437
+ temperature_ignored_with_reasoning: arm.temperature_ignored_with_reasoning,
438
+ statistics: statistics(arm.summary, reason),
439
+ control_statistics: statistics(arm.control_summary, reason),
440
+ };
441
+ }),
442
+ app_url: experimentLink(experiment.app_url, ctx, { workload: experiment.workload_id, id: experiment.id }, notices),
443
+ },
444
+ };
445
+ });
446
+ }
447
+
448
+ // The service's refusal of an estimate, start or read, as fixed tokens and
449
+ // validated numbers. Its message text is never kept.
450
+ export function experimentRefusal(body) {
451
+ const error = isObject(body?.error) ? body.error : null;
452
+ if (error === null || !isToken(error.code)) return null;
453
+ const refusal = { code: error.code, reason: isToken(error.reason) ? error.reason : null };
454
+ for (const key of ["estimate_usd", "budget_usd", "spent_24h_usd", "daily_budget_usd", "committed_usd"]) {
455
+ if (isAmount(error[key])) refusal[key] = error[key];
456
+ }
457
+ if (Array.isArray(error.allowed) && error.allowed.length <= EXPERIMENT_REASONING_EFFORTS.length &&
458
+ error.allowed.every((entry) => EXPERIMENT_REASONING_EFFORTS.includes(entry))) {
459
+ refusal.allowed = [...error.allowed];
460
+ }
461
+ return refusal;
462
+ }
@@ -0,0 +1,189 @@
1
+ import { STATISTICS } from "./experiments-contract.js";
2
+ import { readMessage } from "./read-output.js";
3
+ import { PLAIN, fields, headline, paragraph, render, steps, toneFor } from "./style.js";
4
+
5
+ // Fixed text for experiments results. Nothing here contains a value from a
6
+ // service response other than validated metadata; the service's own refusal
7
+ // message is never printed.
8
+
9
+ const MESSAGES = {
10
+ experiments_unavailable:
11
+ "This deployment does not offer experiments to this credential, for example because the feature is off. " +
12
+ "Nothing was read or started.",
13
+ experiment_start_key_required:
14
+ "Starting an experiment needs a coding-agent key with Debug access and Allow analysis starts, in " +
15
+ "--agent-token-file or METERGRAPH_AGENT_TOKEN. A saved sign in is Metadata only. Nothing was sent.",
16
+ experiment_start_not_granted:
17
+ "This key cannot start experiments: it needs exactly Debug access and the Allow analysis starts grant from a " +
18
+ "workspace owner, and nothing more. Nothing was started.",
19
+ experiment_start_unavailable:
20
+ "The service does not offer experiment starts to this key, for example because the feature is off or the " +
21
+ "granting owner is no longer an owner. Nothing was started.",
22
+ experiment_not_found: "No experiment with this ID is visible to this credential's workspace.",
23
+ over_budget:
24
+ "The estimate is above the experiment's budget. Sample fewer calls, drop a treatment or lower max_tokens. " +
25
+ "Nothing was started.",
26
+ no_replayable_calls:
27
+ "The workload has no recent calls with a captured prompt and response to replay. Nothing was started.",
28
+ experiments_in_progress: "Two experiments are already running in this workspace. Nothing was started.",
29
+ daily_budget_exhausted: "This workspace has used its daily experiment budget. Nothing was started.",
30
+ route_unpriced: "A treatment's model has no catalog price, so its replays cannot be budgeted. Nothing was started.",
31
+ invalid_reasoning_effort:
32
+ "A treatment's reasoning_effort is not offered by its model; data.refusal.allowed lists the ones it offers.",
33
+ experiment_refused: "The service refused the experiment request; data.refusal has its code. Nothing was started.",
34
+ request_mismatch:
35
+ "The service did not confirm the request this command sent, so nothing from its answer was printed.",
36
+ experiment_estimate_invalid: "The service's estimate did not match the experiments contract. Nothing from it was printed.",
37
+ experiment_start_unconfirmed:
38
+ "The start request reached the service, but its answer was not confirmed. An experiment may exist and may be " +
39
+ "running: list this workload's experiments before starting again, since each start creates one.",
40
+ experiment_launch_failed:
41
+ "The service recorded the experiment but could not launch its runner, so it failed and nothing ran.",
42
+ experiment_response_invalid: "The service's experiment did not match the experiments contract. Nothing from it was printed.",
43
+ experiments_response_invalid: "The service's experiment list did not match the experiments contract. Nothing from it was printed.",
44
+ };
45
+ for (const reason of ["invalid_settings", "invalid_sample", "invalid_treatment", "invalid_treatments", "invalid_tracked_fields", "invalid_request"]) {
46
+ MESSAGES[reason] = "The service refused the request's arguments; data.refusal has its reason. Nothing was started.";
47
+ }
48
+
49
+ const NOTICES = {
50
+ experiment_link_omitted:
51
+ "A dashboard link was left out because it did not point at this origin, workspace and experiment.",
52
+ workspace_not_pinned: "No workspace was pinned, so the key's own workspace was used.",
53
+ unsafe_text_omitted: "Some names were left out because they contained characters that could change how a terminal shows text.",
54
+ };
55
+
56
+ const TITLES = {
57
+ list: "Experiments",
58
+ estimate: "Experiment estimate",
59
+ start: "Experiment started",
60
+ get: "Experiment",
61
+ };
62
+
63
+ export function experimentsMessage(outcome, reason) {
64
+ return MESSAGES[reason] ?? readMessage(outcome, reason);
65
+ }
66
+
67
+ const usd = (value) => (value === null ? "n/a" : `$${value < 0.01 && value > 0 ? value.toFixed(6) : value.toFixed(2)}`);
68
+ const rate = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
69
+ const number = (value) => (value === null ? "n/a" : String(Math.round(value)));
70
+
71
+ function settingsText(settings) {
72
+ const parts = Object.entries(settings).map(([key, value]) =>
73
+ key === "extra_params"
74
+ ? Object.entries(value).flatMap(([name, fields]) => Object.entries(fields).map(([field, entry]) => `${name}.${field}=${entry}`)).join(" ")
75
+ : `${key}=${value}`);
76
+ return parts.length > 0 ? parts.join(" ") : "production settings";
77
+ }
78
+
79
+ function armLine(arm) {
80
+ return `${arm.label}: ${arm.provider}/${arm.model} (${settingsText(arm.settings)})`;
81
+ }
82
+
83
+ const STATISTIC_TITLES = {
84
+ cost_per_call_usd: "COST/CALL",
85
+ latency_ms_p50: "P50_MS",
86
+ output_tokens_p50: "P50_OUT_TOK",
87
+ output_chars_p50: "P50_CHARS",
88
+ json_parse_rate: "JSON_OK",
89
+ same_keys_rate: "SAME_KEYS",
90
+ empty_or_refused_rate: "EMPTY_REFUSED",
91
+ cut_off_rate: "CUT_OFF",
92
+ };
93
+
94
+ function statisticCell(name, value) {
95
+ if (name === "cost_per_call_usd") return usd(value);
96
+ if (name.endsWith("_rate")) return rate(value);
97
+ return number(value);
98
+ }
99
+
100
+ function statisticsTable(experiment) {
101
+ const header = ["ARM", "CASES", "ERRORS", ...STATISTICS.map((name) => STATISTIC_TITLES[name])];
102
+ const row = (label, stats) => [label, String(stats.cases), String(stats.errors), ...STATISTICS.map((name) => statisticCell(name, stats[name]))];
103
+ const rows = [row("production", experiment.control.statistics)];
104
+ for (const arm of experiment.arms) {
105
+ rows.push(row(arm.label, arm.statistics), row(` production on ${arm.label}'s cases`, arm.control_statistics));
106
+ }
107
+ const widths = header.map((_, index) => Math.max(...[header, ...rows].map((cells) => cells[index].length)));
108
+ return [header, ...rows].map((cells) => cells.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd());
109
+ }
110
+
111
+ function resultLines(action, report) {
112
+ const lines = [];
113
+ if (action === "list") {
114
+ if (report.experiments.length === 0) return [[["Experiments", "none"]], []];
115
+ const rows = [["Experiments", `${report.experiments.length}${report.page.truncated ? " (more exist; only the newest results are shown)" : ""}`]];
116
+ const body = report.experiments.map((item) =>
117
+ `${item.id} ${item.status.padEnd(7)} ${item.workload_id ?? "(name omitted)"} ${item.arms.map((arm) => arm.label).join(", ")} spent ${usd(item.spent_usd)}`);
118
+ return [rows, body];
119
+ }
120
+ if (action === "estimate") {
121
+ const { estimate } = report;
122
+ const rows = [
123
+ ["Workload", report.request.workload_id],
124
+ ["Sample", `last ${report.request.sample.last_n} replayable calls, ${estimate.sample.cases} found`],
125
+ ["Production models", Object.entries(estimate.sample.models_seen).map(([model, count]) => `${model} (${count})`).join(", ") || "none"],
126
+ ["Estimate", `${usd(estimate.estimate_usd)} of a ${usd(estimate.budget_usd)} budget${estimate.over_budget ? " (over budget)" : ""}`],
127
+ ["Daily cap", `${usd(estimate.spent_24h_usd)} of ${usd(estimate.daily_budget_usd)} committed in 24 hours${estimate.daily_budget_exhausted ? " (a start would pass it)" : ""}`],
128
+ ["Start grant", report.start_granted ? "this credential has it" : "not on this credential"],
129
+ ];
130
+ const body = report.request.treatments.map((arm, index) => `${armLine(arm)} ~${usd(estimate.arms[index].estimate_usd)}`);
131
+ return [rows, body];
132
+ }
133
+ if (action === "start") {
134
+ const { experiment } = report;
135
+ const rows = [
136
+ ["Experiment", experiment.id],
137
+ ["Status", experiment.status],
138
+ ["Workload", experiment.workload_id],
139
+ ["Estimate", `${usd(experiment.estimate_usd)} of a ${usd(experiment.budget_usd)} budget`],
140
+ ["Dashboard", report.app_url],
141
+ ];
142
+ return [rows, report.request.treatments.map(armLine)];
143
+ }
144
+ const { experiment } = report;
145
+ const rows = [
146
+ ["Experiment", experiment.id],
147
+ ["Status", experiment.status + (experiment.error_code ? ` (${experiment.error_code})` : "")],
148
+ ["Workload", experiment.workload_id],
149
+ ["Cases", `${experiment.sample.replayed} replayed, ${experiment.sample.not_replayable} not replayable`],
150
+ ["Production", experiment.control.model === null ? null : `${experiment.control.provider ?? "?"}/${experiment.control.model}`],
151
+ ["Spent", `${usd(experiment.spend.spent_usd)} of a ${usd(experiment.spend.budget_usd)} budget`],
152
+ ["Tracked fields", experiment.tracked_fields.length > 0 ? experiment.tracked_fields.join(", ") : null],
153
+ ["Dashboard", experiment.app_url],
154
+ ];
155
+ lines.push(...experiment.arms.map(armLine));
156
+ if (experiment.sample.replayed > 0) lines.push("", ...statisticsTable(experiment));
157
+ return [rows, lines];
158
+ }
159
+
160
+ export function experimentsText(result, message, style = PLAIN, action) {
161
+ const data = result.data;
162
+ const title = `${TITLES[action]}${result.ok ? "" : action === "start" ? " was not started" : " could not be read"}`;
163
+ const lines = [
164
+ `${headline(style, result.ok ? "info" : toneFor(result.outcome), title, result.outcome)} ${
165
+ style.dim(action === "start" ? "Replays captured prompts within the server's budget" : "Metadata only")}`,
166
+ "",
167
+ ];
168
+ const rows = [["Origin", data.origin], ["Workspace", data.workspace?.id ?? null]];
169
+ if (result.ok) {
170
+ const [more, body] = resultLines(action, data.result);
171
+ lines.push(...fields(style, [...rows, ...more]));
172
+ if (body.length > 0) lines.push("", ...body.map((line) => (line === "" ? "" : ` ${line}`)));
173
+ } else {
174
+ lines.push(...fields(style, rows), "", ...paragraph(style, message));
175
+ }
176
+ const notices = (data.notices ?? []).map((token) => NOTICES[token]).filter(Boolean);
177
+ if (notices.length > 0) {
178
+ lines.push("");
179
+ for (const text of notices) {
180
+ const [first, ...rest] = paragraph(style, text, " ");
181
+ lines.push(` ${style.yellow("!")} ${first.trimStart()}`, ...rest);
182
+ }
183
+ }
184
+ const next = data.next_action;
185
+ if (next?.message) {
186
+ lines.push("", ...steps(style, [["", next.message], ["Run", next.command ?? null], ["Open", next.url ?? null]]));
187
+ }
188
+ return render(lines);
189
+ }