regent-httpsig 0.4.0__py3-none-any.whl → 0.5.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.
regent_httpsig/budget.py CHANGED
@@ -5,11 +5,23 @@ The auth token carries a spending envelope::
5
5
  "budget": { "amount": 2000000, "unit": "USD", "decimals": 6 } # = $2.00
6
6
 
7
7
  and the resource meters every request against it: reserve the request's maximum
8
- cost atomically, serve, commit the actual cost, release the difference. The
9
- draft requires consumption to be aggregated atomically across all live auth
10
- tokens for the key ``(iss, sub, aud)`` (§14.4), so the meter pools the grants
11
- of a principal's live tokens and counts reservations + consumption against
12
- that pool.
8
+ cost atomically, serve, commit the actual cost, release the difference.
9
+
10
+ Two things are counted, against different keys (draft §Aggregation):
11
+
12
+ * the **cap** is per auth token — committed consumption plus outstanding
13
+ reservations against the presented ``jti`` never exceed *its* ``budget``;
14
+ no cross-token arithmetic, one token never draws on a sibling's grant;
15
+ * the **ledger** is per person — consumption is posted to ``(iss, sub, aud)``
16
+ for the consumption record and the usage counters. It is not a second
17
+ ceiling: a request that fits its token's budget is never refused because
18
+ of a per-person total.
19
+
20
+ (0.4 and earlier pooled a principal's live grants into one purse. That let a
21
+ jti spend past its own grant, so the figure recorded against it could exceed
22
+ what its issuer granted and the overflow — spent from a sibling's allocation —
23
+ was never attributed. Dropped in 0.5.0; the ``required`` member makes a
24
+ fragmented agent's re-authorization a calculation instead.)
13
25
 
14
26
  Everything here is framework-free; the FastAPI glue lives in
15
27
  :mod:`regent_httpsig.fastapi` (``BudgetMiddleware``).
@@ -42,7 +54,7 @@ class InvalidBudgetClaim(ValueError):
42
54
 
43
55
 
44
56
  class UnitMismatch(ValueError):
45
- """A grant's unit/decimals differ from the pool's — one envelope, one unit."""
57
+ """A grant's unit/decimals differ from the ledger's — one envelope, one unit."""
46
58
 
47
59
 
48
60
  @dataclass(frozen=True)
@@ -80,8 +92,8 @@ class BudgetClaim:
80
92
 
81
93
  @dataclass(frozen=True)
82
94
  class Reservation:
83
- """An atomic hold on the pool for one in-flight request. Never revised —
84
- committed (with the actual cost) or released, exactly once."""
95
+ """An atomic hold on one token's budget for one in-flight request. Never
96
+ revised — committed (with the actual cost) or released, exactly once."""
85
97
 
86
98
  rid: int
87
99
  key: MeterKey
@@ -91,7 +103,8 @@ class Reservation:
91
103
 
92
104
  @dataclass(frozen=True)
93
105
  class InsufficientBudget:
94
- """Refusal: the request's maximum cost exceeds the pool's remaining balance.
106
+ """Refusal: the request's maximum cost exceeds the presented token's
107
+ remaining balance.
95
108
  ``exhausted`` distinguishes the draft's two reason tokens: an empty envelope
96
109
  (``budget-exhausted``) vs a too-expensive request (``insufficient-budget``)."""
97
110
 
@@ -204,21 +217,25 @@ class InMemoryMeter:
204
217
  return pool
205
218
 
206
219
  @staticmethod
207
- def _remaining(pool: _Pool) -> int:
208
- live = sum(a for a, _ in pool.grants.values())
209
- spent = sum(pool.consumed.get(jti, 0) for jti in pool.grants)
210
- held = sum(a for _, a, _ in pool.reservations.values())
211
- return max(0, live - spent - held)
220
+ def _remaining(pool: _Pool, jti: str) -> int:
221
+ """The presented token's balance: its grant minus what was committed
222
+ against it minus what is held for it. Sibling tokens of the same
223
+ person do not enter — the cap is per token (§Aggregation)."""
224
+ grant = pool.grants.get(jti)
225
+ if grant is None:
226
+ return 0
227
+ held = sum(a for j, a, _ in pool.reservations.values() if j == jti)
228
+ return max(0, grant[0] - pool.consumed.get(jti, 0) - held)
212
229
 
213
230
  # ── public interface (the BudgetMeter contract) ──────────────────────────
214
231
 
215
232
  async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
216
233
  exp: float, jkt: str = "") -> None:
217
- """Register a token's envelope in the principal's pool (idempotent per
218
- ``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
234
+ """Register a token's envelope under the person's ledger key (idempotent
235
+ per ``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
219
236
  recorded so consumption records can be scoped to the presenting agent
220
237
  (one agent must not learn about its siblings). Raises
221
- :class:`UnitMismatch` if the pool already runs in a different unit —
238
+ :class:`UnitMismatch` if the ledger already runs in a different unit —
222
239
  one envelope, one unit, no FX at the meter."""
223
240
  async with self._lock:
224
241
  now = time.monotonic()
@@ -229,7 +246,7 @@ class InMemoryMeter:
229
246
  key, _Pool(unit=claim.unit, decimals=claim.decimals))
230
247
  if (pool.unit, pool.decimals) != (claim.unit, claim.decimals):
231
248
  raise UnitMismatch(
232
- f"pool runs in {pool.unit}/{pool.decimals}, "
249
+ f"ledger runs in {pool.unit}/{pool.decimals}, "
233
250
  f"grant is {claim.unit}/{claim.decimals}")
234
251
  pool.last_activity = now
235
252
  if jkt:
@@ -244,7 +261,7 @@ class InMemoryMeter:
244
261
  pool = self._purge(key, now)
245
262
  if pool is None or jti not in pool.grants:
246
263
  return InsufficientBudget(remaining=0, exhausted=True)
247
- remaining = self._remaining(pool)
264
+ remaining = self._remaining(pool, jti)
248
265
  if max_cost > remaining:
249
266
  return InsufficientBudget(remaining=remaining,
250
267
  exhausted=remaining == 0)
@@ -255,7 +272,7 @@ class InMemoryMeter:
255
272
 
256
273
  async def commit(self, res: Reservation, actual: int) -> int:
257
274
  """Commit the actual cost (clamped to the reserved amount — reservations
258
- are never revised upward) and return the pool's remaining balance."""
275
+ are never revised upward) and return the token's remaining balance."""
259
276
  async with self._lock:
260
277
  now = time.monotonic()
261
278
  pool = self._purge(res.key, now)
@@ -267,7 +284,7 @@ class InMemoryMeter:
267
284
  pool.last_activity = now
268
285
  if cost > 0:
269
286
  self._record_usage(res.key, pool, res.jti, cost)
270
- return self._remaining(pool)
287
+ return self._remaining(pool, res.jti)
271
288
 
272
289
  async def release(self, res: Reservation) -> int:
273
290
  async with self._lock:
@@ -275,12 +292,14 @@ class InMemoryMeter:
275
292
  if pool is None:
276
293
  return 0
277
294
  pool.reservations.pop(res.rid, None)
278
- return self._remaining(pool)
295
+ return self._remaining(pool, res.jti)
279
296
 
280
- async def remaining(self, key: MeterKey) -> int:
297
+ async def remaining(self, key: MeterKey, jti: str) -> int:
298
+ """The presented token's remaining balance (0 for an unknown or
299
+ expired ``jti``)."""
281
300
  async with self._lock:
282
301
  pool = self._purge(key, time.monotonic())
283
- return 0 if pool is None else self._remaining(pool)
302
+ return 0 if pool is None else self._remaining(pool, jti)
284
303
 
285
304
  def _record_usage(self, key: MeterKey, pool: _Pool, jti: str,
286
305
  amount: int) -> None:
@@ -322,11 +341,25 @@ class InMemoryMeter:
322
341
  or ``None`` before the first commit."""
323
342
  return self._metering_unit
324
343
 
344
+ async def consumed_record(self, key: MeterKey, jti: str) -> dict[str, Any] | None:
345
+ """The consumption record for the resource token's ``budget_consumed``
346
+ claim (draft §The Consumption Record): ``{"jti", "consumed"}`` for the
347
+ PRESENTED token — its total metered so far — or ``None`` when nothing
348
+ was metered against it. One record, the presented token's; the spend
349
+ under a person's other tokens is the usage endpoint's to report."""
350
+ async with self._lock:
351
+ pool = self._purge(key, time.monotonic())
352
+ if pool is None:
353
+ return None
354
+ total = pool.consumed.get(jti, 0)
355
+ return {"jti": jti, "consumed": total} if total > 0 else None
356
+
325
357
  async def consumed_records(self, key: MeterKey,
326
358
  jkt: str | None = None) -> list[dict[str, Any]]:
327
- """Per-token consumption for the resource token's ``budget_consumed``
328
- claim: ``[{"jti": ..., "consumed": ...}, ...]``. Non-destructive — the
329
- PS deduplicates by ``jti``, so reporting the same record twice is safe.
359
+ """Audit view: per-token consumption under this ledger key,
360
+ ``[{"jti": ..., "consumed": ...}, ...]``. Not what goes on the wire —
361
+ the resource token carries :meth:`consumed_record` — but the figures a
362
+ PS-side reconciliation or an operator wants to see.
330
363
 
331
364
  When ``jkt`` is given, records are scoped to tokens bound to that key:
332
365
  the agent carrying the resource token sees only its OWN spending, never
regent_httpsig/fastapi.py CHANGED
@@ -122,7 +122,7 @@ RequiredSignatureDep = Depends(require_signature)
122
122
 
123
123
  PriceFn = Callable[[Request], "int | None | Awaitable[int | None]"]
124
124
  ResourceTokenProvider = Callable[
125
- [MeterKey, "list[dict[str, Any]]"], "str | None | Awaitable[str | None]"
125
+ [MeterKey, "dict[str, Any] | None"], "str | None | Awaitable[str | None]"
126
126
  ]
127
127
 
128
128
 
@@ -158,9 +158,12 @@ class BudgetMiddleware(BaseHTTPMiddleware):
158
158
  ``require=True`` refuses them with 401 + ``AAuth-Requirement``.
159
159
  - Error responses (4xx/5xx) release the reservation — nothing was served,
160
160
  the envelope is not charged.
161
- - ``resource_token_provider(key, consumed_records)`` (optional) mints the
162
- resource token embedded in budget-refusal responses so the agent can
163
- carry ``budget_consumed`` back to its PS for re-authorization.
161
+ - ``resource_token_provider(key, record)`` (optional) mints the resource
162
+ token embedded in budget-refusal responses so the agent can carry
163
+ ``budget_consumed`` back to its PS for re-authorization. ``record`` is
164
+ the PRESENTED token's ``{"jti", "consumed"}`` (draft §The Consumption
165
+ Record — one record, two members) or ``None`` when nothing was metered
166
+ against it yet.
164
167
  """
165
168
 
166
169
  def __init__(
@@ -223,7 +226,7 @@ class BudgetMiddleware(BaseHTTPMiddleware):
223
226
  reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
224
227
  return await self._refusal_with_token(
225
228
  reason=reason, envelope=envelope, remaining=outcome.remaining,
226
- key=key, jkt=sig.keyid,
229
+ key=key, jti=jti,
227
230
  # `required` rides only on insufficient-budget: what THIS
228
231
  # request needed, so the agent can lower its bound and retry.
229
232
  required=int(max_cost) if reason == "insufficient-budget" else None,
@@ -247,7 +250,7 @@ class BudgetMiddleware(BaseHTTPMiddleware):
247
250
  # when `cost` is omitted — with `remaining` already net of the
248
251
  # hold, and commit when the stream completes. The agent recovers
249
252
  # the exact figure from the next response's `remaining`.
250
- remaining = await self._meter.remaining(key)
253
+ remaining = await self._meter.remaining(key, jti)
251
254
  response.headers["AAuth-Budget"] = build_aauth_budget_header(
252
255
  remaining=remaining, reserved=int(max_cost),
253
256
  unit=envelope.unit, decimals=envelope.decimals,
@@ -311,14 +314,17 @@ class BudgetMiddleware(BaseHTTPMiddleware):
311
314
 
312
315
  async def _refusal_with_token(
313
316
  self, *, reason: str, envelope: BudgetClaim,
314
- remaining: int, key: MeterKey, jkt: str | None = None,
317
+ remaining: int, key: MeterKey, jti: str,
315
318
  required: int | None = None,
316
319
  ) -> Response:
317
320
  token: str | None = None
318
321
  if self._resource_token is not None:
319
322
  try:
320
- records = await self._meter.consumed_records(key, jkt=jkt)
321
- token = await _maybe_await(self._resource_token(key, records))
323
+ # One record, the presented token's (§The Consumption Record):
324
+ # what THIS jti has cost so far. Siblings' spend never rides
325
+ # home with this agent — the usage endpoint reports that.
326
+ record = await self._meter.consumed_record(key, jti)
327
+ token = await _maybe_await(self._resource_token(key, record))
322
328
  except Exception: # noqa: BLE001 — refusal must not fail on the extras
323
329
  logger.warning("resource_token_provider failed", exc_info=True)
324
330
  return self._refusal(reason=reason, envelope=envelope,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: regent-httpsig
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth.
5
5
  Project-URL: Homepage, https://github.com/regent-protocol/regent-httpsig
6
6
  Project-URL: Repository, https://github.com/regent-protocol/regent-httpsig
@@ -146,8 +146,10 @@ to the control plane. The middleware does the whole resource-side checklist:
146
146
  verify the token against your pinned PS, atomically reserve → commit →
147
147
  release per request, answer with `AAuth-Budget`, and refuse exhausted
148
148
  envelopes with a `401` + `AAuth-Requirement` (optionally carrying your signed
149
- resource token with the agent's own consumption records — scoped to its key,
150
- so one agent never learns about a sibling's spending):
149
+ resource token with **one** consumption record — the presented token's
150
+ `{jti, consumed}` — so one agent never learns about a sibling's spending).
151
+ The cap is **per token**: a jti never draws on a sibling's grant; the
152
+ `(iss, sub, aud)` key is only the per-person ledger behind records and usage:
151
153
 
152
154
  ```python
153
155
  from regent_httpsig import HttpsigConfig, HttpsigVerifier, InMemoryMeter
@@ -1,16 +1,16 @@
1
1
  regent_httpsig/__init__.py,sha256=Ch-PdsMDo_uKG4U5A4xklU3jbCWdJTWXHG86n5VA7dc,1709
2
- regent_httpsig/budget.py,sha256=RfasLL3HAdLt5kJ2L8DeGoA3FaVXe94LIn1nzboWYKA,15095
2
+ regent_httpsig/budget.py,sha256=sjNJiFyfqLHEdnbh8e4bmqZr2gPveYynZ5eXUYpES_k,16967
3
3
  regent_httpsig/cli.py,sha256=WUSLQ2WuEX4E85L2C2EgoUfu5cWpj6MRSG1UVNHpzcw,2005
4
4
  regent_httpsig/config.py,sha256=_xAIFYAyNbXzKg-famXLAs7eNdYwuvNA7NHQxcno0G4,2439
5
- regent_httpsig/fastapi.py,sha256=a2m_azGEoVx9gghJTkKaHFUpUYGrxTmFK8IKW7-9AUQ,15266
5
+ regent_httpsig/fastapi.py,sha256=IijwDjnDKntRzavFzj2o6HM3XqvCHi2VZOv_xL_MLTA,15660
6
6
  regent_httpsig/jwk.py,sha256=h4vgnIgpnyKeWdWpm7g_hEyXZOPVREWn-bD4JN07tT0,1637
7
7
  regent_httpsig/netguard.py,sha256=Sqg08mdC94RWvnQLJaH4weZW0nN2VlTJF5wVF8VYGHY,2121
8
8
  regent_httpsig/sfv.py,sha256=O8eK3Xyw-lewSepLJQcBKfPZAIBZCYatw4zDNDsxxYo,7407
9
9
  regent_httpsig/sign.py,sha256=B5ChxFxuuKL0i10bGrgFImzyh8GMjWIqNDECXehBWTA,4397
10
10
  regent_httpsig/usage.py,sha256=PSIPXCtUZyh5cjn4SzwOgrXuJrwDIWu-kffJ-4NFeS0,9453
11
11
  regent_httpsig/verify.py,sha256=8z-x3trF5B8P8D1aDYoqzF4R5h8BVv91CKiubFKFIiI,19915
12
- regent_httpsig-0.4.0.dist-info/METADATA,sha256=FcXizrgtqWf9JnuL3ui_YtXDQFNgh-b5DG2zHzYJg-4,12023
13
- regent_httpsig-0.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
- regent_httpsig-0.4.0.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
15
- regent_httpsig-0.4.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
16
- regent_httpsig-0.4.0.dist-info/RECORD,,
12
+ regent_httpsig-0.5.0.dist-info/METADATA,sha256=9_2zaZrS2LBdENWDPnYVWrJhtIh9vgbG_7e7J5f3X_s,12187
13
+ regent_httpsig-0.5.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
+ regent_httpsig-0.5.0.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
15
+ regent_httpsig-0.5.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
16
+ regent_httpsig-0.5.0.dist-info/RECORD,,