aaa-server 0.1.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.
aaa_server/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """HTTP/SSE server for AAA agents: authentication, tenant isolation, rate limits, quotas and approvals."""
2
+
3
+ from .a2a import A2AConfig
4
+ from .admission import (
5
+ ConcurrencyLimiter,
6
+ Denied,
7
+ InMemoryRateLimiter,
8
+ Quota,
9
+ QuotaStore,
10
+ RateDecision,
11
+ RateLimiter,
12
+ RedisRateLimiter,
13
+ )
14
+ from .app import ServerConfig, clamp_budget, create_app, default_event_view
15
+ from .auth import (
16
+ ApiKey,
17
+ ApiKeyAuthenticator,
18
+ Authenticator,
19
+ AuthError,
20
+ JWKSKeys,
21
+ JWTAuthenticator,
22
+ Principal,
23
+ hash_api_key,
24
+ )
25
+
26
+ __all__ = [
27
+ "A2AConfig",
28
+ "ApiKey",
29
+ "ApiKeyAuthenticator",
30
+ "AuthError",
31
+ "Authenticator",
32
+ "ConcurrencyLimiter",
33
+ "Denied",
34
+ "InMemoryRateLimiter",
35
+ "JWKSKeys",
36
+ "JWTAuthenticator",
37
+ "Principal",
38
+ "Quota",
39
+ "QuotaStore",
40
+ "RateDecision",
41
+ "RateLimiter",
42
+ "RedisRateLimiter",
43
+ "ServerConfig",
44
+ "clamp_budget",
45
+ "create_app",
46
+ "default_event_view",
47
+ "hash_api_key",
48
+ ]
aaa_server/a2a.py ADDED
@@ -0,0 +1,351 @@
1
+ """Agent2Agent (A2A) protocol, EXPERIMENTAL: serve one algorithm as an A2A agent.
2
+
3
+ Targets **A2A 1.0**, the JSON-RPC binding (`SendMessage`, `SendStreamingMessage`, `GetTask`, `ListTasks`, `CancelTask`,
4
+ `SubscribeToTask`) and an agent card at `/.well-known/agent-card.json`. The wire shapes were taken from the protocol's own
5
+ generated types and checked against the official `a2a-sdk` client in the tests. Out of scope: push notifications, the extended
6
+ card, multi-turn tasks (a message naming an existing task is refused), non-text parts, gRPC and REST bindings.
7
+
8
+ A2A tasks ARE runs: they go through the same authentication, tenant scope, admission and budget as `POST /v1/runs`, and the
9
+ tenant comes only from the credential. A task that parks on a human approval is reported as `TASK_STATE_INPUT_REQUIRED`; the
10
+ approval itself is given through the REST API by a principal with the `approve` scope."""
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ from collections.abc import AsyncIterator, Callable
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, datetime
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from starlette.requests import Request
22
+ from starlette.responses import JSONResponse, Response
23
+
24
+ from aaa.core.ids import new_id
25
+ from aaa.core.outcome import OutcomeKind
26
+ from aaa.runtime.engine import RUN_COMPLETED, RUNMETA
27
+ from aaa.tools.handler import (
28
+ APPROVAL_DENIED,
29
+ APPROVAL_GRANTED,
30
+ APPROVAL_REQUESTED,
31
+ COMPLETED,
32
+ FAILED,
33
+ )
34
+
35
+ from .auth import Principal
36
+
37
+ if TYPE_CHECKING:
38
+ from .app import ServerApp
39
+
40
+ VERSION = "1.0"
41
+ TASK_NOT_FOUND, NOT_CANCELABLE, UNSUPPORTED_OP = -32001, -32002, -32004
42
+ CONTENT_TYPE, VERSION_UNSUPPORTED = -32005, -32009
43
+ INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL, PARSE_ERROR = (
44
+ -32600,
45
+ -32601,
46
+ -32602,
47
+ -32603,
48
+ -32700,
49
+ )
50
+ MAX_TEXT = 100_000
51
+
52
+
53
+ def _default_text(value: Any) -> str:
54
+ if isinstance(value, dict) and isinstance(value.get("text"), str):
55
+ return value["text"]
56
+ return value if isinstance(value, str) else json.dumps(value, default=str)
57
+
58
+
59
+ @dataclass
60
+ class A2AConfig:
61
+ """`url` is this server's public address (the card must carry an absolute URL)."""
62
+
63
+ algorithm: str # a key of ServerConfig.algorithms
64
+ url: str
65
+ name: str = "AAA agent"
66
+ description: str = "An agent served by aaa-server."
67
+ version: str = "0.1"
68
+ skills: tuple[dict[str, Any], ...] = ()
69
+ to_input: Callable[[str], dict[str, Any]] = lambda text: {"task": text}
70
+ result_text: Callable[[Any], str] = _default_text
71
+ wait_s: float = (
72
+ 300.0 # how long a blocking SendMessage waits before answering with the working task
73
+ )
74
+ extra: dict[str, Any] = field(default_factory=dict)
75
+
76
+ def card(self) -> dict[str, Any]:
77
+ skills = list(self.skills) or [
78
+ {"id": self.algorithm, "name": self.name, "description": self.description, "tags": []}
79
+ ]
80
+ return {
81
+ "name": self.name,
82
+ "description": self.description,
83
+ "version": self.version,
84
+ "supportedInterfaces": [
85
+ {
86
+ "url": self.url.rstrip("/") + "/a2a",
87
+ "protocolBinding": "JSONRPC",
88
+ "protocolVersion": VERSION,
89
+ }
90
+ ],
91
+ "capabilities": {"streaming": True, "pushNotifications": False},
92
+ "securitySchemes": {"bearer": {"httpAuthSecurityScheme": {"scheme": "Bearer"}}},
93
+ "securityRequirements": [{"schemes": {"bearer": {}}}],
94
+ "defaultInputModes": ["text/plain"],
95
+ "defaultOutputModes": ["text/plain"],
96
+ "skills": skills,
97
+ **self.extra,
98
+ }
99
+
100
+
101
+ class RpcError(Exception):
102
+ def __init__(self, code: int, message: str) -> None:
103
+ super().__init__(message)
104
+ self.code, self.message = code, message
105
+
106
+
107
+ def _now() -> str:
108
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
109
+
110
+
111
+ def _agent_message(task_id: str, text: str) -> dict[str, Any]:
112
+ return {
113
+ "messageId": new_id(),
114
+ "taskId": task_id,
115
+ "contextId": task_id,
116
+ "role": "ROLE_AGENT",
117
+ "parts": [{"text": text}],
118
+ }
119
+
120
+
121
+ class A2AEndpoint:
122
+ def __init__(self, app: ServerApp, cfg: A2AConfig) -> None:
123
+ self.app, self.cfg = app, cfg
124
+
125
+ @property
126
+ def algo(self) -> Any:
127
+ return self.app.cfg.algorithms[self.cfg.algorithm]
128
+
129
+ # -- task views -------------------------------------------------------------------------
130
+ async def _pending_approval(self, rid: str) -> bool:
131
+ asked, settled = set(), set()
132
+ for e in await self.app.engine.store.read(rid):
133
+ cid = e.data.get("call_id")
134
+ if e.type == APPROVAL_REQUESTED:
135
+ asked.add(cid)
136
+ elif e.type in (APPROVAL_GRANTED, APPROVAL_DENIED, COMPLETED, FAILED):
137
+ settled.add(cid)
138
+ return bool(asked - settled)
139
+
140
+ async def task(self, p: Principal, rid: str, *, artifacts: bool = True) -> dict[str, Any]:
141
+ tid = self.app._public(p, rid)
142
+ meta = await self.app.engine.project(RUNMETA, rid)
143
+ done, message = meta.completed, None
144
+ if done is None:
145
+ if await self._pending_approval(rid):
146
+ state, message = "TASK_STATE_INPUT_REQUIRED", "waiting for a human approval"
147
+ else:
148
+ state = "TASK_STATE_WORKING"
149
+ else:
150
+ kind = str(done.get("kind"))
151
+ state = {
152
+ OutcomeKind.SUCCESS.value: "TASK_STATE_COMPLETED",
153
+ OutcomeKind.CANCELLED.value: "TASK_STATE_CANCELED",
154
+ OutcomeKind.BLOCKED.value: "TASK_STATE_REJECTED",
155
+ }.get(kind, "TASK_STATE_FAILED")
156
+ if state != "TASK_STATE_COMPLETED":
157
+ message = done.get("reason") or str(kind)
158
+ out: dict[str, Any] = {
159
+ "id": tid,
160
+ "contextId": tid,
161
+ "status": {
162
+ "state": state,
163
+ "timestamp": _now(),
164
+ **({"message": _agent_message(tid, message)} if message else {}),
165
+ },
166
+ }
167
+ if artifacts and state == "TASK_STATE_COMPLETED":
168
+ out["artifacts"] = [
169
+ {
170
+ "artifactId": "result",
171
+ "name": "result",
172
+ "parts": [{"text": self.cfg.result_text(done.get("value") if done else None)}],
173
+ }
174
+ ]
175
+ return out
176
+
177
+ async def _owned(self, p: Principal, params: dict[str, Any]) -> Any:
178
+ tid = params.get("id")
179
+ if not isinstance(tid, str) or not tid:
180
+ raise RpcError(INVALID_PARAMS, "id is required")
181
+ try:
182
+ h = await self.app.engine.tenant(p.tenant).handle(tid)
183
+ started = await self.app.engine._started_of(h.run_id)
184
+ except KeyError:
185
+ raise RpcError(TASK_NOT_FOUND, "task not found") from None
186
+ if started.data.get("algo") != self.algo.name:
187
+ raise RpcError(TASK_NOT_FOUND, "task not found")
188
+ return h
189
+
190
+ # -- methods ----------------------------------------------------------------------------
191
+ def _text_of(self, params: dict[str, Any]) -> str:
192
+ msg = params.get("message")
193
+ if not isinstance(msg, dict) or msg.get("role") not in (None, "ROLE_USER"):
194
+ raise RpcError(INVALID_PARAMS, "message with role ROLE_USER is required")
195
+ if msg.get("taskId"):
196
+ raise RpcError(UNSUPPORTED_OP, "continuing an existing task is not supported")
197
+ parts = msg.get("parts")
198
+ if not isinstance(parts, list) or not parts:
199
+ raise RpcError(INVALID_PARAMS, "message.parts is required")
200
+ texts = []
201
+ for part in parts:
202
+ if not isinstance(part, dict) or not isinstance(part.get("text"), str):
203
+ raise RpcError(CONTENT_TYPE, "only text parts are supported")
204
+ texts.append(part["text"])
205
+ text = "\n".join(texts)
206
+ if len(text) > MAX_TEXT:
207
+ raise RpcError(INVALID_PARAMS, "message is too long")
208
+ return text
209
+
210
+ async def send(self, p: Principal, params: dict[str, Any]) -> dict[str, Any]:
211
+ text = self._text_of(params)
212
+ rid, _ = await self.app.launch(p, self.algo, self.cfg.to_input(text))
213
+ cfgd = params.get("configuration") or {}
214
+ if not (isinstance(cfgd, dict) and cfgd.get("returnImmediately")):
215
+ try:
216
+ await asyncio.wait_for(self.app.engine.handle(rid).result(), self.cfg.wait_s)
217
+ except TimeoutError:
218
+ pass
219
+ return {"task": await self.task(p, rid)}
220
+
221
+ async def get(self, p: Principal, params: dict[str, Any]) -> dict[str, Any]:
222
+ h = await self._owned(p, params)
223
+ return await self.task(p, h.run_id)
224
+
225
+ async def cancel(self, p: Principal, params: dict[str, Any]) -> dict[str, Any]:
226
+ h = await self._owned(p, params)
227
+ if (await self.app.engine.project(RUNMETA, h.run_id)).completed is not None:
228
+ raise RpcError(NOT_CANCELABLE, "task is already finished")
229
+ await h.cancel(f"cancelled by {p.subject}")
230
+ try:
231
+ await asyncio.wait_for(h.result(), 5)
232
+ except TimeoutError:
233
+ pass
234
+ return await self.task(p, h.run_id)
235
+
236
+ async def list(self, p: Principal, params: dict[str, Any]) -> dict[str, Any]:
237
+ infos = [
238
+ r
239
+ for r in await self.app.engine.tenant(p.tenant).runs()
240
+ if r.started.data.get("algo") == self.algo.name
241
+ ][-50:]
242
+ tasks = [await self.task(p, r.stream, artifacts=False) for r in infos]
243
+ return {
244
+ "tasks": tasks,
245
+ "nextPageToken": "",
246
+ "pageSize": len(tasks),
247
+ "totalSize": len(tasks),
248
+ }
249
+
250
+ async def frames(
251
+ self, p: Principal, rid: str, *, snapshot: dict[str, Any]
252
+ ) -> AsyncIterator[dict[str, Any]]:
253
+ """StreamResponse objects: the task, then text as it is written, then the terminal status."""
254
+ tid = snapshot["id"]
255
+ yield {"task": snapshot}
256
+ streamed = False
257
+ start = (
258
+ (await self.app.engine.store.last_seq(rid)) + 1
259
+ if snapshot["status"]["state"] != "TASK_STATE_WORKING"
260
+ else 0
261
+ )
262
+ async for e in self.app.engine.store.subscribe(rid, start):
263
+ if e.type == "aaa.llm.delta" and e.data.get("tag") in (None, "main"):
264
+ yield {
265
+ "artifactUpdate": {
266
+ "taskId": tid,
267
+ "contextId": tid,
268
+ "artifact": {
269
+ "artifactId": "stream",
270
+ "parts": [{"text": e.data.get("text", "")}],
271
+ },
272
+ "append": streamed,
273
+ }
274
+ }
275
+ streamed = True
276
+ elif e.type == RUN_COMPLETED:
277
+ final = await self.task(p, rid)
278
+ if final.get("artifacts"):
279
+ yield {
280
+ "artifactUpdate": {
281
+ "taskId": tid,
282
+ "contextId": tid,
283
+ "artifact": final["artifacts"][0],
284
+ "lastChunk": True,
285
+ }
286
+ }
287
+ yield {"statusUpdate": {"taskId": tid, "contextId": tid, "status": final["status"]}}
288
+ return
289
+
290
+ # -- HTTP entry --------------------------------------------------------------------------
291
+ async def rpc(self, request: Request, p: Principal) -> Response:
292
+ ver = request.headers.get("a2a-version")
293
+ raw = await self.app._raw(request)
294
+ try:
295
+ msg = json.loads(raw)
296
+ except ValueError:
297
+ return JSONResponse(self._err(None, PARSE_ERROR, "parse error"), 400)
298
+ if (
299
+ not isinstance(msg, dict)
300
+ or msg.get("jsonrpc") != "2.0"
301
+ or not isinstance(msg.get("method"), str)
302
+ ):
303
+ return JSONResponse(self._err(None, INVALID_REQUEST, "invalid JSON-RPC request"), 400)
304
+ id_, method, params = msg.get("id"), msg["method"], msg.get("params") or {}
305
+ if ver and ver.split(".")[0] != "1":
306
+ return JSONResponse(self._err(id_, VERSION_UNSUPPORTED, "only A2A 1.x is supported"))
307
+ if not isinstance(params, dict):
308
+ return JSONResponse(self._err(id_, INVALID_PARAMS, "params must be an object"))
309
+ try:
310
+ if method in ("SendStreamingMessage", "SubscribeToTask"):
311
+ return await self._stream(p, id_, method, params)
312
+ fn = {
313
+ "SendMessage": self.send,
314
+ "GetTask": self.get,
315
+ "CancelTask": self.cancel,
316
+ "ListTasks": self.list,
317
+ }.get(method)
318
+ if fn is None:
319
+ return JSONResponse(
320
+ self._err(id_, METHOD_NOT_FOUND, f"method {method!r} is not supported")
321
+ )
322
+ return JSONResponse({"jsonrpc": "2.0", "id": id_, "result": await fn(p, params)})
323
+ except RpcError as e:
324
+ return JSONResponse(self._err(id_, e.code, e.message))
325
+
326
+ async def _stream(
327
+ self, p: Principal, id_: Any, method: str, params: dict[str, Any]
328
+ ) -> Response:
329
+ from contextlib import AsyncExitStack
330
+
331
+ if method == "SendStreamingMessage":
332
+ text = self._text_of(params)
333
+ rid, _ = await self.app.launch(p, self.algo, self.cfg.to_input(text))
334
+ else:
335
+ h = await self._owned(p, params)
336
+ rid = h.run_id
337
+ if (await self.app.engine.project(RUNMETA, rid)).completed is not None:
338
+ raise RpcError(UNSUPPORTED_OP, "task is already finished")
339
+ snapshot = await self.task(p, rid)
340
+ stack = AsyncExitStack()
341
+ await stack.enter_async_context(self.app.streams.hold(p.tenant))
342
+
343
+ async def body() -> AsyncIterator[bytes]:
344
+ async for frame in self.frames(p, rid, snapshot=snapshot):
345
+ yield f"data: {json.dumps({'jsonrpc': '2.0', 'id': id_, 'result': frame})}\n\n".encode()
346
+
347
+ return self.app._stream_response(p, body, stack, trailer=False)
348
+
349
+ @staticmethod
350
+ def _err(id_: Any, code: int, message: str) -> dict[str, Any]:
351
+ return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}}
@@ -0,0 +1,247 @@
1
+ """Admission control: rate limits, concurrency caps and event-sourced usage quotas, all keyed by tenant.
2
+
3
+ Decisions are made BEFORE any work is started. Quota is a projection over usage events in the same event store
4
+ that holds the runs, so it survives restarts and is the same on every server process; the check-and-reserve is an
5
+ optimistic-concurrency append, so two requests racing for the last unit of quota cannot both win."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from collections import defaultdict
11
+ from collections.abc import Callable
12
+ from contextlib import asynccontextmanager
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime
15
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
16
+
17
+ from pydantic import BaseModel
18
+
19
+ from aaa.core.errors import ConcurrencyError
20
+ from aaa.events import Event, Projection
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import AsyncIterator
24
+
25
+ from aaa.events import EventStore
26
+
27
+
28
+ class Denied(Exception):
29
+ """A request was refused before any work started. `status` is the HTTP status to answer with."""
30
+
31
+ def __init__(self, status: int, code: str, message: str, retry_after: float | None = None):
32
+ super().__init__(message)
33
+ self.status, self.code, self.message, self.retry_after = status, code, message, retry_after
34
+
35
+
36
+ # -- rate limiting -------------------------------------------------------------------------------
37
+ @dataclass(frozen=True)
38
+ class RateDecision:
39
+ allowed: bool
40
+ retry_after: float = 0.0 # seconds until `cost` tokens will be available
41
+
42
+
43
+ @runtime_checkable
44
+ class RateLimiter(Protocol):
45
+ async def take(self, key: str, cost: float = 1.0) -> RateDecision: ...
46
+
47
+
48
+ class InMemoryRateLimiter:
49
+ """Token bucket per key: `burst` tokens, refilled at `per_second`. One process only; use the Redis limiter to
50
+ share a limit between server processes."""
51
+
52
+ def __init__(
53
+ self, per_second: float, burst: float, *, clock: Callable[[], float] | None = None
54
+ ) -> None:
55
+ if per_second <= 0 or burst < 1:
56
+ raise ValueError("per_second must be > 0 and burst >= 1")
57
+ self.rate, self.burst = per_second, burst
58
+ self._clock = clock or time.monotonic
59
+ self._state: dict[str, tuple[float, float]] = {} # key -> (tokens, last)
60
+
61
+ async def take(self, key: str, cost: float = 1.0) -> RateDecision:
62
+ now = self._clock()
63
+ tokens, last = self._state.get(key, (self.burst, now))
64
+ tokens = min(self.burst, tokens + (now - last) * self.rate)
65
+ if tokens >= cost:
66
+ self._state[key] = (tokens - cost, now)
67
+ return RateDecision(True)
68
+ self._state[key] = (tokens, now)
69
+ return RateDecision(False, (cost - tokens) / self.rate)
70
+
71
+
72
+ _LUA_BUCKET = """
73
+ local t = redis.call('TIME')
74
+ local now = tonumber(t[1]) + tonumber(t[2]) / 1000000
75
+ local rate, burst, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
76
+ local s = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
77
+ local tokens, ts = tonumber(s[1]), tonumber(s[2])
78
+ if tokens == nil then tokens, ts = burst, now end
79
+ tokens = math.min(burst, tokens + math.max(0, now - ts) * rate)
80
+ local ok, wait = 0, 0
81
+ if tokens >= cost then tokens = tokens - cost; ok = 1 else wait = (cost - tokens) / rate end
82
+ redis.call('HSET', KEYS[1], 'tokens', tostring(tokens), 'ts', tostring(now))
83
+ redis.call('PEXPIRE', KEYS[1], math.ceil(burst / rate * 1000) + 1000)
84
+ return {ok, tostring(wait)}
85
+ """
86
+
87
+
88
+ class RedisRateLimiter:
89
+ """The same token bucket in Redis, evaluated atomically by a Lua script, so every server process shares one
90
+ limit. Time comes from the Redis server (`TIME`), not from the callers' clocks."""
91
+
92
+ def __init__(self, redis: Any, per_second: float, burst: float, *, prefix: str = "aaa:rl:"):
93
+ if per_second <= 0 or burst < 1:
94
+ raise ValueError("per_second must be > 0 and burst >= 1")
95
+ self.redis, self.rate, self.burst, self.prefix = redis, per_second, burst, prefix
96
+
97
+ async def take(self, key: str, cost: float = 1.0) -> RateDecision:
98
+ ok, wait = await self.redis.eval(
99
+ _LUA_BUCKET, 1, self.prefix + key, self.rate, self.burst, cost
100
+ )
101
+ return RateDecision(bool(int(ok)), float(wait))
102
+
103
+
104
+ # -- concurrency ---------------------------------------------------------------------------------
105
+ class ConcurrencyLimiter:
106
+ """At most `limit` things in flight per key in THIS process (used for open SSE streams)."""
107
+
108
+ def __init__(self, limit: int) -> None:
109
+ self.limit = limit
110
+ self._n: dict[str, int] = defaultdict(int)
111
+
112
+ @asynccontextmanager
113
+ async def hold(self, key: str) -> AsyncIterator[None]:
114
+ if self._n[key] >= self.limit:
115
+ raise Denied(429, "too_many_streams", "too many concurrent streams", retry_after=1.0)
116
+ self._n[key] += 1
117
+ try:
118
+ yield
119
+ finally:
120
+ self._n[key] -= 1
121
+ if self._n[key] <= 0:
122
+ self._n.pop(key, None)
123
+
124
+ def active(self, key: str) -> int:
125
+ return self._n.get(key, 0)
126
+
127
+
128
+ # -- quota ---------------------------------------------------------------------------------------
129
+ ADMITTED = "aaa_server.usage.admitted"
130
+ SETTLED = "aaa_server.usage.settled"
131
+
132
+
133
+ class Usage(BaseModel):
134
+ admitted: int = 0
135
+ settled: int = 0
136
+ tokens: int = 0
137
+ cost: float = 0.0
138
+
139
+
140
+ def _fold(u: Usage, e: Event) -> Usage:
141
+ if e.type == ADMITTED:
142
+ u.admitted += 1
143
+ elif e.type == SETTLED:
144
+ u.settled += 1
145
+ u.tokens += int(e.data.get("tokens", 0))
146
+ u.cost += float(e.data.get("cost", 0.0))
147
+ return u
148
+
149
+
150
+ USAGE = Projection(Usage, _fold, name="aaa_server.usage", version=1, state_type=Usage, every=256)
151
+
152
+
153
+ class Quota(BaseModel):
154
+ """Per-tenant limits per UTC day. `None` = unlimited. `max_cost` is checked against spend SETTLED so far
155
+ (a run's cost is recorded when it finishes), so concurrent runs can overshoot it by what they are still
156
+ spending; pair it with per-run budgets."""
157
+
158
+ max_runs: int | None = None
159
+ max_tokens: int | None = None
160
+ max_cost: float | None = None
161
+
162
+
163
+ class QuotaStore:
164
+ def __init__(
165
+ self,
166
+ store: EventStore,
167
+ default: Quota,
168
+ *,
169
+ overrides: dict[str, Quota] | None = None,
170
+ clock: Callable[[], datetime] | None = None,
171
+ ) -> None:
172
+ self.store, self.default, self.overrides = store, default, overrides or {}
173
+ self._clock = clock or (lambda: datetime.now(UTC))
174
+
175
+ def limits(self, tenant: str) -> Quota:
176
+ return self.overrides.get(tenant, self.default)
177
+
178
+ def stream(self, tenant: str) -> str:
179
+ return f"aaa_server.quota/{tenant}/{self._clock():%Y-%m-%d}"
180
+
181
+ async def usage(self, tenant: str) -> Usage:
182
+ return (await USAGE.load(self.store, self.stream(tenant))).state
183
+
184
+ async def admit(self, tenant: str, unit_id: str) -> None:
185
+ """Reserve one run/turn against today's quota or raise `Denied(429)`. Idempotent per `unit_id`."""
186
+ lim = self.limits(tenant)
187
+ stream = self.stream(tenant)
188
+ for _ in range(8):
189
+ events = await self.store.read(stream)
190
+ u = USAGE.replay(events)
191
+ if any(e.id == f"{unit_id}:admit" for e in events):
192
+ return
193
+ for cap, used, what in (
194
+ (lim.max_runs, u.admitted, "runs"),
195
+ (lim.max_tokens, u.tokens, "tokens"),
196
+ (lim.max_cost, u.cost, "cost"),
197
+ ):
198
+ if cap is not None and used >= cap:
199
+ raise Denied(429, "quota_exceeded", f"daily {what} quota exhausted")
200
+ try:
201
+ await self.store.append(
202
+ stream,
203
+ [
204
+ Event(
205
+ id=f"{unit_id}:admit",
206
+ type=ADMITTED,
207
+ stream=stream,
208
+ tenant=tenant,
209
+ source="aaa-server",
210
+ )
211
+ ],
212
+ expected_seq=events[-1].seq if events else -1,
213
+ )
214
+ return
215
+ except ConcurrencyError:
216
+ continue # someone else took a unit: re-check against the new total
217
+ raise Denied(503, "busy", "could not reserve quota, retry", retry_after=1.0)
218
+
219
+ async def settle(self, tenant: str, unit_id: str, *, tokens: int, cost: float) -> None:
220
+ """Record what a finished run actually used (idempotent per `unit_id`)."""
221
+ stream = self.stream(tenant)
222
+ await self.store.append(
223
+ stream,
224
+ [
225
+ Event(
226
+ id=f"{unit_id}:settle",
227
+ type=SETTLED,
228
+ stream=stream,
229
+ tenant=tenant,
230
+ source="aaa-server",
231
+ data={"tokens": tokens, "cost": cost},
232
+ )
233
+ ],
234
+ )
235
+
236
+
237
+ __all__ = [
238
+ "ConcurrencyLimiter",
239
+ "Denied",
240
+ "InMemoryRateLimiter",
241
+ "Quota",
242
+ "QuotaStore",
243
+ "RateDecision",
244
+ "RateLimiter",
245
+ "RedisRateLimiter",
246
+ "Usage",
247
+ ]