import asyncio import tempfile from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, cast import pytest from claude_agent_sdk import ( AssistantMessage, RateLimitEvent, RateLimitInfo, ResultMessage, TextBlock, ToolResultBlock, ToolUseBlock, UserMessage, ) from httpx import ASGITransport, AsyncClient from test_conversations import ScriptedClient, World from beaver_gateway.core.auth import TokenStore from beaver_gateway.core.registry import McpRegistry from beaver_gateway.core.transcript import build_entries from beaver_gateway.frontends.admin.frontend import build_app as build_admin from beaver_gateway.frontends.api.frontend import build_app as build_api from beaver_gateway.frontends.base import GatewayRuntime from beaver_gateway.storage.models import RateLimit, Usage TOKEN = "tok" HEADERS = {"Authorization": f"Bearer {TOKEN}"} class ToolClient(ScriptedClient): async def receive_response(self): yield AssistantMessage( content=[ToolUseBlock(id="tu_1", name="Bash", input={"command": "ls"})], model="m", ) yield AssistantMessage( content=[ToolUseBlock(id="tu_2", name="Read", input={"file_path": "x"})], model="m", parent_tool_use_id="tu_1", ) yield RateLimitEvent( rate_limit_info=RateLimitInfo( status="allowed_warning", resets_at=int(datetime.now(UTC).timestamp()) + 3600, rate_limit_type="five_hour", utilization=0.8, raw={"status": "allowed_warning"}, ), uuid="r", session_id=self.session_id, ) hold = ScriptedClient.hold if hold is not None: await hold.wait() yield UserMessage(content=[ToolResultBlock(tool_use_id="tu_1", content="done")]) yield AssistantMessage(content=[TextBlock(text="ok")], model="m") yield ResultMessage( subtype="success", duration_ms=1, duration_api_ms=1, is_error=False, num_turns=1, session_id=self.session_id, stop_reason="end_turn", total_cost_usd=0.5, usage={"input_tokens": 10, "output_tokens": 5}, model_usage=cast( "Any", { "claude-opus-5": { "inputTokens": 8, "outputTokens": 4, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0, "webSearchRequests": 1, "costUSD": 0.4, }, "claude-haiku-4-5": { "inputTokens": 2, "outputTokens": 1, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.1, }, }, ), ) class Api: def __init__(self, world: World, memory_root: Path | None = None) -> None: self.world = world self.store = TokenStore(world.db, bootstrap={"t": TOKEN}) self.runtime = GatewayRuntime( agents=world.conversations._agents, # noqa: SLF001 mcps=McpRegistry([]), backends={"a": world.backend, "d": world.deep_backend}, token_store=self.store, db=world.db, admin_user="admin", admin_pass="secret", session_secret="s" * 32, frontends=(world.api, world.markdown), conversations=world.conversations, bus=world.bus, pool=world.pool, ) self.app = build_api(self.runtime, memory_root=memory_root) self.http = AsyncClient( transport=ASGITransport(app=self.app), base_url="http://api" ) async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: res = await self.http.get(path, params=params, headers=HEADERS) assert res.status_code == 200, res.text return res.json() @pytest.fixture async def world() -> World: root = Path(tempfile.mkdtemp(prefix="beaver-api-")) w = await World(root).setup() yield w await w.conversations.stop() await w.pool.close_all() await w.db.dispose() async def seed_usage(world: World, rows: list[dict[str, Any]]) -> None: async with world.db.session() as session: for row in rows: session.add(Usage(**row)) await session.commit() async def test_usage_groups_by_agent_day_and_model(world: World) -> None: api = Api(world) now = datetime.now(UTC).replace(tzinfo=None) await seed_usage( world, [ { "ts": now - timedelta(hours=1), "agent_name": "a", "conversation_id": "c1", "model": "claude-opus-5", "input_tokens": 10, "output_tokens": 20, "cache_read_tokens": 100, "cache_creation_tokens": 5, "cost_usd": 0.5, "model_usage": { "claude-opus-5": { "inputTokens": 10, "outputTokens": 20, "cacheReadInputTokens": 100, "cacheCreationInputTokens": 5, "webSearchRequests": 2, "costUSD": 0.5, } }, }, { "ts": now - timedelta(days=2), "agent_name": "d", "conversation_id": "c2", "model": "claude-sonnet-5", "input_tokens": 1, "output_tokens": 2, "cost_usd": 0.1, }, ], ) by_agent = await api.get("/api/usage", {"group_by": "agent"}) assert [r["agent"] for r in by_agent["rows"]] == ["a"] assert by_agent["total"] == { "turns": 1, "input": 10, "output": 20, "cache_read": 100, "cache_creation": 5, "cost_usd": 0.5, "web_searches": 2, } since = (datetime.now(UTC) - timedelta(days=3)).isoformat() by_day = await api.get("/api/usage", {"group_by": "day", "since": since}) assert len(by_day["rows"]) == 2 assert by_day["rows"][0]["day"] < by_day["rows"][1]["day"] by_model = await api.get("/api/usage", {"group_by": "model", "since": since}) models = {r["model"]: r for r in by_model["rows"]} assert models["claude-opus-5"]["web_searches"] == 2 assert models["claude-sonnet-5"]["output"] == 2 by_conv = await api.get("/api/usage", {"group_by": "conversation", "since": since}) assert {r["conversation"] for r in by_conv["rows"]} == {"c1", "c2"} bad = await api.http.get("/api/usage", params={"group_by": "x"}, headers=HEADERS) assert bad.status_code == 400 async def test_limits_report_latest_window_with_gateway_spend(world: World) -> None: api = Api(world) now = datetime.now(UTC) async with world.db.session() as session: session.add( RateLimit( window="five_hour", status="allowed", utilization=0.2, resets_at=now + timedelta(hours=4), ) ) session.add( RateLimit( window="five_hour", status="allowed_warning", utilization=0.9, resets_at=now + timedelta(hours=4), ) ) session.add(RateLimit(window="seven_day", status="allowed", utilization=0.3)) await session.commit() await seed_usage( world, [ { "ts": (now - timedelta(minutes=30)).replace(tzinfo=None), "agent_name": "a", "model": "m", "output_tokens": 7, "cost_usd": 0.25, } ], ) out = await api.get("/api/limits") windows = {w["window"]: w for w in out["windows"]} assert windows["five_hour"]["utilization"] == 0.9 assert windows["five_hour"]["status"] == "allowed_warning" assert windows["five_hour"]["gateway"]["output"] == 7 assert windows["five_hour"]["gateway"]["cost_usd"] == 0.25 assert windows["seven_day"]["utilization"] == 0.3 assert [w["window"] for w in out["windows"]] == ["five_hour", "seven_day"] assert len(out["history"]) == 3 async def test_memory_tree_and_file(world: World) -> None: root = world.root / "zone" (root / "дни").mkdir(parents=True) (root / "дни" / "2026-08-27.md").write_text("# day", encoding="utf-8") (root / "состояние.md").write_text("# state", encoding="utf-8") (root / ".hidden").write_text("x", encoding="utf-8") api = Api(world, memory_root=root) tree = await api.get("/api/memory") names = [n["name"] for n in tree["tree"]] assert names == ["дни", "состояние.md"] assert tree["tree"][0]["children"][0]["path"] == "дни/2026-08-27.md" file = await api.get("/api/memory/file", {"path": "дни/2026-08-27.md"}) assert file["content"] == "# day" escape = await api.http.get( "/api/memory/file", params={"path": "../w.db"}, headers=HEADERS ) assert escape.status_code == 404 unset = Api(world) res = await unset.http.get("/api/memory", headers=HEADERS) assert res.status_code == 404 async def test_describe_snapshots_open_tools_and_records_rate_limit( world: World, ) -> None: world.backend._factory = ToolClient # noqa: SLF001 api = Api(world) async def record(event: Any) -> None: async with world.db.session() as session: session.add( Usage( agent_name=event.agent_name, conversation_id=event.conversation_id, model=event.model, cost_usd=event.usage.cost_usd, model_usage=event.usage.model_usage, ) ) await session.commit() world.backend._usage_sink = record # noqa: SLF001 conv = await world.conversations.create(kind="master", agent="a", origin="test") ScriptedClient.hold = asyncio.Event() await world.conversations.post(conv, "go") await asyncio.sleep(0.3) described = await api.get(f"/api/conversations/{conv.external_id}") turn = described["turn"] assert turn is not None and turn["id"] == described["running_turn"] tools = {t["tool_use_id"]: t for t in turn["tools"]} assert tools["tu_1"]["name"] == "Bash" and tools["tu_1"]["ended_at"] is None assert tools["tu_2"]["parent_tool_use_id"] == "tu_1" ScriptedClient.hold.set() await world.settle(conv, 1) described = await api.get(f"/api/conversations/{conv.external_id}") assert described["turn"] is None limits = await api.get("/api/limits") assert limits["windows"][0]["utilization"] == 0.8 assert limits["windows"][0]["gateway"]["cost_usd"] == 0.5 usage = await api.get("/api/usage", {"group_by": "model"}) assert {r["model"] for r in usage["rows"]} == {"claude-opus-5", "claude-haiku-4-5"} assert usage["total"]["web_searches"] == 1 conv = await world.conversations.get(conv.external_id) assert conv is not None and conv.session_id is not None await world.store.append( world.key(conv.session_id), build_entries( [ {"role": "user", "content": "go"}, {"role": "assistant", "content": [{"type": "text", "text": "ok"}]}, ], session_id=conv.session_id, cwd=str(world.root), model="m", ), ) history = await api.get(f"/api/conversations/{conv.external_id}/history") assert [m["role"] for m in history["messages"]] == ["user", "assistant"] entries = await api.get(f"/api/conversations/{conv.external_id}/entries") assert entries["total"] == len(entries["entries"]) == 2 page = await api.get( f"/api/conversations/{conv.external_id}/entries", {"limit": 1, "offset": 0} ) assert page["offset"] == 0 and len(page["entries"]) == 1 async def test_tokens_and_audit_need_admin_scope(world: World) -> None: api = Api(world) api.store.grant("api-only", "weak", scope="api") weak = {"Authorization": "Bearer weak"} assert (await api.http.get("/api/tokens", headers=weak)).status_code == 403 created = await api.http.post( "/api/tokens", json={"name": "cursor", "scope": "mcp"}, headers=HEADERS ) assert created.status_code == 201 plaintext = created.json()["plaintext"] await api.store.invalidate() identity = await api.store.verify(plaintext) assert identity is not None and identity.scope == "mcp" listed = await api.get("/api/tokens") assert [t["name"] for t in listed["tokens"]] == ["cursor"] token_id = listed["tokens"][0]["id"] revoked = await api.http.post(f"/api/tokens/{token_id}/revoke", headers=HEADERS) assert revoked.status_code == 200 assert (await api.get("/api/tokens"))["tokens"] == [] audit = await api.get("/api/audit") assert [r["kind"] for r in audit["records"]] == ["token_revoke", "token_create"] async def test_admin_login_hands_out_the_ui_bearer(world: World) -> None: api = Api(world) ui_dir = world.root / "build" ui_dir.mkdir() (ui_dir / "index.html").write_text("ui", encoding="utf-8") admin = build_admin(api.runtime, token="ui-bearer", ui_dir=ui_dir) api.store.grant("admin-ui", "ui-bearer") http = AsyncClient(transport=ASGITransport(app=admin), base_url="http://admin") assert (await http.get("/admin/auth/session")).status_code == 401 bad = await http.post( "/admin/auth/login", json={"username": "admin", "password": "nope"} ) assert bad.status_code == 401 ok = await http.post( "/admin/auth/login", json={"username": "admin", "password": "secret"} ) assert ok.status_code == 200 session = (await http.get("/admin/auth/session")).json() assert session["user"] == "admin" and session["token"] == "ui-bearer" assert session["api_base"] is None spa = await http.get("/admin/conversations/abc") assert spa.status_code == 200 and spa.text == "ui" assert (await http.get("/")).status_code == 307 escape = await http.get("/admin/%2e%2e/pyproject.toml") assert escape.status_code == 200 and escape.text == "ui" me = await api.http.get( "/api/agents", headers={"Authorization": "Bearer ui-bearer"} ) assert me.status_code == 200 assert (await http.post("/admin/auth/logout")).status_code == 204 assert (await http.get("/admin/auth/session")).status_code == 401