feat: implement raycast backend

This commit is contained in:
hh
2026-05-19 21:06:01 +02:00
parent 221e660c5c
commit 757065f21c
16 changed files with 1415 additions and 31 deletions
+136 -9
View File
@@ -1,28 +1,155 @@
"""Process entrypoint.
Phase 0.3 — load the user's ``/config/config.py`` via
``config_loader``, build registries, print the
``loaded N agents, M mcps, K frontends`` line from the Phase 0 DoD,
exit cleanly. Phase 0.4 will install uvloop and start uvicorn(s) for
the frontends and the internal MCP app.
Phase 1.4 — async ``main``: install uvloop, load the user config, build
registries + per-agent backends (only ``RaycastBackend`` so far), wire
each frontend with a ``GatewayRuntime``, and run all
``frontend.serve()`` coroutines concurrently. Without any frontends we
still print the Phase 0 DoD line and exit cleanly so the bare skeleton
keeps working.
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import AsyncExitStack
from typing import TYPE_CHECKING
import uvloop
from raycast_api import Client as RaycastClient
from raycast_api.config import Config as RaycastConfig
from beaver_gateway import config_loader
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.backends.raycast import RaycastBackend
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.settings import Settings
if TYPE_CHECKING:
from beaver_gateway.backends.base import Backend
_log = logging.getLogger("beaver_gateway.cli")
def main() -> None:
settings = Settings() # ty: ignore[missing-argument]
"""Sync wrapper: uvloop loop factory + asyncio.run."""
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
asyncio.run(_async_main(), loop_factory=uvloop.new_event_loop)
async def _async_main() -> None:
settings = Settings() # ty: ignore[missing-argument]
gateway = config_loader.load(settings.config_path)
agents = AgentRegistry(gateway.agents)
mcps = McpRegistry(gateway.mcps)
token_store = TokenStore.from_env(settings.bootstrap_tokens)
print(
f"beaver-gateway: loaded {len(agents)} agents, "
f"{len(mcps)} mcps, {len(gateway.frontends)} frontends"
async with AsyncExitStack() as stack:
backends: dict[str, Backend] = await _build_backends(
settings=settings, agents=agents, stack=stack
)
runtime = GatewayRuntime(
agents=agents,
mcps=mcps,
backends=backends,
token_store=token_store,
)
for fe in gateway.frontends:
fe.configure(runtime)
_log.info(
"beaver-gateway: loaded %d agents, %d mcps, %d frontends",
len(agents),
len(mcps),
len(gateway.frontends),
)
# Keep the Phase 0 DoD line on stdout for grep-friendly smoke
# tests, in addition to the structured log line above.
print(
f"beaver-gateway: loaded {len(agents)} agents, "
f"{len(mcps)} mcps, {len(gateway.frontends)} frontends"
)
if not gateway.frontends:
return
async with asyncio.TaskGroup() as tg:
for fe in gateway.frontends:
tg.create_task(fe.serve())
async def _build_backends(
*,
settings: Settings,
agents: AgentRegistry,
stack: AsyncExitStack,
) -> dict[str, Backend]:
"""Construct one backend per agent name.
The Raycast ``Client`` is shared across every ``RaycastAgent``
(bearer + device-id are process-wide), so we open it lazily — only
when at least one ``RaycastAgent`` is present — and close it via
the caller's exit stack.
"""
backends: dict[str, Backend] = {}
raycast_agents = [a for a in agents if isinstance(a, RaycastAgent)]
if raycast_agents:
client = await _try_open_raycast_client(settings, stack)
if client is not None:
raycast_backend = RaycastBackend(client)
for a in raycast_agents:
backends[a.name] = raycast_backend
# Phase 2: ClaudeAgent → ClaudeCodeBackendAdapter goes here.
return backends
async def _try_open_raycast_client(
settings: Settings, stack: AsyncExitStack
) -> RaycastClient | None:
"""Open a ``raycast_api.Client`` if creds are available, else warn + skip.
Skipping is gentler than failing startup: the user can bring up the
gateway, list their agents, and still hit a ``ClaudeAgent``; affected
``RaycastAgent`` instances 503 with a specific message at request time.
Missing ``raycast.json`` falls into the same bucket — first-run
users won't have it yet.
"""
if not settings.raycast_bearer:
_log.warning(
"RaycastAgent present but RAYCAST_BEARER is unset — those agents will 503"
)
return None
if not settings.raycast_device_id:
_log.warning(
"RaycastAgent present but RAYCAST_DEVICE_ID is unset — those agents "
"will 503 (generate once with `python -c 'import secrets; "
"print(secrets.token_hex(32))'`)"
)
return None
if not settings.raycast_config_path.exists():
_log.warning(
"RaycastAgent present but %s is missing — those agents will 503",
settings.raycast_config_path,
)
return None
config = RaycastConfig.load(settings.raycast_config_path)
client = RaycastClient(
config=config,
bearer_token=settings.raycast_bearer,
device_id=settings.raycast_device_id,
locale=settings.raycast_locale,
)
return await stack.enter_async_context(client)