q-runtime-core 0.9.0__py3-none-any.whl
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.
- q_runtime_core-0.9.0.dist-info/METADATA +299 -0
- q_runtime_core-0.9.0.dist-info/RECORD +36 -0
- q_runtime_core-0.9.0.dist-info/WHEEL +5 -0
- q_runtime_core-0.9.0.dist-info/entry_points.txt +2 -0
- q_runtime_core-0.9.0.dist-info/licenses/LICENSE +3 -0
- q_runtime_core-0.9.0.dist-info/top_level.txt +1 -0
- qruntime/__init__.py +76 -0
- qruntime/budget.py +295 -0
- qruntime/cli.py +594 -0
- qruntime/core/__init__.py +0 -0
- qruntime/core/invariants.py +67 -0
- qruntime/core/model.py +140 -0
- qruntime/core/transition.py +92 -0
- qruntime/exceptions.py +132 -0
- qruntime/integrations/__init__.py +0 -0
- qruntime/integrations/official_x402_demo.py +94 -0
- qruntime/loader.py +39 -0
- qruntime/policies/__init__.py +0 -0
- qruntime/policies/authority.py +1 -0
- qruntime/policies/budget.py +1 -0
- qruntime/policies/evidence.py +1 -0
- qruntime/policy.py +248 -0
- qruntime/rails/__init__.py +0 -0
- qruntime/rails/simulated.py +4 -0
- qruntime/rails/x402.py +284 -0
- qruntime/rails/x402_capture.py +194 -0
- qruntime/rails/x402_external.py +575 -0
- qruntime/rails/x402_external_suite.py +65 -0
- qruntime/rails/x402_fullcycle.py +4651 -0
- qruntime/rails/x402_verify.py +202 -0
- qruntime/results.py +112 -0
- qruntime/runtime.py +243 -0
- qruntime/spend.py +584 -0
- qruntime/verify/__init__.py +0 -0
- qruntime/verify/explorer.py +59 -0
- qruntime/verify/report.py +38 -0
qruntime/budget.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from qruntime.spend import (
|
|
6
|
+
SpendSummary,
|
|
7
|
+
_normalize_agent_id,
|
|
8
|
+
_normalize_session_id,
|
|
9
|
+
)
|
|
10
|
+
from qruntime.policy import _normalize_origin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class BudgetLimit:
|
|
15
|
+
network: str
|
|
16
|
+
asset: str
|
|
17
|
+
max_amount_atomic: int
|
|
18
|
+
origin: str | None = None
|
|
19
|
+
agent_id: str | None = None
|
|
20
|
+
session_id: str | None = None
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
if (
|
|
24
|
+
not isinstance(self.network, str)
|
|
25
|
+
or not self.network.strip()
|
|
26
|
+
):
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"budget network must be "
|
|
29
|
+
"a non-empty string"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if (
|
|
33
|
+
not isinstance(self.asset, str)
|
|
34
|
+
or not self.asset.strip()
|
|
35
|
+
):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"budget asset must be "
|
|
38
|
+
"a non-empty string"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if (
|
|
42
|
+
isinstance(
|
|
43
|
+
self.max_amount_atomic,
|
|
44
|
+
bool,
|
|
45
|
+
)
|
|
46
|
+
or not isinstance(
|
|
47
|
+
self.max_amount_atomic,
|
|
48
|
+
int,
|
|
49
|
+
)
|
|
50
|
+
or self.max_amount_atomic <= 0
|
|
51
|
+
):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"budget maximum must be "
|
|
54
|
+
"a positive integer"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if self.origin is not None:
|
|
58
|
+
object.__setattr__(
|
|
59
|
+
self,
|
|
60
|
+
"origin",
|
|
61
|
+
_normalize_origin(
|
|
62
|
+
self.origin
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if self.agent_id is not None:
|
|
67
|
+
object.__setattr__(
|
|
68
|
+
self,
|
|
69
|
+
"agent_id",
|
|
70
|
+
_normalize_agent_id(
|
|
71
|
+
self.agent_id
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if self.session_id is not None:
|
|
76
|
+
object.__setattr__(
|
|
77
|
+
self,
|
|
78
|
+
"session_id",
|
|
79
|
+
_normalize_session_id(
|
|
80
|
+
self.session_id
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class BudgetDecision:
|
|
87
|
+
allowed: bool
|
|
88
|
+
|
|
89
|
+
network: str
|
|
90
|
+
asset: str
|
|
91
|
+
|
|
92
|
+
limit_atomic: int
|
|
93
|
+
|
|
94
|
+
confirmed_atomic: int
|
|
95
|
+
pending_atomic: int
|
|
96
|
+
requested_atomic: int
|
|
97
|
+
|
|
98
|
+
used_atomic: int
|
|
99
|
+
projected_atomic: int
|
|
100
|
+
remaining_before_atomic: int
|
|
101
|
+
remaining_after_atomic: int
|
|
102
|
+
|
|
103
|
+
reason: str | None = None
|
|
104
|
+
reserved_atomic: int = 0
|
|
105
|
+
origin: str | None = None
|
|
106
|
+
agent_id: str | None = None
|
|
107
|
+
session_id: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def evaluate_budget(
|
|
111
|
+
limit: BudgetLimit,
|
|
112
|
+
*,
|
|
113
|
+
summary: SpendSummary,
|
|
114
|
+
requested_amount_atomic: int,
|
|
115
|
+
) -> BudgetDecision:
|
|
116
|
+
if summary.origin != limit.origin:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"spend summary origin scope "
|
|
119
|
+
"does not match budget origin"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if summary.agent_id != limit.agent_id:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"spend summary agent scope "
|
|
125
|
+
"does not match budget agent"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if summary.session_id != limit.session_id:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"spend summary session scope "
|
|
131
|
+
"does not match budget session"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if (
|
|
135
|
+
isinstance(
|
|
136
|
+
requested_amount_atomic,
|
|
137
|
+
bool,
|
|
138
|
+
)
|
|
139
|
+
or not isinstance(
|
|
140
|
+
requested_amount_atomic,
|
|
141
|
+
int,
|
|
142
|
+
)
|
|
143
|
+
or requested_amount_atomic <= 0
|
|
144
|
+
):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
"requested amount must be "
|
|
147
|
+
"a positive integer"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
confirmed = summary.confirmed_amount(
|
|
151
|
+
network=limit.network,
|
|
152
|
+
asset=limit.asset,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
pending = (
|
|
156
|
+
summary.pending_exposure_amount(
|
|
157
|
+
network=limit.network,
|
|
158
|
+
asset=limit.asset,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
reserved = (
|
|
163
|
+
summary.reserved_exposure_amount(
|
|
164
|
+
network=limit.network,
|
|
165
|
+
asset=limit.asset,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
used = (
|
|
170
|
+
confirmed
|
|
171
|
+
+ pending
|
|
172
|
+
+ reserved
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
projected = (
|
|
176
|
+
used
|
|
177
|
+
+ requested_amount_atomic
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
remaining_before = max(
|
|
181
|
+
limit.max_amount_atomic - used,
|
|
182
|
+
0,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
remaining_after = max(
|
|
186
|
+
limit.max_amount_atomic
|
|
187
|
+
- projected,
|
|
188
|
+
0,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
allowed = (
|
|
192
|
+
projected
|
|
193
|
+
<= limit.max_amount_atomic
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
reason = None
|
|
197
|
+
|
|
198
|
+
if not allowed:
|
|
199
|
+
reason = "aggregate_budget_exceeded"
|
|
200
|
+
|
|
201
|
+
return BudgetDecision(
|
|
202
|
+
allowed=allowed,
|
|
203
|
+
network=limit.network,
|
|
204
|
+
asset=limit.asset,
|
|
205
|
+
limit_atomic=(
|
|
206
|
+
limit.max_amount_atomic
|
|
207
|
+
),
|
|
208
|
+
confirmed_atomic=confirmed,
|
|
209
|
+
pending_atomic=pending,
|
|
210
|
+
reserved_atomic=reserved,
|
|
211
|
+
origin=limit.origin,
|
|
212
|
+
agent_id=limit.agent_id,
|
|
213
|
+
session_id=limit.session_id,
|
|
214
|
+
requested_atomic=(
|
|
215
|
+
requested_amount_atomic
|
|
216
|
+
),
|
|
217
|
+
used_atomic=used,
|
|
218
|
+
projected_atomic=projected,
|
|
219
|
+
remaining_before_atomic=(
|
|
220
|
+
remaining_before
|
|
221
|
+
),
|
|
222
|
+
remaining_after_atomic=(
|
|
223
|
+
remaining_after
|
|
224
|
+
),
|
|
225
|
+
reason=reason,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True, slots=True)
|
|
230
|
+
class BudgetSet:
|
|
231
|
+
"""Multiple budget constraints that must all allow."""
|
|
232
|
+
|
|
233
|
+
limits: tuple[BudgetLimit, ...]
|
|
234
|
+
|
|
235
|
+
def __post_init__(self) -> None:
|
|
236
|
+
normalized = tuple(self.limits)
|
|
237
|
+
|
|
238
|
+
if not normalized:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
"budget set must contain "
|
|
241
|
+
"at least one limit"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
for limit in normalized:
|
|
245
|
+
if not isinstance(
|
|
246
|
+
limit,
|
|
247
|
+
BudgetLimit,
|
|
248
|
+
):
|
|
249
|
+
raise TypeError(
|
|
250
|
+
"budget set entries must "
|
|
251
|
+
"be BudgetLimit instances"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
seen = set()
|
|
255
|
+
|
|
256
|
+
for limit in normalized:
|
|
257
|
+
key = (
|
|
258
|
+
limit.network,
|
|
259
|
+
limit.asset,
|
|
260
|
+
limit.origin,
|
|
261
|
+
limit.agent_id,
|
|
262
|
+
limit.session_id,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if key in seen:
|
|
266
|
+
raise ValueError(
|
|
267
|
+
"budget set contains duplicate "
|
|
268
|
+
"budget scope"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
seen.add(key)
|
|
272
|
+
|
|
273
|
+
object.__setattr__(
|
|
274
|
+
self,
|
|
275
|
+
"limits",
|
|
276
|
+
normalized,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True, slots=True)
|
|
281
|
+
class BudgetSetDecision:
|
|
282
|
+
"""Result of evaluating all limits in one budget set."""
|
|
283
|
+
|
|
284
|
+
allowed: bool
|
|
285
|
+
decisions: tuple[BudgetDecision, ...]
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def first_denied(
|
|
289
|
+
self,
|
|
290
|
+
) -> BudgetDecision | None:
|
|
291
|
+
for decision in self.decisions:
|
|
292
|
+
if not decision.allowed:
|
|
293
|
+
return decision
|
|
294
|
+
|
|
295
|
+
return None
|