mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
248 lines
9.0 KiB
Python
248 lines
9.0 KiB
Python
"""REST surface for OpenSwarm-built apps (the app-side SDK's host half).
|
|
|
|
Apps already hold the install token (frontend via ?token=, backends via
|
|
OPENSWARM_HOST_TOKEN_FILE), and workflows/agents already expose first-class
|
|
routes the SDK helpers call directly. This SubApp adds only what REST could
|
|
not do before: a provider-agnostic LLM completion, and an agent spawn that
|
|
can land its card at a position on the canvas.
|
|
"""
|
|
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any, AsyncIterator, Dict, Optional
|
|
|
|
from fastapi import HTTPException, Request
|
|
from pydantic import BaseModel, ConfigDict
|
|
from typeguard import typechecked
|
|
|
|
from backend.config.Apps import SubApp
|
|
|
|
|
|
@asynccontextmanager
|
|
async def apps_sdk_lifespan() -> AsyncIterator[None]:
|
|
yield
|
|
|
|
|
|
apps_sdk = SubApp("apps-sdk", apps_sdk_lifespan)
|
|
|
|
|
|
class LlmRequest(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
prompt: str
|
|
system: Optional[str] = None
|
|
# Short model name (sonnet/haiku/gpt-5-mini/...); absent means the cheap tier of whatever provider the user runs.
|
|
model: Optional[str] = None
|
|
max_tokens: int = 1024
|
|
|
|
|
|
class LlmReply(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
text: str
|
|
model: str
|
|
|
|
|
|
@apps_sdk.router.post("/llm")
|
|
@typechecked
|
|
async def llm(body: LlmRequest) -> LlmReply:
|
|
from backend.apps.agents.providers.registry import resolve_aux_model, resolve_model_id_for_sdk
|
|
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
|
from backend.apps.settings.settings import load_settings
|
|
|
|
if not body.prompt.strip():
|
|
raise HTTPException(status_code=422, detail="prompt is empty")
|
|
settings = load_settings()
|
|
if body.model:
|
|
api_model = resolve_model_id_for_sdk(body.model, settings)
|
|
else:
|
|
api_model, _ = await resolve_aux_model(settings)
|
|
client = get_anthropic_client_for_model(settings, api_model)
|
|
kwargs: Dict[str, Any] = {
|
|
"model": api_model,
|
|
"max_tokens": max(1, min(body.max_tokens, 8192)),
|
|
"messages": [{"role": "user", "content": body.prompt}],
|
|
}
|
|
if body.system:
|
|
kwargs["system"] = body.system
|
|
try:
|
|
# STREAM, never .create(): 9router's non-Anthropic lanes answer as real SSE that the non-streaming client parses to empty content.
|
|
async with client.messages.stream(**kwargs) as stream:
|
|
resp = await stream.get_final_message()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"LLM call failed: {e}")
|
|
text = "".join(getattr(b, "text", "") for b in resp.content if getattr(b, "type", "") == "text")
|
|
return LlmReply(text=text, model=api_model)
|
|
|
|
|
|
class SpawnAgentRequest(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
prompt: str
|
|
name: str = "Agent"
|
|
model: Optional[str] = None
|
|
dashboard_id: Optional[str] = None
|
|
# Canvas-space position for the spawned card; both present or the placement broadcast is skipped.
|
|
x: Optional[float] = None
|
|
y: Optional[float] = None
|
|
|
|
|
|
class SpawnAgentReply(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
session_id: str
|
|
|
|
|
|
class ToolsListRequest(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
output_id: Optional[str] = None
|
|
|
|
|
|
@typechecked
|
|
def resolve_app_from_origin(origin: str) -> Optional[str]:
|
|
"""Server-derived app identity: a webview app's fetch carries Origin http://127.0.0.1:<port>,
|
|
and the runtime manager knows which app owns that port. Stronger than a self-reported id."""
|
|
try:
|
|
from urllib.parse import urlparse
|
|
|
|
from backend.apps.outputs.runtime import manager
|
|
|
|
parsed = urlparse(origin)
|
|
if parsed.hostname not in ("127.0.0.1", "localhost") or not parsed.port:
|
|
return None
|
|
for registry in (manager.runtimes, manager.idle_lru):
|
|
for rt in registry.values():
|
|
if parsed.port in (rt.frontend_port, rt.port):
|
|
return rt.workspace_id
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
class AppToolServerRow(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
id: str
|
|
name: str
|
|
description: str
|
|
|
|
|
|
@apps_sdk.router.post("/tools/list")
|
|
@typechecked
|
|
async def tools_list(body: ToolsListRequest) -> Dict[str, Any]:
|
|
"""Connected tool servers an app could ask to use: the SAME enabled, vetted set agents see,
|
|
nothing wider. Sub-tools come from POST /api/tools/{id}/discover; calls go through the grant."""
|
|
from backend.apps.tools_lib.tools_lib import load_all_tools
|
|
|
|
rows = [
|
|
AppToolServerRow(id=tool.id, name=tool.name, description=tool.description[:200])
|
|
for tool in load_all_tools()
|
|
if tool.mcp_config and tool.enabled and tool.auth_status in ("configured", "connected")
|
|
]
|
|
return {"servers": [r.model_dump() for r in rows]}
|
|
|
|
|
|
class ToolCallRequest(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
# App backends name themselves via OPENSWARM_OUTPUT_ID; webview apps are identified by Origin instead.
|
|
output_id: Optional[str] = None
|
|
# "<tool_id>:<ToolName>" from /tools/list.
|
|
tool: str
|
|
args: Dict[str, Any] = {}
|
|
|
|
|
|
@apps_sdk.router.post("/tools/call")
|
|
@typechecked
|
|
async def tools_call(body: ToolCallRequest, request: Request) -> Dict[str, Any]:
|
|
"""The grant gate: denied is refused flat, ungranted blocks on a user approval card, granted
|
|
dispatches through the same transport + credential path agents use. Enforced server-side."""
|
|
import json as p_json
|
|
|
|
from backend.apps.apps_sdk.tool_grants import grant_status, request_grant
|
|
from backend.apps.outputs.workspace_io import load_output
|
|
from backend.apps.tools_lib.mcp_call import call_mcp_tool
|
|
|
|
# Origin wins over the body: it's derived from which live app runtime owns the calling port.
|
|
output_id = resolve_app_from_origin(request.headers.get("origin", "")) or body.output_id
|
|
if not output_id:
|
|
raise HTTPException(status_code=403, detail="Could not identify the calling app; tool access is per-app.")
|
|
tool_id, sep, tool_name = body.tool.partition(":")
|
|
if not sep or not tool_id or not tool_name:
|
|
raise HTTPException(status_code=422, detail="tool must be '<tool_id>:<ToolName>' from /tools/list")
|
|
status = grant_status(output_id, body.tool)
|
|
if status == "denied":
|
|
raise HTTPException(status_code=403, detail=f"The user has denied this app access to {tool_name}.")
|
|
if status != "granted":
|
|
try:
|
|
output = load_output(output_id)
|
|
app_name = output.name if output else output_id
|
|
except Exception:
|
|
app_name = output_id
|
|
allowed = await request_grant(output_id, app_name, body.tool, tool_name, p_json.dumps(body.args)[:400])
|
|
if not allowed:
|
|
raise HTTPException(status_code=403, detail=f"The user did not approve this app using {tool_name}.")
|
|
text = await call_mcp_tool(tool_id, tool_name, body.args)
|
|
return {"result": text}
|
|
|
|
|
|
class GrantResolveRequest(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
request_id: str
|
|
allow: bool
|
|
remember: bool = False
|
|
|
|
|
|
@apps_sdk.router.delete("/tools/grants/{output_id}")
|
|
@typechecked
|
|
async def reset_tool_grants(output_id: str) -> Dict[str, bool]:
|
|
from backend.apps.apps_sdk.tool_grants import clear_grants
|
|
|
|
clear_grants(output_id)
|
|
return {"ok": True}
|
|
|
|
|
|
@apps_sdk.router.post("/tools/grant")
|
|
@typechecked
|
|
async def tools_grant(body: GrantResolveRequest) -> Dict[str, bool]:
|
|
from backend.apps.apps_sdk.tool_grants import resolve_grant
|
|
|
|
return {"ok": resolve_grant(body.request_id, body.allow, body.remember)}
|
|
|
|
|
|
@apps_sdk.router.post("/agents/spawn")
|
|
@typechecked
|
|
async def spawn_agent(body: SpawnAgentRequest) -> SpawnAgentReply:
|
|
import asyncio
|
|
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
from backend.apps.agents.core.models import AgentConfig
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
|
|
if not body.prompt.strip():
|
|
raise HTTPException(status_code=422, detail="prompt is empty")
|
|
dashboard_id = body.dashboard_id
|
|
if dashboard_id is None:
|
|
# An app webview has no idea which dashboard it lives on; without one the canvas lifecycle
|
|
# never creates a card, so the spawn is real but invisible. Most-recent dashboard wins.
|
|
from backend.apps.dashboards.dashboards import load_all
|
|
boards = sorted(load_all(), key=lambda d: d.updated_at, reverse=True)
|
|
dashboard_id = boards[0].id if boards else None
|
|
config = AgentConfig(
|
|
name=body.name,
|
|
prompt=body.prompt,
|
|
model=body.model or "sonnet",
|
|
dashboard_id=dashboard_id,
|
|
)
|
|
session = await agent_manager.launch_agent(config)
|
|
asyncio.create_task(agent_manager.send_message(session.id, body.prompt))
|
|
if body.x is not None and body.y is not None:
|
|
await ws_manager.broadcast_global("apps_sdk:place_agent_card", {
|
|
"session_id": session.id,
|
|
"dashboard_id": dashboard_id,
|
|
"x": body.x,
|
|
"y": body.y,
|
|
})
|
|
return SpawnAgentReply(session_id=session.id)
|