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/app.py
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
"""The ASGI application. Every route is: authenticate -> authorise (scope) -> rate-limit -> validate -> act, and
|
|
2
|
+
every act goes through a `TenantScope` / `TenantSessions` bound to the authenticated principal's tenant."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
11
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
import anyio
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
|
17
|
+
from pydantic_core import to_jsonable_python
|
|
18
|
+
from starlette.applications import Starlette
|
|
19
|
+
from starlette.background import BackgroundTask
|
|
20
|
+
from starlette.middleware import Middleware
|
|
21
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
22
|
+
from starlette.requests import Request
|
|
23
|
+
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
24
|
+
from starlette.routing import Route
|
|
25
|
+
|
|
26
|
+
from aaa.core.ids import new_id
|
|
27
|
+
from aaa.events import Event
|
|
28
|
+
from aaa.llm.parts import Part
|
|
29
|
+
from aaa.mcp.server import MCPToolServer
|
|
30
|
+
from aaa.runtime.budget import Budget
|
|
31
|
+
from aaa.runtime.engine import RUN_COMPLETED, RUNMETA, Algorithm, Engine
|
|
32
|
+
from aaa.session import SessionBusy, SessionClosed, SessionManager
|
|
33
|
+
from aaa.tools.handler import (
|
|
34
|
+
APPROVAL_DENIED,
|
|
35
|
+
APPROVAL_GRANTED,
|
|
36
|
+
APPROVAL_REQUESTED,
|
|
37
|
+
COMPLETED,
|
|
38
|
+
FAILED,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from . import mcp_http
|
|
42
|
+
from .a2a import A2AConfig, A2AEndpoint
|
|
43
|
+
from .admission import ConcurrencyLimiter, Denied, Quota, QuotaStore, RateLimiter
|
|
44
|
+
from .auth import (
|
|
45
|
+
SCOPE_ADMIN,
|
|
46
|
+
SCOPE_APPROVE,
|
|
47
|
+
SCOPE_RUN,
|
|
48
|
+
SCOPE_SESSION,
|
|
49
|
+
Authenticator,
|
|
50
|
+
AuthError,
|
|
51
|
+
Principal,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
log = logging.getLogger("aaa_server")
|
|
55
|
+
|
|
56
|
+
_ID = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
|
57
|
+
_TURN_RUN = re.compile(r"^.+:t\d+$")
|
|
58
|
+
EventView = Callable[[Event], "dict[str, Any] | None"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# -- what leaves the process ---------------------------------------------------------------------
|
|
62
|
+
def _clip(value: Any, limit: int = 4096) -> Any:
|
|
63
|
+
text = json.dumps(value, default=str)
|
|
64
|
+
return value if len(text) <= limit else {"truncated": True, "preview": text[:limit]}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def default_event_view(e: Event) -> dict[str, Any] | None:
|
|
68
|
+
"""The projection of the log a client may see. Anything not listed here is not sent: system prompts, memory
|
|
69
|
+
contents, guard internals and raw model requests stay inside."""
|
|
70
|
+
d = e.data
|
|
71
|
+
match e.type:
|
|
72
|
+
case "aaa.run.started":
|
|
73
|
+
return {"type": "run.started", "algo": d.get("algo")}
|
|
74
|
+
case "aaa.run.completed":
|
|
75
|
+
return {"type": "run.completed", "kind": d.get("kind"), "reason": d.get("reason")}
|
|
76
|
+
case "aaa.llm.delta" if d.get("tag") in (None, "main"):
|
|
77
|
+
return {"type": "delta", "text": d.get("text", "")}
|
|
78
|
+
case "aaa.tool.requested":
|
|
79
|
+
return {"type": "tool.requested", "name": d.get("name"), "call_id": d.get("call_id")}
|
|
80
|
+
case "aaa.tool.completed":
|
|
81
|
+
return {"type": "tool.completed", "name": d.get("name"), "call_id": d.get("call_id")}
|
|
82
|
+
case "aaa.tool.failed":
|
|
83
|
+
err = d.get("error") or {}
|
|
84
|
+
return {
|
|
85
|
+
"type": "tool.failed",
|
|
86
|
+
"name": d.get("name"),
|
|
87
|
+
"call_id": d.get("call_id"),
|
|
88
|
+
"error": err.get("type"),
|
|
89
|
+
}
|
|
90
|
+
case "aaa.approval.requested":
|
|
91
|
+
return {
|
|
92
|
+
"type": "approval.requested",
|
|
93
|
+
"name": d.get("name"),
|
|
94
|
+
"call_id": d.get("call_id"),
|
|
95
|
+
"arguments": _clip(d.get("arguments", {})),
|
|
96
|
+
}
|
|
97
|
+
case "aaa.session.reply":
|
|
98
|
+
return {"type": "session.reply", "turn": d.get("turn"), "text": d.get("text")}
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# -- configuration -------------------------------------------------------------------------------
|
|
103
|
+
@dataclass
|
|
104
|
+
class ServerConfig:
|
|
105
|
+
engine: Engine
|
|
106
|
+
authenticator: Authenticator
|
|
107
|
+
algorithms: Mapping[str, Algorithm] = field(default_factory=dict)
|
|
108
|
+
sessions: Mapping[str, SessionManager] = field(default_factory=dict)
|
|
109
|
+
budget: Budget = field(
|
|
110
|
+
default_factory=lambda: Budget(max_steps=50, max_tokens=200_000, deadline_s=300.0)
|
|
111
|
+
)
|
|
112
|
+
rate_limiter: RateLimiter | None = None
|
|
113
|
+
quota: QuotaStore | None = None
|
|
114
|
+
max_open_runs: int | None = None
|
|
115
|
+
max_streams: int = 8
|
|
116
|
+
max_stream_s: float = 900.0
|
|
117
|
+
keepalive_s: float = 15.0
|
|
118
|
+
turn_timeout_s: float = 300.0
|
|
119
|
+
max_body_bytes: int = 1 << 20
|
|
120
|
+
cors_origins: tuple[str, ...] = ()
|
|
121
|
+
event_view: EventView = default_event_view
|
|
122
|
+
mcp: MCPToolServer | None = None # serve these tools at POST /mcp
|
|
123
|
+
mcp_origins: tuple[str, ...] = () # browser Origins allowed to call /mcp; none by default
|
|
124
|
+
a2a: A2AConfig | None = None # EXPERIMENTAL: serve one algorithm as an A2A agent
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# -- request models (unknown fields are an error: a client cannot smuggle in a tenant) -----------
|
|
128
|
+
class _Strict(BaseModel):
|
|
129
|
+
model_config = ConfigDict(extra="forbid")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class BudgetRequest(_Strict):
|
|
133
|
+
max_steps: int | None = Field(None, ge=1)
|
|
134
|
+
max_tokens: int | None = Field(None, ge=1)
|
|
135
|
+
max_cost: float | None = Field(None, gt=0)
|
|
136
|
+
deadline_s: float | None = Field(None, gt=0)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class RunRequest(_Strict):
|
|
140
|
+
algo: str = Field(max_length=128)
|
|
141
|
+
input: dict[str, Any] = {}
|
|
142
|
+
budget: BudgetRequest | None = None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ApprovalRequest(_Strict):
|
|
146
|
+
call_id: str = Field(max_length=256)
|
|
147
|
+
decision: Literal["grant", "deny"]
|
|
148
|
+
reason: str | None = Field(None, max_length=500)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class SessionRequest(_Strict):
|
|
152
|
+
agent: str | None = Field(None, max_length=128)
|
|
153
|
+
title: str | None = Field(None, max_length=200)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class MessageRequest(_Strict):
|
|
157
|
+
text: str = Field(max_length=100_000)
|
|
158
|
+
attachments: list[dict[str, Any]] = []
|
|
159
|
+
stream: bool = False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_PARTS = TypeAdapter(Part)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _forbid_blob_refs(value: Any) -> None:
|
|
166
|
+
"""A client names data inline or by URL, never by blob reference: those are the server's own storage."""
|
|
167
|
+
if isinstance(value, dict):
|
|
168
|
+
if "blob" in value:
|
|
169
|
+
raise ValueError("blob references are not accepted from clients")
|
|
170
|
+
for v in value.values():
|
|
171
|
+
_forbid_blob_refs(v)
|
|
172
|
+
elif isinstance(value, list):
|
|
173
|
+
for v in value:
|
|
174
|
+
_forbid_blob_refs(v)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def clamp_budget(server: Budget, asked: BudgetRequest | None) -> Budget:
|
|
178
|
+
"""The server's budget is a ceiling: a request may lower a limit or fill one the server left open."""
|
|
179
|
+
a = asked.model_dump() if asked else {}
|
|
180
|
+
out: dict[str, Any] = {}
|
|
181
|
+
for name in ("max_steps", "max_tokens", "max_cost", "deadline_s"):
|
|
182
|
+
cap, want = getattr(server, name), a.get(name)
|
|
183
|
+
out[name] = cap if want is None else (want if cap is None else min(cap, want))
|
|
184
|
+
return Budget(**out)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# -- the app -------------------------------------------------------------------------------------
|
|
188
|
+
class _Reply(Exception):
|
|
189
|
+
def __init__(self, status: int, code: str, message: str, headers: dict[str, str] | None = None):
|
|
190
|
+
super().__init__(message)
|
|
191
|
+
self.status, self.code, self.message, self.headers = status, code, message, headers or {}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _error(status: int, code: str, message: str, rid: str, headers: dict[str, str] | None = None):
|
|
195
|
+
return JSONResponse(
|
|
196
|
+
{"error": {"code": code, "message": message, "request_id": rid}},
|
|
197
|
+
status_code=status,
|
|
198
|
+
headers={"X-Request-Id": rid, **(headers or {})},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _sse(event: str, data: Any, id: int | None = None) -> bytes:
|
|
203
|
+
head = f"id: {id}\n" if id is not None else ""
|
|
204
|
+
return f"{head}event: {event}\ndata: {json.dumps(data, default=str)}\n\n".encode()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ServerApp:
|
|
208
|
+
def __init__(self, cfg: ServerConfig) -> None:
|
|
209
|
+
self.cfg = cfg
|
|
210
|
+
self.engine = cfg.engine
|
|
211
|
+
self.streams = ConcurrencyLimiter(cfg.max_streams)
|
|
212
|
+
self.a2a = A2AEndpoint(self, cfg.a2a) if cfg.a2a else None
|
|
213
|
+
if cfg.quota is not None:
|
|
214
|
+
self.engine.subscribe(RUN_COMPLETED, self._settle, group="aaa-server.quota")
|
|
215
|
+
|
|
216
|
+
# -- plumbing ---------------------------------------------------------------------------
|
|
217
|
+
def _managers(self, name: str | None) -> list[SessionManager]:
|
|
218
|
+
if name is None:
|
|
219
|
+
return list(self.cfg.sessions.values())
|
|
220
|
+
m = self.cfg.sessions.get(name)
|
|
221
|
+
return [m] if m else []
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _public(p: Principal, rid: str) -> str:
|
|
225
|
+
return rid.removeprefix(f"{p.tenant}/")
|
|
226
|
+
|
|
227
|
+
async def _raw(self, request: Request) -> bytes:
|
|
228
|
+
limit = self.cfg.max_body_bytes
|
|
229
|
+
declared = request.headers.get("content-length")
|
|
230
|
+
if declared and declared.isdigit() and int(declared) > limit:
|
|
231
|
+
raise _Reply(413, "too_large", "request body too large")
|
|
232
|
+
chunks, size = [], 0
|
|
233
|
+
async for c in request.stream():
|
|
234
|
+
size += len(c)
|
|
235
|
+
if size > limit:
|
|
236
|
+
raise _Reply(413, "too_large", "request body too large")
|
|
237
|
+
chunks.append(c)
|
|
238
|
+
return b"".join(chunks)
|
|
239
|
+
|
|
240
|
+
async def _body(self, request: Request, model: type[Any]) -> Any:
|
|
241
|
+
raw = await self._raw(request)
|
|
242
|
+
try:
|
|
243
|
+
return model.model_validate(json.loads(raw or b"{}"))
|
|
244
|
+
except (ValueError, ValidationError) as e:
|
|
245
|
+
detail = (
|
|
246
|
+
"; ".join(
|
|
247
|
+
f"{'.'.join(map(str, x['loc']))}: {x['msg']}"
|
|
248
|
+
for x in e.errors(include_input=False, include_url=False, include_context=False)
|
|
249
|
+
)
|
|
250
|
+
if isinstance(e, ValidationError)
|
|
251
|
+
else "body is not valid JSON"
|
|
252
|
+
)
|
|
253
|
+
raise _Reply(422, "invalid_request", detail) from None
|
|
254
|
+
|
|
255
|
+
def route(
|
|
256
|
+
self, scope: str | None, fn: Callable[..., Awaitable[Response]]
|
|
257
|
+
) -> Callable[[Request], Awaitable[Response]]:
|
|
258
|
+
async def endpoint(request: Request) -> Response:
|
|
259
|
+
rid = new_id()
|
|
260
|
+
try:
|
|
261
|
+
try:
|
|
262
|
+
p = await self.cfg.authenticator.authenticate(
|
|
263
|
+
{k.lower(): v for k, v in request.headers.items()}
|
|
264
|
+
)
|
|
265
|
+
except AuthError as e:
|
|
266
|
+
log.info("auth refused (request %s): %s", rid, e)
|
|
267
|
+
raise _Reply(
|
|
268
|
+
401,
|
|
269
|
+
"unauthorized",
|
|
270
|
+
"authentication required",
|
|
271
|
+
{"WWW-Authenticate": "Bearer"},
|
|
272
|
+
) from None
|
|
273
|
+
if scope is not None and not p.can(scope):
|
|
274
|
+
raise _Reply(403, "forbidden", "this credential may not do that")
|
|
275
|
+
if self.cfg.rate_limiter is not None:
|
|
276
|
+
d = await self.cfg.rate_limiter.take(f"{p.tenant}/{p.subject}")
|
|
277
|
+
if not d.allowed:
|
|
278
|
+
raise Denied(429, "rate_limited", "rate limit exceeded", d.retry_after)
|
|
279
|
+
resp = await fn(request, p)
|
|
280
|
+
resp.headers["X-Request-Id"] = rid
|
|
281
|
+
return resp
|
|
282
|
+
except _Reply as r:
|
|
283
|
+
return _error(r.status, r.code, r.message, rid, r.headers)
|
|
284
|
+
except Denied as d:
|
|
285
|
+
h = (
|
|
286
|
+
{"Retry-After": str(max(1, int(d.retry_after + 0.999)))}
|
|
287
|
+
if d.retry_after
|
|
288
|
+
else {}
|
|
289
|
+
)
|
|
290
|
+
return _error(d.status, d.code, d.message, rid, h)
|
|
291
|
+
except KeyError:
|
|
292
|
+
return _error(404, "not_found", "not found", rid)
|
|
293
|
+
except Exception:
|
|
294
|
+
log.exception("unhandled error (request %s)", rid)
|
|
295
|
+
return _error(500, "internal", "internal error", rid)
|
|
296
|
+
|
|
297
|
+
return endpoint
|
|
298
|
+
|
|
299
|
+
# -- quota settlement -------------------------------------------------------------------
|
|
300
|
+
async def _settle(self, e: Event) -> list[Event]:
|
|
301
|
+
q = self.cfg.quota
|
|
302
|
+
if q is None or not e.tenant or not e.run_id:
|
|
303
|
+
return []
|
|
304
|
+
try:
|
|
305
|
+
started = await self.engine._started_of(e.run_id)
|
|
306
|
+
except KeyError:
|
|
307
|
+
return []
|
|
308
|
+
parent, algo = started.data.get("parent"), str(started.data.get("algo", ""))
|
|
309
|
+
# Count each unit of work once: top-level runs and session turns (a session's own ledger already
|
|
310
|
+
# contains its turns, and a run's ledger contains its children).
|
|
311
|
+
if algo.startswith("aaa.session:") or (parent and not _TURN_RUN.match(e.run_id)):
|
|
312
|
+
return []
|
|
313
|
+
meta = await self.engine.project(RUNMETA, e.run_id)
|
|
314
|
+
await q.settle(e.tenant, e.run_id, tokens=meta.ledger.tokens, cost=meta.ledger.cost)
|
|
315
|
+
return []
|
|
316
|
+
|
|
317
|
+
# -- endpoints: health --------------------------------------------------------------------
|
|
318
|
+
async def healthz(self, request: Request) -> Response:
|
|
319
|
+
return JSONResponse({"ok": True})
|
|
320
|
+
|
|
321
|
+
# -- endpoints: runs ------------------------------------------------------------------------
|
|
322
|
+
async def _summary(self, p: Principal, rid: str) -> dict[str, Any]:
|
|
323
|
+
started = await self.engine._started_of(rid)
|
|
324
|
+
meta = await self.engine.project(RUNMETA, rid)
|
|
325
|
+
done = meta.completed
|
|
326
|
+
return {
|
|
327
|
+
"run_id": self._public(p, rid),
|
|
328
|
+
"algo": started.data.get("algo"),
|
|
329
|
+
"status": "completed" if done is not None else "running",
|
|
330
|
+
"outcome": (
|
|
331
|
+
{
|
|
332
|
+
"kind": done.get("kind"),
|
|
333
|
+
"value": done.get("value"),
|
|
334
|
+
"reason": done.get("reason"),
|
|
335
|
+
}
|
|
336
|
+
if done is not None
|
|
337
|
+
else None
|
|
338
|
+
),
|
|
339
|
+
"usage": meta.ledger.model_dump(),
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async def launch(
|
|
343
|
+
self,
|
|
344
|
+
p: Principal,
|
|
345
|
+
algo: Algorithm,
|
|
346
|
+
input: dict[str, Any],
|
|
347
|
+
*,
|
|
348
|
+
key: str | None = None,
|
|
349
|
+
asked: BudgetRequest | None = None,
|
|
350
|
+
) -> tuple[str, bool]:
|
|
351
|
+
"""Admit and start a run for `p`: (namespaced run id, whether it was newly started). Every entry point (REST,
|
|
352
|
+
A2A) goes through here, so the checks cannot differ between them."""
|
|
353
|
+
try:
|
|
354
|
+
_forbid_blob_refs(input)
|
|
355
|
+
except ValueError as e:
|
|
356
|
+
raise _Reply(422, "invalid_request", str(e)) from None
|
|
357
|
+
scope = self.engine.tenant(p.tenant)
|
|
358
|
+
if key is not None and not _ID.match(key):
|
|
359
|
+
raise _Reply(422, "invalid_request", "Idempotency-Key must match [A-Za-z0-9._-]{1,128}")
|
|
360
|
+
if key is not None:
|
|
361
|
+
rid = scope.run_id(key)
|
|
362
|
+
try:
|
|
363
|
+
started = await self.engine._started_of(rid)
|
|
364
|
+
except KeyError:
|
|
365
|
+
started = None
|
|
366
|
+
if started is not None: # a replay: same request -> same run; different request -> 409
|
|
367
|
+
same = started.data.get("algo") == algo.name and started.data.get(
|
|
368
|
+
"input"
|
|
369
|
+
) == to_jsonable_python(input, serialize_unknown=True)
|
|
370
|
+
if not same:
|
|
371
|
+
raise _Reply(
|
|
372
|
+
409, "idempotency_conflict", "that key was used for another request"
|
|
373
|
+
)
|
|
374
|
+
return rid, False
|
|
375
|
+
if self.cfg.max_open_runs is not None:
|
|
376
|
+
open_now = len(await scope.runs(open_only=True))
|
|
377
|
+
if open_now >= self.cfg.max_open_runs:
|
|
378
|
+
raise Denied(429, "too_many_runs", "too many open runs", retry_after=1.0)
|
|
379
|
+
if self.cfg.quota is not None:
|
|
380
|
+
await self.cfg.quota.admit(p.tenant, scope.run_id(key) if key else new_id())
|
|
381
|
+
handle = await scope.start(
|
|
382
|
+
algo,
|
|
383
|
+
input,
|
|
384
|
+
run_id=key,
|
|
385
|
+
budget=clamp_budget(self.cfg.budget, asked),
|
|
386
|
+
user=p.subject,
|
|
387
|
+
)
|
|
388
|
+
return handle.run_id, True
|
|
389
|
+
|
|
390
|
+
async def start_run(self, request: Request, p: Principal) -> Response:
|
|
391
|
+
req: RunRequest = await self._body(request, RunRequest)
|
|
392
|
+
algo = self.cfg.algorithms.get(req.algo)
|
|
393
|
+
if algo is None:
|
|
394
|
+
raise _Reply(404, "unknown_algorithm", "no such algorithm")
|
|
395
|
+
rid, created = await self.launch(
|
|
396
|
+
p, algo, req.input, key=request.headers.get("idempotency-key"), asked=req.budget
|
|
397
|
+
)
|
|
398
|
+
return JSONResponse(await self._summary(p, rid), status_code=202 if created else 200)
|
|
399
|
+
|
|
400
|
+
async def list_runs(self, request: Request, p: Principal) -> Response:
|
|
401
|
+
scope = self.engine.tenant(p.tenant)
|
|
402
|
+
open_only = request.query_params.get("open") == "true"
|
|
403
|
+
try:
|
|
404
|
+
limit = min(max(int(request.query_params.get("limit", "50")), 1), 200)
|
|
405
|
+
except ValueError:
|
|
406
|
+
raise _Reply(422, "invalid_request", "limit must be an integer") from None
|
|
407
|
+
infos = (await scope.runs(open_only=open_only))[-limit:]
|
|
408
|
+
return JSONResponse(
|
|
409
|
+
{
|
|
410
|
+
"runs": [
|
|
411
|
+
{
|
|
412
|
+
"run_id": self._public(p, r.stream),
|
|
413
|
+
"algo": r.started.data.get("algo"),
|
|
414
|
+
"status": "running" if r.completed is None else "completed",
|
|
415
|
+
}
|
|
416
|
+
for r in infos
|
|
417
|
+
]
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
async def get_run(self, request: Request, p: Principal) -> Response:
|
|
422
|
+
scope = self.engine.tenant(p.tenant)
|
|
423
|
+
h = await scope.handle(request.path_params["run_id"])
|
|
424
|
+
return JSONResponse(await self._summary(p, h.run_id))
|
|
425
|
+
|
|
426
|
+
async def cancel_run(self, request: Request, p: Principal) -> Response:
|
|
427
|
+
h = await self.engine.tenant(p.tenant).handle(request.path_params["run_id"])
|
|
428
|
+
await h.cancel(f"cancelled by {p.subject}")
|
|
429
|
+
return JSONResponse({"run_id": self._public(p, h.run_id), "status": "cancelling"}, 202)
|
|
430
|
+
|
|
431
|
+
async def resolve_approval(self, request: Request, p: Principal) -> Response:
|
|
432
|
+
h = await self.engine.tenant(p.tenant).handle(request.path_params["run_id"])
|
|
433
|
+
req: ApprovalRequest = await self._body(request, ApprovalRequest)
|
|
434
|
+
events = await self.engine.store.read(h.run_id)
|
|
435
|
+
asked, settled = set(), set()
|
|
436
|
+
for e in events:
|
|
437
|
+
cid = e.data.get("call_id")
|
|
438
|
+
if e.type == APPROVAL_REQUESTED:
|
|
439
|
+
asked.add(cid)
|
|
440
|
+
elif e.type in (APPROVAL_GRANTED, APPROVAL_DENIED, COMPLETED, FAILED):
|
|
441
|
+
settled.add(cid)
|
|
442
|
+
if req.call_id not in asked or req.call_id in settled:
|
|
443
|
+
raise _Reply(409, "no_pending_approval", "no approval is pending for that call")
|
|
444
|
+
await h.signal(
|
|
445
|
+
APPROVAL_GRANTED if req.decision == "grant" else APPROVAL_DENIED,
|
|
446
|
+
{
|
|
447
|
+
"call_id": req.call_id,
|
|
448
|
+
"by": p.subject,
|
|
449
|
+
**({"reason": req.reason} if req.reason else {}),
|
|
450
|
+
},
|
|
451
|
+
)
|
|
452
|
+
return JSONResponse({"call_id": req.call_id, "decision": req.decision}, 202)
|
|
453
|
+
|
|
454
|
+
# -- endpoints: event stream -------------------------------------------------------------
|
|
455
|
+
def _stream_response(
|
|
456
|
+
self,
|
|
457
|
+
p: Principal,
|
|
458
|
+
body: Callable[[], AsyncIterator[bytes]],
|
|
459
|
+
stack: AsyncExitStack,
|
|
460
|
+
*,
|
|
461
|
+
trailer: bool = True,
|
|
462
|
+
) -> StreamingResponse:
|
|
463
|
+
async def guarded() -> AsyncIterator[bytes]:
|
|
464
|
+
try:
|
|
465
|
+
with anyio.move_on_after(self.cfg.max_stream_s):
|
|
466
|
+
async for chunk in body():
|
|
467
|
+
yield chunk
|
|
468
|
+
if trailer: # protocols with their own framing (A2A) must not see an extra event
|
|
469
|
+
yield _sse("end", {"reason": "max_duration_or_done"})
|
|
470
|
+
finally:
|
|
471
|
+
await stack.aclose()
|
|
472
|
+
|
|
473
|
+
return StreamingResponse(
|
|
474
|
+
guarded(),
|
|
475
|
+
media_type="text/event-stream",
|
|
476
|
+
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
|
477
|
+
background=BackgroundTask(stack.aclose),
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
async def _paced(self, source: AsyncIterator[Any]) -> AsyncIterator[Any]:
|
|
481
|
+
"""Items from `source`, with `None` yielded whenever `keepalive_s` passes quietly."""
|
|
482
|
+
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
|
|
483
|
+
|
|
484
|
+
async def pump() -> None:
|
|
485
|
+
try:
|
|
486
|
+
async for item in source:
|
|
487
|
+
await queue.put(("item", item))
|
|
488
|
+
await queue.put(("end", None))
|
|
489
|
+
except Exception as e: # noqa: BLE001
|
|
490
|
+
await queue.put(("err", e))
|
|
491
|
+
|
|
492
|
+
task = asyncio.create_task(pump())
|
|
493
|
+
try:
|
|
494
|
+
while True:
|
|
495
|
+
try:
|
|
496
|
+
kind, item = await asyncio.wait_for(queue.get(), self.cfg.keepalive_s)
|
|
497
|
+
except TimeoutError:
|
|
498
|
+
yield None
|
|
499
|
+
continue
|
|
500
|
+
if kind == "end":
|
|
501
|
+
return
|
|
502
|
+
if kind == "err":
|
|
503
|
+
raise item
|
|
504
|
+
yield item
|
|
505
|
+
finally:
|
|
506
|
+
task.cancel()
|
|
507
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
508
|
+
|
|
509
|
+
async def run_events(self, request: Request, p: Principal) -> Response:
|
|
510
|
+
h = await self.engine.tenant(p.tenant).handle(request.path_params["run_id"])
|
|
511
|
+
raw = request.headers.get("last-event-id") or request.query_params.get("after")
|
|
512
|
+
try:
|
|
513
|
+
start = int(raw) + 1 if raw is not None else 0
|
|
514
|
+
except ValueError:
|
|
515
|
+
raise _Reply(
|
|
516
|
+
422, "invalid_request", "Last-Event-ID must be an event sequence number"
|
|
517
|
+
) from None
|
|
518
|
+
if start < 0:
|
|
519
|
+
raise _Reply(422, "invalid_request", "Last-Event-ID must be >= 0")
|
|
520
|
+
stack = AsyncExitStack()
|
|
521
|
+
await stack.enter_async_context(self.streams.hold(f"{p.tenant}"))
|
|
522
|
+
|
|
523
|
+
async def body() -> AsyncIterator[bytes]:
|
|
524
|
+
async def finished() -> AsyncIterator[Event]:
|
|
525
|
+
for e in await self.engine.store.read(h.run_id, start):
|
|
526
|
+
yield e
|
|
527
|
+
|
|
528
|
+
# A finished run is read, not tailed: resuming after its last event must end, not wait forever.
|
|
529
|
+
done = (await self.engine.project(RUNMETA, h.run_id)).completed is not None
|
|
530
|
+
src = finished() if done else self.engine.store.subscribe(h.run_id, start)
|
|
531
|
+
async for e in self._paced(src.__aiter__()):
|
|
532
|
+
if e is None:
|
|
533
|
+
yield b": keepalive\n\n"
|
|
534
|
+
continue
|
|
535
|
+
view = self.cfg.event_view(await self.engine.hydrate(e))
|
|
536
|
+
if view is not None:
|
|
537
|
+
yield _sse(view["type"], view, e.seq)
|
|
538
|
+
if e.type == RUN_COMPLETED:
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
return self._stream_response(p, body, stack)
|
|
542
|
+
|
|
543
|
+
# -- endpoints: sessions ---------------------------------------------------------------------
|
|
544
|
+
async def _session(self, p: Principal, sid: str) -> Any:
|
|
545
|
+
for m in self.cfg.sessions.values():
|
|
546
|
+
try:
|
|
547
|
+
return await m.tenant(p.tenant).get(sid)
|
|
548
|
+
except KeyError:
|
|
549
|
+
continue
|
|
550
|
+
raise KeyError(sid)
|
|
551
|
+
|
|
552
|
+
async def create_session(self, request: Request, p: Principal) -> Response:
|
|
553
|
+
req: SessionRequest = await self._body(request, SessionRequest)
|
|
554
|
+
ms = self._managers(req.agent)
|
|
555
|
+
if len(ms) != 1 or (req.agent is None and len(self.cfg.sessions) != 1):
|
|
556
|
+
raise _Reply(422, "invalid_request", "name one of the configured agents")
|
|
557
|
+
if self.cfg.quota is not None:
|
|
558
|
+
await self.cfg.quota.admit(p.tenant, new_id())
|
|
559
|
+
s = await ms[0].tenant(p.tenant).create(user=p.subject, title=req.title)
|
|
560
|
+
return JSONResponse({"session_id": self._public(p, s.id)}, status_code=201)
|
|
561
|
+
|
|
562
|
+
async def list_sessions(self, request: Request, p: Principal) -> Response:
|
|
563
|
+
out = []
|
|
564
|
+
for m in self.cfg.sessions.values():
|
|
565
|
+
for i in await m.tenant(p.tenant).list(user=p.subject):
|
|
566
|
+
out.append({"session_id": self._public(p, i.id), "open": i.open})
|
|
567
|
+
return JSONResponse({"sessions": out})
|
|
568
|
+
|
|
569
|
+
async def get_session(self, request: Request, p: Principal) -> Response:
|
|
570
|
+
s = await self._session(p, request.path_params["session_id"])
|
|
571
|
+
turns = [
|
|
572
|
+
{"turn": t.n, "user": t.user, "reply": t.assistant, "status": t.status}
|
|
573
|
+
for t in await s.history()
|
|
574
|
+
]
|
|
575
|
+
return JSONResponse({"session_id": self._public(p, s.id), "turns": turns})
|
|
576
|
+
|
|
577
|
+
async def close_session(self, request: Request, p: Principal) -> Response:
|
|
578
|
+
s = await self._session(p, request.path_params["session_id"])
|
|
579
|
+
await s.close(f"closed by {p.subject}")
|
|
580
|
+
return JSONResponse({"session_id": self._public(p, s.id), "status": "closed"})
|
|
581
|
+
|
|
582
|
+
async def send_message(self, request: Request, p: Principal) -> Response:
|
|
583
|
+
s = await self._session(p, request.path_params["session_id"])
|
|
584
|
+
req: MessageRequest = await self._body(request, MessageRequest)
|
|
585
|
+
try:
|
|
586
|
+
_forbid_blob_refs(req.attachments)
|
|
587
|
+
parts = [_PARTS.validate_python(a) for a in req.attachments]
|
|
588
|
+
except ValueError as e:
|
|
589
|
+
raise _Reply(422, "invalid_request", f"bad attachment: {e}") from None
|
|
590
|
+
if self.cfg.quota is not None:
|
|
591
|
+
await self.cfg.quota.admit(p.tenant, new_id())
|
|
592
|
+
try:
|
|
593
|
+
if not req.stream:
|
|
594
|
+
r = await s.send(req.text, attachments=parts, timeout_s=self.cfg.turn_timeout_s)
|
|
595
|
+
return JSONResponse(
|
|
596
|
+
{"turn": r.turn, "text": r.text, "kind": r.kind, "reason": r.reason}
|
|
597
|
+
)
|
|
598
|
+
ts = await s.stream(req.text, attachments=parts)
|
|
599
|
+
except (SessionBusy, SessionClosed) as e:
|
|
600
|
+
raise _Reply(
|
|
601
|
+
409, type(e).__name__.lower(), str(e).replace(s.id, "this session")
|
|
602
|
+
) from None
|
|
603
|
+
except TimeoutError:
|
|
604
|
+
raise _Reply(504, "timeout", "the turn did not finish in time") from None
|
|
605
|
+
stack = AsyncExitStack()
|
|
606
|
+
await stack.enter_async_context(self.streams.hold(f"{p.tenant}"))
|
|
607
|
+
|
|
608
|
+
async def body() -> AsyncIterator[bytes]:
|
|
609
|
+
async for piece in ts:
|
|
610
|
+
yield _sse("delta", {"type": "delta", "text": piece})
|
|
611
|
+
r = await ts.result()
|
|
612
|
+
yield _sse(
|
|
613
|
+
"session.reply",
|
|
614
|
+
{"type": "session.reply", "turn": r.turn, "text": r.text, "kind": r.kind},
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
return self._stream_response(p, body, stack)
|
|
618
|
+
|
|
619
|
+
# -- endpoints: A2A -----------------------------------------------------------------------
|
|
620
|
+
async def agent_card(self, request: Request) -> Response:
|
|
621
|
+
assert self.cfg.a2a is not None
|
|
622
|
+
return JSONResponse(self.cfg.a2a.card())
|
|
623
|
+
|
|
624
|
+
# -- endpoints: MCP ---------------------------------------------------------------------------
|
|
625
|
+
async def mcp_post(self, request: Request, p: Principal) -> Response:
|
|
626
|
+
core = self.cfg.mcp
|
|
627
|
+
if core is None:
|
|
628
|
+
raise _Reply(404, "not_found", "not found")
|
|
629
|
+
origin = request.headers.get("origin")
|
|
630
|
+
if (
|
|
631
|
+
origin is not None and origin not in self.cfg.mcp_origins
|
|
632
|
+
): # DNS-rebinding defence (MCP spec)
|
|
633
|
+
raise _Reply(403, "forbidden", "origin not allowed")
|
|
634
|
+
try:
|
|
635
|
+
message = json.loads(await self._raw(request))
|
|
636
|
+
except ValueError:
|
|
637
|
+
return JSONResponse(mcp_http._error(None, mcp_http.PARSE_ERROR, "parse error"), 400)
|
|
638
|
+
answer = await mcp_http.handle(core, message, tenant=p.tenant, user=p.subject)
|
|
639
|
+
return Response(status_code=202) if answer is None else JSONResponse(answer)
|
|
640
|
+
|
|
641
|
+
async def mcp_other(self, request: Request, p: Principal) -> Response:
|
|
642
|
+
return Response(status_code=405, headers={"Allow": "POST"})
|
|
643
|
+
|
|
644
|
+
# -- endpoints: usage --------------------------------------------------------------------------
|
|
645
|
+
async def usage(self, request: Request, p: Principal) -> Response:
|
|
646
|
+
q = self.cfg.quota
|
|
647
|
+
if q is None:
|
|
648
|
+
raise _Reply(404, "not_found", "not found")
|
|
649
|
+
u = await q.usage(p.tenant)
|
|
650
|
+
lim: Quota = q.limits(p.tenant)
|
|
651
|
+
return JSONResponse({"usage": u.model_dump(), "limits": lim.model_dump()})
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def create_app(cfg: ServerConfig, *, manage_engine: bool = False) -> Starlette:
|
|
655
|
+
"""The ASGI app. With `manage_engine=True` the app starts and stops `cfg.engine` with the server (what
|
|
656
|
+
`aaa-server serve` does); otherwise you own the engine's lifetime."""
|
|
657
|
+
app = ServerApp(cfg)
|
|
658
|
+
r = app.route
|
|
659
|
+
routes = [
|
|
660
|
+
Route("/healthz", app.healthz, methods=["GET"]),
|
|
661
|
+
Route("/v1/runs", r(SCOPE_RUN, app.start_run), methods=["POST"]),
|
|
662
|
+
Route("/v1/runs", r(SCOPE_RUN, app.list_runs), methods=["GET"]),
|
|
663
|
+
Route("/v1/runs/{run_id}", r(SCOPE_RUN, app.get_run), methods=["GET"]),
|
|
664
|
+
Route("/v1/runs/{run_id}/events", r(SCOPE_RUN, app.run_events), methods=["GET"]),
|
|
665
|
+
Route("/v1/runs/{run_id}/cancel", r(SCOPE_RUN, app.cancel_run), methods=["POST"]),
|
|
666
|
+
Route(
|
|
667
|
+
"/v1/runs/{run_id}/approvals", r(SCOPE_APPROVE, app.resolve_approval), methods=["POST"]
|
|
668
|
+
),
|
|
669
|
+
Route("/v1/sessions", r(SCOPE_SESSION, app.create_session), methods=["POST"]),
|
|
670
|
+
Route("/v1/sessions", r(SCOPE_SESSION, app.list_sessions), methods=["GET"]),
|
|
671
|
+
Route("/v1/sessions/{session_id}", r(SCOPE_SESSION, app.get_session), methods=["GET"]),
|
|
672
|
+
Route("/v1/sessions/{session_id}", r(SCOPE_SESSION, app.close_session), methods=["DELETE"]),
|
|
673
|
+
Route(
|
|
674
|
+
"/v1/sessions/{session_id}/messages",
|
|
675
|
+
r(SCOPE_SESSION, app.send_message),
|
|
676
|
+
methods=["POST"],
|
|
677
|
+
),
|
|
678
|
+
*(
|
|
679
|
+
[
|
|
680
|
+
Route("/a2a", r(SCOPE_RUN, app.a2a.rpc), methods=["POST"]),
|
|
681
|
+
Route("/.well-known/agent-card.json", app.agent_card, methods=["GET"]),
|
|
682
|
+
]
|
|
683
|
+
if app.a2a and cfg.a2a
|
|
684
|
+
else []
|
|
685
|
+
),
|
|
686
|
+
Route("/mcp", r(SCOPE_RUN, app.mcp_post), methods=["POST"]),
|
|
687
|
+
Route("/mcp", r(SCOPE_RUN, app.mcp_other), methods=["GET", "DELETE"]),
|
|
688
|
+
Route("/v1/usage", r(SCOPE_ADMIN, app.usage), methods=["GET"]),
|
|
689
|
+
]
|
|
690
|
+
middleware = []
|
|
691
|
+
if cfg.cors_origins:
|
|
692
|
+
if "*" in cfg.cors_origins:
|
|
693
|
+
raise ValueError("list CORS origins explicitly; a wildcard is not accepted")
|
|
694
|
+
middleware.append(
|
|
695
|
+
Middleware(
|
|
696
|
+
CORSMiddleware,
|
|
697
|
+
allow_origins=list(cfg.cors_origins),
|
|
698
|
+
allow_methods=["GET", "POST", "DELETE"],
|
|
699
|
+
allow_headers=["Authorization", "Content-Type", "Idempotency-Key", "Last-Event-ID"],
|
|
700
|
+
allow_credentials=False,
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
@asynccontextmanager
|
|
705
|
+
async def engine_lifespan(_: Starlette) -> AsyncIterator[None]:
|
|
706
|
+
async with cfg.engine:
|
|
707
|
+
yield
|
|
708
|
+
|
|
709
|
+
return Starlette(
|
|
710
|
+
routes=routes, middleware=middleware, lifespan=engine_lifespan if manage_engine else None
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
__all__ = [
|
|
715
|
+
"ApprovalRequest",
|
|
716
|
+
"BudgetRequest",
|
|
717
|
+
"MessageRequest",
|
|
718
|
+
"RunRequest",
|
|
719
|
+
"ServerConfig",
|
|
720
|
+
"clamp_budget",
|
|
721
|
+
"create_app",
|
|
722
|
+
"default_event_view",
|
|
723
|
+
]
|