metergraph-cli 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -11
- package/assets/skill/SKILL.md +52 -3
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +13 -13
- package/assets/skills/metergraph-analyze/SKILL.md +62 -4
- package/assets/skills/metergraph-investigate/SKILL.md +222 -35
- package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
- package/assets/skills/metergraph-report/SKILL.md +19 -2
- package/bin/metergraph.js +1 -1
- package/package.json +1 -1
- package/src/agent-key.js +160 -0
- package/src/approval-handoff.js +5 -0
- package/src/args.js +572 -17
- package/src/auth-browser.js +5 -19
- package/src/auth-callback.js +24 -15
- package/src/auth-login.js +55 -12
- package/src/auth-oauth.js +77 -7
- package/src/callback-page.js +58 -0
- package/src/cli.js +190 -45
- package/src/constants.js +73 -3
- package/src/environment.js +169 -0
- package/src/experiments-contract.js +462 -0
- package/src/experiments-output.js +189 -0
- package/src/experiments.js +397 -0
- package/src/origin.js +6 -0
- package/src/output.js +437 -131
- package/src/pricing-args.js +163 -0
- package/src/pricing-output.js +206 -0
- package/src/pricing-reference.js +195 -0
- package/src/pricing.js +307 -0
- package/src/read-contract.js +976 -16
- package/src/read-output.js +449 -30
- package/src/read.js +312 -60
- package/src/setup-deployment.js +11 -3
- package/src/setup-interactive.js +341 -0
- package/src/setup-refusal.js +172 -0
- package/src/setup-state.js +4 -2
- package/src/setup.js +227 -53
- package/src/skill-bundle.js +2 -2
- package/src/style.js +101 -0
- package/src/transport.js +13 -7
- package/src/usage-aggregates.js +197 -0
- package/src/verify-output.js +21 -11
package/src/read-contract.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { normalizeUuid } from "./auth-oauth.js";
|
|
2
|
-
import {
|
|
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,
|
|
15
|
-
//
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
typeof value
|
|
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
|
|
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
|
|
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
|
+
};
|