feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot
This commit is contained in:
@@ -848,6 +848,7 @@ def _usage_of(result: ResultMessage | None) -> TurnUsage:
|
||||
cost_usd=result.total_cost_usd,
|
||||
duration_ms=result.duration_ms,
|
||||
num_turns=result.num_turns,
|
||||
model_usage=cast("dict[str, Any] | None", result.model_usage),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -357,6 +357,7 @@ async def _build_backends(
|
||||
cost_usd=event.usage.cost_usd,
|
||||
duration_ms=event.usage.duration_ms,
|
||||
num_turns=event.usage.num_turns,
|
||||
model_usage=event.usage.model_usage,
|
||||
)
|
||||
try:
|
||||
async with db.session() as session:
|
||||
|
||||
@@ -236,6 +236,11 @@ class TokenStore:
|
||||
self._flusher_task = None
|
||||
await self._flush_now()
|
||||
|
||||
def grant(self, name: str, value: str, *, scope: str = _BOOTSTRAP_SCOPE) -> None:
|
||||
"""Add an in-memory token for the process lifetime (the admin UI's bearer)."""
|
||||
self._bootstrap_by_value[value] = name
|
||||
self._bootstrap_scopes[name] = scope
|
||||
|
||||
async def invalidate(self) -> None:
|
||||
"""Force the next verify to re-read from DB (Phase 4.3 admin hook)."""
|
||||
self._loaded_at = 0.0
|
||||
|
||||
@@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from claude_agent_sdk import (
|
||||
AssistantMessage,
|
||||
RateLimitEvent,
|
||||
ResultMessage,
|
||||
StreamEvent,
|
||||
ToolResultBlock,
|
||||
@@ -43,6 +44,7 @@ from beaver_gateway.core.transcript import (
|
||||
messages_from_entries,
|
||||
render_messages,
|
||||
strip_tool_entries,
|
||||
text_of,
|
||||
window_entries,
|
||||
)
|
||||
from beaver_gateway.core.turn_capture import TurnCapture
|
||||
@@ -51,6 +53,7 @@ from beaver_gateway.storage.models import (
|
||||
Conversation,
|
||||
ConversationBinding,
|
||||
InjectQueueItem,
|
||||
RateLimit,
|
||||
Schedule,
|
||||
)
|
||||
|
||||
@@ -133,6 +136,23 @@ class _Runner:
|
||||
wake: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
task: asyncio.Task[None] | None = None
|
||||
turn_id: str | None = None
|
||||
origin: str | None = None
|
||||
text: str | None = None
|
||||
started_at: datetime | None = None
|
||||
tools: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
"""Tool calls of the running turn, in order; ``describe`` hands them to a
|
||||
panel that subscribed mid-turn."""
|
||||
|
||||
def snapshot(self) -> dict[str, Any] | None:
|
||||
if self.turn_id is None:
|
||||
return None
|
||||
return {
|
||||
"id": self.turn_id,
|
||||
"origin": self.origin,
|
||||
"text": self.text,
|
||||
"started_at": _iso(self.started_at),
|
||||
"tools": list(self.tools.values()),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -442,8 +462,21 @@ class Conversations:
|
||||
live = self._pool.get(conv.external_id)
|
||||
out["live"] = live is not None
|
||||
out["busy"] = live.busy if live is not None else False
|
||||
runner = self._runners.get(cast("int", conv.id))
|
||||
out["turn"] = runner.snapshot() if runner is not None else None
|
||||
pending = self.pending_question(conv.external_id)
|
||||
out["question"] = (
|
||||
{"id": pending[0], "questions": pending[1]} if pending else None
|
||||
)
|
||||
return out
|
||||
|
||||
async def rate_limits(self, *, limit: int = 100) -> list[RateLimit]:
|
||||
async with self._db.session() as session:
|
||||
result = await session.exec(
|
||||
select(RateLimit).order_by(col(RateLimit.id).desc()).limit(limit)
|
||||
)
|
||||
return list(result.all())
|
||||
|
||||
# ---- routing -------------------------------------------------------
|
||||
|
||||
@property
|
||||
@@ -593,14 +626,21 @@ class Conversations:
|
||||
return ForkResult(conversation=child, text=text, capture=capture)
|
||||
|
||||
async def read(self, conv: Conversation, *, window: int | None = None) -> str:
|
||||
return render_messages(await self.history(conv), window=window)
|
||||
|
||||
async def history(self, conv: Conversation) -> list[dict[str, Any]]:
|
||||
return messages_from_entries(cast("Any", await self.entries(conv)))
|
||||
|
||||
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
|
||||
if conv.session_id is None:
|
||||
return ""
|
||||
entries = await self._store.load(cast("Any", self._store_key(conv)))
|
||||
if not entries:
|
||||
return ""
|
||||
return render_messages(
|
||||
messages_from_entries(cast("Any", entries)), window=window
|
||||
)
|
||||
return []
|
||||
key = {**self._store_key(conv), "subpath": subpath}
|
||||
return list(await self._store.load(cast("Any", key)) or [])
|
||||
|
||||
async def subpaths(self, conv: Conversation) -> list[str]:
|
||||
if conv.session_id is None:
|
||||
return []
|
||||
return list(await self._store.list_subkeys(cast("Any", self._store_key(conv))))
|
||||
|
||||
async def inject(
|
||||
self,
|
||||
@@ -816,6 +856,10 @@ class Conversations:
|
||||
resume = session_id if session_id is not None else conv.session_id
|
||||
async with runner.lock:
|
||||
runner.turn_id = turn_id
|
||||
runner.origin = origin
|
||||
runner.text = _prompt_preview(messages)
|
||||
runner.started_at = datetime.now(UTC)
|
||||
runner.tools = {}
|
||||
await self._mark_running(conv, turn_id)
|
||||
self._bus.publish(
|
||||
"turn.start",
|
||||
@@ -823,6 +867,7 @@ class Conversations:
|
||||
turn_id=turn_id,
|
||||
origin=origin,
|
||||
item_origin=item_origin,
|
||||
text=runner.text,
|
||||
)
|
||||
stop = "error"
|
||||
cut = False
|
||||
@@ -836,7 +881,7 @@ class Conversations:
|
||||
kind=conv.kind,
|
||||
pinned=conv.kind == "master",
|
||||
tools=tools,
|
||||
observer=self._observer(conv.external_id, turn_id, origin),
|
||||
observer=self._observer(conv, runner, turn_id, origin),
|
||||
turn_id=turn_id,
|
||||
)
|
||||
async for event in events:
|
||||
@@ -1117,11 +1162,15 @@ class Conversations:
|
||||
)
|
||||
|
||||
def _observer(
|
||||
self, conversation_id: str, turn_id: str, origin: str
|
||||
self, conv: Conversation, runner: _Runner, turn_id: str, origin: str
|
||||
) -> Callable[[Any], None]:
|
||||
conversation_id = conv.external_id
|
||||
|
||||
def observe(message: Any) -> None:
|
||||
parent = getattr(message, "parent_tool_use_id", None)
|
||||
if isinstance(message, StreamEvent):
|
||||
if isinstance(message, RateLimitEvent):
|
||||
self._observe_rate_limit(conv, message)
|
||||
elif isinstance(message, StreamEvent):
|
||||
self._bus.publish(
|
||||
"stream",
|
||||
conversation_id=conversation_id,
|
||||
@@ -1133,7 +1182,7 @@ class Conversations:
|
||||
elif isinstance(message, AssistantMessage):
|
||||
for block in message.content:
|
||||
if isinstance(block, ToolUseBlock):
|
||||
self._bus.publish(
|
||||
event = self._bus.publish(
|
||||
"tool",
|
||||
conversation_id=conversation_id,
|
||||
turn_id=turn_id,
|
||||
@@ -1143,11 +1192,21 @@ class Conversations:
|
||||
name=block.name,
|
||||
input=block.input,
|
||||
)
|
||||
runner.tools[block.id] = {
|
||||
"tool_use_id": block.id,
|
||||
"name": block.name,
|
||||
"input": block.input,
|
||||
"parent_tool_use_id": parent,
|
||||
"started_at": event["ts"],
|
||||
"ended_at": None,
|
||||
"is_error": None,
|
||||
"content": None,
|
||||
}
|
||||
elif isinstance(message, UserMessage):
|
||||
blocks = message.content if isinstance(message.content, list) else ()
|
||||
for block in blocks:
|
||||
if isinstance(block, ToolResultBlock):
|
||||
self._bus.publish(
|
||||
event = self._bus.publish(
|
||||
"tool.result",
|
||||
conversation_id=conversation_id,
|
||||
turn_id=turn_id,
|
||||
@@ -1157,6 +1216,11 @@ class Conversations:
|
||||
is_error=bool(block.is_error),
|
||||
content=_result_preview(block.content),
|
||||
)
|
||||
node = runner.tools.get(block.tool_use_id)
|
||||
if node is not None:
|
||||
node["ended_at"] = event["ts"]
|
||||
node["is_error"] = event["is_error"]
|
||||
node["content"] = event["content"]
|
||||
elif isinstance(message, ResultMessage) and parent is None:
|
||||
self._bus.publish(
|
||||
"result",
|
||||
@@ -1170,6 +1234,40 @@ class Conversations:
|
||||
|
||||
return observe
|
||||
|
||||
def _observe_rate_limit(self, conv: Conversation, message: RateLimitEvent) -> None:
|
||||
info = message.rate_limit_info
|
||||
row = RateLimit(
|
||||
window=info.rate_limit_type or "unknown",
|
||||
status=info.status,
|
||||
utilization=info.utilization,
|
||||
resets_at=_from_unix(info.resets_at),
|
||||
overage_status=info.overage_status,
|
||||
overage_resets_at=_from_unix(info.overage_resets_at),
|
||||
agent_name=conv.agent_name,
|
||||
session_id=message.session_id,
|
||||
raw=dict(info.raw),
|
||||
)
|
||||
self._bus.publish(
|
||||
"rate_limit",
|
||||
conversation_id=conv.external_id,
|
||||
window=row.window,
|
||||
status=row.status,
|
||||
utilization=row.utilization,
|
||||
resets_at=_iso(row.resets_at),
|
||||
overage_status=row.overage_status,
|
||||
)
|
||||
task = asyncio.create_task(self._record_rate_limit(row))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
|
||||
async def _record_rate_limit(self, row: RateLimit) -> None:
|
||||
try:
|
||||
async with self._db.session() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("rate limit write failed")
|
||||
|
||||
async def _mark_running(self, conv: Conversation, turn_id: str) -> None:
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.running_turn = turn_id
|
||||
@@ -1254,6 +1352,17 @@ def _iso(value: datetime | None) -> str | None:
|
||||
return _aware(value).isoformat(timespec="seconds") if value is not None else None
|
||||
|
||||
|
||||
def _prompt_preview(messages: Sequence[Any], limit: int = 400) -> str | None:
|
||||
if not messages:
|
||||
return None
|
||||
text = text_of(messages[-1].get("content"))
|
||||
return text[:limit] if text else None
|
||||
|
||||
|
||||
def _from_unix(value: int | None) -> datetime | None:
|
||||
return datetime.fromtimestamp(value, tz=UTC) if value is not None else None
|
||||
|
||||
|
||||
def _result_preview(
|
||||
content: str | list[dict[str, Any]] | None, limit: int = 400
|
||||
) -> str:
|
||||
|
||||
@@ -24,6 +24,8 @@ class TurnUsage:
|
||||
cost_usd: float | None = None
|
||||
duration_ms: int | None = None
|
||||
num_turns: int | None = None
|
||||
model_usage: dict[str, Any] | None = None
|
||||
"""``ResultMessage.model_usage`` verbatim: per-model tokens, cost, web searches."""
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
"""Admin UI (Phase 4.3).
|
||||
|
||||
Browser-facing console: login, dashboard, token CRUD, audit viewer.
|
||||
Templates live in ``./templates``; the package loader picks them up via
|
||||
``importlib.resources`` so the layout works editable and inside the
|
||||
Docker image without copy hacks.
|
||||
"""
|
||||
"""Admin console: the ``ui/`` SPA served from the admin port plus its login."""
|
||||
|
||||
from beaver_gateway.frontends.admin.frontend import AdminFrontend
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,206 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}beaver-gateway · admin{% endblock %}</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fbfbfd;
|
||||
--fg: #1d1d1f;
|
||||
--muted: #6e6e73;
|
||||
--line: #e5e5ea;
|
||||
--accent: #0071e3;
|
||||
--danger: #d70015;
|
||||
--surface: #ffffff;
|
||||
--code-bg: #f5f5f7;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
|
||||
body {
|
||||
font-family: -apple-system, "SF Pro Display", "SF Pro Text",
|
||||
BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code, pre, kbd, samp {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
pre {
|
||||
background: var(--code-bg);
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
header.top {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
header.top .inner {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
header.top h1 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
nav.tabs { display: flex; gap: 1.1rem; flex: 1; }
|
||||
nav.tabs a {
|
||||
color: var(--fg);
|
||||
padding: 0.25rem 0.1rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
nav.tabs a.active {
|
||||
border-bottom-color: var(--fg);
|
||||
}
|
||||
header.top .actor {
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
main {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 2.25rem 1.5rem 4rem;
|
||||
}
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
margin: 2rem 0 0.75rem;
|
||||
}
|
||||
h2:first-of-type { margin-top: 0; }
|
||||
.muted { color: var(--muted); }
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.revoked td { color: var(--muted); }
|
||||
button, .btn {
|
||||
font-family: inherit;
|
||||
font-size: 0.92em;
|
||||
padding: 0.45rem 0.95rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
button:hover, .btn:hover { background: var(--code-bg); }
|
||||
button.primary, .btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
button.primary:hover, .btn.primary:hover {
|
||||
background: #005bb5;
|
||||
}
|
||||
button.danger { color: var(--danger); border-color: #f0c5c9; }
|
||||
button.danger:hover { background: #fdeff1; }
|
||||
form.inline { display: inline; margin: 0; }
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) 160px auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
.form-grid label {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
input[type="text"], input[type="password"], select {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 0.95em;
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
}
|
||||
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.banner {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #cfe8ff;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
.banner.error {
|
||||
background: #fff0f0;
|
||||
border-color: #f5c2c5;
|
||||
color: var(--danger);
|
||||
}
|
||||
.banner.warn {
|
||||
background: #fff7e6;
|
||||
border-color: #fadf9c;
|
||||
}
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--code-bg);
|
||||
font-size: 0.78em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pill.scope-wild { background: #ecfdf3; color: #027a48; }
|
||||
.pill.scope-admin { background: #fff4e6; color: #b54708; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% block header %}
|
||||
<header class="top">
|
||||
<div class="inner">
|
||||
<h1>beaver-gateway</h1>
|
||||
<nav class="tabs">
|
||||
<a href="{{ p }}/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a>
|
||||
<a href="{{ p }}/chat" class="{% if active == 'chat' %}active{% endif %}">Chat</a>
|
||||
<a href="{{ p }}/pty" class="{% if active == 'pty' %}active{% endif %}">PTY</a>
|
||||
<a href="{{ p }}/tokens" class="{% if active == 'tokens' %}active{% endif %}">Tokens</a>
|
||||
<a href="{{ p }}/audit" class="{% if active == 'audit' %}active{% endif %}">Audit</a>
|
||||
</nav>
|
||||
<span class="actor">Signed in as <strong>{{ user }}</strong></span>
|
||||
<form class="inline" method="post" action="{{ p }}/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||
<button type="submit">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,12 +0,0 @@
|
||||
{# Renders into #token-create-result (hx-target on the form). The
|
||||
two OOB swaps reach the rest of the page: prepend the new row,
|
||||
and erase the empty-state placeholder if it's still in the DOM. #}
|
||||
<div class="banner warn">
|
||||
<strong>Token created: {{ token.name }}</strong>
|
||||
<p class="muted" style="margin:0.25rem 0 0.5rem;">Copy it now — this is the only time it will be shown.</p>
|
||||
<pre style="margin:0;">{{ plaintext }}</pre>
|
||||
</div>
|
||||
<tbody id="tokens-rows" hx-swap-oob="afterbegin">
|
||||
{% include "_token_row.html" %}
|
||||
</tbody>
|
||||
<tr id="tokens-empty" hx-swap-oob="delete"></tr>
|
||||
@@ -1 +0,0 @@
|
||||
<div class="banner error">{{ message }}</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
<tr id="token-row-{{ token.id }}" {% if token.revoked_at %}class="revoked"{% endif %}>
|
||||
<td><strong>{{ token.name }}</strong></td>
|
||||
<td>
|
||||
<span class="pill {% if token.scope == '*' %}scope-wild{% elif token.scope == 'admin' %}scope-admin{% endif %}">{{ token.scope }}</span>
|
||||
</td>
|
||||
<td><code>{{ token.created_at | fmt_dt }}</code></td>
|
||||
<td><code>{{ token.last_used_at | fmt_dt }}</code></td>
|
||||
<td><code>{{ token.revoked_at | fmt_dt }}</code></td>
|
||||
<td style="text-align:right;">
|
||||
{% if not token.revoked_at %}
|
||||
<form
|
||||
class="inline"
|
||||
hx-post="{{ p }}/tokens/{{ token.id }}/revoke"
|
||||
hx-target="#token-row-{{ token.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Revoke token {{ token.name }}? This cannot be undone."
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||
<button class="danger" type="submit">Revoke</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="muted">revoked</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1,37 +0,0 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% set active = "audit" %}
|
||||
{% block title %}beaver-gateway · audit{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Audit log</h2>
|
||||
<div class="card">
|
||||
{% if audit %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th><th>Actor</th><th>Kind</th><th>Agent</th><th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in audit %}
|
||||
<tr>
|
||||
<td><code>{{ row.ts | fmt_dt }}</code></td>
|
||||
<td>{{ row.actor }}</td>
|
||||
<td><span class="pill">{{ row.kind }}</span></td>
|
||||
<td>{{ row.agent_name or "—" }}</td>
|
||||
<td><code>{{ row.detail_json | fmt_detail }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top:1rem;">
|
||||
{% if next_before %}
|
||||
<a href="{{ p }}/audit?before={{ next_before }}">Older entries →</a>
|
||||
{% else %}
|
||||
End of log.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="muted">Nothing logged yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,355 +0,0 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% set active = "chat" %}
|
||||
{% block title %}beaver-gateway · chat{% endblock %}
|
||||
{% block content %}
|
||||
<div class="chat-wrap">
|
||||
<div class="chat-toolbar">
|
||||
<label>
|
||||
<span>Agent</span>
|
||||
<select id="agent-select">
|
||||
{% for a in agents %}
|
||||
<option value="{{ a.name }}">{{ a.name }} · {{ a.model }}</option>
|
||||
{% else %}
|
||||
<option disabled>no agents with a backend</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="spacer"></div>
|
||||
<button id="new-chat-btn" type="button">New chat</button>
|
||||
</div>
|
||||
<div id="messages" class="chat-messages" aria-live="polite"></div>
|
||||
<form id="chat-form" class="chat-input">
|
||||
<textarea id="chat-text" rows="3" placeholder="Message… (⌘/Ctrl+Enter to send)" required></textarea>
|
||||
<button type="submit" class="primary" id="send-btn">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-wrap {
|
||||
display: flex; flex-direction: column;
|
||||
height: calc(100vh - 200px); min-height: 480px;
|
||||
}
|
||||
.chat-toolbar {
|
||||
display: flex; gap: 0.85rem; align-items: end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.chat-toolbar .spacer { flex: 1; }
|
||||
.chat-toolbar label {
|
||||
display: flex; flex-direction: column; gap: 0.3rem;
|
||||
min-width: 260px;
|
||||
}
|
||||
.chat-toolbar label > span {
|
||||
font-size: 0.8em; color: var(--muted);
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.chat-messages {
|
||||
flex: 1; overflow-y: auto;
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 1.25rem;
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
}
|
||||
.chat-empty { color: var(--muted); margin: auto; }
|
||||
.msg { display: flex; }
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.user .bubble {
|
||||
max-width: 78%; padding: 0.7rem 0.95rem; border-radius: 14px;
|
||||
background: var(--accent); color: white;
|
||||
white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
.msg.assistant .blocks {
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
max-width: 88%;
|
||||
}
|
||||
.block-text {
|
||||
background: var(--code-bg); padding: 0.7rem 0.95rem;
|
||||
border-radius: 14px;
|
||||
white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
details.tool-call, details.thinking-block {
|
||||
border: 1px solid var(--line); border-radius: 10px;
|
||||
background: var(--surface); padding: 0 0.85rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
details.tool-call > summary,
|
||||
details.thinking-block > summary {
|
||||
cursor: pointer; padding: 0.55rem 0;
|
||||
list-style: none;
|
||||
display: flex; align-items: center; gap: 0.55rem;
|
||||
}
|
||||
details > summary::-webkit-details-marker { display: none; }
|
||||
details.tool-call > summary::before,
|
||||
details.thinking-block > summary::before {
|
||||
content: "▸"; color: var(--muted); font-size: 0.78em;
|
||||
}
|
||||
details.tool-call[open] > summary::before,
|
||||
details.thinking-block[open] > summary::before { content: "▾"; }
|
||||
.tool-name {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tool-id, .tool-label {
|
||||
color: var(--muted); font-size: 0.78em;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
details.tool-call pre, details.thinking-block pre {
|
||||
margin: 0 0 0.6rem; padding: 0.7rem 0.85rem;
|
||||
background: var(--code-bg); border-radius: 8px;
|
||||
font-size: 0.85em; max-height: 360px; overflow: auto;
|
||||
}
|
||||
.tool-label { display: block; margin: 0.15rem 0 0.25rem; }
|
||||
.chat-input { display: flex; gap: 0.75rem; margin-top: 1rem; }
|
||||
.chat-input textarea {
|
||||
flex: 1; padding: 0.7rem 0.9rem;
|
||||
border: 1px solid var(--line); border-radius: 12px;
|
||||
font-family: inherit; font-size: 0.95em;
|
||||
background: var(--surface); color: var(--fg); resize: vertical;
|
||||
}
|
||||
.chat-input textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.chat-input button { align-self: stretch; padding-left: 1.4rem; padding-right: 1.4rem; }
|
||||
.chat-error {
|
||||
color: var(--danger); font-size: 0.85em;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: #fff0f0; border-radius: 10px;
|
||||
border: 1px solid #f5c2c5;
|
||||
}
|
||||
.chat-input button:disabled { opacity: 0.55; cursor: progress; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const CSRF = {{ csrf | tojson }};
|
||||
const URL_PREFIX = {{ p | tojson }};
|
||||
const agentSelect = document.getElementById("agent-select");
|
||||
const messagesEl = document.getElementById("messages");
|
||||
const form = document.getElementById("chat-form");
|
||||
const textEl = document.getElementById("chat-text");
|
||||
const sendBtn = document.getElementById("send-btn");
|
||||
const newBtn = document.getElementById("new-chat-btn");
|
||||
|
||||
// Anthropic-style history sent to the backend. We keep assistant
|
||||
// content text-only — tool_use blocks can't round-trip without
|
||||
// matching tool_result, and backends (claude-code) run tools
|
||||
// internally anyway.
|
||||
let apiMessages = [];
|
||||
|
||||
function renderEmpty() {
|
||||
messagesEl.innerHTML = '<div class="chat-empty">No messages yet — say something.</div>';
|
||||
}
|
||||
renderEmpty();
|
||||
|
||||
newBtn.addEventListener("click", () => {
|
||||
apiMessages = [];
|
||||
renderEmpty();
|
||||
});
|
||||
|
||||
function clearEmpty() {
|
||||
const e = messagesEl.querySelector(".chat-empty");
|
||||
if (e) e.remove();
|
||||
}
|
||||
function scrollDown() { messagesEl.scrollTop = messagesEl.scrollHeight; }
|
||||
|
||||
function appendUser(text) {
|
||||
clearEmpty();
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg user";
|
||||
const bub = document.createElement("div");
|
||||
bub.className = "bubble";
|
||||
bub.textContent = text;
|
||||
row.appendChild(bub);
|
||||
messagesEl.appendChild(row);
|
||||
scrollDown();
|
||||
}
|
||||
function appendAssistant() {
|
||||
clearEmpty();
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg assistant";
|
||||
const blocks = document.createElement("div");
|
||||
blocks.className = "blocks";
|
||||
row.appendChild(blocks);
|
||||
messagesEl.appendChild(row);
|
||||
scrollDown();
|
||||
return blocks;
|
||||
}
|
||||
function appendError(text) {
|
||||
clearEmpty();
|
||||
const e = document.createElement("div");
|
||||
e.className = "chat-error";
|
||||
e.textContent = text;
|
||||
messagesEl.appendChild(e);
|
||||
scrollDown();
|
||||
}
|
||||
|
||||
function ensureBlock(state, index, type, extra) {
|
||||
if (state.blocks[index]) return state.blocks[index];
|
||||
const block = { type };
|
||||
if (type === "text") {
|
||||
const el = document.createElement("div");
|
||||
el.className = "block-text";
|
||||
state.container.appendChild(el);
|
||||
block.el = el;
|
||||
} else if (type === "thinking") {
|
||||
const det = document.createElement("details");
|
||||
det.className = "thinking-block";
|
||||
const sum = document.createElement("summary");
|
||||
sum.textContent = "Thinking";
|
||||
det.appendChild(sum);
|
||||
const pre = document.createElement("pre");
|
||||
det.appendChild(pre);
|
||||
state.container.appendChild(det);
|
||||
block.el = pre;
|
||||
} else if (type === "tool_use") {
|
||||
const det = document.createElement("details");
|
||||
det.className = "tool-call";
|
||||
const sum = document.createElement("summary");
|
||||
const name = document.createElement("span");
|
||||
name.className = "tool-name";
|
||||
name.textContent = "🔧 " + (extra.name || "tool");
|
||||
sum.appendChild(name);
|
||||
if (extra.id) {
|
||||
const idEl = document.createElement("span");
|
||||
idEl.className = "tool-id";
|
||||
idEl.textContent = extra.id;
|
||||
sum.appendChild(idEl);
|
||||
}
|
||||
det.appendChild(sum);
|
||||
const label = document.createElement("span");
|
||||
label.className = "tool-label";
|
||||
label.textContent = "input";
|
||||
det.appendChild(label);
|
||||
const pre = document.createElement("pre");
|
||||
det.appendChild(pre);
|
||||
state.container.appendChild(det);
|
||||
block.el = pre;
|
||||
block.jsonBuf = "";
|
||||
block.seedInput = extra.input;
|
||||
}
|
||||
state.blocks[index] = block;
|
||||
return block;
|
||||
}
|
||||
|
||||
function applyEvent(state, ev) {
|
||||
const t = ev.type;
|
||||
if (t === "content_block_start") {
|
||||
const cb = ev.content_block || {};
|
||||
if (cb.type === "text") {
|
||||
ensureBlock(state, ev.index, "text", {});
|
||||
} else if (cb.type === "thinking") {
|
||||
ensureBlock(state, ev.index, "thinking", {});
|
||||
} else if (cb.type === "tool_use") {
|
||||
ensureBlock(state, ev.index, "tool_use",
|
||||
{ name: cb.name, id: cb.id, input: cb.input });
|
||||
}
|
||||
} else if (t === "content_block_delta") {
|
||||
const d = ev.delta || {};
|
||||
const b = state.blocks[ev.index];
|
||||
if (!b) return;
|
||||
if (d.type === "text_delta") {
|
||||
b.el.textContent += d.text || "";
|
||||
state.assistantText += d.text || "";
|
||||
scrollDown();
|
||||
} else if (d.type === "thinking_delta") {
|
||||
b.el.textContent += d.thinking || "";
|
||||
scrollDown();
|
||||
} else if (d.type === "input_json_delta") {
|
||||
b.jsonBuf += d.partial_json || "";
|
||||
}
|
||||
} else if (t === "content_block_stop") {
|
||||
const b = state.blocks[ev.index];
|
||||
if (!b) return;
|
||||
if (b.type === "tool_use") {
|
||||
let input = null;
|
||||
if (b.jsonBuf && b.jsonBuf.trim()) {
|
||||
try { input = JSON.parse(b.jsonBuf); }
|
||||
catch { input = b.jsonBuf; }
|
||||
} else if (b.seedInput !== undefined && b.seedInput !== null) {
|
||||
input = b.seedInput;
|
||||
}
|
||||
b.el.textContent = input === null
|
||||
? "(no input)"
|
||||
: (typeof input === "string"
|
||||
? input
|
||||
: JSON.stringify(input, null, 2));
|
||||
}
|
||||
} else if (t === "error") {
|
||||
const msg = (ev.error && ev.error.message) || "stream error";
|
||||
appendError(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = textEl.value.trim();
|
||||
if (!text) return;
|
||||
const model = agentSelect.value;
|
||||
if (!model) { appendError("no agent selected"); return; }
|
||||
|
||||
apiMessages.push({ role: "user", content: text });
|
||||
appendUser(text);
|
||||
textEl.value = "";
|
||||
sendBtn.disabled = true;
|
||||
|
||||
const container = appendAssistant();
|
||||
const state = { container, blocks: {}, assistantText: "" };
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(URL_PREFIX + "/chat/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": CSRF,
|
||||
},
|
||||
body: JSON.stringify({ model, messages: apiMessages }),
|
||||
});
|
||||
} catch (e) {
|
||||
appendError("network error: " + e.message);
|
||||
sendBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (!resp.ok || !resp.body) {
|
||||
let msg = resp.status + " " + resp.statusText;
|
||||
try { const body = await resp.text(); if (body) msg = body; } catch {}
|
||||
appendError(msg);
|
||||
sendBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let i;
|
||||
while ((i = buf.indexOf("\n\n")) >= 0) {
|
||||
const raw = buf.slice(0, i);
|
||||
buf = buf.slice(i + 2);
|
||||
if (!raw) continue;
|
||||
let dataLine = "";
|
||||
for (const line of raw.split("\n")) {
|
||||
if (line.startsWith("data:")) dataLine += line.slice(5).trimStart();
|
||||
}
|
||||
if (!dataLine) continue;
|
||||
let payload;
|
||||
try { payload = JSON.parse(dataLine); } catch { continue; }
|
||||
applyEvent(state, payload);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.assistantText) {
|
||||
apiMessages.push({ role: "assistant", content: state.assistantText });
|
||||
}
|
||||
sendBtn.disabled = false;
|
||||
textEl.focus();
|
||||
}
|
||||
|
||||
form.addEventListener("submit", (e) => { e.preventDefault(); send(); });
|
||||
textEl.addEventListener("keydown", (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,490 +0,0 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% set active = "dashboard" %}
|
||||
{% block title %}beaver-gateway · dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Agents</h2>
|
||||
<div class="card">
|
||||
{% if agents %}
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Type</th><th>Model</th><th>Exposed MCPs</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in agents %}
|
||||
<tr>
|
||||
<td><code>{{ a.name }}</code></td>
|
||||
<td><span class="pill">{{ a.__class__.__name__ }}</span></td>
|
||||
<td><code>{{ a.model }}</code></td>
|
||||
<td>
|
||||
{% for em in a.expose_mcps %}<code>{{ em.name }}</code>{% if not loop.last %}, {% endif %}{% else %}<span class="muted">—</span>{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">No agents configured.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2>MCP namespaces</h2>
|
||||
<div class="card">
|
||||
{% if mcps %}
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Kind</th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in mcps %}
|
||||
<tr>
|
||||
<td><code>{{ m.name }}</code></td>
|
||||
<td><span class="pill">{{ m.kind }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">No MCP servers configured.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2>Endpoints</h2>
|
||||
<div class="card endpoints">
|
||||
{% if endpoints.agents or endpoints.mcps %}
|
||||
<div class="ep-controls">
|
||||
<label class="ep-token">
|
||||
<span>Token (hint)</span>
|
||||
<select id="ep-token-select">
|
||||
<option value="" data-scope="">— select a known token —</option>
|
||||
{% for t in tokens %}
|
||||
<option value="{{ t.name }}" data-scope="{{ t.scope }}">{{ t.name }} · scope {{ t.scope }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="ep-secret">
|
||||
<span>Bearer secret</span>
|
||||
<input type="password" id="ep-token-secret" autocomplete="off" spellcheck="false"
|
||||
placeholder="paste plaintext (we only store the Argon2 hash)">
|
||||
</label>
|
||||
<label class="ep-show">
|
||||
<input type="checkbox" id="ep-show-secret"> <span>show</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted ep-note">
|
||||
Beaver stores only an Argon2 hash of each token, so the plaintext can't be reconstructed.
|
||||
Paste the value you saved at creation; if you've lost it, <a href="{{ p }}/tokens">mint a new one</a>.
|
||||
Below: pick a token to see which endpoints its scope covers, paste the secret to fill it
|
||||
into URL / curl, then click Copy.
|
||||
</p>
|
||||
|
||||
{% if endpoints.agents %}
|
||||
<h3 class="ep-h3">Agents — <code>POST /v1/messages</code></h3>
|
||||
<table class="ep-table" data-required-scope="messages">
|
||||
<thead><tr><th>Agent</th><th>Model</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for ep in endpoints.agents %}
|
||||
<tr class="ep-row" data-kind="messages" data-agent="{{ ep.agent }}" data-url="{{ ep.url }}">
|
||||
<td>
|
||||
<code>{{ ep.agent }}</code>
|
||||
<span class="pill">{{ ep.agent_type }}</span>
|
||||
<span class="ep-scope-warn" hidden>scope mismatch</span>
|
||||
</td>
|
||||
<td><code>{{ ep.model }}</code></td>
|
||||
<td><code class="ep-url">{{ ep.url }}</code></td>
|
||||
<td class="ep-actions">
|
||||
<button type="button" data-action="copy-url">Copy URL</button>
|
||||
<button type="button" data-action="copy-curl">Copy curl</button>
|
||||
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% elif endpoints.anthropic_base is none and agents %}
|
||||
<p class="muted">
|
||||
Agents are declared but no <code>AnthropicMessagesFrontend</code> is configured —
|
||||
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if endpoints.mcps %}
|
||||
<h3 class="ep-h3">MCP — streamable HTTP</h3>
|
||||
<table class="ep-table" data-required-scope="mcp">
|
||||
<thead><tr><th>Namespace</th><th>Kind</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for ep in endpoints.mcps %}
|
||||
<tr class="ep-row" data-kind="mcp" data-namespace="{{ ep.namespace }}" data-url="{{ ep.url }}">
|
||||
<td>
|
||||
<code>{{ ep.namespace }}</code>
|
||||
<span class="ep-scope-warn" hidden>scope mismatch</span>
|
||||
</td>
|
||||
<td><span class="pill">{{ ep.kind }}</span></td>
|
||||
<td><code class="ep-url">{{ ep.url }}</code></td>
|
||||
<td class="ep-actions">
|
||||
<button type="button" data-action="copy-url">Copy URL</button>
|
||||
<button type="button" data-action="copy-url-token">Copy URL+token</button>
|
||||
<button type="button" data-action="copy-curl">Copy curl</button>
|
||||
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% elif endpoints.mcp_base is none and mcps %}
|
||||
<p class="muted">
|
||||
MCP servers are declared but no <code>McpServerFrontend</code> is configured —
|
||||
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if endpoints.markdown %}
|
||||
<h3 class="ep-h3">Markdown — <code>POST /chat</code></h3>
|
||||
<table class="ep-table" data-required-scope="messages">
|
||||
<thead><tr><th>Vault</th><th>Default agent</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="ep-row"
|
||||
data-kind="markdown"
|
||||
data-url="{{ endpoints.markdown.url }}"
|
||||
data-base="{{ endpoints.markdown.base }}"
|
||||
data-sample-agent="{{ endpoints.markdown.sample_agent }}">
|
||||
<td>
|
||||
<code>{{ endpoints.markdown.vault_path }}</code>
|
||||
<span class="ep-scope-warn" hidden>scope mismatch</span>
|
||||
{% if endpoints.markdown.log_all_chats %}
|
||||
<span class="pill">log_all_chats · {{ endpoints.markdown.logged_subdir }}/</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if endpoints.markdown.default_agent %}
|
||||
<code>{{ endpoints.markdown.default_agent }}</code>
|
||||
{% else %}
|
||||
<span class="muted">— (request must set <code>agent</code> or frontmatter)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<code class="ep-url">{{ endpoints.markdown.url }}</code>
|
||||
<div class="muted" style="font-size:0.85em; margin-top:0.25rem;">
|
||||
plugin base: <code>{{ endpoints.markdown.base }}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td class="ep-actions">
|
||||
<button type="button" data-action="copy-url">Copy URL</button>
|
||||
<button type="button" data-action="copy-base">Copy plugin base</button>
|
||||
<button type="button" data-action="copy-curl">Copy curl</button>
|
||||
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted">
|
||||
Nothing exposed yet — declare agents / MCPs and the matching frontends
|
||||
(<code>AnthropicMessagesFrontend</code>, <code>McpServerFrontend</code>) in your config.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2>Recent activity</h2>
|
||||
<div class="card">
|
||||
{% if audit %}
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Actor</th><th>Kind</th><th>Agent</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in audit %}
|
||||
<tr>
|
||||
<td><code>{{ row.ts | fmt_dt }}</code></td>
|
||||
<td>{{ row.actor }}</td>
|
||||
<td><span class="pill">{{ row.kind }}</span></td>
|
||||
<td>{{ row.agent_name or "—" }}</td>
|
||||
<td><code>{{ row.detail_json | fmt_detail }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top:0.85rem;"><a href="{{ p }}/audit">Full log →</a></p>
|
||||
{% else %}
|
||||
<p class="muted">Nothing logged yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.endpoints .ep-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) 2fr auto;
|
||||
gap: 0.85rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.endpoints .ep-controls label > span {
|
||||
display: block;
|
||||
font-size: 0.8em; color: var(--muted);
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.endpoints .ep-show {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding-bottom: 0.6rem; color: var(--muted); font-size: 0.9em;
|
||||
}
|
||||
.endpoints .ep-note {
|
||||
font-size: 0.85em; margin: 0.25rem 0 1.2rem;
|
||||
}
|
||||
.endpoints .ep-h3 {
|
||||
font-size: 0.95rem; font-weight: 600;
|
||||
margin: 1.5rem 0 0.55rem;
|
||||
}
|
||||
.endpoints .ep-table { table-layout: auto; }
|
||||
.endpoints .ep-table th.ep-actions-th { width: 1%; }
|
||||
.endpoints .ep-url {
|
||||
word-break: break-all;
|
||||
}
|
||||
.endpoints .ep-actions {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
.endpoints .ep-actions button {
|
||||
padding: 0.32rem 0.7rem;
|
||||
font-size: 0.85em;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
.endpoints .ep-scope-warn {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
background: #fff7e6;
|
||||
border: 1px solid #fadf9c;
|
||||
color: #8a5a00;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72em;
|
||||
}
|
||||
.endpoints .ep-row.scope-mismatch td:not(.ep-actions) { opacity: 0.55; }
|
||||
.endpoints .ep-row.scope-mismatch .ep-actions button { opacity: 0.7; }
|
||||
.endpoints .ep-curl-row td { padding-top: 0; padding-bottom: 0; }
|
||||
.endpoints .ep-curl {
|
||||
margin: 0.4rem 0 1rem;
|
||||
font-size: 0.82em;
|
||||
max-height: 280px;
|
||||
}
|
||||
.endpoints button[data-copied="1"] {
|
||||
background: #ecfdf3 !important;
|
||||
border-color: #b6e6cb !important;
|
||||
color: #027a48;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.endpoints .ep-controls { grid-template-columns: 1fr; }
|
||||
.endpoints .ep-actions { text-align: left; padding-top: 0.4rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const card = document.querySelector(".endpoints");
|
||||
if (!card) return;
|
||||
const sel = card.querySelector("#ep-token-select");
|
||||
const secret = card.querySelector("#ep-token-secret");
|
||||
const showCb = card.querySelector("#ep-show-secret");
|
||||
if (!sel || !secret || !showCb) return;
|
||||
|
||||
const PLACEHOLDER = "<YOUR_TOKEN>";
|
||||
|
||||
function currentToken() {
|
||||
const v = secret.value;
|
||||
return v && v.length > 0 ? v : null;
|
||||
}
|
||||
|
||||
function selectedScope() {
|
||||
const o = sel.options[sel.selectedIndex];
|
||||
return o ? (o.getAttribute("data-scope") || "") : "";
|
||||
}
|
||||
|
||||
function scopeCovers(have, need) {
|
||||
if (!have) return true; // no token selected — don't grey anything out
|
||||
if (have === "*") return true;
|
||||
return have === need;
|
||||
}
|
||||
|
||||
function escSh(s) {
|
||||
// single-quote shell escape: close, escape, reopen.
|
||||
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
function buildCurl(row) {
|
||||
const kind = row.getAttribute("data-kind");
|
||||
const url = row.getAttribute("data-url");
|
||||
const tok = currentToken() || PLACEHOLDER;
|
||||
if (kind === "messages") {
|
||||
const agent = row.getAttribute("data-agent");
|
||||
const body = JSON.stringify({
|
||||
model: agent,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
stream: true,
|
||||
});
|
||||
return [
|
||||
"curl -N \\",
|
||||
" -H " + escSh("x-api-key: " + tok) + " \\",
|
||||
" -H 'content-type: application/json' \\",
|
||||
" -d " + escSh(body) + " \\",
|
||||
" " + url,
|
||||
].join("\n");
|
||||
}
|
||||
if (kind === "markdown") {
|
||||
const agent = row.getAttribute("data-sample-agent") || "";
|
||||
const body = JSON.stringify({
|
||||
filename: "example.md",
|
||||
content: "### User:\n\nhello\n",
|
||||
agent: agent,
|
||||
});
|
||||
return [
|
||||
"curl \\",
|
||||
" -H " + escSh("Authorization: Bearer " + tok) + " \\",
|
||||
" -H 'content-type: application/json' \\",
|
||||
" -d " + escSh(body) + " \\",
|
||||
" " + url,
|
||||
].join("\n");
|
||||
}
|
||||
if (kind === "mcp") {
|
||||
// Streamable-HTTP MCP wants `initialize` first — the response
|
||||
// carries the `Mcp-Session-Id` header you must echo back on
|
||||
// every subsequent call (tools/list, tools/call, ...). We use
|
||||
// `-i` so the session-id is visible in the response, and ship
|
||||
// the proper handshake body so a fresh paste actually works.
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "beaver-admin-curl", version: "0" },
|
||||
},
|
||||
});
|
||||
return [
|
||||
"# 1) initialize — grab Mcp-Session-Id from the response headers",
|
||||
"curl -N -i \\",
|
||||
" -H " + escSh("Authorization: Bearer " + tok) + " \\",
|
||||
" -H 'content-type: application/json' \\",
|
||||
" -H 'accept: application/json, text/event-stream' \\",
|
||||
" -d " + escSh(body) + " \\",
|
||||
" " + url,
|
||||
"",
|
||||
"# 2) reuse that id on follow-up calls, e.g. tools/list:",
|
||||
"# curl -N \\",
|
||||
"# -H " + escSh("Authorization: Bearer " + tok) + " \\",
|
||||
"# -H 'Mcp-Session-Id: <paste-from-step-1>' \\",
|
||||
"# -H 'content-type: application/json' \\",
|
||||
"# -H 'accept: application/json, text/event-stream' \\",
|
||||
"# -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}' \\",
|
||||
"# " + url,
|
||||
].join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function refreshScopeMarks() {
|
||||
const have = selectedScope();
|
||||
card.querySelectorAll(".ep-table").forEach((tbl) => {
|
||||
const need = tbl.getAttribute("data-required-scope") || "";
|
||||
const ok = scopeCovers(have, need);
|
||||
tbl.querySelectorAll("tr.ep-row").forEach((row) => {
|
||||
const warn = row.querySelector(".ep-scope-warn");
|
||||
if (!ok) {
|
||||
row.classList.add("scope-mismatch");
|
||||
if (warn) warn.hidden = false;
|
||||
} else {
|
||||
row.classList.remove("scope-mismatch");
|
||||
if (warn) warn.hidden = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshOpenCurls() {
|
||||
card.querySelectorAll("tr.ep-curl-row").forEach((cr) => {
|
||||
if (cr.hidden) return;
|
||||
const row = cr.previousElementSibling;
|
||||
const pre = cr.querySelector(".ep-curl");
|
||||
if (row && pre) pre.textContent = buildCurl(row);
|
||||
});
|
||||
}
|
||||
|
||||
function copyText(btn, text) {
|
||||
const done = () => {
|
||||
btn.setAttribute("data-copied", "1");
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = "✓ copied";
|
||||
setTimeout(() => {
|
||||
btn.removeAttribute("data-copied");
|
||||
btn.textContent = prev;
|
||||
}, 1200);
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, () => {
|
||||
// Fallback for non-secure contexts.
|
||||
fallbackCopy(text);
|
||||
done();
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
done();
|
||||
}
|
||||
}
|
||||
function fallbackCopy(text) {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed"; ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
sel.addEventListener("change", () => {
|
||||
const name = sel.value;
|
||||
secret.placeholder = name
|
||||
? "paste plaintext for '" + name + "' (we only store the hash)"
|
||||
: "paste plaintext (we only store the Argon2 hash)";
|
||||
refreshScopeMarks();
|
||||
});
|
||||
secret.addEventListener("input", refreshOpenCurls);
|
||||
showCb.addEventListener("change", () => {
|
||||
secret.type = showCb.checked ? "text" : "password";
|
||||
});
|
||||
|
||||
card.addEventListener("click", (ev) => {
|
||||
const btn = ev.target.closest("button[data-action]");
|
||||
if (!btn) return;
|
||||
const row = btn.closest("tr.ep-row");
|
||||
if (!row) return;
|
||||
const action = btn.getAttribute("data-action");
|
||||
const url = row.getAttribute("data-url");
|
||||
const tok = currentToken() || PLACEHOLDER;
|
||||
if (action === "copy-url") {
|
||||
copyText(btn, url);
|
||||
} else if (action === "copy-base") {
|
||||
const base = row.getAttribute("data-base");
|
||||
if (base) copyText(btn, base);
|
||||
} else if (action === "copy-url-token") {
|
||||
const sep = url.indexOf("?") >= 0 ? "&" : "?";
|
||||
copyText(btn, url + sep + "token=" + encodeURIComponent(tok));
|
||||
} else if (action === "copy-curl") {
|
||||
copyText(btn, buildCurl(row));
|
||||
} else if (action === "toggle-curl") {
|
||||
const curlRow = row.nextElementSibling;
|
||||
if (!curlRow || !curlRow.classList.contains("ep-curl-row")) return;
|
||||
const pre = curlRow.querySelector(".ep-curl");
|
||||
if (curlRow.hidden) {
|
||||
if (pre) pre.textContent = buildCurl(row);
|
||||
curlRow.hidden = false;
|
||||
btn.textContent = "▾ curl";
|
||||
btn.setAttribute("aria-expanded", "true");
|
||||
} else {
|
||||
curlRow.hidden = true;
|
||||
btn.textContent = "▸ curl";
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
refreshScopeMarks();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,83 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>beaver-gateway · log in</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #fbfbfd; color: #1d1d1f; }
|
||||
body {
|
||||
font-family: -apple-system, "SF Pro Display", system-ui, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e5ea;
|
||||
border-radius: 16px;
|
||||
padding: 2rem 2.25rem;
|
||||
width: min(360px, 92vw);
|
||||
box-shadow: 0 10px 24px rgba(0,0,0,0.04);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
p.muted { color: #6e6e73; margin: 0 0 1.4rem; font-size: 0.92em; }
|
||||
label { display: block; margin: 0.85rem 0 0.3rem; font-size: 0.82em; color: #6e6e73; }
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.95em;
|
||||
border: 1px solid #e5e5ea;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input:focus { outline: 2px solid #0071e3; outline-offset: 1px; }
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.65rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.95em;
|
||||
background: #0071e3;
|
||||
border: 1px solid #0071e3;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #005bb5; }
|
||||
.error {
|
||||
background: #fff0f0;
|
||||
border: 1px solid #f5c2c5;
|
||||
color: #d70015;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>beaver-gateway</h1>
|
||||
<p class="muted">Sign in to manage tokens and view audit logs.</p>
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ p }}/login">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,146 +0,0 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% set active = "pty" %}
|
||||
{% block title %}PTY {{ session_id }} · beaver-gateway{% endblock %}
|
||||
{% block content %}
|
||||
<style>
|
||||
/* xterm.js styles inlined from the CDN bundle — keep them tight and
|
||||
constrained to the terminal block so they don't leak into the rest
|
||||
of the admin UI. */
|
||||
.term-wrap {
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.term-host {
|
||||
height: 70vh;
|
||||
}
|
||||
.term-host .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
.term-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.term-toolbar .status {
|
||||
margin-left: auto;
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.term-toolbar .status.connected { color: #027a48; }
|
||||
.term-toolbar .status.dropped { color: var(--danger); }
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
|
||||
<h2>PTY <code>{{ session_id }}</code></h2>
|
||||
<p class="muted">
|
||||
Live view of the claude subprocess TUI. Keystrokes you type here go
|
||||
straight into its stdin — Enter, arrows, Ctrl-C, paste, all work.
|
||||
<a href="{{ p }}/pty">← back to list</a>
|
||||
</p>
|
||||
|
||||
<div class="term-toolbar">
|
||||
<button id="reconnect-btn" type="button">Reconnect</button>
|
||||
<button id="enter-btn" type="button" title="Send a single \r — handy if a paste is stuck in the input box">Send Enter</button>
|
||||
<button id="ctrl-c-btn" type="button">Send Ctrl-C</button>
|
||||
<span id="status" class="status">connecting…</span>
|
||||
</div>
|
||||
|
||||
<div class="term-wrap">
|
||||
<div id="term" class="term-host"></div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const sessionId = {{ session_id|tojson }};
|
||||
const wsBase =
|
||||
(location.protocol === "https:" ? "wss://" : "ws://") +
|
||||
location.host +
|
||||
{{ p|tojson }} +
|
||||
"/pty/" + encodeURIComponent(sessionId) + "/ws";
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: 13,
|
||||
theme: {
|
||||
background: "#000000",
|
||||
foreground: "#d0d0d0",
|
||||
},
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fit = new FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(document.getElementById("term"));
|
||||
// Defer fit until after layout settles; xterm's measurement reads
|
||||
// computed CSS that isn't stable until the page paints once.
|
||||
requestAnimationFrame(() => fit.fit());
|
||||
window.addEventListener("resize", () => fit.fit());
|
||||
|
||||
const statusEl = document.getElementById("status");
|
||||
function setStatus(text, cls) {
|
||||
statusEl.textContent = text;
|
||||
statusEl.className = "status" + (cls ? " " + cls : "");
|
||||
}
|
||||
|
||||
let ws = null;
|
||||
let manuallyClosed = false;
|
||||
|
||||
function connect() {
|
||||
manuallyClosed = false;
|
||||
setStatus("connecting…", "");
|
||||
ws = new WebSocket(wsBase);
|
||||
ws.binaryType = "arraybuffer";
|
||||
ws.onopen = () => setStatus("connected", "connected");
|
||||
ws.onclose = () => {
|
||||
if (!manuallyClosed) setStatus("disconnected", "dropped");
|
||||
};
|
||||
ws.onerror = () => setStatus("error", "dropped");
|
||||
ws.onmessage = (ev) => {
|
||||
if (typeof ev.data === "string") {
|
||||
term.write(ev.data);
|
||||
} else {
|
||||
// ArrayBuffer — feed raw bytes to xterm. It expects either
|
||||
// string or Uint8Array; the latter preserves byte boundaries
|
||||
// exactly, which matters for ANSI sequences split across frames.
|
||||
term.write(new Uint8Array(ev.data));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Keystrokes -> server. xterm.js gives us the exact byte sequence
|
||||
// the terminal would emit (e.g. Enter -> "\r", arrows -> "\x1b[A"
|
||||
// etc.), so we just pass it through.
|
||||
term.onData((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("reconnect-btn").addEventListener("click", () => {
|
||||
if (ws) {
|
||||
manuallyClosed = true;
|
||||
ws.close();
|
||||
}
|
||||
connect();
|
||||
});
|
||||
document.getElementById("enter-btn").addEventListener("click", () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\r");
|
||||
term.focus();
|
||||
});
|
||||
document.getElementById("ctrl-c-btn").addEventListener("click", () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\x03");
|
||||
term.focus();
|
||||
});
|
||||
|
||||
connect();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,46 +0,0 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% set active = "pty" %}
|
||||
{% block title %}PTY · beaver-gateway · admin{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Live PTY sessions</h2>
|
||||
<p class="muted">
|
||||
Each row is a running <code>claude</code> subprocess, most recently used first.
|
||||
Open one to see what's currently rendered on its TUI and (if needed) type into
|
||||
it directly. Sessions idle past the agent's TTL are terminated automatically.
|
||||
</p>
|
||||
|
||||
{% if sessions %}
|
||||
<div class="card" style="padding: 0;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session ID</th>
|
||||
<th>Agent</th>
|
||||
<th>Idle</th>
|
||||
<th>Age</th>
|
||||
<th>PID</th>
|
||||
<th>Buffer</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in sessions %}
|
||||
<tr>
|
||||
<td><code>{{ s.session_id }}</code></td>
|
||||
<td>{{ s.agent }}</td>
|
||||
<td>{{ s.idle }}</td>
|
||||
<td class="muted">{{ s.age }}</td>
|
||||
<td><span class="pill">{{ s.pid or "?" }}</span></td>
|
||||
<td>{{ s.buffer_size }} bytes</td>
|
||||
<td>
|
||||
<a class="btn primary" href="{{ p }}/pty/{{ s.session_id }}">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="banner">No live PTY sessions right now. Start a turn and refresh.</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,64 +0,0 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% set active = "tokens" %}
|
||||
{% block title %}beaver-gateway · tokens{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Create token</h2>
|
||||
<div class="card">
|
||||
<p class="muted">Plaintext is shown <strong>once</strong>, immediately after creation. Copy it before you navigate away — the database only ever holds the Argon2 hash.</p>
|
||||
<div id="token-create-result"></div>
|
||||
<form
|
||||
hx-post="{{ p }}/tokens"
|
||||
hx-target="#token-create-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="if(event.detail.successful){this.reset();}"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" placeholder="cursor / claude-desktop / mobile …" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="scope">Scope</label>
|
||||
<select id="scope" name="scope">
|
||||
{% for s in scopes %}
|
||||
<option value="{{ s }}" {% if s == "*" %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button class="primary" type="submit">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2>
|
||||
Tokens
|
||||
<span class="muted" style="font-size:0.8em; font-weight:400; margin-left:0.5rem;">
|
||||
{% if include_revoked %}
|
||||
<a href="{{ p }}/tokens">Hide revoked</a>
|
||||
{% else %}
|
||||
<a href="{{ p }}/tokens?include_revoked=1">Show revoked</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Scope</th><th>Created</th><th>Last used</th><th>Revoked</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
{# Render the tbody unconditionally so the HTMX OOB swap on
|
||||
create has a target even when the table starts empty. #}
|
||||
<tbody id="tokens-rows">
|
||||
{% for token in tokens %}
|
||||
{% include "_token_row.html" %}
|
||||
{% else %}
|
||||
<tr id="tokens-empty"><td colspan="6" class="muted">No tokens yet. Create one above.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,27 +1,35 @@
|
||||
"""``ApiFrontend`` - ``/api/conversations``, SSE events, sessions, usage (§3.9).
|
||||
"""``ApiFrontend`` - ``/api/*``: conversations, SSE, sessions, usage, limits (§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.
|
||||
Bearer scope ``api``; token and audit management need ``admin``. 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.
|
||||
|
||||
Usage figures come from the ``usage`` table (one row per turn, API-price
|
||||
``cost_usd`` and per-model ``model_usage`` from the SDK's
|
||||
``ResultMessage``); subscription quotas come from ``rate_limits``
|
||||
(``RateLimitEvent``). The quota covers the whole subscription, so
|
||||
``/api/limits`` puts the gateway's own spend for the window next to it
|
||||
for calibration by eye.
|
||||
"""
|
||||
|
||||
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 sqlalchemy import func
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlmodel import col
|
||||
from sqlmodel import col, select
|
||||
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.core.auth import VALID_SCOPES, hash_token
|
||||
from beaver_gateway.core.conversations import SEEDS
|
||||
from beaver_gateway.core.kinds import Kind, as_kind
|
||||
from beaver_gateway.frontends._auth import require_token
|
||||
@@ -32,20 +40,37 @@ from beaver_gateway.frontends._sse import (
|
||||
sse_pack,
|
||||
)
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.storage.models import Usage
|
||||
from beaver_gateway.storage import (
|
||||
create_token,
|
||||
list_audit_records,
|
||||
list_tokens,
|
||||
revoke_token,
|
||||
)
|
||||
from beaver_gateway.storage.models import Conversation, RateLimit, Token, Usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Iterable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
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"
|
||||
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
|
||||
|
||||
|
||||
class ApiFrontend(Frontend):
|
||||
@@ -62,6 +87,7 @@ class ApiFrontend(Frontend):
|
||||
branch_agent: str | None = None,
|
||||
deep_agent: str | None = None,
|
||||
job_agent: str | None = None,
|
||||
memory_root: Path | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
@@ -70,6 +96,7 @@ class ApiFrontend(Frontend):
|
||||
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._app: FastAPI | None = None
|
||||
|
||||
def agent_for(self, kind: Kind) -> str | None:
|
||||
@@ -84,7 +111,7 @@ class ApiFrontend(Frontend):
|
||||
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)
|
||||
self._app = build_app(runtime, memory_root=self.memory_root)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
@@ -98,7 +125,7 @@ class ApiFrontend(Frontend):
|
||||
await server.serve()
|
||||
|
||||
|
||||
def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915
|
||||
app = FastAPI(title="beaver-gateway / API")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -146,6 +173,10 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
)
|
||||
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"}
|
||||
@@ -158,20 +189,26 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
{
|
||||
"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,
|
||||
"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)
|
||||
},
|
||||
"port": getattr(fe, "port", None),
|
||||
"public_base_url": getattr(fe, "public_base_url", None),
|
||||
}
|
||||
for fe in conversations.frontends
|
||||
for fe in runtime.frontends
|
||||
],
|
||||
"mcps": [{"name": m.name, "kind": m.kind} for m in runtime.mcps],
|
||||
}
|
||||
|
||||
@app.get("/api/conversations")
|
||||
@@ -179,7 +216,9 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
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"))
|
||||
status=q.get("status"),
|
||||
kind=q.get("kind"),
|
||||
limit=query_int(request, "limit", 200),
|
||||
)
|
||||
return {"conversations": [conversations.public(r) for r in rows]}
|
||||
|
||||
@@ -238,7 +277,7 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
"priority": i.priority,
|
||||
"origin": i.origin,
|
||||
"status": i.status,
|
||||
"created_at": i.created_at.isoformat(),
|
||||
"created_at": _iso(i.created_at),
|
||||
"text": i.text[:200],
|
||||
}
|
||||
for i in await conversations.queue.recent(cast("int", conv.id), limit=20)
|
||||
@@ -256,6 +295,29 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
"text": await conversations.read(conv, window=window),
|
||||
}
|
||||
|
||||
@app.get("/api/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("/api/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(
|
||||
"/api/conversations/{public_id}/messages", status_code=status.HTTP_202_ACCEPTED
|
||||
)
|
||||
@@ -309,6 +371,18 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
conv = await conv_of(public_id)
|
||||
return await conversations.say(conv, text_of(await body_of(request)))
|
||||
|
||||
@app.post("/api/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(
|
||||
"/api/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@@ -460,11 +534,10 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
{
|
||||
"id": s.id,
|
||||
"conversation_row": s.conversation_id,
|
||||
"execute_at": s.execute_at.isoformat(),
|
||||
"execute_at": _iso(s.execute_at),
|
||||
"text": s.text,
|
||||
"delivered_at": s.delivered_at.isoformat()
|
||||
if s.delivered_at
|
||||
else None,
|
||||
"created_at": _iso(s.created_at),
|
||||
"delivered_at": _iso(s.delivered_at),
|
||||
}
|
||||
for s in await conversations.schedules(conv)
|
||||
]
|
||||
@@ -473,35 +546,156 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
@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),
|
||||
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"))
|
||||
)
|
||||
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()
|
||||
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"),
|
||||
"by_agent": [_usage_row("agent", r) for r in by_agent],
|
||||
"by_conversation": [_usage_row("conversation", r) for r in by_conversation],
|
||||
"until": until.isoformat(timespec="seconds"),
|
||||
"group_by": group_by,
|
||||
"total": _sum_usage(rows),
|
||||
"rows": groups,
|
||||
}
|
||||
|
||||
@app.get("/api/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] = {}
|
||||
for row in rows:
|
||||
latest.setdefault(row.window, row)
|
||||
now = datetime.now(UTC)
|
||||
windows = []
|
||||
for window, row in latest.items():
|
||||
length = WINDOWS.get(window)
|
||||
since = (
|
||||
_aware(row.resets_at) - length
|
||||
if length is not None and row.resets_at is not None
|
||||
else _aware(row.ts)
|
||||
)
|
||||
gateway = _sum_usage(await _usage_rows(runtime, since, now))
|
||||
gateway["since"] = since.isoformat(timespec="seconds")
|
||||
windows.append({**_limit_public(row), "gateway": gateway})
|
||||
windows.sort(
|
||||
key=lambda w: (WINDOWS.get(w["window"], timedelta.max), w["window"])
|
||||
)
|
||||
return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]}
|
||||
|
||||
@app.get("/api/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("/api/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("/api/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("/api/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 VALID_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("/api/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("/api/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)
|
||||
@@ -515,19 +709,6 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
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)
|
||||
@@ -539,3 +720,220 @@ def _sse(runtime: GatewayRuntime, *, conversation_id: str | None) -> StreamingRe
|
||||
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 _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 _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
|
||||
|
||||
@@ -22,6 +22,7 @@ from beaver_gateway.storage.models import (
|
||||
ConversationBinding,
|
||||
Delivery,
|
||||
InjectQueueItem,
|
||||
RateLimit,
|
||||
Schedule,
|
||||
TelegramUpdate,
|
||||
Token,
|
||||
@@ -38,6 +39,7 @@ __all__ = [
|
||||
"Delivery",
|
||||
"InjectQueueItem",
|
||||
"PostgresSessionStore",
|
||||
"RateLimit",
|
||||
"Schedule",
|
||||
"TelegramUpdate",
|
||||
"Token",
|
||||
|
||||
@@ -319,6 +319,39 @@ class Usage(SQLModel, table=True):
|
||||
cost_usd: float | None = Field(default=None)
|
||||
duration_ms: int | None = Field(default=None)
|
||||
num_turns: int | None = Field(default=None)
|
||||
model_usage: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(JSON().with_variant(JSONB(), "postgresql"), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
class RateLimit(SQLModel, table=True):
|
||||
"""One ``RateLimitEvent`` from the SDK; the newest row per ``window`` is the state.
|
||||
|
||||
The quota is per subscription, not per agent or conversation - the row
|
||||
only remembers which session reported it.
|
||||
"""
|
||||
|
||||
__tablename__ = "rate_limits"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
ts: datetime = Field(default_factory=_utcnow, index=True)
|
||||
window: str = Field(index=True)
|
||||
status: str
|
||||
utilization: float | None = Field(default=None)
|
||||
resets_at: datetime | None = Field(default=None)
|
||||
overage_status: str | None = Field(default=None)
|
||||
overage_resets_at: datetime | None = Field(default=None)
|
||||
agent_name: str | None = Field(default=None)
|
||||
session_id: str | None = Field(default=None)
|
||||
raw: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
sa_column=Column(
|
||||
JSON().with_variant(JSONB(), "postgresql"),
|
||||
nullable=False,
|
||||
server_default=text("'{}'"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -328,6 +361,7 @@ __all__ = [
|
||||
"ConversationMessage",
|
||||
"Delivery",
|
||||
"InjectQueueItem",
|
||||
"RateLimit",
|
||||
"Schedule",
|
||||
"TelegramUpdate",
|
||||
"Token",
|
||||
|
||||
Reference in New Issue
Block a user