metergraph-cli 0.2.1 → 0.2.2

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