Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/typescript/src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export type CostSource = "provider" | "caller" | "price_resolver" | "estimate";
export class CostObservation {
constructor(readonly amount: number | string, readonly currency: string, readonly source: CostSource, readonly pricingVersion?: string) {}
asEventValue(): Record<string, unknown> {
if (typeof this.amount !== "number" && (typeof this.amount !== "string" || !this.amount.trim())) {
throw new Error("cost amount must be a finite non-negative number");
}
const amount = Number(this.amount);
if (!Number.isFinite(amount) || amount < 0) throw new Error("cost amount must be a finite non-negative number");
if (!/^[A-Z]{3}$/.test(this.currency)) throw new Error("cost currency must be a three-letter uppercase ASCII code");
Expand Down
15 changes: 15 additions & 0 deletions packages/typescript/test/cost-boundaries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import {CostObservation} from "../src/index.js";

test("cost accepts numeric zero and explicit numeric strings", () => {
for (const amount of [0, "0", 1.25, "1.25"]) {
assert.equal(new CostObservation(amount, "USD", "provider").asEventValue().amount, Number(amount));
}
});

test("cost refuses values that Number would silently coerce", () => {
for (const amount of [true, false, null, [], [1], {}, "", " ", "\t\n"]) {
assert.throws(() => new CostObservation(amount as number, "USD", "provider").asEventValue(), /finite non-negative/);
}
});
29 changes: 23 additions & 6 deletions src/agentrust_telemetry/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"cache_write_tokens",
"reasoning_tokens",
)
_MAX_SAFE_INTEGER = 9007199254740991


@dataclass(frozen=True)
Expand All @@ -30,18 +31,23 @@ class CostObservation:
pricing_version: str | None = None

def as_event_value(self) -> dict[str, Any]:
if isinstance(self.amount, bool):
if isinstance(self.amount, bool) or not isinstance(self.amount, (int, float, Decimal)):
raise TypeError("cost amount must be a finite non-negative number")
amount = float(self.amount)
if not math.isfinite(amount) or amount < 0:
raise ValueError("cost amount must be a finite non-negative number")
if len(self.currency) != 3 or not self.currency.isascii() or not self.currency.isupper():
if (
not isinstance(self.currency, str)
or len(self.currency) != 3
or not self.currency.isascii()
or not self.currency.isupper()
):
raise ValueError("cost currency must be a three-letter uppercase ASCII code")
if self.source not in {"provider", "caller", "price_resolver", "estimate"}:
raise ValueError("cost source is not supported")
value = {"amount": amount, "currency": self.currency, "source": self.source}
if self.pricing_version is not None:
if not self.pricing_version:
if not isinstance(self.pricing_version, str) or not self.pricing_version:
raise ValueError("pricing_version must be non-empty")
value["pricing_version"] = self.pricing_version
return value
Expand Down Expand Up @@ -84,7 +90,11 @@ def usage_record(
}
for field in TOKEN_FIELDS:
value = optional[field]
if value is not None and (isinstance(value, bool) or not isinstance(value, int)):
if value is not None and (
isinstance(value, bool)
or not isinstance(value, int)
or not 0 <= value <= _MAX_SAFE_INTEGER
):
raise TypeError(f"{field} must be a non-negative integer")
payload.update({key: value for key, value in optional.items() if value is not None})
if cost is not None:
Expand Down Expand Up @@ -120,7 +130,11 @@ def add(self, event: dict[str, Any]) -> bool:
raise ValueError("usage event must have a non-empty event_id")
for field in TOKEN_FIELDS:
value = event.get(field)
if value is not None and (isinstance(value, bool) or not isinstance(value, int)):
if value is not None and (
isinstance(value, bool)
or not isinstance(value, int)
or not 0 <= value <= _MAX_SAFE_INTEGER
):
raise TypeError(f"{field} must be a non-negative integer")
if "cost" in event:
amount = event["cost"]["amount"]
Expand Down Expand Up @@ -162,7 +176,10 @@ def rollup(
observed = [event[field] for event in matches if field in event]
coverage[field] = len(observed)
if observed:
payload[field] = sum(observed)
total = sum(observed)
if total > _MAX_SAFE_INTEGER:
raise ValueError(f"{field} rollup exceeds the safe-integer range")
payload[field] = total

costs = [event["cost"] for event in matches if "cost" in event]
currencies = {cost["currency"] for cost in costs}
Expand Down
17 changes: 17 additions & 0 deletions tests/test_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ def test_cost_requires_finite_amount_and_explicit_provenance(self):
def test_bool_is_not_accepted_as_token_integer(self):
with self.assertRaisesRegex(TypeError, "input_tokens"):
self.leaf(input_tokens=True)
with self.assertRaisesRegex(TypeError, "input_tokens"):
self.leaf(input_tokens=2**53)
with self.assertRaisesRegex(TypeError, "input_tokens"):
self.leaf(input_tokens=-1)
acc = UsageAccumulator()
acc.add(self.leaf(input_tokens=2**53 - 1))
acc.add(self.leaf(input_tokens=1))
with self.assertRaisesRegex(ValueError, "safe-integer"):
acc.rollup(
self.factory,
scope="agent_run",
run_id="run-1",
agent_id="agent-1",
workflow_id="workflow-1",
operation="chat",
)


def test_rollup_deduplicates_and_reports_partial_coverage(self):
first = self.leaf(cost=CostObservation(0.10, "USD", "provider"))
Expand Down