metergraph-cli 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +60 -11
  2. package/assets/skill/SKILL.md +52 -3
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +13 -13
  5. package/assets/skills/metergraph-analyze/SKILL.md +62 -4
  6. package/assets/skills/metergraph-investigate/SKILL.md +222 -35
  7. package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
  8. package/assets/skills/metergraph-report/SKILL.md +19 -2
  9. package/bin/metergraph.js +1 -1
  10. package/package.json +1 -1
  11. package/src/agent-key.js +160 -0
  12. package/src/approval-handoff.js +5 -0
  13. package/src/args.js +572 -17
  14. package/src/auth-browser.js +5 -19
  15. package/src/auth-callback.js +24 -15
  16. package/src/auth-login.js +55 -12
  17. package/src/auth-oauth.js +77 -7
  18. package/src/callback-page.js +58 -0
  19. package/src/cli.js +190 -45
  20. package/src/constants.js +73 -3
  21. package/src/environment.js +169 -0
  22. package/src/experiments-contract.js +462 -0
  23. package/src/experiments-output.js +189 -0
  24. package/src/experiments.js +397 -0
  25. package/src/origin.js +6 -0
  26. package/src/output.js +437 -131
  27. package/src/pricing-args.js +163 -0
  28. package/src/pricing-output.js +206 -0
  29. package/src/pricing-reference.js +195 -0
  30. package/src/pricing.js +307 -0
  31. package/src/read-contract.js +976 -16
  32. package/src/read-output.js +449 -30
  33. package/src/read.js +312 -60
  34. package/src/setup-deployment.js +11 -3
  35. package/src/setup-interactive.js +341 -0
  36. package/src/setup-refusal.js +172 -0
  37. package/src/setup-state.js +4 -2
  38. package/src/setup.js +227 -53
  39. package/src/skill-bundle.js +2 -2
  40. package/src/style.js +101 -0
  41. package/src/transport.js +13 -7
  42. package/src/usage-aggregates.js +197 -0
  43. package/src/verify-output.js +21 -11
@@ -1,5 +1,10 @@
1
1
  import { normalizeUuid } from "./auth-oauth.js";
2
- import { AGENT_CONTRACT_VERSION, MAX_CURSOR_LENGTH, METADATA_SCOPE } from "./constants.js";
2
+ import {
3
+ AGENT_CONTRACT_VERSION,
4
+ MAX_CURSOR_LENGTH,
5
+ MAX_METADATA_VALUE_LENGTH,
6
+ METADATA_SCOPE,
7
+ } from "./constants.js";
3
8
  import { traceLink } from "./trace-contract.js";
4
9
 
5
10
  // Validators for the service's Metadata read documents. Each one checks the
@@ -11,24 +16,30 @@ import { traceLink } from "./trace-contract.js";
11
16
  // { ok: true, value } or { ok: false, outcome, reason }
12
17
  // with fixed tokens. notices is a Set that collects fixed notice tokens.
13
18
 
14
- // Every capability name the contract defines, in its order. The last three
15
- // read content or replay and are never available to a Metadata grant.
19
+ // Every capability name the contract defines. Content, replay and start
20
+ // entries are never available to a Metadata grant.
16
21
  export const CAPABILITY_NAMES = Object.freeze([
17
22
  "workspace_context",
18
23
  "capability_discovery",
19
24
  "routes",
20
25
  "usage",
26
+ "usage_aggregates",
21
27
  "ingestion_health",
22
28
  "incidents",
23
29
  "trace_metadata",
24
30
  "reports",
25
31
  "report_detail",
26
32
  "report_evidence",
33
+ "experiments",
34
+ "experiment_estimate",
35
+ "experiment",
36
+ "experiment_cases",
37
+ "experiment_start",
27
38
  "trace_content",
28
39
  "trace_replay",
29
40
  ]);
30
41
  const PRIVACY_CLASSES = Object.freeze(["metadata", "content", "replay"]);
31
- const SCOPES = Object.freeze(["agent:metadata", "agent:read", "agent:replay"]);
42
+ const SCOPES = Object.freeze(["agent:metadata", "agent:read", "agent:replay", "agent:analysis-run"]);
32
43
 
33
44
  const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$/;
34
45
  const IDENT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
@@ -102,12 +113,28 @@ const isObject = (value) => value !== null && typeof value === "object" && !Arra
102
113
  const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
103
114
  const isAmount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
104
115
  const isToken = (value) => typeof value === "string" && TOKEN.test(value);
105
- const isTimestamp = (value) =>
106
- typeof value === "string" && value.length <= 40 && TIMESTAMP.test(value) && Number.isFinite(Date.parse(value));
107
- const isDate = (value) =>
108
- typeof value === "string" && DATE.test(value) && Number.isFinite(Date.parse(`${value}T00:00:00Z`));
116
+ // An ISO 8601 timestamp with a real calendar date and clock time; the parser
117
+ // would roll 2026-02-29 or 24:00 over to another instant.
118
+ const isTimestamp = (value) => {
119
+ if (typeof value !== "string" || value.length > 40 || !TIMESTAMP.test(value)) return false;
120
+ if (!Number.isFinite(Date.parse(value))) return false;
121
+ const [year, month, day] = value.slice(0, 10).split("-").map(Number);
122
+ const calendar = new Date(Date.UTC(year, month - 1, day));
123
+ if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day) {
124
+ return false;
125
+ }
126
+ const [hours, minutes, seconds] = value.slice(11, 19).split(":").map(Number);
127
+ return hours <= 23 && minutes <= 59 && seconds <= 59;
128
+ };
129
+ // A real calendar date: the parser rolls 2026-02-30 over to March, so the
130
+ // parsed date must print back as the same string.
131
+ const isDate = (value) => {
132
+ if (typeof value !== "string" || !DATE.test(value)) return false;
133
+ const parsed = Date.parse(`${value}T00:00:00Z`);
134
+ return Number.isFinite(parsed) && new Date(parsed).toISOString().slice(0, 10) === value;
135
+ };
109
136
 
110
- function hasUnsafeText(value) {
137
+ export function hasUnsafeText(value) {
111
138
  for (const char of value) {
112
139
  const code = char.codePointAt(0);
113
140
  if (UNSAFE_RANGES.some(([low, high]) => code >= low && code <= high)) return true;
@@ -123,6 +150,104 @@ export function isSafeFilter(value) {
123
150
  return typeof value === "string" && value.length > 0 && value.length <= MAX_TEXT && !hasUnsafeText(value);
124
151
  }
125
152
 
153
+ // The one JSON object sent as the metadata query parameter. filters are
154
+ // already sorted by key, so the text is stable.
155
+ export function metadataQueryValue(filters) {
156
+ return JSON.stringify(Object.fromEntries(filters.map(({ key, value }) => [key, value])));
157
+ }
158
+
159
+ // The query string a read command sends, built only from validated options.
160
+ // Argument validation measures this same object, so its length check and the
161
+ // transport's agree exactly. The routes endpoint takes none.
162
+ export function readQuery(options, request = null) {
163
+ if (options.command === "routes") return null;
164
+ if (options.command === "usage" && options.grouped) return groupedReadQuery(request ?? groupedRequest(options));
165
+ const query = new URLSearchParams();
166
+ query.set("days", String(options.days));
167
+ query.set("limit", String(options.limit));
168
+ if (options.metadata) query.set("metadata", metadataQueryValue(options.metadata));
169
+ if (options.command === "traces") {
170
+ if (options.route !== null) query.set("route", options.route);
171
+ if (options.status !== null) query.set("status", options.status);
172
+ if (options.cursor !== null) query.set("cursor", options.cursor);
173
+ }
174
+ return query;
175
+ }
176
+
177
+ // What a grouped usage document must echo back, from validated options.
178
+ export function groupedRequest(options) {
179
+ const { groupBy, filters, since, until } = options.grouped;
180
+ return {
181
+ days: options.days,
182
+ since,
183
+ until,
184
+ limit: options.limit,
185
+ groupBy,
186
+ filters,
187
+ metadata: options.metadata ?? null,
188
+ };
189
+ }
190
+
191
+ // Grouped usage query: one group_by parameter per dimension (a metadata key
192
+ // is sent whole, never split on a comma), a preset or an explicit window, and
193
+ // one repeated parameter per exact filter value.
194
+ function groupedReadQuery(request) {
195
+ const query = new URLSearchParams();
196
+ for (const dimension of request.groupBy) query.append("group_by", dimension);
197
+ if (request.days !== null) query.set("days", String(request.days));
198
+ else {
199
+ query.set("since", request.since);
200
+ if (request.until !== null) query.set("until", request.until);
201
+ }
202
+ query.set("limit", String(request.limit));
203
+ for (const key of ["route", "provider", "model", "environment"]) {
204
+ for (const value of request.filters[key] ?? []) query.append(key, value);
205
+ }
206
+ if (request.metadata) query.set("metadata", metadataQueryValue(request.metadata));
207
+ return query;
208
+ }
209
+
210
+ // Rows do not carry metadata, so the only proof a metadata filter applied is
211
+ // the service's echo in coverage.filters.metadata. It must list exactly the
212
+ // requested pairs, in key order. A service that does not echo them (an older
213
+ // one) is refused rather than trusted, and an echo of pairs the CLI did not
214
+ // send is refused too.
215
+ function checkMetadataEcho(body, request) {
216
+ const requested = request.metadata ?? [];
217
+ const filters = isObject(body.coverage) && isObject(body.coverage.filters) ? body.coverage.filters : null;
218
+ const echoed = filters === null ? undefined : filters.metadata;
219
+ if (echoed === undefined || echoed === null) {
220
+ if (requested.length > 0) throw new Invalid("unsupported", "metadata_filter_unsupported");
221
+ return;
222
+ }
223
+ if (!Array.isArray(echoed) || echoed.length !== requested.length) {
224
+ throw new Invalid("verification_failed", "metadata_filter_mismatch");
225
+ }
226
+ echoed.forEach((entry, index) => {
227
+ if (
228
+ !isObject(entry) ||
229
+ entry.key !== requested[index].key ||
230
+ entry.value !== requested[index].value
231
+ ) {
232
+ throw new Invalid("verification_failed", "metadata_filter_mismatch");
233
+ }
234
+ });
235
+ }
236
+
237
+ // A metadata value is a customer's opaque string. Like a route name it is
238
+ // untrusted text: one that could change how a terminal shows output is
239
+ // replaced with null and noted.
240
+ function metadataText(value, notices, reason) {
241
+ if (typeof value !== "string" || value.length > MAX_METADATA_VALUE_LENGTH) {
242
+ throw new Invalid("verification_failed", reason);
243
+ }
244
+ if (hasUnsafeText(value)) {
245
+ notices.add("unsafe_text_omitted");
246
+ return null;
247
+ }
248
+ return value;
249
+ }
250
+
126
251
  // Text a person reads, such as a route or model name. It is untrusted
127
252
  // metadata, never an instruction. A string that could change how a terminal
128
253
  // shows output is replaced with null and noted; any other type is invalid.
@@ -219,6 +344,773 @@ function nameList(value, notices, reason) {
219
344
  return names;
220
345
  }
221
346
 
347
+ // Coverage, pricing and attribution evidence. The service adds these objects
348
+ // to usage and trace documents; an older service omits them, and that is
349
+ // reported rather than read as complete. Reasons are fixed tokens. A reason
350
+ // this CLI does not know is kept, and unless the service already says
351
+ // partial it makes completeness unknown: unknown evidence never reads as
352
+ // complete.
353
+ const PARTIAL_REASONS = new Set([
354
+ "row_limit",
355
+ "page_unread",
356
+ "window_clamped",
357
+ "retention_clipped",
358
+ "ingestion_pending",
359
+ "ingestion_failed",
360
+ "scope_incomplete",
361
+ ]);
362
+ const UNKNOWN_REASONS = new Set(["ingestion_unverified", "evidence_missing"]);
363
+ const PRICING_REASONS = new Set(["pricing_missing", "pricing_partial", "pricing_unknown"]);
364
+ // Name why a metadata grouping has a null group (calls without the key, or
365
+ // with an ineligible value). Those calls are still counted, so these never
366
+ // change completeness.
367
+ const METADATA_REASONS = new Set(["metadata_key_missing", "metadata_value_ineligible"]);
368
+ const COMPLETENESS = ["complete", "partial", "unknown"];
369
+ const RANK = Object.freeze({ complete: 0, unknown: 1, partial: 2 });
370
+ const ROW_SHAPES = ["top_n", "page", "all"];
371
+ const TOTALS_SCOPES = ["population", "returned_rows", "page"];
372
+ const FILTER_KEYS = ["route", "provider", "model", "status", "workload", "trace_id", "request_id", "environments"];
373
+
374
+ // The worst of the service's completeness and what the reasons imply.
375
+ export function completenessOf(stated, reasons) {
376
+ let derived = "complete";
377
+ for (const reason of reasons) {
378
+ if (PARTIAL_REASONS.has(reason)) derived = "partial";
379
+ else if (!PRICING_REASONS.has(reason) && !METADATA_REASONS.has(reason) && derived === "complete") {
380
+ derived = "unknown";
381
+ }
382
+ }
383
+ return RANK[stated] >= RANK[derived] ? stated : derived;
384
+ }
385
+
386
+ function windowSpan(value, reason) {
387
+ if (value === null) return null;
388
+ if (!isObject(value) || !isTimestamp(value.since) || !isTimestamp(value.until) || !isAmount(value.days)) {
389
+ throw new Invalid("verification_failed", reason);
390
+ }
391
+ return { since: value.since, until: value.until, days: value.days };
392
+ }
393
+
394
+ function coverageOf(value, notices, reason) {
395
+ if (value === undefined || value === null) {
396
+ notices.add("coverage_not_reported");
397
+ return null;
398
+ }
399
+ const rows = value.rows;
400
+ const nullableCount = (entry) => entry === null || isCount(entry);
401
+ if (
402
+ !isObject(value) ||
403
+ !isTimestamp(value.as_of) ||
404
+ !isObject(value.filters) ||
405
+ !isObject(rows) ||
406
+ !ROW_SHAPES.includes(rows.shape) ||
407
+ !(rows.ordering === null || isSafeFilter(rows.ordering)) ||
408
+ !isCount(rows.returned) ||
409
+ !nullableCount(rows.limit) ||
410
+ !(rows.truncated === null || typeof rows.truncated === "boolean") ||
411
+ !nullableCount(rows.matched) ||
412
+ !(value.next_cursor === null || isCursor(value.next_cursor)) ||
413
+ !TOTALS_SCOPES.includes(value.totals_scope) ||
414
+ !COMPLETENESS.includes(value.completeness) ||
415
+ !Array.isArray(value.reasons) ||
416
+ value.reasons.length > MAX_LIST ||
417
+ !value.reasons.every(isToken)
418
+ ) {
419
+ throw new Invalid("verification_failed", reason);
420
+ }
421
+ // Applied filters are echoed by name only, as whether one was applied.
422
+ // Their values are request arguments or names and are not printed back.
423
+ const filters = {};
424
+ for (const key of FILTER_KEYS) {
425
+ if (Object.hasOwn(value.filters, key)) filters[key] = value.filters[key] !== null;
426
+ }
427
+ // Metadata is echoed as a list of pairs; an empty list means none applied.
428
+ if (Object.hasOwn(value.filters, "metadata")) {
429
+ filters.metadata = Array.isArray(value.filters.metadata) && value.filters.metadata.length > 0;
430
+ }
431
+ const reasons = [...new Set(value.reasons)].sort();
432
+ const completeness = completenessOf(value.completeness, reasons);
433
+ if (completeness === "partial") notices.add("coverage_partial");
434
+ if (completeness === "unknown") notices.add("coverage_unknown");
435
+ return {
436
+ as_of: value.as_of,
437
+ requested_window: windowSpan(value.requested_window ?? null, reason),
438
+ effective_window: windowSpan(value.effective_window ?? null, reason),
439
+ filters_applied: filters,
440
+ rows: {
441
+ shape: rows.shape,
442
+ ordering: rows.ordering,
443
+ returned: rows.returned,
444
+ limit: rows.limit,
445
+ truncated: rows.truncated,
446
+ matched: rows.matched,
447
+ },
448
+ next_cursor: value.next_cursor,
449
+ totals_scope: value.totals_scope,
450
+ completeness,
451
+ reasons,
452
+ };
453
+ }
454
+
455
+ // The coverage object must describe the document it arrived in: the same row
456
+ // shape, totals scope, limit, returned count, truncation, cursor and
457
+ // requested days. Stale or mismatched coverage fails closed rather than
458
+ // deciding completeness.
459
+ function checkCoverage(coverage, expected, reason) {
460
+ if (coverage === null) return;
461
+ const rows = coverage.rows;
462
+ if (
463
+ rows.shape !== expected.shape ||
464
+ coverage.totals_scope !== expected.totalsScope ||
465
+ rows.limit !== expected.limit ||
466
+ rows.returned !== expected.returned ||
467
+ rows.truncated !== expected.truncated ||
468
+ coverage.next_cursor !== expected.nextCursor ||
469
+ (rows.matched !== null && rows.matched < rows.returned) ||
470
+ (coverage.requested_window !== null && coverage.requested_window.days !== expected.days)
471
+ ) {
472
+ throw new Invalid("verification_failed", reason);
473
+ }
474
+ }
475
+
476
+ const PRICING_COUNTS = ["priced_calls", "zero_cost_calls", "partial_calls", "unpriced_calls", "unknown_calls"];
477
+
478
+ // calls is the number of calls the pricing describes, when the document
479
+ // states it. The four disjoint classes must account for exactly those calls,
480
+ // so an undercounted pricing object can never present cost as exact.
481
+ function pricingOf(value, notices, reason, { note = true, calls = null } = {}) {
482
+ if (value === undefined || value === null) return null;
483
+ if (
484
+ !isObject(value) ||
485
+ !PRICING_COUNTS.every((key) => isCount(value[key])) ||
486
+ value.zero_cost_calls > value.priced_calls ||
487
+ (value.cost !== "exact" && value.cost !== "lower_bound") ||
488
+ (calls !== null &&
489
+ value.priced_calls + value.partial_calls + value.unpriced_calls + value.unknown_calls !== calls)
490
+ ) {
491
+ throw new Invalid("verification_failed", reason);
492
+ }
493
+ // Exact cost needs every call priced; a service claiming otherwise is
494
+ // read as a lower bound.
495
+ const unpricedAny = value.partial_calls + value.unpriced_calls + value.unknown_calls > 0;
496
+ const cost = unpricedAny ? "lower_bound" : value.cost;
497
+ if (cost === "lower_bound" && note) notices.add("pricing_lower_bound");
498
+ return { ...Object.fromEntries(PRICING_COUNTS.map((key) => [key, value[key]])), cost };
499
+ }
500
+
501
+ // trace_total: each trace row aggregates the whole trace. matching_calls: a
502
+ // route, status or request filter selected calls, so each row covers only
503
+ // its matching calls.
504
+ const TRACE_COST_BASES = Object.freeze(["trace_total", "matching_calls"]);
505
+
506
+ // level, when given, is the only attribution level the read can have:
507
+ // trace reads return whole-trace totals whatever the document claims.
508
+ function attributionOf(value, reason, level = null) {
509
+ if (value === undefined || value === null) return null;
510
+ if (level !== null && isObject(value) && (value.level !== level ||
511
+ (level === "trace" && (!TRACE_COST_BASES.includes(value.cost_basis) || value.per_provider_exact !== false)))) {
512
+ throw new Invalid("verification_failed", reason);
513
+ }
514
+ const nullableCount = (entry) => entry === null || isCount(entry);
515
+ if (
516
+ !isObject(value) ||
517
+ (value.level !== "call" && value.level !== "trace") ||
518
+ (value.cost_basis !== "call" && !TRACE_COST_BASES.includes(value.cost_basis)) ||
519
+ typeof value.per_provider_exact !== "boolean" ||
520
+ !nullableCount(value.mixed_provider_rows) ||
521
+ !nullableCount(value.mixed_model_rows)
522
+ ) {
523
+ throw new Invalid("verification_failed", reason);
524
+ }
525
+ // A whole-trace total is never an exact per-provider split, whatever the
526
+ // document says.
527
+ const perProviderExact = value.level === "trace" ? false : value.per_provider_exact;
528
+ return {
529
+ level: value.level,
530
+ cost_basis: value.cost_basis,
531
+ per_provider_exact: perProviderExact,
532
+ mixed_provider_rows: value.mixed_provider_rows,
533
+ mixed_model_rows: value.mixed_model_rows,
534
+ };
535
+ }
536
+
537
+ function populationTotalsOf(value, reason) {
538
+ if (value === undefined || value === null) return null;
539
+ if (
540
+ !isObject(value) ||
541
+ value.scope !== "population" ||
542
+ !isCount(value.calls) ||
543
+ !isCount(value.error_calls) ||
544
+ !isAmount(value.cost_usd) ||
545
+ !isCount(value.input_tokens) ||
546
+ !isCount(value.output_tokens)
547
+ ) {
548
+ throw new Invalid("verification_failed", reason);
549
+ }
550
+ return {
551
+ scope: "population",
552
+ calls: value.calls,
553
+ error_calls: value.error_calls,
554
+ cost_usd: value.cost_usd,
555
+ input_tokens: value.input_tokens,
556
+ output_tokens: value.output_tokens,
557
+ };
558
+ }
559
+
560
+ // Grouped usage: GET /v1/agent/usage/aggregates (the MCP tool
561
+ // metergraph_get_usage_aggregates returns the same document). Groups are
562
+ // computed by the service per call, so a trace that used two providers adds
563
+ // each call's cost to that call's provider. totals cover every call the
564
+ // window and filters match, whatever the limit. The window is UTC.
565
+ const USAGE_GROUP_DIMENSIONS = ["provider", "model", "route", "day"];
566
+ // At most one "metadata.<key>" dimension; its group field is the whole name.
567
+ const METADATA_DIMENSION = "metadata.";
568
+ const TOKEN_COUNTS = ["input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "reasoning_tokens"];
569
+ const HOUR_MS = 60 * 60 * 1000;
570
+ const DAY_MS = 24 * HOUR_MS;
571
+ const utcMidnight = (ms) => Math.floor(ms / DAY_MS) * DAY_MS;
572
+ // How far a window that ends "now" may be from this request: request latency
573
+ // plus clock difference between this machine and the service.
574
+ const REQUEST_TIME_SKEW_MS = 15 * 60 * 1000;
575
+
576
+ const P95_METHOD = "histogram_estimate";
577
+ // The service rounds amounts to 8 decimals; a product or sum of n rounded
578
+ // amounts may differ from the exact value by about n half-units, plus slack.
579
+ const costTolerance = (count) => 1e-6 + count * 1e-8;
580
+ // The service's order for grouped usage; truncation keeps its first groups.
581
+ const AGGREGATE_ORDERING = "cost_usd desc, calls desc, group key asc";
582
+ // An internal join key built from the raw group values, never printed: a
583
+ // value replaced by null for display (unsafe text) still compares by its
584
+ // real identity across windows.
585
+ export const GROUP_KEY = Symbol("group_key");
586
+ // The environments a grouped read covered (the service's echo, or null for
587
+ // all), kept off the printed result so comparisons can require the same set.
588
+ export const READ_ENVIRONMENTS = Symbol("read_environments");
589
+
590
+ // One group's or the population's metrics, shared by items and totals.
591
+ // Latency is labelled as the service defines it: avg_ms is the mean over the
592
+ // calls that reported latency (samples) and p95_ms is an estimate from a
593
+ // fixed histogram (p95_method), never an exact percentile.
594
+ function usageMetrics(row, reason) {
595
+ const nullableCount = (value) => value === null || isCount(value);
596
+ const latency = row.latency;
597
+ if (
598
+ !isCount(row.calls) ||
599
+ !isCount(row.error_calls) ||
600
+ row.error_calls > row.calls ||
601
+ !isAmount(row.cost_usd) ||
602
+ !(row.cost_per_call_usd === null || isAmount(row.cost_per_call_usd)) ||
603
+ (row.calls === 0) !== (row.cost_per_call_usd === null) ||
604
+ !TOKEN_COUNTS.every((key) => isCount(row[key])) ||
605
+ !isObject(latency) ||
606
+ !nullableCount(latency.avg_ms) ||
607
+ !nullableCount(latency.p95_ms) ||
608
+ // Output labels p95 as a fixed-histogram estimate, so only that method is read.
609
+ latency.p95_method !== P95_METHOD ||
610
+ !isCount(latency.samples) ||
611
+ latency.samples > row.calls ||
612
+ // Latency measured from no calls is no latency.
613
+ (latency.samples === 0) !== (latency.avg_ms === null) ||
614
+ (latency.samples === 0) !== (latency.p95_ms === null) ||
615
+ // cost_per_call_usd must be cost_usd / calls, to the service's 8-decimal rounding.
616
+ (row.calls > 0 && Math.abs(row.cost_per_call_usd * row.calls - row.cost_usd) > costTolerance(row.calls))
617
+ ) {
618
+ throw new Invalid("verification_failed", reason);
619
+ }
620
+ return {
621
+ calls: row.calls,
622
+ error_calls: row.error_calls,
623
+ error_rate: row.calls === 0 ? null : Math.round((row.error_calls / row.calls) * 10000) / 10000,
624
+ cost_usd: row.cost_usd,
625
+ cost_per_call_usd: row.cost_per_call_usd,
626
+ ...Object.fromEntries(TOKEN_COUNTS.map((key) => [key, row[key]])),
627
+ latency: {
628
+ avg_ms: latency.avg_ms,
629
+ p95_ms: latency.p95_ms,
630
+ p95_method: latency.p95_method,
631
+ samples: latency.samples,
632
+ },
633
+ };
634
+ }
635
+
636
+ // The UTC window the service read, checked against what was asked. A preset
637
+ // covers the current UTC day so far and the days - 1 whole UTC days before
638
+ // it; an explicit window is [since, until). partial_days lists the UTC days
639
+ // the window covers only part of.
640
+ function usageWindow(value, request, reason) {
641
+ if (
642
+ !isObject(value) ||
643
+ value.timezone !== "UTC" ||
644
+ !isTimestamp(value.since) ||
645
+ !isTimestamp(value.until) ||
646
+ typeof value.until_is_request_time !== "boolean"
647
+ ) {
648
+ throw new Invalid("verification_failed", reason);
649
+ }
650
+ const since = Date.parse(value.since);
651
+ const until = Date.parse(value.until);
652
+ if (since >= until) throw new Invalid("verification_failed", reason);
653
+ if (request.days !== null) {
654
+ if (
655
+ value.preset !== "utc_days" ||
656
+ value.days !== request.days ||
657
+ !value.until_is_request_time ||
658
+ since !== utcMidnight(until) - (request.days - 1) * DAY_MS
659
+ ) {
660
+ throw new Invalid("verification_failed", "window_mismatch");
661
+ }
662
+ } else if (
663
+ value.preset !== null ||
664
+ value.days !== null ||
665
+ since !== Date.parse(request.since) ||
666
+ (request.until === null
667
+ ? !value.until_is_request_time
668
+ : value.until_is_request_time || until !== Date.parse(request.until))
669
+ ) {
670
+ throw new Invalid("verification_failed", "window_mismatch");
671
+ }
672
+ // A window that ends at the request time (a preset, or --since without
673
+ // --until) must end within a bounded clock skew of this request, so a
674
+ // response kept from earlier is never read as ending now.
675
+ if (value.until_is_request_time && request.requestedAt !== undefined && request.requestedAt !== null) {
676
+ if (Math.abs(until - request.requestedAt) > REQUEST_TIME_SKEW_MS) {
677
+ throw new Invalid("verification_failed", "window_mismatch");
678
+ }
679
+ }
680
+ const partialDays = [];
681
+ if (since !== utcMidnight(since)) partialDays.push(new Date(since).toISOString().slice(0, 10));
682
+ const lastDay = utcMidnight(until - 1);
683
+ if (until !== utcMidnight(until) && (partialDays.length === 0 || lastDay !== utcMidnight(since))) {
684
+ partialDays.push(new Date(lastDay).toISOString().slice(0, 10));
685
+ }
686
+ const result = {
687
+ // utc_days: from 00:00 UTC, days - 1 whole UTC days before today, to now.
688
+ // explicit: exactly [since, until).
689
+ kind: value.preset === "utc_days" ? "utc_days" : "explicit",
690
+ timezone: "UTC",
691
+ preset: value.preset,
692
+ days: value.days,
693
+ since: value.since,
694
+ until: value.until,
695
+ until_is_request_time: value.until_is_request_time,
696
+ hours: Math.round(((until - since) / HOUR_MS) * 100) / 100,
697
+ partial_days: partialDays,
698
+ };
699
+ // First and last UTC days the window covers; not part of the output.
700
+ Object.defineProperties(result, {
701
+ since_day: { value: new Date(since).toISOString().slice(0, 10) },
702
+ last_day: { value: new Date(until - 1).toISOString().slice(0, 10) },
703
+ });
704
+ return result;
705
+ }
706
+
707
+ // Exact filters echoed in coverage.filters must be the ones requested. An
708
+ // environment list the CLI did not ask for is the key's own restriction.
709
+ function checkUsageFilters(raw, request, notices) {
710
+ const echoed = raw?.filters;
711
+ if (!isObject(echoed)) throw new Invalid("verification_failed", "usage_response_invalid");
712
+ // Exactly the same values: no duplicates, nothing missing, nothing extra.
713
+ const sameSet = (left, right) =>
714
+ new Set(left).size === left.length &&
715
+ left.length === right.length &&
716
+ left.every((entry) => right.includes(entry));
717
+ // Filters grouped usage never sends must not be reported as applied.
718
+ for (const [key, entry] of Object.entries(echoed)) {
719
+ if (!["route", "provider", "model", "environments", "metadata"].includes(key) && entry !== null && entry !== undefined) {
720
+ throw new Invalid("verification_failed", "filter_mismatch");
721
+ }
722
+ }
723
+ for (const key of ["route", "provider", "model"]) {
724
+ const asked = request.filters[key];
725
+ const got = echoed[key] ?? null;
726
+ if (asked === null ? got !== null : !(Array.isArray(got) && sameSet(got, asked))) {
727
+ throw new Invalid("verification_failed", "filter_mismatch");
728
+ }
729
+ }
730
+ const askedEnv = request.filters.environment;
731
+ const gotEnv = echoed.environments ?? null;
732
+ if (gotEnv !== null && (!Array.isArray(gotEnv) || !gotEnv.every((entry) => typeof entry === "string"))) {
733
+ throw new Invalid("verification_failed", "usage_response_invalid");
734
+ }
735
+ if (askedEnv !== null) {
736
+ if (gotEnv === null || !gotEnv.every((entry) => askedEnv.includes(entry))) {
737
+ throw new Invalid("verification_failed", "filter_mismatch");
738
+ }
739
+ if (!sameSet(gotEnv, askedEnv)) notices.add("environment_restricted_key");
740
+ } else if (gotEnv !== null) {
741
+ notices.add("environment_restricted_key");
742
+ }
743
+ return gotEnv === null ? null : [...gotEnv].sort();
744
+ }
745
+
746
+ // Where a metadata grouping's calls went. Listed groups with a value, values
747
+ // past the limit (other_values), calls without the key (missing_key) and
748
+ // calls with a non-string or oversize value (ineligible_value) add up to the
749
+ // population exactly; a document where they do not is refused.
750
+ function metadataGroupsOf(value, items, population, dimension, reason, { truncated, matched, onlyDimension }) {
751
+ const key = dimension.slice(METADATA_DIMENSION.length);
752
+ const bucket = (entry, withGroups = false) => {
753
+ if (!isObject(entry) || !isCount(entry.calls) || !isAmount(entry.cost_usd) || (withGroups && !isCount(entry.groups))) {
754
+ throw new Invalid("verification_failed", reason);
755
+ }
756
+ return withGroups
757
+ ? { groups: entry.groups, calls: entry.calls, cost_usd: entry.cost_usd }
758
+ : { calls: entry.calls, cost_usd: entry.cost_usd };
759
+ };
760
+ if (!isObject(value) || value.key !== key || !isObject(value.unlisted)) {
761
+ throw new Invalid("verification_failed", reason);
762
+ }
763
+ const unlisted = {
764
+ other_values: bucket(value.unlisted.other_values, true),
765
+ missing_key: bucket(value.unlisted.missing_key),
766
+ ineligible_value: bucket(value.unlisted.ineligible_value),
767
+ };
768
+ // The service's eight-decimal precision, so a tiny cost never reads as zero.
769
+ const round = (amount) => Math.round(amount * 1e8) / 1e8;
770
+ const valued = items.filter((item) => item.has_value);
771
+ const listed = {
772
+ calls: valued.reduce((sum, item) => sum + item.calls, 0),
773
+ cost_usd: round(valued.reduce((sum, item) => sum + item.cost_usd, 0)),
774
+ };
775
+ const bucketCost =
776
+ unlisted.other_values.cost_usd + unlisted.missing_key.cost_usd + unlisted.ineligible_value.cost_usd;
777
+ if (
778
+ listed.calls + unlisted.other_values.calls + unlisted.missing_key.calls + unlisted.ineligible_value.calls !==
779
+ population.calls ||
780
+ Math.abs(listed.cost_usd + bucketCost - population.cost_usd) > costTolerance(items.length + 3)
781
+ ) {
782
+ throw new Invalid("verification_failed", reason);
783
+ }
784
+ // Every group has calls, so unlisted values and unlisted calls come
785
+ // together; none exist when nothing was cut, and never more than the
786
+ // matched groups that were not returned.
787
+ const other = unlisted.other_values;
788
+ if (
789
+ (other.groups === 0) !== (other.calls === 0) ||
790
+ (other.calls === 0 && other.cost_usd !== 0) ||
791
+ (!truncated && other.groups !== 0) ||
792
+ (matched !== null && other.groups > matched - items.length)
793
+ ) {
794
+ throw new Invalid("verification_failed", reason);
795
+ }
796
+ // The listed no-value rows are those buckets' calls: all of them when
797
+ // nothing was cut or the key is the only dimension, never more otherwise.
798
+ const noValue = items.filter((item) => !item.has_value);
799
+ const noValueCalls = noValue.reduce((sum, item) => sum + item.calls, 0);
800
+ const noValueCost = noValue.reduce((sum, item) => sum + item.cost_usd, 0);
801
+ const bucketCalls = unlisted.missing_key.calls + unlisted.ineligible_value.calls;
802
+ const bucketNoValueCost = unlisted.missing_key.cost_usd + unlisted.ineligible_value.cost_usd;
803
+ const whole = !truncated || (onlyDimension && noValue.length > 0);
804
+ if (
805
+ whole
806
+ ? noValueCalls !== bucketCalls || Math.abs(noValueCost - bucketNoValueCost) > costTolerance(noValue.length + 2)
807
+ : noValueCalls > bucketCalls || noValueCost - bucketNoValueCost > costTolerance(noValue.length + 2)
808
+ ) {
809
+ throw new Invalid("verification_failed", reason);
810
+ }
811
+ const unattributed = {
812
+ calls: unlisted.missing_key.calls + unlisted.ineligible_value.calls,
813
+ cost_usd: round(unlisted.missing_key.cost_usd + unlisted.ineligible_value.cost_usd),
814
+ };
815
+ return {
816
+ key,
817
+ listed_with_value: listed,
818
+ unlisted,
819
+ // Spend that no value of the key accounts for.
820
+ unattributed,
821
+ };
822
+ }
823
+
824
+ export function usageAggregatesReport(body, ctx, request, notices) {
825
+ return run(() => {
826
+ const reason = "usage_response_invalid";
827
+ const origin = provenance(body, ctx, reason);
828
+ contentExcluded(body);
829
+ const span = usageWindow(body.window, request, reason);
830
+ if (
831
+ !Array.isArray(body.group_by) ||
832
+ body.group_by.length !== request.groupBy.length ||
833
+ body.group_by.some((dimension, index) => dimension !== request.groupBy[index])
834
+ ) {
835
+ throw new Invalid("verification_failed", "group_by_mismatch");
836
+ }
837
+ const proof = evidence(body.evidence, reason);
838
+ const codes = warnings(body.warnings, notices, reason);
839
+ checkMetadataEcho(body, request);
840
+ // Grouped usage always carries coverage; without it the groups cannot be
841
+ // shown to be complete or filtered as asked.
842
+ if (body.coverage === undefined || body.coverage === null) {
843
+ throw new Invalid("unsupported", "coverage_not_reported");
844
+ }
845
+ if (!isObject(body.coverage)) throw new Invalid("verification_failed", reason);
846
+ const readEnvironments = checkUsageFilters(body.coverage, request, notices);
847
+ const coverage = coverageOf(body.coverage, notices, reason);
848
+ if (
849
+ coverage.rows.shape !== "top_n" ||
850
+ coverage.totals_scope !== "population" ||
851
+ coverage.rows.limit !== request.limit ||
852
+ // A top-N read has no pages.
853
+ coverage.next_cursor !== null
854
+ ) {
855
+ throw new Invalid("verification_failed", reason);
856
+ }
857
+ // Coverage must describe the window validated above: the same requested
858
+ // window, and an effective window inside it that ends at the same time
859
+ // (only metadata retention may clip its start, and it names that).
860
+ const requested = coverage.requested_window;
861
+ const effective = coverage.effective_window;
862
+ if (
863
+ requested === null ||
864
+ effective === null ||
865
+ Date.parse(requested.since) !== Date.parse(span.since) ||
866
+ Date.parse(requested.until) !== Date.parse(span.until) ||
867
+ Date.parse(effective.until) !== Date.parse(span.until) ||
868
+ Date.parse(effective.since) < Date.parse(span.since) ||
869
+ Date.parse(effective.since) > Date.parse(effective.until) ||
870
+ (Date.parse(effective.since) !== Date.parse(span.since) && !coverage.reasons.includes("retention_clipped"))
871
+ ) {
872
+ throw new Invalid("verification_failed", "window_mismatch");
873
+ }
874
+ if (!Array.isArray(body.items) || body.items.length > request.limit) {
875
+ throw new Invalid("verification_failed", reason);
876
+ }
877
+ const seen = new Set();
878
+ const metadataDimension = request.groupBy.find((dimension) => dimension.startsWith(METADATA_DIMENSION)) ?? null;
879
+ const items = body.items.map((raw) => {
880
+ const row = rowObject(raw, reason);
881
+ const group = {};
882
+ for (const dimension of USAGE_GROUP_DIMENSIONS) {
883
+ const wanted = request.groupBy.includes(dimension);
884
+ if (!wanted) {
885
+ if (row[dimension] !== undefined) throw new Invalid("verification_failed", reason);
886
+ continue;
887
+ }
888
+ if (dimension === "day") {
889
+ // A UTC day the verified window does not cover is refused.
890
+ if (!isDate(row.day) || row.day < span.since_day || row.day > span.last_day) {
891
+ throw new Invalid("verification_failed", reason);
892
+ }
893
+ group.day = row.day;
894
+ } else {
895
+ group[dimension] = displayText(row[dimension], notices, reason);
896
+ }
897
+ }
898
+ for (const name of Object.keys(row)) {
899
+ if (name.startsWith(METADATA_DIMENSION) && name !== metadataDimension) {
900
+ throw new Invalid("verification_failed", reason);
901
+ }
902
+ }
903
+ if (metadataDimension !== null) {
904
+ // null: calls without the key, or with a non-string or oversize value.
905
+ const value = row[metadataDimension];
906
+ if (value !== null && typeof value !== "string") throw new Invalid("verification_failed", reason);
907
+ group.metadata_key = metadataDimension.slice(METADATA_DIMENSION.length);
908
+ group.metadata_value = value === null ? null : metadataText(value, notices, reason);
909
+ group.has_value = value !== null;
910
+ }
911
+ const key = JSON.stringify(request.groupBy.map((dimension) => raw[dimension]));
912
+ if (seen.has(key)) throw new Invalid("verification_failed", reason);
913
+ seen.add(key);
914
+ const metrics = usageMetrics(row, reason);
915
+ const item = {
916
+ ...group,
917
+ ...metrics,
918
+ pricing: pricingOf(row.pricing, notices, reason, { note: false, calls: metrics.calls }),
919
+ };
920
+ if (group.day !== undefined) item.partial_day = span.partial_days.includes(group.day);
921
+ Object.defineProperty(item, GROUP_KEY, { value: key, enumerable: true });
922
+ return item;
923
+ });
924
+ const totals = body.totals;
925
+ if (!isObject(totals) || totals.scope !== "population") throw new Invalid("verification_failed", reason);
926
+ const population = { scope: "population", ...usageMetrics(totals, reason) };
927
+ let dashboard = null;
928
+ if (body.completeness !== undefined && body.completeness !== null) {
929
+ const value = body.completeness;
930
+ if (!isObject(value) || typeof value.complete !== "boolean" || !isCount(value.returned) || !isCount(value.limit)) {
931
+ throw new Invalid("verification_failed", reason);
932
+ }
933
+ // The dashboard summary must agree with the request and the rows.
934
+ if (value.mode !== "top_n" || value.returned !== body.items.length || value.limit !== request.limit) {
935
+ throw new Invalid("verification_failed", reason);
936
+ }
937
+ dashboard = { mode: "top_n", complete: value.complete, returned: value.returned, limit: value.limit };
938
+ }
939
+ const matched = coverage.rows.matched;
940
+ const truncated =
941
+ coverage.rows.truncated === true || (matched !== null && matched > items.length) || dashboard?.complete === false;
942
+ // The groups partition the matching calls: rows the service says are all
943
+ // of them must add up to the population totals, and a count that says
944
+ // otherwise is refused.
945
+ const listedCalls = items.reduce((sum, item) => sum + item.calls, 0);
946
+ if (
947
+ coverage.rows.returned !== items.length ||
948
+ (matched !== null && matched < items.length) ||
949
+ // A top-N read has no cursor, so it fills the limit when more matched.
950
+ (matched !== null && items.length !== Math.min(request.limit, matched)) ||
951
+ (coverage.rows.truncated === false && matched !== null && matched !== items.length) ||
952
+ // The truncation flag must say exactly whether matched groups were left out.
953
+ (matched !== null && coverage.rows.truncated !== null && coverage.rows.truncated !== (matched > items.length)) ||
954
+ // Evidence describes the returned rows.
955
+ proof.rows !== items.length ||
956
+ listedCalls > population.calls ||
957
+ (!truncated && listedCalls !== population.calls) ||
958
+ // The dashboard summary's completeness must match the coverage rows.
959
+ (dashboard !== null && matched !== null && dashboard.complete !== (matched === items.length)) ||
960
+ // Every group has calls, and calls in the population mean listed groups.
961
+ items.some((item) => item.calls === 0) ||
962
+ (population.calls > 0 && items.length === 0)
963
+ ) {
964
+ throw new Invalid("verification_failed", reason);
965
+ }
966
+ // Every group set must reconcile with the population: an untruncated one
967
+ // exactly (counts) or within rounding (cost), a truncated one never above it.
968
+ const sumOf = (field) =>
969
+ items.reduce((sum, item) => sum + (field === "latency_samples_" ? item.latency.samples : item[field]), 0);
970
+ for (const field of ["error_calls", ...TOKEN_COUNTS]) {
971
+ if (truncated ? sumOf(field) > population[field] : sumOf(field) !== population[field]) {
972
+ throw new Invalid("verification_failed", reason);
973
+ }
974
+ }
975
+ // Calls all priced at zero cost nothing.
976
+ const allZero = (pricing, cost) =>
977
+ pricing &&
978
+ pricing.priced_calls > 0 &&
979
+ pricing.priced_calls === pricing.zero_cost_calls &&
980
+ pricing.partial_calls + pricing.unpriced_calls + pricing.unknown_calls === 0 &&
981
+ cost !== 0;
982
+ if (allZero(body.pricing, population.cost_usd) || items.some((item) => allZero(item.pricing, item.cost_usd))) {
983
+ throw new Invalid("verification_failed", reason);
984
+ }
985
+ // Latency samples and pricing classes add up the same way.
986
+ const pairs = [[sumOf("latency_samples_"), population.latency.samples]];
987
+ const groupPricing = body.pricing;
988
+ for (const key of PRICING_COUNTS) {
989
+ if (isObject(groupPricing) && items.every((item) => item.pricing)) {
990
+ pairs.push([items.reduce((sum, item) => sum + item.pricing[key], 0), groupPricing[key]]);
991
+ }
992
+ }
993
+ for (const [listed, whole] of pairs) {
994
+ if (!isCount(whole) || (truncated ? listed > whole : listed !== whole)) {
995
+ throw new Invalid("verification_failed", reason);
996
+ }
997
+ }
998
+ const costGap = sumOf("cost_usd") - population.cost_usd;
999
+ if (costGap > costTolerance(items.length + 1) || (!truncated && -costGap > costTolerance(items.length + 1))) {
1000
+ throw new Invalid("verification_failed", reason);
1001
+ }
1002
+ // The groups must come in the service's stated cost order; a cut list is
1003
+ // only the highest-cost groups when that is the order.
1004
+ // Cost descending, then calls descending for equal cost. The final key
1005
+ // (group key ascending) follows the service's collation, which the CLI
1006
+ // does not reproduce.
1007
+ const costOrdered = items.every((item, index) => {
1008
+ if (index === 0) return true;
1009
+ const before = items[index - 1];
1010
+ return before.cost_usd > item.cost_usd || (before.cost_usd === item.cost_usd && before.calls >= item.calls);
1011
+ });
1012
+ if (!costOrdered || coverage.rows.ordering !== AGGREGATE_ORDERING) {
1013
+ throw new Invalid("verification_failed", reason);
1014
+ }
1015
+ if (truncated) notices.add("groups_truncated");
1016
+ if (!proof.complete) notices.add("evidence_incomplete");
1017
+ if (span.until_is_request_time && span.partial_days.length > 0) notices.add("current_day_partial");
1018
+ if (coverage.reasons.some((code) => METADATA_REASONS.has(code))) notices.add("metadata_values_missing");
1019
+ if (codes.some((entry) => entry.code === "metadata_key_mostly_missing")) notices.add("metadata_key_mostly_missing");
1020
+ const metadataGroups = metadataDimension === null ? null : metadataGroupsOf(body.metadata_groups, items, population, metadataDimension, reason, {
1021
+ truncated, matched, onlyDimension: request.groupBy.length === 1,
1022
+ });
1023
+ // Grouped usage is attributed per call; anything else is not the promised split.
1024
+ const groupedAttribution = attributionOf(body.attribution, reason, "call");
1025
+ if (
1026
+ groupedAttribution === null ||
1027
+ groupedAttribution.cost_basis !== "call" ||
1028
+ groupedAttribution.per_provider_exact !== true
1029
+ ) {
1030
+ throw new Invalid("verification_failed", reason);
1031
+ }
1032
+ const complete = proof.complete && !truncated && coverage.completeness === "complete";
1033
+ const result = {
1034
+ provenance: origin,
1035
+ source: "usage_aggregates",
1036
+ window: span,
1037
+ evidence: proof,
1038
+ warnings: codes,
1039
+ content_included: false,
1040
+ group_by: [...request.groupBy],
1041
+ filters: {
1042
+ route: request.filters.route?.length ?? 0,
1043
+ provider: request.filters.provider?.length ?? 0,
1044
+ model: request.filters.model?.length ?? 0,
1045
+ environment: request.filters.environment?.length ?? 0,
1046
+ metadata: metadataSupplied(request),
1047
+ },
1048
+ truncated,
1049
+ complete,
1050
+ empty: population.calls === 0,
1051
+ rows: items.length,
1052
+ matched_groups: matched,
1053
+ items,
1054
+ totals: population,
1055
+ metadata_groups: metadataGroups,
1056
+ coverage,
1057
+ completeness: dashboard,
1058
+ pricing: pricingOf(body.pricing, notices, reason, { calls: population.calls }),
1059
+ attribution: groupedAttribution,
1060
+ };
1061
+ // Not printed (symbol keys are not serialized); compared by --compare.
1062
+ result[READ_ENVIRONMENTS] = readEnvironments;
1063
+ return result;
1064
+ });
1065
+ }
1066
+
1067
+ // Combines the validated reports of consecutive trace pages (as returned by
1068
+ // tracesReport) into one coverage verdict for a local aggregation. It is
1069
+ // complete only when the last page has no cursor and no page, read or not,
1070
+ // carries any other reason the service gave for being incomplete. Running
1071
+ // out of cursors alone never proves completeness.
1072
+ export function combinePages(pages) {
1073
+ if (!Array.isArray(pages) || pages.length === 0) {
1074
+ return { complete: false, completeness: "unknown", reasons: ["evidence_missing"], pages: 0, next_cursor: null };
1075
+ }
1076
+ const reasons = new Set();
1077
+ let completeness = "complete";
1078
+ const worse = (value) => {
1079
+ if (value === "partial" || (value === "unknown" && completeness === "complete")) completeness = value;
1080
+ };
1081
+ pages.forEach((page, index) => {
1082
+ const last = index === pages.length - 1;
1083
+ if (!last && page.next_cursor === null) {
1084
+ // A page without a cursor followed by more pages is not a sequence.
1085
+ reasons.add("evidence_missing");
1086
+ worse("unknown");
1087
+ }
1088
+ if (page.coverage === null || page.coverage === undefined) {
1089
+ if (!page.evidence.complete && !(page.truncated && !last)) {
1090
+ reasons.add("evidence_missing");
1091
+ worse("unknown");
1092
+ }
1093
+ return;
1094
+ }
1095
+ // A cursor that was followed is not an unread page.
1096
+ const own = page.coverage.reasons.filter((reason) => last || reason !== "page_unread");
1097
+ for (const reason of own) reasons.add(reason);
1098
+ worse(completenessOf("complete", own));
1099
+ });
1100
+ const final = pages[pages.length - 1];
1101
+ if (final.next_cursor !== null) {
1102
+ reasons.add("page_unread");
1103
+ worse("partial");
1104
+ }
1105
+ return {
1106
+ complete: completeness === "complete",
1107
+ completeness,
1108
+ reasons: [...reasons].sort(),
1109
+ pages: pages.length,
1110
+ next_cursor: final.next_cursor,
1111
+ };
1112
+ }
1113
+
222
1114
  // GET /v1/agent/workspace, already checked by checkWorkspace during the
223
1115
  // session. Returns the approved workspace fields only.
224
1116
  export function workspaceContext(body, ctx, notices) {
@@ -352,14 +1244,19 @@ export function usageReport(body, ctx, request, notices) {
352
1244
  const origin = provenance(body, ctx, reason);
353
1245
  contentExcluded(body);
354
1246
  const span = window(body.window, request.days, reason);
355
- if (body.days !== request.days || typeof body.truncated !== "boolean") {
356
- throw new Invalid("verification_failed", reason);
357
- }
358
1247
  const proof = evidence(body.evidence, reason);
359
1248
  const codes = warnings(body.warnings, notices, reason);
360
1249
  if (!Array.isArray(body.items) || body.items.length > request.limit) {
361
1250
  throw new Invalid("verification_failed", reason);
362
1251
  }
1252
+ checkMetadataEcho(body, request);
1253
+ const metadataFilters = metadataSupplied(request);
1254
+ if (body.days !== request.days || typeof body.truncated !== "boolean") {
1255
+ throw new Invalid("verification_failed", reason);
1256
+ }
1257
+ if (body.group_by !== undefined && body.group_by !== null) {
1258
+ throw new Invalid("verification_failed", reason);
1259
+ }
363
1260
  const items = body.items.map((raw) => {
364
1261
  const row = rowObject(raw, reason);
365
1262
  const nullableCount = (value) => value === null || isCount(value);
@@ -386,27 +1283,50 @@ export function usageReport(body, ctx, request, notices) {
386
1283
  output_tokens: row.output_tokens,
387
1284
  avg_latency_ms: row.avg_latency_ms,
388
1285
  p95_latency_ms: row.p95_latency_ms,
1286
+ pricing: pricingOf(row.pricing, notices, reason, { note: false, calls: row.calls }),
389
1287
  };
390
1288
  });
391
- const complete = proof.complete && !body.truncated;
1289
+ const coverage = coverageOf(body.coverage, notices, reason);
1290
+ checkCoverage(coverage, {
1291
+ shape: "top_n", totalsScope: "population", limit: request.limit, returned: items.length,
1292
+ truncated: body.truncated, nextCursor: null, days: request.days,
1293
+ }, reason);
1294
+ const population = populationTotalsOf(body.totals, reason);
1295
+ const complete = proof.complete && !body.truncated && (coverage === null || coverage.completeness === "complete");
392
1296
  if (!proof.complete) notices.add("evidence_incomplete");
393
1297
  if (body.truncated) notices.add("rows_truncated");
394
1298
  return {
395
1299
  provenance: origin,
396
- window: span,
1300
+ // The route-by-day read's window is the last days x 24 hours, ending
1301
+ // now: it is not aligned to UTC days, so its first and last dates are
1302
+ // partial. Grouped usage windows are labelled "utc_days" or "explicit".
1303
+ window: { ...span, kind: "rolling", timezone: "UTC", hours: span.days * 24, aligned_to_utc_days: false },
397
1304
  evidence: proof,
398
1305
  warnings: codes,
399
1306
  content_included: false,
1307
+ group_by: null,
1308
+ filters: { metadata: metadataFilters },
400
1309
  truncated: body.truncated,
401
1310
  complete,
402
1311
  empty: items.length === 0,
403
1312
  rows: items.length,
404
1313
  items,
405
1314
  totals: totalsOf(items, complete),
1315
+ coverage,
1316
+ population_totals: population,
1317
+ pricing: pricingOf(body.pricing, notices, reason, { calls: population === null ? null : population.calls }),
1318
+ attribution: attributionOf(body.attribution, reason, "call"),
406
1319
  };
407
1320
  });
408
1321
  }
409
1322
 
1323
+ // Filter values are not printed back, like any other free-text argument;
1324
+ // only the keys and how many were applied.
1325
+ function metadataSupplied(request) {
1326
+ const filters = request.metadata ?? [];
1327
+ return { count: filters.length, keys: filters.map((filter) => filter.key) };
1328
+ }
1329
+
410
1330
  // Sums over the returned rows only. complete says whether those rows are the
411
1331
  // whole window; when it is false these are not workspace totals.
412
1332
  function totalsOf(items, complete) {
@@ -512,6 +1432,7 @@ export function tracesReport(body, ctx, request, notices) {
512
1432
  }
513
1433
  const rawRows = body.traces.map((raw) => rowObject(raw, reason));
514
1434
  checkFilters(body, rawRows, request);
1435
+ checkMetadataEcho(body, request);
515
1436
  const traces = rawRows.map((row) => {
516
1437
  const nullableAmount = (value) => value === null || isAmount(value);
517
1438
  if (
@@ -527,7 +1448,9 @@ export function tracesReport(body, ctx, request, notices) {
527
1448
  !isCount(row.cache_read_tokens) ||
528
1449
  !isCount(row.cache_write_tokens) ||
529
1450
  !nullableAmount(row.cost_usd) ||
530
- (row.status !== "success" && row.status !== "error")
1451
+ (row.status !== "success" && row.status !== "error") ||
1452
+ !(row.mixed_providers === undefined || typeof row.mixed_providers === "boolean") ||
1453
+ !(row.mixed_models === undefined || typeof row.mixed_models === "boolean")
531
1454
  ) {
532
1455
  throw new Invalid("verification_failed", reason);
533
1456
  }
@@ -549,12 +1472,27 @@ export function tracesReport(body, ctx, request, notices) {
549
1472
  routes: nameList(row.routes, notices, reason),
550
1473
  providers: nameList(row.providers, notices, reason),
551
1474
  models: nameList(row.models, notices, reason),
1475
+ // Derived from the row's own provider and model lists as well, so an
1476
+ // older service that does not flag them still shows a mixed trace.
1477
+ mixed_providers: row.mixed_providers === true || (Array.isArray(row.providers) && row.providers.length > 1),
1478
+ mixed_models: row.mixed_models === true || (Array.isArray(row.models) && row.models.length > 1),
1479
+ pricing: pricingOf(row.pricing, notices, reason, { note: false, calls: row.span_count }),
552
1480
  link: link.value,
553
1481
  link_workspace_bound: link.workspaceBound === true,
554
1482
  };
555
1483
  });
556
1484
  if (!proof.complete) notices.add("evidence_incomplete");
557
1485
  if (page.truncated) notices.add("more_pages");
1486
+ const coverage = coverageOf(body.coverage, notices, reason);
1487
+ checkCoverage(coverage, {
1488
+ shape: "page", totalsScope: "page", limit: page.limit, returned: traces.length,
1489
+ truncated: page.truncated, nextCursor: page.next_cursor, days: request.days,
1490
+ }, reason);
1491
+ const pagePricing = pricingOf(body.pricing, notices, reason, {
1492
+ calls: traces.reduce((sum, trace) => sum + trace.span_count, 0),
1493
+ });
1494
+ if (traces.some((trace) => trace.mixed_providers)) notices.add("mixed_provider_traces");
1495
+ if (traces.some((trace) => trace.cost_usd === null)) notices.add("unpriced_traces");
558
1496
  const missingLinks = traces.length === 0 || traces.some((trace) => trace.link === null);
559
1497
  if (missingLinks) notices.add("trace_links_unavailable");
560
1498
  const unboundLinks = traces.some((trace) => trace.link !== null && !trace.link_workspace_bound);
@@ -571,11 +1509,18 @@ export function tracesReport(body, ctx, request, notices) {
571
1509
  route_supplied: request.route !== null,
572
1510
  status: request.status,
573
1511
  cursor_supplied: request.cursor !== null,
1512
+ metadata: metadataSupplied(request),
574
1513
  },
575
1514
  page: { limit: page.limit, rows: traces.length, truncated: page.truncated, next_cursor: page.next_cursor },
576
1515
  truncated: page.truncated,
577
1516
  next_cursor: page.next_cursor,
578
- complete: proof.complete && !page.truncated,
1517
+ complete: proof.complete && !page.truncated && (coverage === null || coverage.completeness === "complete"),
1518
+ coverage,
1519
+ pricing: pagePricing,
1520
+ // null when the service does not report it, like coverage and
1521
+ // pricing. Each trace's mixed_providers flag and cost stay whole-trace
1522
+ // facts either way.
1523
+ attribution: attributionOf(body.attribution, reason, "trace"),
579
1524
  empty: traces.length === 0,
580
1525
  link_status: missingLinks ? "server_link_unavailable" :
581
1526
  unboundLinks ? "workspace_binding_unavailable" : "available",
@@ -607,3 +1552,18 @@ function checkFilters(body, rows, request) {
607
1552
  }
608
1553
  }
609
1554
  }
1555
+
1556
+ // Shared with the experiments validators (experiments-contract.js), which
1557
+ // hold their documents to the same rules.
1558
+ export {
1559
+ Invalid,
1560
+ displayText,
1561
+ isAmount,
1562
+ isCount,
1563
+ isObject,
1564
+ isTimestamp,
1565
+ isToken,
1566
+ provenance,
1567
+ rowObject,
1568
+ run,
1569
+ };