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 +48 -0
- aaa_server/a2a.py +351 -0
- aaa_server/admission.py +247 -0
- aaa_server/app.py +723 -0
- aaa_server/auth.py +255 -0
- aaa_server/cli.py +100 -0
- aaa_server/mcp_http.py +64 -0
- aaa_server/py.typed +0 -0
- aaa_server-0.1.0.dist-info/METADATA +67 -0
- aaa_server-0.1.0.dist-info/RECORD +13 -0
- aaa_server-0.1.0.dist-info/WHEEL +4 -0
- aaa_server-0.1.0.dist-info/entry_points.txt +2 -0
- aaa_server-0.1.0.dist-info/licenses/LICENSE +21 -0
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}}
|
aaa_server/admission.py
ADDED
|
@@ -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
|
+
]
|