1240 lines
46 KiB
Python
1240 lines
46 KiB
Python
"""``ApiFrontend`` - ``/api/*``: conversations, SSE, sessions, usage, limits.
|
|
|
|
Bearer scope ``api``; token and audit management need ``admin``. Every
|
|
write goes through conversations/service.py; the frontend only shapes JSON.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import secrets
|
|
from collections import Counter
|
|
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 sqlmodel import col, select
|
|
|
|
from beaver_gateway.agents.claude import ClaudeAgent
|
|
from beaver_gateway.agents.prompts import assemble
|
|
from beaver_gateway.backends.transcript import prompt_count
|
|
from beaver_gateway.conversations.context import compose
|
|
from beaver_gateway.conversations.injects import URGENCY
|
|
from beaver_gateway.conversations.kinds import Kind, as_kind
|
|
from beaver_gateway.conversations.service import SEEDS, implied_title
|
|
from beaver_gateway.frontends.base import Frontend
|
|
from beaver_gateway.frontends.bearer import require_token
|
|
from beaver_gateway.frontends.sse import (
|
|
KEEPALIVE,
|
|
SSE_HEADERS,
|
|
events_with_heartbeat,
|
|
sse_pack,
|
|
)
|
|
from beaver_gateway.frontends.urls import frontend_url
|
|
from beaver_gateway.security import audit
|
|
from beaver_gateway.security.auth import hash_token
|
|
from beaver_gateway.storage import (
|
|
create_token,
|
|
list_audit_records,
|
|
list_tokens,
|
|
revoke_token,
|
|
)
|
|
from beaver_gateway.storage.models import (
|
|
Conversation,
|
|
InjectQueueItem,
|
|
RateLimit,
|
|
Token,
|
|
Usage,
|
|
)
|
|
from beaver_gateway.vault.links import LinkIndex
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator, Iterable, Sequence
|
|
from pathlib import Path
|
|
|
|
from beaver_gateway.conversations.service import Conversations
|
|
from beaver_gateway.frontends.base import GatewayRuntime
|
|
from beaver_gateway.jobs.scheduler import Job, Scheduler
|
|
|
|
_log = logging.getLogger("beaver_gateway.frontends.api")
|
|
|
|
__all__ = ["ApiFrontend"]
|
|
|
|
SCOPE = "api"
|
|
ADMIN_SCOPE = "admin"
|
|
GROUPS = ("day", "agent", "conversation", "model")
|
|
WINDOWS: dict[str, timedelta] = {
|
|
"five_hour": timedelta(hours=5),
|
|
"seven_day": timedelta(days=7),
|
|
"seven_day_opus": timedelta(days=7),
|
|
"seven_day_sonnet": timedelta(days=7),
|
|
}
|
|
MEMORY_MAX_DEPTH = 12
|
|
MEMORY_MAX_FILE = 2_000_000
|
|
MEMORY_MAX_ENTRIES = 5000
|
|
SEARCH_MIN = 2
|
|
GRAPH_LIMIT = 120
|
|
SEARCH_LIMIT = 20
|
|
SNIPPET_MAX = 160
|
|
|
|
|
|
class ApiFrontend(Frontend):
|
|
name = "api"
|
|
kinds = ("master", "branch", "deep", "job")
|
|
path = "/api"
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
master_agent: str | None = None,
|
|
branch_agent: str | None = None,
|
|
deep_agent: str | None = None,
|
|
job_agent: str | None = None,
|
|
memory_root: Path | None = None,
|
|
vault_root: Path | None = None,
|
|
) -> None:
|
|
self.master_agent = master_agent
|
|
self.branch_agent = branch_agent
|
|
self.deep_agent = deep_agent
|
|
self.job_agent = job_agent
|
|
self.memory_root = memory_root.resolve() if memory_root is not None else None
|
|
self.vault_root = vault_root.resolve() if vault_root is not None else None
|
|
self._app: FastAPI | None = None
|
|
|
|
def agent_for(self, kind: Kind) -> str | None:
|
|
return {
|
|
"master": self.master_agent,
|
|
"branch": self.branch_agent,
|
|
"deep": self.deep_agent,
|
|
"job": self.job_agent,
|
|
}.get(kind)
|
|
|
|
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, memory_root=self.memory_root, vault_root=self.vault_root
|
|
)
|
|
|
|
def app(self) -> FastAPI | None:
|
|
return self._app
|
|
|
|
|
|
def build_app( # noqa: PLR0915
|
|
runtime: GatewayRuntime,
|
|
*,
|
|
memory_root: Path | None = None,
|
|
vault_root: Path | None = None,
|
|
) -> FastAPI:
|
|
app = FastAPI(title="beaver-gateway / API")
|
|
links = LinkIndex(vault_root) if vault_root is not None else None
|
|
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.resolve(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 urgency_of(data: dict[str, Any], default: str) -> Any:
|
|
urgency = str(data.get("urgency") or default)
|
|
if urgency not in URGENCY:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST, f"urgency must be one of {URGENCY}"
|
|
)
|
|
return urgency
|
|
|
|
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
|
|
|
|
def query_int(request: Request, key: str, default: int) -> int:
|
|
raw = request.query_params.get(key)
|
|
return int(raw) if raw and raw.isdigit() else default
|
|
|
|
@app.get("/healthz")
|
|
async def healthz() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
@app.get("/agents")
|
|
async def list_agents(request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
return {
|
|
"agents": [
|
|
{
|
|
"name": a.name,
|
|
"model": a.model,
|
|
"type": a.__class__.__name__,
|
|
"kinds": list(getattr(a, "kinds", ())),
|
|
"effort": getattr(getattr(a, "options", None), "effort", None),
|
|
}
|
|
for a in runtime.agents
|
|
],
|
|
"frontends": [
|
|
{
|
|
"name": fe.name or fe.__class__.__name__,
|
|
"type": fe.__class__.__name__,
|
|
"kinds": list(fe.kinds),
|
|
"default_agents": {
|
|
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
|
|
},
|
|
"path": fe.path,
|
|
"scope": fe.scope,
|
|
"url": frontend_url(request, runtime, fe),
|
|
}
|
|
for fe in runtime.frontends
|
|
],
|
|
"mcps": [{"name": m.name, "kind": m.kind} for m in runtime.mcps],
|
|
}
|
|
|
|
@app.get("/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=query_int(request, "limit", 200),
|
|
)
|
|
latest = await conversations.queue.latest(cast("int", r.id) for r in rows)
|
|
firsts = await conversations.first_user_texts(
|
|
cast("int", r.id) for r in rows if not r.title
|
|
)
|
|
contexts = await conversations.context_tokens_by_id(r.external_id for r in rows)
|
|
public_ids = {r.id: r.external_id for r in rows}
|
|
return {
|
|
"conversations": [
|
|
{
|
|
**conversations.public(r),
|
|
"title": r.title or implied_title(firsts.get(cast("int", r.id))),
|
|
"last_item": _queue_item(latest.get(cast("int", r.id))),
|
|
"context_tokens": contexts.get(r.external_id, 0),
|
|
"parent": public_ids.get(r.parent_id) if r.parent_id else None,
|
|
}
|
|
for r in rows
|
|
]
|
|
}
|
|
|
|
@app.get("/search")
|
|
async def search(request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
query = (request.query_params.get("q") or "").strip()
|
|
if len(query) < SEARCH_MIN:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST, f"q must be at least {SEARCH_MIN} chars"
|
|
)
|
|
limit = min(query_int(request, "limit", SEARCH_LIMIT), SEARCH_LIMIT)
|
|
rows = await conversations.search(query, limit=limit)
|
|
firsts = await conversations.first_user_texts(
|
|
cast("int", r.id) for r in rows if not r.title
|
|
)
|
|
contexts = await conversations.context_tokens_by_id(r.external_id for r in rows)
|
|
files = _grep_memory(memory_root, query, limit=limit) if memory_root else []
|
|
return {
|
|
"query": query,
|
|
"conversations": [
|
|
{
|
|
**conversations.public(r),
|
|
"title": r.title or implied_title(firsts.get(cast("int", r.id))),
|
|
"context_tokens": contexts.get(r.external_id, 0),
|
|
}
|
|
for r in rows
|
|
],
|
|
"files": files,
|
|
}
|
|
|
|
@app.post("/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)
|
|
agent = data.get("agent")
|
|
try:
|
|
kind = as_kind(str(data.get("kind") or "deep"))
|
|
except ValueError as exc:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
|
if kind == "fork":
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "fork is internal")
|
|
if agent is not None and (
|
|
not isinstance(agent, str) or agent not in runtime.agents
|
|
):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "unknown `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, LookupError) 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=conv.agent_name,
|
|
conversation=conv.external_id,
|
|
seed=seed,
|
|
)
|
|
return await conversations.describe(conv)
|
|
|
|
@app.get("/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"] = [
|
|
_queue_item(i)
|
|
for i in await conversations.queue.recent(cast("int", conv.id), limit=20)
|
|
]
|
|
return out
|
|
|
|
@app.get("/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.get("/conversations/{public_id}/history")
|
|
async def get_history(public_id: str, request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
conv = await conv_of(public_id)
|
|
return {"id": conv.external_id, "messages": await conversations.history(conv)}
|
|
|
|
@app.get("/conversations/{public_id}/context")
|
|
async def get_context(public_id: str, request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
conv = await conv_of(public_id)
|
|
agent = runtime.agents.get(conv.agent_name)
|
|
if not isinstance(agent, ClaudeAgent):
|
|
raise HTTPException(
|
|
status.HTTP_404_NOT_FOUND, f"no claude agent behind {public_id}"
|
|
)
|
|
entries = list(await conversations.entries(conv))
|
|
for sub in await conversations.subpaths(conv):
|
|
entries.extend(await conversations.entries(conv, subpath=sub))
|
|
out = compose(
|
|
agent,
|
|
as_kind(conv.kind),
|
|
entries,
|
|
context_tokens=await conversations.context_tokens(conv),
|
|
)
|
|
out["id"] = conv.external_id
|
|
out["turns"] = prompt_count(entries)
|
|
if request.query_params.get("prompt"):
|
|
sources = agent.prompt_for(as_kind(conv.kind))
|
|
out["prompt_text"] = (
|
|
assemble(sources) if sources is not None else agent.system_prompt
|
|
)
|
|
return out
|
|
|
|
@app.get("/vault/graph")
|
|
async def vault_graph(request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
if links is None:
|
|
raise HTTPException(
|
|
status.HTTP_404_NOT_FOUND,
|
|
"no vault root: set ApiFrontend(vault_root=...)",
|
|
)
|
|
paths = [p for p in request.query_params.getlist("path") if p]
|
|
limit = min(query_int(request, "limit", GRAPH_LIMIT), GRAPH_LIMIT)
|
|
return {"root": str(links.root), **links.neighbours(paths, limit=limit)}
|
|
|
|
@app.get("/conversations/{public_id}/entries")
|
|
async def get_entries(public_id: str, request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
conv = await conv_of(public_id)
|
|
subpath = request.query_params.get("subpath") or ""
|
|
entries = await conversations.entries(conv, subpath=subpath)
|
|
limit = query_int(request, "limit", 100)
|
|
offset = query_int(request, "offset", max(len(entries) - limit, 0))
|
|
return {
|
|
"id": conv.external_id,
|
|
"subpath": subpath,
|
|
"subpaths": await conversations.subpaths(conv),
|
|
"total": len(entries),
|
|
"offset": offset,
|
|
"entries": entries[offset : offset + limit],
|
|
}
|
|
|
|
@app.post(
|
|
"/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("/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 = urgency_of(data, "normal")
|
|
item = await conversations.inject(
|
|
conv,
|
|
text_of(data),
|
|
urgency=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("/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("/conversations/{public_id}/answer")
|
|
async def post_answer(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)
|
|
question_id = text_of(data, "question_id")
|
|
if not conversations.answer(question_id, text_of(data, "answer")):
|
|
raise HTTPException(
|
|
status.HTTP_404_NOT_FOUND, f"no open question {question_id}"
|
|
)
|
|
return {"id": conv.external_id, "question_id": question_id}
|
|
|
|
@app.post("/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 = data.get("agent")
|
|
if agent is not None and not isinstance(agent, str):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "`agent` must be a string")
|
|
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=child.agent_name,
|
|
conversation=child.external_id,
|
|
parent=parent.external_id,
|
|
seed=seed,
|
|
)
|
|
return await conversations.describe(child)
|
|
|
|
@app.post("/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("/conversations/{public_id}/close")
|
|
async def post_close(public_id: str, request: Request) -> dict[str, Any]:
|
|
token = await require_token(request, runtime, scope=SCOPE)
|
|
conv = await conv_of(public_id)
|
|
if conv.kind != "deep":
|
|
closed = await conversations.close(conv)
|
|
return {"id": closed.external_id, "status": closed.status}
|
|
try:
|
|
result = await conversations.distill(conv, reason="api")
|
|
except (ValueError, LookupError) as exc:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
|
except RuntimeError as exc:
|
|
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
|
await audit.log(
|
|
runtime,
|
|
actor=f"token:{token}",
|
|
kind="api_close",
|
|
agent_name=conv.agent_name,
|
|
conversation=conv.external_id,
|
|
digest=str(result.digest.path) if result.digest else None,
|
|
)
|
|
return {
|
|
"id": conv.external_id,
|
|
"status": "closed",
|
|
"fork": result.fork.external_id,
|
|
"text": result.text,
|
|
"digest": str(result.digest.path) if result.digest else None,
|
|
"error": result.error,
|
|
}
|
|
|
|
@app.post("/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("/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 = data.get("external_id")
|
|
try:
|
|
if isinstance(external_id, str) and external_id.strip():
|
|
await conversations.bind(
|
|
conv,
|
|
frontend=frontend,
|
|
external_id=external_id,
|
|
visible=bool(data.get("visible", True)),
|
|
)
|
|
elif await conversations.frontend(frontend).materialize(conv) is None:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
f"frontend {frontend!r} cannot open a window for {conv.kind!r}",
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
|
return await conversations.describe(conv)
|
|
|
|
@app.patch("/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("/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("/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("/events")
|
|
async def all_events(request: Request) -> Any:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
return _sse(runtime, conversation_id=None)
|
|
|
|
@app.get("/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("/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": await conversations.schedules(conv)}
|
|
|
|
def scheduler_of() -> Scheduler:
|
|
if runtime.scheduler is None:
|
|
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "no scheduler")
|
|
return cast("Scheduler", runtime.scheduler)
|
|
|
|
@app.get("/jobs")
|
|
async def jobs(request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
return await scheduler_of().snapshot()
|
|
|
|
def job_of(scheduler: Scheduler, name: str) -> Job:
|
|
job = scheduler.job(name)
|
|
if job is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, f"no job {name!r}")
|
|
return job
|
|
|
|
@app.post("/jobs/{name}/run", status_code=status.HTTP_202_ACCEPTED)
|
|
async def run_job(name: str, request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
scheduler = scheduler_of()
|
|
job = job_of(scheduler, name)
|
|
return {"job": await scheduler.trigger(job, await body_of(request))}
|
|
|
|
@app.get("/jobs/{name}/runs")
|
|
async def job_runs(name: str, request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
scheduler = scheduler_of()
|
|
job = job_of(scheduler, name)
|
|
limit = min(int(request.query_params.get("limit", 50)), 500)
|
|
return {"job": job.name, "runs": await scheduler.runs(job.name, limit)}
|
|
|
|
@app.delete("/jobs/queue/{job_id}")
|
|
async def cancel_job(job_id: int, request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
if not await scheduler_of().cancel(job_id):
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, f"no queued job {job_id}")
|
|
return {"cancelled": job_id}
|
|
|
|
@app.post(
|
|
"/conversations/{public_id}/schedule", status_code=status.HTTP_201_CREATED
|
|
)
|
|
async def post_schedule(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:
|
|
job_id, when = await conversations.schedule(
|
|
conv,
|
|
text_of(data, "at"),
|
|
text_of(data, "text"),
|
|
urgency=urgency_of(data, "wake"),
|
|
)
|
|
except (RuntimeError, ValueError) as exc:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
|
return {"job": job_id, "execute_at": _iso(when)}
|
|
|
|
@app.get("/usage")
|
|
async def usage(request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
q = request.query_params
|
|
group_by = q.get("group_by", "agent")
|
|
if group_by not in GROUPS:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST, f"group_by must be one of {GROUPS}"
|
|
)
|
|
until = _parse_time(q.get("until")) or datetime.now(UTC)
|
|
since = _parse_time(q.get("since")) or until - timedelta(
|
|
hours=float(q.get("hours", "24"))
|
|
)
|
|
rows = await _usage_rows(runtime, since, until)
|
|
groups = _group_usage(rows, group_by)
|
|
if group_by == "conversation":
|
|
titles = await _conversation_titles(runtime, [g["key"] for g in groups])
|
|
for g in groups:
|
|
g.update(titles.get(g["key"], {}))
|
|
return {
|
|
"since": since.isoformat(timespec="seconds"),
|
|
"until": until.isoformat(timespec="seconds"),
|
|
"group_by": group_by,
|
|
"total": _sum_usage(rows),
|
|
"rows": groups,
|
|
}
|
|
|
|
@app.get("/limits")
|
|
async def limits(request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
rows = await conversations.rate_limits(limit=200)
|
|
latest: dict[str, RateLimit] = {}
|
|
unified: dict[str, tuple[RateLimit, dict[str, Any]]] = {}
|
|
for row in rows:
|
|
latest.setdefault(row.window, row)
|
|
for name, info in _unified_windows(row).items():
|
|
unified.setdefault(name, (row, info))
|
|
now = datetime.now(UTC)
|
|
windows = []
|
|
for window in dict.fromkeys([*latest, *unified]):
|
|
row = latest.get(window)
|
|
seen = unified.get(window)
|
|
public = _limit_public(row) if row else _limit_blank(window, seen)
|
|
resets = row.resets_at if row else None
|
|
if seen and _newer_figure(seen, row):
|
|
carrier, info = seen
|
|
public["utilization"] = info.get("utilization")
|
|
public["reported_at"] = _iso(carrier.ts)
|
|
resets = _unix_dt(info.get("resetsAt")) or resets
|
|
public["resets_at"] = _iso(resets)
|
|
length = _window_length(window)
|
|
anchor = row if row is not None else (seen[0] if seen else None)
|
|
if length is not None and resets is not None:
|
|
since = _aware(resets) - length
|
|
else:
|
|
since = _aware(anchor.ts) if anchor else now
|
|
gateway = _sum_usage(await _usage_rows(runtime, since, now))
|
|
gateway["since"] = since.isoformat(timespec="seconds")
|
|
known = next(
|
|
(
|
|
r
|
|
for r in rows
|
|
if r.window == window
|
|
and r.utilization is not None
|
|
and _aware(r.ts) >= since
|
|
),
|
|
None,
|
|
)
|
|
windows.append(
|
|
{
|
|
**public,
|
|
"gateway": gateway,
|
|
"last_known": _limit_public(known) if known else None,
|
|
}
|
|
)
|
|
windows.sort(
|
|
key=lambda w: (_window_length(w["window"]) or timedelta.max, w["window"])
|
|
)
|
|
return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]}
|
|
|
|
@app.get("/memory")
|
|
async def memory_tree(request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
root = _memory_root(memory_root)
|
|
return {"root": str(root), "tree": _tree(root, root, depth=0)}
|
|
|
|
@app.get("/memory/file")
|
|
async def memory_file(request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=SCOPE)
|
|
root = _memory_root(memory_root)
|
|
target = _memory_path(root, request.query_params.get("path") or "")
|
|
if not target.is_file():
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such file")
|
|
stat = target.stat()
|
|
if stat.st_size > MEMORY_MAX_FILE:
|
|
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "file too large")
|
|
try:
|
|
content = target.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise HTTPException(
|
|
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "not a text file"
|
|
) from exc
|
|
return {
|
|
"path": str(target.relative_to(root)),
|
|
"size": stat.st_size,
|
|
"mtime": _mtime(stat.st_mtime),
|
|
"content": content,
|
|
}
|
|
|
|
@app.get("/scopes")
|
|
async def scopes(request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
|
by_frontend = {
|
|
fe.scope: fe.name or fe.path or "" for fe in runtime.frontends if fe.scope
|
|
}
|
|
return {
|
|
"scopes": [
|
|
{"scope": s, "frontend": by_frontend.get(s)}
|
|
for s in sorted(runtime.scopes)
|
|
]
|
|
}
|
|
|
|
@app.get("/tokens")
|
|
async def tokens(request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
|
include_revoked = request.query_params.get("include_revoked") == "1"
|
|
async with runtime.db.session() as session:
|
|
rows = await list_tokens(session, include_revoked=include_revoked)
|
|
return {"tokens": [_token_public(t) for t in rows]}
|
|
|
|
@app.post("/tokens", status_code=status.HTTP_201_CREATED)
|
|
async def token_create(request: Request) -> dict[str, Any]:
|
|
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
|
|
data = await body_of(request)
|
|
name = text_of(data, "name").strip()
|
|
scope = str(data.get("scope") or "*")
|
|
if scope not in runtime.scopes:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}")
|
|
plaintext = secrets.token_urlsafe(32)
|
|
async with runtime.db.session() as session:
|
|
try:
|
|
row = await create_token(
|
|
session, name=name, scope=scope, hashed_value=hash_token(plaintext)
|
|
)
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status.HTTP_409_CONFLICT, f"could not create token {name!r}: {exc}"
|
|
) from exc
|
|
await runtime.token_store.invalidate()
|
|
await audit.log(
|
|
runtime,
|
|
actor=f"token:{actor}",
|
|
kind="token_create",
|
|
name=name,
|
|
scope=scope,
|
|
token_id=row.id,
|
|
)
|
|
return {"token": _token_public(row), "plaintext": plaintext}
|
|
|
|
@app.post("/tokens/{token_id}/revoke")
|
|
async def token_revoke(token_id: int, request: Request) -> dict[str, Any]:
|
|
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
|
|
async with runtime.db.session() as session:
|
|
ok = await revoke_token(session, token_id=token_id)
|
|
if not ok:
|
|
raise HTTPException(
|
|
status.HTTP_404_NOT_FOUND, f"no active token with id {token_id}"
|
|
)
|
|
await runtime.token_store.invalidate()
|
|
await audit.log(
|
|
runtime, actor=f"token:{actor}", kind="token_revoke", token_id=token_id
|
|
)
|
|
return {"id": token_id, "revoked": True}
|
|
|
|
@app.get("/audit")
|
|
async def audit_list(request: Request) -> dict[str, Any]:
|
|
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
|
before_raw = request.query_params.get("before")
|
|
before = int(before_raw) if before_raw and before_raw.isdigit() else None
|
|
limit = min(query_int(request, "limit", 50), 500)
|
|
async with runtime.db.session() as session:
|
|
rows = await list_audit_records(session, limit=limit, before_id=before)
|
|
return {
|
|
"records": [
|
|
{
|
|
"id": r.id,
|
|
"ts": _iso(r.ts),
|
|
"actor": r.actor,
|
|
"kind": r.kind,
|
|
"agent": r.agent_name,
|
|
"detail": _detail(r.detail_json),
|
|
}
|
|
for r in rows
|
|
],
|
|
"next_before": rows[-1].id if len(rows) == limit else None,
|
|
}
|
|
|
|
@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 _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)
|
|
|
|
|
|
def _parse_time(raw: str | None) -> datetime | None:
|
|
if not raw:
|
|
return None
|
|
try:
|
|
value = datetime.fromisoformat(raw)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST, f"bad timestamp {raw!r}"
|
|
) from exc
|
|
return _aware(value)
|
|
|
|
|
|
def _aware(value: datetime) -> datetime:
|
|
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
|
|
|
|
|
def _iso(value: datetime | None) -> str | None:
|
|
return _aware(value).isoformat(timespec="seconds") if value is not None else None
|
|
|
|
|
|
def _mtime(value: float) -> str:
|
|
return datetime.fromtimestamp(value, tz=UTC).isoformat(timespec="seconds")
|
|
|
|
|
|
async def _usage_rows(
|
|
runtime: GatewayRuntime, since: datetime, until: datetime
|
|
) -> list[Usage]:
|
|
stmt = (
|
|
select(Usage)
|
|
.where(
|
|
col(Usage.ts) >= since.astimezone(UTC).replace(tzinfo=None),
|
|
col(Usage.ts) < until.astimezone(UTC).replace(tzinfo=None),
|
|
)
|
|
.order_by(col(Usage.ts))
|
|
)
|
|
async with runtime.db.session() as session:
|
|
return list((await session.exec(stmt)).all())
|
|
|
|
|
|
def _empty() -> dict[str, Any]:
|
|
return {
|
|
"turns": 0,
|
|
"input": 0,
|
|
"output": 0,
|
|
"cache_read": 0,
|
|
"cache_creation": 0,
|
|
"cost_usd": 0.0,
|
|
"web_searches": 0,
|
|
}
|
|
|
|
|
|
def _add(acc: dict[str, Any], row: Usage) -> None:
|
|
acc["turns"] += 1
|
|
acc["input"] += row.input_tokens
|
|
acc["output"] += row.output_tokens
|
|
acc["cache_read"] += row.cache_read_tokens
|
|
acc["cache_creation"] += row.cache_creation_tokens
|
|
acc["cost_usd"] += row.cost_usd or 0.0
|
|
for per_model in (row.model_usage or {}).values():
|
|
acc["web_searches"] += int(per_model.get("webSearchRequests") or 0)
|
|
|
|
|
|
def _add_model(acc: dict[str, Any], per_model: dict[str, Any]) -> None:
|
|
acc["turns"] += 1
|
|
acc["input"] += int(per_model.get("inputTokens") or 0)
|
|
acc["output"] += int(per_model.get("outputTokens") or 0)
|
|
acc["cache_read"] += int(per_model.get("cacheReadInputTokens") or 0)
|
|
acc["cache_creation"] += int(per_model.get("cacheCreationInputTokens") or 0)
|
|
acc["cost_usd"] += float(per_model.get("costUSD") or 0.0)
|
|
acc["web_searches"] += int(per_model.get("webSearchRequests") or 0)
|
|
|
|
|
|
def _finish(acc: dict[str, Any]) -> dict[str, Any]:
|
|
acc["cost_usd"] = round(acc["cost_usd"], 4)
|
|
return acc
|
|
|
|
|
|
def _sum_usage(rows: Iterable[Usage]) -> dict[str, Any]:
|
|
acc = _empty()
|
|
for row in rows:
|
|
_add(acc, row)
|
|
return _finish(acc)
|
|
|
|
|
|
def _group_usage(rows: Sequence[Usage], group_by: str) -> list[dict[str, Any]]:
|
|
groups: dict[str, dict[str, Any]] = {}
|
|
agents: dict[str, Counter[str]] = {}
|
|
for row in rows:
|
|
if group_by == "model":
|
|
per_model = row.model_usage or {}
|
|
if not per_model:
|
|
_add(groups.setdefault(row.model, _empty()), row)
|
|
for model, mu in per_model.items():
|
|
_add_model(groups.setdefault(model, _empty()), mu)
|
|
continue
|
|
key = _group_key(row, group_by)
|
|
_add(groups.setdefault(key, _empty()), row)
|
|
agents.setdefault(key, Counter())[row.agent_name] += 1
|
|
out = []
|
|
for key, acc in groups.items():
|
|
entry = {"key": key, group_by: key, **_finish(acc)}
|
|
if key in agents:
|
|
entry["agent"] = agents[key].most_common(1)[0][0]
|
|
out.append(entry)
|
|
if group_by == "day":
|
|
out.sort(key=lambda g: g["key"])
|
|
else:
|
|
out.sort(key=lambda g: (-g["cost_usd"], -g["output"], g["key"]))
|
|
return out
|
|
|
|
|
|
def _group_key(row: Usage, group_by: str) -> str:
|
|
if group_by == "day":
|
|
return _aware(row.ts).date().isoformat()
|
|
if group_by == "agent":
|
|
return row.agent_name
|
|
return row.conversation_id or "-"
|
|
|
|
|
|
async def _conversation_titles(
|
|
runtime: GatewayRuntime, ids: Sequence[str]
|
|
) -> dict[str, dict[str, Any]]:
|
|
wanted = [i for i in ids if i != "-"]
|
|
if not wanted:
|
|
return {}
|
|
async with runtime.db.session() as session:
|
|
rows = (
|
|
await session.exec(
|
|
select(Conversation).where(col(Conversation.external_id).in_(wanted))
|
|
)
|
|
).all()
|
|
return {
|
|
r.external_id: {"title": r.title, "kind": r.kind, "status": r.status}
|
|
for r in rows
|
|
}
|
|
|
|
|
|
def _queue_item(item: InjectQueueItem | None) -> dict[str, Any] | None:
|
|
if item is None:
|
|
return None
|
|
return {
|
|
"id": item.id,
|
|
"priority": item.priority,
|
|
"origin": item.origin,
|
|
"status": item.status,
|
|
"created_at": _iso(item.created_at),
|
|
"text": item.text[:200],
|
|
}
|
|
|
|
|
|
def _window_length(name: str) -> timedelta | None:
|
|
if name.startswith("five_hour"):
|
|
return timedelta(hours=5)
|
|
if "seven_day" in name:
|
|
return timedelta(days=7)
|
|
return WINDOWS.get(name)
|
|
|
|
|
|
def _unix_dt(value: Any) -> datetime | None:
|
|
if isinstance(value, int | float) and value > 0:
|
|
return datetime.fromtimestamp(value, tz=UTC)
|
|
return None
|
|
|
|
|
|
def _unified_windows(row: RateLimit) -> dict[str, dict[str, Any]]:
|
|
"""Every window the event carried a figure for, not just the one it was about."""
|
|
raw = row.raw if isinstance(row.raw, dict) else {}
|
|
found = raw.get("unifiedWindows")
|
|
if not isinstance(found, dict):
|
|
return {}
|
|
return {
|
|
str(name): info
|
|
for name, info in found.items()
|
|
if isinstance(info, dict) and info.get("utilization") is not None
|
|
}
|
|
|
|
|
|
def _newer_figure(
|
|
seen: tuple[RateLimit, dict[str, Any]], row: RateLimit | None
|
|
) -> bool:
|
|
if row is None or row.utilization is None:
|
|
return True
|
|
return seen[0].id != row.id and _aware(seen[0].ts) > _aware(row.ts)
|
|
|
|
|
|
def _limit_blank(
|
|
window: str, seen: tuple[RateLimit, dict[str, Any]] | None
|
|
) -> dict[str, Any]:
|
|
carrier = seen[0] if seen else None
|
|
return {
|
|
"id": None,
|
|
"ts": _iso(carrier.ts) if carrier else None,
|
|
"window": window,
|
|
"status": "allowed",
|
|
"utilization": None,
|
|
"resets_at": None,
|
|
"overage_status": None,
|
|
"overage_resets_at": None,
|
|
"agent": carrier.agent_name if carrier else None,
|
|
"session_id": carrier.session_id if carrier else None,
|
|
}
|
|
|
|
|
|
def _limit_public(row: RateLimit) -> dict[str, Any]:
|
|
return {
|
|
"id": row.id,
|
|
"ts": _iso(row.ts),
|
|
"window": row.window,
|
|
"status": row.status,
|
|
"utilization": row.utilization,
|
|
"resets_at": _iso(row.resets_at),
|
|
"overage_status": row.overage_status,
|
|
"overage_resets_at": _iso(row.overage_resets_at),
|
|
"agent": row.agent_name,
|
|
"session_id": row.session_id,
|
|
}
|
|
|
|
|
|
def _token_public(row: Token) -> dict[str, Any]:
|
|
return {
|
|
"id": row.id,
|
|
"name": row.name,
|
|
"scope": row.scope,
|
|
"created_at": _iso(row.created_at),
|
|
"last_used_at": _iso(row.last_used_at),
|
|
"revoked_at": _iso(row.revoked_at),
|
|
}
|
|
|
|
|
|
def _detail(raw: str) -> Any:
|
|
try:
|
|
return json.loads(raw) if raw else {}
|
|
except json.JSONDecodeError:
|
|
return raw
|
|
|
|
|
|
def _memory_root(root: Path | None) -> Path:
|
|
if root is None or not root.is_dir():
|
|
raise HTTPException(
|
|
status.HTTP_404_NOT_FOUND,
|
|
"memory root is not configured (ApiFrontend(memory_root=...))",
|
|
)
|
|
return root.resolve()
|
|
|
|
|
|
def _memory_path(root: Path, raw: str) -> Path:
|
|
target = (root / raw).resolve()
|
|
if not target.is_relative_to(root):
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such file")
|
|
return target
|
|
|
|
|
|
def _grep_memory(root: Path, query: str, *, limit: int) -> list[dict[str, Any]]:
|
|
needle = query.casefold()
|
|
hits: list[dict[str, Any]] = []
|
|
for path in sorted(root.rglob("*")):
|
|
if len(hits) >= limit:
|
|
break
|
|
if not path.is_file() or any(part.startswith(".") for part in path.parts):
|
|
continue
|
|
if path.suffix not in (".md", ".txt", ".json", ".yaml", ".yml"):
|
|
continue
|
|
try:
|
|
if path.stat().st_size > MEMORY_MAX_FILE:
|
|
continue
|
|
text = path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
rel = str(path.relative_to(root))
|
|
if needle in rel.casefold():
|
|
hits.append({"path": rel, "line": 0, "snippet": ""})
|
|
continue
|
|
for number, line in enumerate(text.splitlines(), 1):
|
|
if needle in line.casefold():
|
|
hits.append(
|
|
{"path": rel, "line": number, "snippet": line.strip()[:SNIPPET_MAX]}
|
|
)
|
|
break
|
|
return hits
|
|
|
|
|
|
def _tree(root: Path, directory: Path, *, depth: int) -> list[dict[str, Any]]:
|
|
if depth > MEMORY_MAX_DEPTH:
|
|
return []
|
|
out: list[dict[str, Any]] = []
|
|
try:
|
|
children = sorted(
|
|
directory.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
|
|
)
|
|
except OSError:
|
|
return out
|
|
for child in children:
|
|
if child.name.startswith(".") or len(out) >= MEMORY_MAX_ENTRIES:
|
|
continue
|
|
try:
|
|
stat = child.stat()
|
|
except OSError:
|
|
continue
|
|
node: dict[str, Any] = {
|
|
"name": child.name,
|
|
"path": str(child.relative_to(root)),
|
|
"type": "dir" if child.is_dir() else "file",
|
|
"mtime": _mtime(stat.st_mtime),
|
|
}
|
|
if child.is_dir():
|
|
node["children"] = _tree(root, child, depth=depth + 1)
|
|
else:
|
|
node["size"] = stat.st_size
|
|
out.append(node)
|
|
return out
|