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/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
+ ]