feat(core,backends,frontends,storage): conversations, inject queue, session pool, gateway mcp tools, api frontend
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
"""``ApiFrontend`` - ``/api/conversations``, SSE events, sessions, usage (§3.9).
|
||||
|
||||
Bearer scope ``api``. Every write goes through ``core/conversations``; the
|
||||
frontend only shapes JSON. ``/api/events`` and
|
||||
``/api/conversations/{id}/events`` replay the gateway bus as SSE with the
|
||||
same keepalive the markdown frontend uses, so a proxy never sees a
|
||||
silent socket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlmodel import col
|
||||
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.core.conversations import KINDS, SEEDS
|
||||
from beaver_gateway.frontends._auth import require_token
|
||||
from beaver_gateway.frontends._sse import (
|
||||
KEEPALIVE,
|
||||
SSE_HEADERS,
|
||||
events_with_heartbeat,
|
||||
sse_pack,
|
||||
)
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.storage.models import Usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from beaver_gateway.core.conversations import Conversations
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.storage.models import Conversation
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.frontends.api")
|
||||
|
||||
__all__ = ["ApiFrontend"]
|
||||
|
||||
SCOPE = "api"
|
||||
|
||||
|
||||
class ApiFrontend(Frontend):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = "0.0.0.0", # noqa: S104
|
||||
port: int = 8004,
|
||||
public_base_url: str | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
self._app: FastAPI | None = None
|
||||
|
||||
def configure(self, runtime: GatewayRuntime) -> None:
|
||||
if runtime.conversations is None or runtime.bus is None:
|
||||
msg = "ApiFrontend needs runtime.conversations and runtime.bus"
|
||||
raise RuntimeError(msg)
|
||||
self._app = _build_app(runtime)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
if self._app is None:
|
||||
msg = "configure() must be called before serve()"
|
||||
raise RuntimeError(msg)
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(self._app, host=self.host, port=self.port, log_level="info")
|
||||
)
|
||||
await server.serve()
|
||||
|
||||
|
||||
def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
app = FastAPI(title="beaver-gateway / API")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
conversations = cast("Conversations", runtime.conversations)
|
||||
|
||||
async def body_of(request: Request) -> dict[str, Any]:
|
||||
if not await request.body():
|
||||
return {}
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, f"invalid JSON: {exc}"
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "body must be an object")
|
||||
return data
|
||||
|
||||
async def conv_of(public_id: str) -> Conversation:
|
||||
conv = await conversations.get(public_id)
|
||||
if conv is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, f"unknown conversation {public_id}"
|
||||
)
|
||||
return conv
|
||||
|
||||
def text_of(data: dict[str, Any], key: str = "text") -> str:
|
||||
text = data.get(key)
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"missing `{key}`")
|
||||
return text
|
||||
|
||||
def int_or_none(data: dict[str, Any], key: str) -> int | None:
|
||||
value = data.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, int) or value < 1:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, f"`{key}` must be a positive int"
|
||||
)
|
||||
return value
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/conversations")
|
||||
async def list_conversations(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
q = request.query_params
|
||||
rows = await conversations.find(
|
||||
status=q.get("status"), kind=q.get("kind"), limit=int(q.get("limit", "200"))
|
||||
)
|
||||
return {"conversations": [conversations.public(r) for r in rows]}
|
||||
|
||||
@app.post("/api/conversations", status_code=status.HTTP_201_CREATED)
|
||||
async def create_conversation(request: Request) -> dict[str, Any]:
|
||||
token = await require_token(request, runtime, scope=SCOPE)
|
||||
data = await body_of(request)
|
||||
kind = str(data.get("kind") or "deep")
|
||||
agent = data.get("agent")
|
||||
if kind not in KINDS or kind == "fork":
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, f"kind must be one of {KINDS[:-1]}"
|
||||
)
|
||||
if not isinstance(agent, str) or agent not in runtime.agents:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "unknown or missing `agent`"
|
||||
)
|
||||
seed = str(data.get("seed") or "clean")
|
||||
if seed not in SEEDS:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, f"seed must be one of {SEEDS}"
|
||||
)
|
||||
parent = await conv_of(str(data["parent"])) if data.get("parent") else None
|
||||
try:
|
||||
conv = await conversations.spawn(
|
||||
kind=kind,
|
||||
agent=agent,
|
||||
seed=seed,
|
||||
parent=parent,
|
||||
text=data.get("text"),
|
||||
title=data.get("title"),
|
||||
window=int_or_none(data, "window"),
|
||||
origin="api",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{token}",
|
||||
kind="api_spawn",
|
||||
agent_name=agent,
|
||||
conversation=conv.external_id,
|
||||
seed=seed,
|
||||
)
|
||||
return await conversations.describe(conv)
|
||||
|
||||
@app.get("/api/conversations/{public_id}")
|
||||
async def get_conversation(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
out = await conversations.describe(conv)
|
||||
out["queue"] = [
|
||||
{
|
||||
"id": i.id,
|
||||
"priority": i.priority,
|
||||
"origin": i.origin,
|
||||
"status": i.status,
|
||||
"created_at": i.created_at.isoformat(),
|
||||
"text": i.text[:200],
|
||||
}
|
||||
for i in await conversations.queue.recent(cast("int", conv.id), limit=20)
|
||||
]
|
||||
return out
|
||||
|
||||
@app.get("/api/conversations/{public_id}/messages")
|
||||
async def get_messages(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
raw = request.query_params.get("window")
|
||||
window = int(raw) if raw and raw.isdigit() else None
|
||||
return {
|
||||
"id": conv.external_id,
|
||||
"text": await conversations.read(conv, window=window),
|
||||
}
|
||||
|
||||
@app.post(
|
||||
"/api/conversations/{public_id}/messages", status_code=status.HTTP_202_ACCEPTED
|
||||
)
|
||||
async def post_message(public_id: str, request: Request) -> dict[str, Any]:
|
||||
token = await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
data = await body_of(request)
|
||||
item = await conversations.post(
|
||||
conv, text_of(data), origin=str(data.get("origin") or "user")
|
||||
)
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{token}",
|
||||
kind="api_message",
|
||||
agent_name=conv.agent_name,
|
||||
conversation=conv.external_id,
|
||||
)
|
||||
return {"id": conv.external_id, "item": item.id, "status": item.status}
|
||||
|
||||
@app.post(
|
||||
"/api/conversations/{public_id}/inject", status_code=status.HTTP_202_ACCEPTED
|
||||
)
|
||||
async def post_inject(public_id: str, request: Request) -> dict[str, Any]:
|
||||
token = await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
data = await body_of(request)
|
||||
urgency = str(data.get("urgency") or "normal")
|
||||
if urgency not in ("normal", "urgent"):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "urgency must be normal|urgent"
|
||||
)
|
||||
item = await conversations.inject(
|
||||
conv,
|
||||
text_of(data),
|
||||
urgency=cast("Any", urgency),
|
||||
origin=str(data.get("origin") or "api"),
|
||||
)
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{token}",
|
||||
kind="api_inject",
|
||||
agent_name=conv.agent_name,
|
||||
conversation=conv.external_id,
|
||||
urgency=urgency,
|
||||
)
|
||||
return {"id": conv.external_id, "item": item.id, "priority": item.priority}
|
||||
|
||||
@app.post("/api/conversations/{public_id}/say")
|
||||
async def post_say(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
return await conversations.say(conv, text_of(await body_of(request)))
|
||||
|
||||
@app.post(
|
||||
"/api/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def post_branch(public_id: str, request: Request) -> dict[str, Any]:
|
||||
token = await require_token(request, runtime, scope=SCOPE)
|
||||
parent = await conv_of(public_id)
|
||||
data = await body_of(request)
|
||||
seed = str(data.get("seed_mode") or data.get("seed") or "morning")
|
||||
if seed not in SEEDS:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, f"seed must be one of {SEEDS}"
|
||||
)
|
||||
agent = str(data.get("agent") or parent.agent_name)
|
||||
try:
|
||||
child = await conversations.spawn(
|
||||
kind="branch",
|
||||
agent=agent,
|
||||
seed=seed,
|
||||
parent=parent,
|
||||
text=data.get("text"),
|
||||
title=data.get("title"),
|
||||
window=int_or_none(data, "window"),
|
||||
origin="api",
|
||||
)
|
||||
except (ValueError, LookupError) as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{token}",
|
||||
kind="api_branch",
|
||||
agent_name=agent,
|
||||
conversation=child.external_id,
|
||||
parent=parent.external_id,
|
||||
seed=seed,
|
||||
)
|
||||
return await conversations.describe(child)
|
||||
|
||||
@app.post("/api/conversations/{public_id}/merge")
|
||||
async def post_merge(public_id: str, request: Request) -> dict[str, Any]:
|
||||
token = await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
try:
|
||||
result = await conversations.merge(conv)
|
||||
except (ValueError, LookupError) as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{token}",
|
||||
kind="api_merge",
|
||||
agent_name=conv.agent_name,
|
||||
conversation=conv.external_id,
|
||||
)
|
||||
return {
|
||||
"id": conv.external_id,
|
||||
"status": "merged",
|
||||
"fork": result.conversation.external_id,
|
||||
"text": result.text,
|
||||
}
|
||||
|
||||
@app.post("/api/conversations/{public_id}/fork")
|
||||
async def post_fork(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
data = await body_of(request)
|
||||
try:
|
||||
result = await conversations.fork(
|
||||
conv,
|
||||
text_of(data, "prompt"),
|
||||
window=int_or_none(data, "window"),
|
||||
strip_tools=bool(data.get("strip_tools", False)),
|
||||
)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
return {
|
||||
"id": conv.external_id,
|
||||
"fork": result.conversation.external_id,
|
||||
"text": result.text,
|
||||
}
|
||||
|
||||
@app.post("/api/conversations/{public_id}/bind")
|
||||
async def post_bind(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
data = await body_of(request)
|
||||
frontend = text_of(data, "frontend")
|
||||
external_id = text_of(data, "external_id")
|
||||
await conversations.bind(
|
||||
conv,
|
||||
frontend=frontend,
|
||||
external_id=external_id,
|
||||
visible=bool(data.get("visible", True)),
|
||||
)
|
||||
return await conversations.describe(conv)
|
||||
|
||||
@app.patch("/api/conversations/{public_id}/flags")
|
||||
async def patch_flags(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
data = await body_of(request)
|
||||
return conversations.public(await conversations.set_flags(conv, data))
|
||||
|
||||
@app.patch("/api/conversations/{public_id}")
|
||||
async def patch_conversation(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
data = await body_of(request)
|
||||
try:
|
||||
if isinstance(data.get("status"), str):
|
||||
conv = await conversations.set_status(conv, data["status"])
|
||||
if isinstance(data.get("title"), str):
|
||||
conv = await conversations.set_title(conv, data["title"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
return conversations.public(conv)
|
||||
|
||||
@app.get("/api/conversations/{public_id}/events")
|
||||
async def conversation_events(public_id: str, request: Request) -> Any:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
return _sse(runtime, conversation_id=conv.external_id)
|
||||
|
||||
@app.get("/api/events")
|
||||
async def all_events(request: Request) -> Any:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
return _sse(runtime, conversation_id=None)
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def sessions(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
pool = runtime.pool
|
||||
return {
|
||||
"rss": pool.rss() if pool is not None else None,
|
||||
"rss_limit": pool.rss_limit if pool is not None else None,
|
||||
"sessions": pool.snapshot() if pool is not None else [],
|
||||
}
|
||||
|
||||
@app.get("/api/schedules")
|
||||
async def schedules(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
raw = request.query_params.get("conversation")
|
||||
conv = await conv_of(raw) if raw else None
|
||||
return {
|
||||
"schedules": [
|
||||
{
|
||||
"id": s.id,
|
||||
"conversation_row": s.conversation_id,
|
||||
"execute_at": s.execute_at.isoformat(),
|
||||
"text": s.text,
|
||||
"delivered_at": s.delivered_at.isoformat()
|
||||
if s.delivered_at
|
||||
else None,
|
||||
}
|
||||
for s in await conversations.schedules(conv)
|
||||
]
|
||||
}
|
||||
|
||||
@app.get("/api/usage")
|
||||
async def usage(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
hours = float(request.query_params.get("hours", "24"))
|
||||
since = datetime.now(UTC) - timedelta(hours=hours)
|
||||
columns = (
|
||||
func.count(),
|
||||
func.coalesce(func.sum(Usage.input_tokens), 0),
|
||||
func.coalesce(func.sum(Usage.output_tokens), 0),
|
||||
func.coalesce(func.sum(Usage.cache_read_tokens), 0),
|
||||
func.coalesce(func.sum(Usage.cache_creation_tokens), 0),
|
||||
func.coalesce(func.sum(Usage.cost_usd), 0.0),
|
||||
)
|
||||
async with runtime.db.session() as session:
|
||||
by_agent = (
|
||||
await session.execute( # ty: ignore[deprecated]
|
||||
sa_select(col(Usage.agent_name), *columns)
|
||||
.where(col(Usage.ts) >= since.replace(tzinfo=None))
|
||||
.group_by(col(Usage.agent_name))
|
||||
)
|
||||
).all()
|
||||
by_conversation = (
|
||||
await session.execute( # ty: ignore[deprecated]
|
||||
sa_select(col(Usage.conversation_id), *columns)
|
||||
.where(col(Usage.ts) >= since.replace(tzinfo=None))
|
||||
.group_by(col(Usage.conversation_id))
|
||||
)
|
||||
).all()
|
||||
return {
|
||||
"since": since.isoformat(timespec="seconds"),
|
||||
"by_agent": [_usage_row("agent", r) for r in by_agent],
|
||||
"by_conversation": [_usage_row("conversation", r) for r in by_conversation],
|
||||
}
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error(_request: Request, exc: HTTPException) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"error": exc.detail},
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _usage_row(label: str, row: Any) -> dict[str, Any]:
|
||||
key, turns, inp, out, cache_read, cache_creation, cost = row
|
||||
return {
|
||||
label: key,
|
||||
"turns": turns,
|
||||
"input": inp,
|
||||
"output": out,
|
||||
"cache_read": cache_read,
|
||||
"cache_creation": cache_creation,
|
||||
"cost_usd": round(float(cost or 0.0), 4),
|
||||
}
|
||||
|
||||
|
||||
def _sse(runtime: GatewayRuntime, *, conversation_id: str | None) -> StreamingResponse:
|
||||
async def gen() -> AsyncIterator[bytes]:
|
||||
stream = runtime.bus.stream(conversation_id=conversation_id)
|
||||
yield sse_pack("hello", {"type": "hello", "conversation_id": conversation_id})
|
||||
async for event in events_with_heartbeat(stream):
|
||||
if event is None:
|
||||
yield KEEPALIVE
|
||||
continue
|
||||
yield sse_pack(str(event["type"]), event)
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=SSE_HEADERS)
|
||||
Reference in New Issue
Block a user