[eric] [eric] add PostHog analytics, 9Router subscription proxy, unified usage tracking

- PostHog integration: collector, analytics subapp, opt-in UI, Analytics page
  - 9Router: auto-start, OAuth subscription flow, /v1/messages Anthropic format support
  - Settings overhaul: multi-provider API keys, subscription connect UI, onboarding modal
  - Unified usage: merge 9Router cost/token data into Settings Usage tab
  - Provider system: providers/, agent_loop, tools/ (unused, for future non-Anthropic support)
  - Agent SDK: restored as primary with 9Router ANTHROPIC_BASE_URL fallback
  - Updated system prompt, credential resolution, dashboard analytics
This commit is contained in:
ciregenz
2026-03-24 14:02:58 -07:00
parent 8d09e46df5
commit ebea25f0c2
38 changed files with 4382 additions and 373 deletions
+331
View File
@@ -0,0 +1,331 @@
"""Owned agent loop — replaces claude_agent_sdk's query() function.
Generalizes the pattern from browser_agent.py (lines 243-334) into a
provider-agnostic, streaming, HITL-aware tool-use loop.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any, Callable, Awaitable
from uuid import uuid4
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
# Type aliases for callbacks
ToolExecutor = Callable[[str, dict], Awaitable[list[dict]]]
# hitl_handler(tool_name, tool_input) -> (approved, updated_input_or_None)
HITLHandler = Callable[[str, dict], Awaitable[tuple[bool, dict | None]]]
# ws_emitter(event_type, data) -> None
WSEmitter = Callable[[str, dict], Awaitable[None]]
class AgentLoop:
"""Provider-agnostic agent loop with streaming and HITL support.
The loop:
1. Sends user message to the model
2. Streams the response (emitting WebSocket events)
3. If the model requests tool use:
a. For each tool call: check HITL permission → execute → collect result
b. Append tool results → go to step 2
4. If the model stops (end_turn/max_tokens): done
"""
def __init__(
self,
session_id: str,
provider: BaseProvider,
model: str,
system_prompt: str | None,
tools: list[ToolSchema],
tool_executor: ToolExecutor,
hitl_handler: HITLHandler,
ws_emitter: WSEmitter,
max_turns: int | None = None,
cwd: str | None = None,
):
self.session_id = session_id
self.provider = provider
self.model = model
self.system_prompt = system_prompt
self.tools = tools
self.tool_executor = tool_executor
self.hitl_handler = hitl_handler
self.ws_emitter = ws_emitter
self.max_turns = max_turns
self.cwd = cwd
# Conversation history in provider-agnostic format
self.messages: list[ProviderMessage] = []
# Token tracking
self.total_input_tokens = 0
self.total_output_tokens = 0
async def run(self, user_content: Any) -> None:
"""Run the agent loop for a single user turn."""
# Append user message
user_msg = self.provider.format_user_message(user_content)
self.messages.append(user_msg)
turn = 0
while True:
if self.max_turns and turn >= self.max_turns:
logger.info(f"Agent {self.session_id}: max turns ({self.max_turns}) reached")
break
turn += 1
# Stream the model response and collect it
response = await self._stream_and_collect()
# Track usage
self.total_input_tokens += response.usage.get("input_tokens", 0)
self.total_output_tokens += response.usage.get("output_tokens", 0)
# Append assistant message to conversation history
assistant_msg = self.provider.format_assistant_message(response)
self.messages.append(assistant_msg)
# If no tool use, we're done
if response.stop_reason != "tool_use":
break
# Execute tools
tool_results = await self._execute_tools(response)
if not tool_results:
break
# Append tool results
self.messages.append(ProviderMessage(role="tool_result", content=tool_results))
async def _stream_and_collect(self) -> ModelResponse:
"""Stream model output, emit WebSocket events, collect full response."""
collected_content: list[ContentBlock] = []
collected_usage: dict[str, int] = {}
stop_reason = "end_turn"
# Track streaming state for WS emissions
stream_text_msg_id: str | None = None
stream_tool_msg_ids: dict[int, str] = {} # block index -> msg_id
block_index_map: dict[int, str] = {} # block index -> msg_id
# Buffers for collecting content
text_buffers: dict[int, str] = {}
json_buffers: dict[int, str] = {}
tool_names: dict[int, str] = {}
tool_ids: dict[int, str] = {}
block_types: dict[int, str] = {}
async for event in self.provider.stream_message(
model=self.model,
system=self.system_prompt,
messages=self.messages,
tools=self.tools,
):
if event.type == "content_block_start":
if event.block_type == "text":
if stream_text_msg_id is None:
stream_text_msg_id = uuid4().hex
await self.ws_emitter("agent:stream_start", {
"message_id": stream_text_msg_id,
"role": "assistant",
})
block_index_map[event.index] = stream_text_msg_id
block_types[event.index] = "text"
text_buffers[event.index] = ""
elif event.block_type == "tool_use":
tool_msg_id = uuid4().hex
stream_tool_msg_ids[event.index] = tool_msg_id
block_index_map[event.index] = tool_msg_id
block_types[event.index] = "tool_use"
tool_names[event.index] = event.tool_name
tool_ids[event.index] = event.tool_id
json_buffers[event.index] = ""
await self.ws_emitter("agent:stream_start", {
"message_id": tool_msg_id,
"role": "tool_call",
"tool_name": event.tool_name,
})
elif event.type == "content_block_delta":
msg_id = block_index_map.get(event.index)
if not msg_id:
continue
if event.delta_type == "text_delta":
text_buffers.setdefault(event.index, "")
text_buffers[event.index] += event.text
await self.ws_emitter("agent:stream_delta", {
"message_id": msg_id,
"delta": event.text,
})
elif event.delta_type == "input_json_delta":
json_buffers.setdefault(event.index, "")
json_buffers[event.index] += event.text
await self.ws_emitter("agent:stream_delta", {
"message_id": msg_id,
"delta": event.text,
})
elif event.type == "content_block_stop":
msg_id = block_index_map.get(event.index)
bt = block_types.get(event.index, "")
if bt == "text":
collected_content.append(
ContentBlock(type="text", text=text_buffers.get(event.index, ""))
)
elif bt == "tool_use":
try:
tool_input = json.loads(json_buffers.get(event.index, "{}"))
except json.JSONDecodeError:
tool_input = {}
collected_content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=tool_ids.get(event.index, uuid4().hex),
name=tool_names.get(event.index, ""),
input=tool_input,
),
))
# Send stream_end for tool blocks (text block ends at message_stop)
if msg_id and bt == "tool_use":
await self.ws_emitter("agent:stream_end", {
"message_id": msg_id,
})
elif event.type == "usage":
# Accumulate token usage from provider stream
for k, v in event.usage.items():
collected_usage[k] = collected_usage.get(k, 0) + v
elif event.type == "message_stop":
# Check if any tool calls means stop_reason is tool_use
has_tool_use = any(b.type == "tool_use" for b in collected_content)
if has_tool_use:
stop_reason = "tool_use"
# End text stream
if stream_text_msg_id:
await self.ws_emitter("agent:stream_end", {
"message_id": stream_text_msg_id,
})
# Build and emit the collected messages
await self._emit_collected_messages(
collected_content, stream_text_msg_id, stream_tool_msg_ids,
)
return ModelResponse(
content=collected_content,
stop_reason=stop_reason,
usage=collected_usage,
)
async def _emit_collected_messages(
self,
content: list[ContentBlock],
text_msg_id: str | None,
tool_msg_ids: dict[int, str],
) -> None:
"""Emit finalized agent:message events for the collected response."""
from backend.apps.agents.models import Message
# Emit text message
text_parts = [b.text for b in content if b.type == "text" and b.text]
if text_parts:
msg = Message(
id=text_msg_id or uuid4().hex,
role="assistant",
content="\n".join(text_parts),
)
await self.ws_emitter("agent:message", {
"message": msg.model_dump(mode="json"),
})
# Emit tool call messages
tool_blocks = [b for b in content if b.type == "tool_use" and b.tool_call]
tool_id_list = sorted(tool_msg_ids.items(), key=lambda x: x[0])
for i, block in enumerate(tool_blocks):
tc = block.tool_call
msg_id = tool_id_list[i][1] if i < len(tool_id_list) else uuid4().hex
msg = Message(
id=msg_id,
role="tool_call",
content={
"id": tc.id,
"tool": tc.name,
"input": tc.input,
},
)
await self.ws_emitter("agent:message", {
"message": msg.model_dump(mode="json"),
})
async def _execute_tools(self, response: ModelResponse) -> list[dict]:
"""Execute all tool calls from a response, respecting HITL permissions.
Returns a list of tool result dicts formatted for the provider.
"""
from backend.apps.agents.models import Message
results = []
for block in response.content:
if block.type != "tool_use" or not block.tool_call:
continue
tc = block.tool_call
start_time = time.time()
# HITL permission check
approved, updated_input = await self.hitl_handler(tc.name, tc.input)
if not approved:
result_content = [{"type": "text", "text": "Tool use was denied by the user."}]
else:
tool_input = updated_input if updated_input else tc.input
try:
result_content = await self.tool_executor(tc.name, tool_input)
except Exception as e:
logger.warning(f"Tool execution error: {tc.name}: {e}")
result_content = [{"type": "text", "text": f"Error executing {tc.name}: {e}"}]
elapsed_ms = int((time.time() - start_time) * 1000)
# Emit tool result to frontend
result_text = ""
for block_item in result_content:
if isinstance(block_item, dict) and block_item.get("type") == "text":
result_text = block_item.get("text", "")
break
result_msg = Message(
role="tool_result",
content={
"text": result_text[:15000] if result_text else "Done.",
"tool_name": tc.name,
"elapsed_ms": elapsed_ms,
},
)
await self.ws_emitter("agent:message", {
"message": result_msg.model_dump(mode="json"),
})
# Format for provider
results.append(
self.provider.format_tool_result(tc.id, result_content)
)
return results
+47 -5
View File
@@ -23,6 +23,7 @@ from backend.apps.tools_lib.tools_lib import (
refresh_google_token,
)
from backend.config.paths import SESSIONS_DIR
from backend.apps.analytics.collector import record as _analytics
logger = logging.getLogger(__name__)
@@ -328,6 +329,7 @@ class AgentManager:
session = AgentSession(
id=session_id,
name=config.name,
provider=getattr(config, "provider", "anthropic"),
model=config.model,
mode=config.mode,
system_prompt=config.system_prompt,
@@ -337,13 +339,20 @@ class AgentManager:
dashboard_id=config.dashboard_id,
)
self.sessions[session_id] = session
_analytics("session.started", {
"model": session.model,
"provider": session.provider,
"mode": session.mode,
"tool_count": len(tools),
}, session_id=session_id, dashboard_id=config.dashboard_id)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": "running",
"session": session.model_dump(mode="json"),
})
return session
def _resolve_context_paths(self, context_paths: list | None) -> str:
@@ -826,9 +835,17 @@ class AgentManager:
"disallowed_tools": effective_disallowed,
"include_partial_messages": True,
}
if not global_settings.anthropic_api_key:
raise ValueError("Anthropic API key not configured. Set it in Settings.")
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
if global_settings.anthropic_api_key:
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
else:
# Try 9Router as fallback for subscription access
from backend.apps.nine_router import is_running as _9r_running
if _9r_running():
options_kwargs["env"] = {
"ANTHROPIC_BASE_URL": "http://localhost:20128",
}
else:
raise ValueError("No AI provider configured. Set an API key or connect a subscription.")
if mcp_servers:
options_kwargs["mcp_servers"] = mcp_servers
if composed_prompt:
@@ -988,6 +1005,30 @@ class AgentManager:
})
finally:
if session_id in self.sessions:
# Analytics
duration = (datetime.now() - session.created_at).total_seconds()
tool_names = [
m.content.get("tool", "") for m in session.messages
if m.role == "tool_call" and isinstance(m.content, dict)
]
user_messages = [
(m.content if isinstance(m.content, str) else str(m.content))[:200]
for m in session.messages if m.role == "user"
]
_analytics("session.completed", {
"model": session.model,
"provider": getattr(session, "provider", "anthropic"),
"mode": session.mode,
"cost_usd": session.cost_usd,
"message_count": len([m for m in session.messages if m.role in ("user", "assistant")]),
"duration_seconds": round(duration, 1),
"status": session.status,
"tool_count": len(tool_names),
"tools_list": list(set(tool_names)),
"session_title": session.name,
"first_user_message": user_messages[0] if user_messages else "",
}, session_id=session_id, dashboard_id=session.dashboard_id)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": session.status,
@@ -1134,6 +1175,7 @@ class AgentManager:
prompt: str,
mode: str | None = None,
model: str | None = None,
provider: str | None = None,
images: list | None = None,
context_paths: list | None = None,
forced_tools: list[str] | None = None,
+95
View File
@@ -178,3 +178,98 @@ async def resume_session(session_id: str):
raise HTTPException(status_code=404, detail=str(e))
return {"session": session.model_dump(mode="json")}
# ---------------------------------------------------------------------------
# 9Router / Subscription endpoints
# ---------------------------------------------------------------------------
@agents.router.get("/subscriptions/status")
async def subscriptions_status():
"""Check if 9Router is running and list connected providers."""
from backend.apps.nine_router import is_running, get_providers, get_models
if not is_running():
return {"running": False, "providers": [], "models": []}
providers = await get_providers()
models = await get_models()
return {"running": True, "providers": providers, "models": models}
@agents.router.post("/subscriptions/connect")
async def subscriptions_connect(body: dict):
"""Start OAuth flow for a subscription provider."""
from backend.apps.nine_router import is_running, ensure_running, start_oauth
provider = body.get("provider", "")
if not provider:
raise HTTPException(status_code=400, detail="provider required")
if not is_running():
await ensure_running()
if not is_running():
raise HTTPException(status_code=503, detail="9Router not available. Please install Node.js.")
try:
result = await start_oauth(provider)
# For auth_code flows, store pending state so the callback can exchange
if result.get("flow") == "authorization_code" and result.get("state"):
from backend.main import _pending_oauth
_pending_oauth[result["state"]] = {
"provider": provider,
"code_verifier": result.get("code_verifier", ""),
"redirect_uri": result.get("redirect_uri", ""),
}
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@agents.router.post("/subscriptions/poll")
async def subscriptions_poll(body: dict):
"""Poll for OAuth completion."""
from backend.apps.nine_router import poll_oauth
provider = body.get("provider", "")
device_code = body.get("device_code", "")
if not provider or not device_code:
raise HTTPException(status_code=400, detail="provider and device_code required")
try:
result = await poll_oauth(
provider, device_code,
code_verifier=body.get("code_verifier"),
extra_data=body.get("extra_data"),
)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@agents.router.post("/subscriptions/exchange")
async def subscriptions_exchange(body: dict):
"""Exchange OAuth code for tokens via 9Router."""
from backend.apps.nine_router import exchange_oauth
provider = body.get("provider", "")
code = body.get("code", "")
redirect_uri = body.get("redirect_uri", "")
code_verifier = body.get("code_verifier", "")
state = body.get("state", "")
if not provider or not code:
raise HTTPException(status_code=400, detail="provider and code required")
try:
result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@agents.router.get("/subscriptions/models")
async def subscriptions_models():
"""List all models available through connected subscriptions."""
from backend.apps.nine_router import is_running, get_models
if not is_running():
return {"models": []}
models = await get_models()
return {"models": models}
+360
View File
@@ -0,0 +1,360 @@
"""Standalone MCP client manager for agent sessions.
Replaces claude_agent_sdk's internal MCP server management.
One MCPClientManager instance per agent session — manages connections
to stdio/http/sse MCP servers, discovers tools, and routes tool calls.
"""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from typing import Any
from backend.apps.agents.providers.base import ToolSchema
logger = logging.getLogger(__name__)
@dataclass
class MCPConnection:
"""A live connection to an MCP server."""
server_name: str
session: Any # mcp.ClientSession
tools: list[ToolSchema] = field(default_factory=list)
class MCPClientManager:
"""Manages connections to MCP servers for a single agent session."""
def __init__(self):
self._connections: dict[str, MCPConnection] = {}
self._exit_stack = AsyncExitStack()
self._started = False
async def __aenter__(self):
await self._exit_stack.__aenter__()
self._started = True
return self
async def __aexit__(self, *exc):
await self.disconnect_all()
try:
await self._exit_stack.__aexit__(*exc)
except (BaseExceptionGroup, ExceptionGroup, Exception) as e:
# MCP subprocess cleanup errors are non-fatal
logger.warning(f"MCP cleanup error (non-fatal): {e}")
self._started = False
async def connect(self, server_name: str, config: dict, timeout: float = 30.0) -> list[ToolSchema]:
"""Connect to an MCP server and return its available tools.
The tools are returned with names prefixed as mcp__<server_name>__<tool_name>.
"""
transport = config.get("type", "stdio")
try:
if transport == "stdio":
coro = self._connect_stdio(server_name, config)
elif transport == "sse":
coro = self._connect_sse(server_name, config)
elif transport == "http":
coro = self._connect_http(server_name, config)
else:
logger.warning(f"Unsupported MCP transport: {transport} for {server_name}")
return []
conn = await asyncio.wait_for(coro, timeout=timeout)
self._connections[server_name] = conn
logger.info(f"MCP connected: {server_name} ({len(conn.tools)} tools)")
return conn.tools
except asyncio.TimeoutError:
logger.warning(f"MCP server {server_name} connection timed out after {timeout}s")
return []
except Exception as e:
logger.warning(f"Failed to connect MCP server {server_name}: {e}")
return []
async def _connect_stdio(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to a stdio MCP server (spawns a subprocess)."""
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters
command = config.get("command", "")
args = config.get("args", [])
env = config.get("env")
params = StdioServerParameters(
command=command,
args=args,
env=env,
)
transport = await self._exit_stack.enter_async_context(
stdio_client(params)
)
read_stream, write_stream = transport
session = await self._exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
await session.initialize()
result = await session.list_tools()
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.name}",
description=t.description or "",
input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}),
)
for t in result.tools
]
return MCPConnection(server_name=server_name, session=session, tools=tools)
async def _connect_sse(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to an SSE MCP server."""
from mcp import ClientSession
from mcp.client.sse import sse_client
url = config.get("url", "")
headers = config.get("headers")
transport = await self._exit_stack.enter_async_context(
sse_client(url=url, headers=headers, timeout=30, sse_read_timeout=300)
)
read_stream, write_stream = transport
session = await self._exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
await session.initialize()
result = await session.list_tools()
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.name}",
description=t.description or "",
input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}),
)
for t in result.tools
]
return MCPConnection(server_name=server_name, session=session, tools=tools)
async def _connect_http(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to a Streamable HTTP MCP server.
Falls back to SSE if streamable HTTP fails.
"""
url = config.get("url", "")
headers = config.get("headers")
# Try streamable HTTP first, fall back to SSE
try:
return await self._connect_http_streamable(server_name, url, headers)
except Exception as e:
logger.info(f"Streamable HTTP failed for {server_name}, trying SSE: {e}")
return await self._connect_sse(server_name, config)
async def _connect_http_streamable(
self, server_name: str, url: str, headers: dict | None,
) -> MCPConnection:
"""Connect via Streamable HTTP (JSON-RPC POST)."""
import httpx
from mcp import ClientSession
# Use httpx for streamable HTTP — keep client alive in the exit stack
client = await self._exit_stack.enter_async_context(
httpx.AsyncClient(timeout=30.0)
)
h = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
**(headers or {}),
}
# Initialize
init_resp = await client.post(url, headers=h, json={
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "self-swarm", "version": "0.1.0"},
},
})
if init_resp.status_code not in (200, 201):
raise ConnectionError(f"MCP initialize failed: {init_resp.status_code}")
session_id = init_resp.headers.get("mcp-session-id", "")
if session_id:
h["mcp-session-id"] = session_id
# Notify initialized
await client.post(url, headers=h, json={
"jsonrpc": "2.0", "method": "notifications/initialized",
})
# List tools
list_resp = await client.post(url, headers=h, json={
"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {},
})
if list_resp.status_code not in (200, 201):
raise ConnectionError(f"MCP tools/list failed: {list_resp.status_code}")
ct = list_resp.headers.get("content-type", "")
if "text/event-stream" in ct:
data = self._parse_sse_json(list_resp.text)
else:
data = list_resp.json()
if not data:
raise ConnectionError("Empty response from MCP server")
tools_list = data.get("result", {}).get("tools", [])
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.get('name', '')}",
description=t.get("description", ""),
input_schema=t.get("inputSchema", t.get("input_schema", {})),
)
for t in tools_list
]
# Store the HTTP client info for call_tool
conn = MCPConnection(server_name=server_name, session=None, tools=tools)
conn._http_client = client # type: ignore[attr-defined]
conn._http_url = url # type: ignore[attr-defined]
conn._http_headers = h # type: ignore[attr-defined]
conn._next_id = 3 # type: ignore[attr-defined]
return conn
@staticmethod
def _parse_sse_json(text: str) -> dict | None:
"""Extract JSON from an SSE response body."""
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("data:"):
payload = stripped[len("data:"):].strip()
if payload:
try:
return json.loads(payload)
except json.JSONDecodeError:
continue
try:
return json.loads(text)
except json.JSONDecodeError:
return None
async def call_tool(
self, server_name: str, tool_name: str, arguments: dict,
) -> list[dict]:
"""Call a tool on a specific MCP server.
Args:
server_name: The MCP server name (e.g. "google-workspace")
tool_name: The bare tool name (without mcp__prefix)
arguments: Tool input arguments
Returns:
List of content blocks: [{"type": "text", "text": "..."}]
"""
conn = self._connections.get(server_name)
if not conn:
return [{"type": "text", "text": f"MCP server {server_name} not connected"}]
try:
if conn.session is not None:
# stdio or SSE — use MCP ClientSession
result = await conn.session.call_tool(tool_name, arguments)
return self._format_mcp_result(result)
elif hasattr(conn, "_http_client"):
# Streamable HTTP — use JSON-RPC
return await self._call_tool_http(conn, tool_name, arguments)
else:
return [{"type": "text", "text": f"No session for MCP server {server_name}"}]
except Exception as e:
logger.warning(f"MCP tool call failed: {server_name}/{tool_name}: {e}")
return [{"type": "text", "text": f"Error calling {tool_name}: {e}"}]
async def _call_tool_http(
self, conn: MCPConnection, tool_name: str, arguments: dict,
) -> list[dict]:
"""Call a tool via Streamable HTTP."""
client = conn._http_client # type: ignore[attr-defined]
url = conn._http_url # type: ignore[attr-defined]
headers = conn._http_headers # type: ignore[attr-defined]
req_id = conn._next_id # type: ignore[attr-defined]
conn._next_id = req_id + 1 # type: ignore[attr-defined]
resp = await client.post(url, headers=headers, json={
"jsonrpc": "2.0",
"id": req_id,
"method": "tools/call",
"params": {"name": tool_name, "arguments": arguments},
}, timeout=300.0)
ct = resp.headers.get("content-type", "")
if "text/event-stream" in ct:
data = self._parse_sse_json(resp.text)
else:
data = resp.json()
if not data:
return [{"type": "text", "text": "Empty response from MCP server"}]
if "error" in data:
return [{"type": "text", "text": f"MCP error: {data['error']}"}]
result = data.get("result", {})
content = result.get("content", [])
return content if content else [{"type": "text", "text": json.dumps(result)}]
@staticmethod
def _format_mcp_result(result: Any) -> list[dict]:
"""Convert an MCP CallToolResult to content blocks."""
if hasattr(result, "content"):
blocks = []
for item in result.content:
if hasattr(item, "text"):
blocks.append({"type": "text", "text": item.text})
elif hasattr(item, "data"):
blocks.append({
"type": "image",
"source": {
"type": "base64",
"media_type": getattr(item, "mimeType", "image/png"),
"data": item.data,
},
})
else:
blocks.append({"type": "text", "text": str(item)})
return blocks if blocks else [{"type": "text", "text": "Done."}]
return [{"type": "text", "text": str(result)}]
def get_all_tool_schemas(self) -> list[ToolSchema]:
"""Return tool schemas from all connected MCP servers."""
schemas = []
for conn in self._connections.values():
schemas.extend(conn.tools)
return schemas
def parse_mcp_tool_name(self, full_name: str) -> tuple[str, str] | None:
"""Parse mcp__<server>__<tool> into (server_name, tool_name).
Returns None if the name doesn't match the MCP naming convention.
"""
import re
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", full_name)
if m:
return m.group(1), m.group(2)
return None
async def disconnect_all(self):
"""Disconnect all MCP servers. Called on session end."""
self._connections.clear()
# The AsyncExitStack handles actual cleanup of transports/sessions
+2
View File
@@ -7,6 +7,7 @@ class AgentConfig(BaseModel):
name: str = Field(default_factory=lambda: f"Agent-{uuid4().hex[:6]}")
model: str = "sonnet"
mode: str = "agent"
provider: str = "anthropic"
system_prompt: Optional[str] = None
allowed_tools: list[str] = Field(default_factory=lambda: ["Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion"])
max_turns: Optional[int] = None
@@ -55,6 +56,7 @@ class AgentSession(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
name: str
status: Literal["running", "waiting_approval", "completed", "error", "stopped"] = "running"
provider: str = "anthropic"
model: str = "sonnet"
mode: str = "agent"
sdk_session_id: Optional[str] = None
+260
View File
@@ -0,0 +1,260 @@
"""Anthropic provider adapter using the native Anthropic SDK."""
from __future__ import annotations
import json
import logging
from typing import Any, AsyncIterator
import anthropic
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
MODEL_MAP = {
"sonnet": "claude-sonnet-4-6",
"opus": "claude-opus-4-6",
"haiku": "claude-haiku-4-5",
}
class AnthropicProvider(BaseProvider):
"""Provider adapter for Anthropic's Messages API."""
def __init__(
self,
api_key: str | None = None,
auth_token: str | None = None,
base_url: str | None = None,
):
kwargs: dict[str, Any] = {}
if auth_token:
kwargs["auth_token"] = auth_token
elif api_key:
kwargs["api_key"] = api_key
if base_url:
kwargs["base_url"] = base_url
self.client = anthropic.AsyncAnthropic(**kwargs)
def get_model_id(self, short_name: str) -> str:
return MODEL_MAP.get(short_name, short_name)
def clean_tool_schema(self, schema: ToolSchema) -> dict:
return {
"name": schema.name,
"description": schema.description,
"input_schema": schema.input_schema,
}
def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict:
return {
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content,
}
def format_user_message(self, content: Any) -> ProviderMessage:
return ProviderMessage(role="user", content=content)
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
blocks = []
for block in response.content:
if block.type == "text":
blocks.append({"type": "text", "text": block.text})
elif block.type == "tool_use" and block.tool_call:
blocks.append({
"type": "tool_use",
"id": block.tool_call.id,
"name": block.tool_call.name,
"input": block.tool_call.input,
})
return ProviderMessage(role="assistant", content=blocks)
def _build_messages(self, messages: list[ProviderMessage]) -> list[dict]:
"""Convert ProviderMessages to Anthropic API format."""
result = []
for msg in messages:
if msg.role == "tool_result":
# Tool results: content is a list of tool_result dicts
if isinstance(msg.content, list):
result.append({"role": "user", "content": msg.content})
else:
result.append({"role": "user", "content": [msg.content]})
elif msg.role == "assistant":
result.append({"role": "assistant", "content": msg.content})
elif msg.role == "user":
result.append({"role": "user", "content": msg.content})
return result
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(messages),
}
if system:
kwargs["system"] = system
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
resp = await self.client.messages.create(**kwargs)
content = []
for block in resp.content:
if block.type == "text":
content.append(ContentBlock(type="text", text=block.text))
elif block.type == "tool_use":
content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=block.id,
name=block.name,
input=block.input,
),
))
return ModelResponse(
content=content,
stop_reason="tool_use" if resp.stop_reason == "tool_use" else "end_turn",
usage={
"input_tokens": resp.usage.input_tokens,
"output_tokens": resp.usage.output_tokens,
},
)
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(messages),
}
if system:
kwargs["system"] = system
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
# Use create() with stream=True for raw SSE events
kwargs["stream"] = True
raw_stream = await self.client.messages.create(**kwargs)
current_block_type: dict[int, str] = {}
current_tool_name: dict[int, str] = {}
current_tool_id: dict[int, str] = {}
current_text: dict[int, str] = {}
current_json: dict[int, str] = {}
async for event in raw_stream:
event_type = getattr(event, "type", "")
if event_type == "content_block_start":
index = event.index
block = event.content_block
block_type = block.type
current_block_type[index] = block_type
if block_type == "text":
current_text[index] = ""
yield StreamEvent(
type="content_block_start",
index=index,
block_type="text",
)
elif block_type == "tool_use":
current_tool_name[index] = block.name
current_tool_id[index] = block.id
current_json[index] = ""
yield StreamEvent(
type="content_block_start",
index=index,
block_type="tool_use",
tool_name=block.name,
tool_id=block.id,
)
elif event_type == "content_block_delta":
index = event.index
delta = event.delta
delta_type = delta.type
if delta_type == "text_delta":
current_text.setdefault(index, "")
current_text[index] += delta.text
yield StreamEvent(
type="content_block_delta",
index=index,
delta_type="text_delta",
text=delta.text,
)
elif delta_type == "input_json_delta":
current_json.setdefault(index, "")
current_json[index] += delta.partial_json
yield StreamEvent(
type="content_block_delta",
index=index,
delta_type="input_json_delta",
text=delta.partial_json,
)
elif event_type == "content_block_stop":
yield StreamEvent(type="content_block_stop", index=event.index)
elif event_type == "message_delta":
# Extract output token usage from the final delta
usage_data = {}
delta_usage = getattr(event, "usage", None)
if delta_usage:
output_tokens = getattr(delta_usage, "output_tokens", 0)
if output_tokens:
usage_data["output_tokens"] = output_tokens
if usage_data:
yield StreamEvent(type="usage", usage=usage_data)
elif event_type == "message_start":
# Extract input token usage from the message start
msg = getattr(event, "message", None)
if msg:
msg_usage = getattr(msg, "usage", None)
if msg_usage:
usage_data = {}
input_tokens = getattr(msg_usage, "input_tokens", 0)
output_tokens = getattr(msg_usage, "output_tokens", 0)
if input_tokens:
usage_data["input_tokens"] = input_tokens
if output_tokens:
usage_data["output_tokens"] = output_tokens
if usage_data:
yield StreamEvent(type="usage", usage=usage_data)
yield StreamEvent(type="message_stop")
async def stream_and_collect(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> tuple[AsyncIterator[StreamEvent], ModelResponse]:
"""Helper: stream events and also return the full collected response.
Not used directly — the AgentLoop handles collection.
"""
raise NotImplementedError("Use stream_message() directly; AgentLoop collects.")
+135
View File
@@ -0,0 +1,135 @@
"""Provider-agnostic base classes for multi-model support.
All provider adapters (Anthropic, OpenAI, Gemini, OpenAI-compatible)
implement BaseProvider, translating their native APIs into these
common data structures.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, AsyncIterator
@dataclass
class ToolSchema:
"""Provider-agnostic tool definition."""
name: str
description: str
input_schema: dict[str, Any]
@dataclass
class ToolCall:
"""A tool invocation requested by the model."""
id: str
name: str
input: dict[str, Any]
@dataclass
class ContentBlock:
"""A block of content from the model response."""
type: str # "text" | "tool_use"
text: str = ""
tool_call: ToolCall | None = None
@dataclass
class ModelResponse:
"""Complete (non-streaming) response from a provider."""
content: list[ContentBlock]
stop_reason: str # "end_turn" | "tool_use" | "max_tokens"
usage: dict[str, int] = field(default_factory=dict)
@dataclass
class StreamEvent:
"""A single streaming event, normalized across providers.
The event types match what the frontend already expects via WebSocket:
content_block_start, content_block_delta, content_block_stop, message_stop.
"""
type: str
index: int = 0
block_type: str = "" # "text" | "tool_use"
delta_type: str = "" # "text_delta" | "input_json_delta"
text: str = ""
tool_name: str = ""
tool_id: str = ""
usage: dict[str, int] = field(default_factory=dict)
@dataclass
class ProviderMessage:
"""Provider-agnostic message for conversation history.
Each provider adapter converts these to/from its native format.
"""
role: str # "user" | "assistant" | "tool_result"
content: Any # str, list[dict], or provider-specific content
class BaseProvider(ABC):
"""Abstract base for LLM provider adapters."""
@abstractmethod
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
"""Stream a model response, yielding normalized StreamEvents."""
...
@abstractmethod
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
"""Non-streaming message creation."""
...
@abstractmethod
def format_tool_result(
self,
tool_use_id: str,
content: list[dict],
) -> dict:
"""Format a tool result in this provider's expected message format."""
...
@abstractmethod
def format_user_message(self, content: Any) -> ProviderMessage:
"""Wrap user content (str or multimodal blocks) into a ProviderMessage."""
...
@abstractmethod
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
"""Convert a ModelResponse into a ProviderMessage for conversation history."""
...
@abstractmethod
def get_model_id(self, short_name: str) -> str:
"""Resolve a short model name to the full API model ID."""
...
def clean_tool_schema(self, schema: ToolSchema) -> dict:
"""Convert a ToolSchema to the provider's native tool format.
Default: Anthropic-style format. Override for providers that need
different formats or schema cleaning (e.g. Gemini).
"""
return {
"name": schema.name,
"description": schema.description,
"input_schema": schema.input_schema,
}
@@ -0,0 +1,330 @@
"""OpenAI-compatible provider adapter.
Works with ANY endpoint that speaks the OpenAI Chat Completions API:
OpenAI, OpenRouter, Together, Groq, Fireworks, Mistral, Ollama, vLLM, etc.
"""
from __future__ import annotations
import json
import logging
from typing import Any, AsyncIterator
from uuid import uuid4
from openai import AsyncOpenAI
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
class OpenAICompatProvider(BaseProvider):
"""Provider adapter for any OpenAI-compatible API endpoint."""
def __init__(
self,
api_key: str = "",
base_url: str | None = None,
):
kwargs: dict[str, Any] = {}
# Always set api_key — use "none" as placeholder if empty (some endpoints don't need real keys)
kwargs["api_key"] = api_key if api_key else "none"
if base_url:
kwargs["base_url"] = base_url
self.client = AsyncOpenAI(**kwargs)
def get_model_id(self, short_name: str) -> str:
# Pass through — user selects exact model ID
return short_name
def clean_tool_schema(self, schema: ToolSchema) -> dict:
"""Convert to OpenAI function calling format."""
return {
"type": "function",
"function": {
"name": schema.name,
"description": schema.description,
"parameters": schema.input_schema,
},
}
def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict:
"""Format tool result as OpenAI expects."""
# OpenAI wants a single string for tool results
text_parts = []
for block in content:
if block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif block.get("type") == "image":
text_parts.append("[image]")
else:
text_parts.append(json.dumps(block))
return {
"role": "tool",
"tool_call_id": tool_use_id,
"content": "\n".join(text_parts) if text_parts else "Done.",
}
def format_user_message(self, content: Any) -> ProviderMessage:
"""Convert user content to OpenAI format."""
if isinstance(content, str):
return ProviderMessage(role="user", content=content)
# Multimodal content (text + images)
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict):
if block.get("type") == "text":
parts.append({"type": "text", "text": block["text"]})
elif block.get("type") == "image":
source = block.get("source", {})
media_type = source.get("media_type", "image/png")
data = source.get("data", "")
parts.append({
"type": "image_url",
"image_url": {"url": f"data:{media_type};base64,{data}"},
})
elif isinstance(block, str):
parts.append({"type": "text", "text": block})
return ProviderMessage(role="user", content=parts)
return ProviderMessage(role="user", content=str(content))
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
"""Convert ModelResponse to OpenAI assistant message format."""
text_parts = []
tool_calls = []
for block in response.content:
if block.type == "text":
text_parts.append(block.text)
elif block.type == "tool_use" and block.tool_call:
tool_calls.append({
"id": block.tool_call.id,
"type": "function",
"function": {
"name": block.tool_call.name,
"arguments": json.dumps(block.tool_call.input),
},
})
msg: dict[str, Any] = {"role": "assistant"}
if text_parts:
msg["content"] = "\n".join(text_parts)
else:
msg["content"] = None
if tool_calls:
msg["tool_calls"] = tool_calls
return ProviderMessage(role="assistant", content=msg)
def _build_messages(
self,
system: str | None,
messages: list[ProviderMessage],
) -> list[dict]:
"""Convert ProviderMessages to OpenAI API format."""
result = []
if system:
result.append({"role": "system", "content": system})
for msg in messages:
if msg.role == "assistant":
# Assistant messages are already in OpenAI format from format_assistant_message
if isinstance(msg.content, dict) and "role" in msg.content:
result.append(msg.content)
else:
# Raw content blocks from provider-agnostic format
text_parts = []
tool_calls = []
if isinstance(msg.content, list):
for block in msg.content:
if isinstance(block, dict):
if block.get("type") == "text":
text_parts.append(block["text"])
elif block.get("type") == "tool_use":
tool_calls.append({
"id": block.get("id", uuid4().hex),
"type": "function",
"function": {
"name": block.get("name", ""),
"arguments": json.dumps(block.get("input", {})),
},
})
api_msg: dict[str, Any] = {
"role": "assistant",
"content": "\n".join(text_parts) if text_parts else None,
}
if tool_calls:
api_msg["tool_calls"] = tool_calls
result.append(api_msg)
elif msg.role == "tool_result":
# Tool results: content is a list of tool result dicts
if isinstance(msg.content, list):
for tr in msg.content:
if isinstance(tr, dict) and "tool_call_id" in tr:
result.append(tr)
elif isinstance(msg.content, dict) and "tool_call_id" in msg.content:
result.append(msg.content)
elif msg.role == "user":
result.append({"role": "user", "content": msg.content})
return result
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(system, messages),
}
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
resp = await self.client.chat.completions.create(**kwargs)
choice = resp.choices[0]
message = choice.message
content: list[ContentBlock] = []
if message.content:
content.append(ContentBlock(type="text", text=message.content))
if message.tool_calls:
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments)
except json.JSONDecodeError:
args = {}
content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=tc.id,
name=tc.function.name,
input=args,
),
))
stop = "end_turn"
if choice.finish_reason == "tool_calls":
stop = "tool_use"
elif message.tool_calls:
stop = "tool_use"
usage_dict = {}
if resp.usage:
usage_dict = {
"input_tokens": resp.usage.prompt_tokens,
"output_tokens": resp.usage.completion_tokens,
}
return ModelResponse(content=content, stop_reason=stop, usage=usage_dict)
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(system, messages),
"stream": True,
"stream_options": {"include_usage": True},
}
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
stream = await self.client.chat.completions.create(**kwargs)
# Track streaming state to emit normalized events
text_started = False
text_index = 0
tool_indices: dict[int, dict] = {} # openai tool_call index -> {name, id, json_buf}
next_block_index = 0
async for chunk in stream:
if not chunk.choices:
# Usage-only chunk at the end
if chunk.usage:
yield StreamEvent(type="usage", usage={
"input_tokens": chunk.usage.prompt_tokens or 0,
"output_tokens": chunk.usage.completion_tokens or 0,
})
continue
delta = chunk.choices[0].delta
finish_reason = chunk.choices[0].finish_reason
# Text content
if delta.content is not None:
if not text_started:
text_started = True
text_index = next_block_index
next_block_index += 1
yield StreamEvent(
type="content_block_start",
index=text_index,
block_type="text",
)
yield StreamEvent(
type="content_block_delta",
index=text_index,
delta_type="text_delta",
text=delta.content,
)
# Tool calls
if delta.tool_calls:
for tc_delta in delta.tool_calls:
tc_idx = tc_delta.index
if tc_idx not in tool_indices:
# New tool call starting
if text_started:
yield StreamEvent(type="content_block_stop", index=text_index)
text_started = False
block_idx = next_block_index
next_block_index += 1
tool_indices[tc_idx] = {
"block_index": block_idx,
"id": tc_delta.id or uuid4().hex,
"name": tc_delta.function.name if tc_delta.function else "",
"json_buf": "",
}
yield StreamEvent(
type="content_block_start",
index=block_idx,
block_type="tool_use",
tool_name=tool_indices[tc_idx]["name"],
tool_id=tool_indices[tc_idx]["id"],
)
info = tool_indices[tc_idx]
if tc_delta.function and tc_delta.function.name:
info["name"] = tc_delta.function.name
if tc_delta.function and tc_delta.function.arguments:
info["json_buf"] += tc_delta.function.arguments
yield StreamEvent(
type="content_block_delta",
index=info["block_index"],
delta_type="input_json_delta",
text=tc_delta.function.arguments,
)
# Finish
if finish_reason is not None:
if text_started:
yield StreamEvent(type="content_block_stop", index=text_index)
for info in tool_indices.values():
yield StreamEvent(type="content_block_stop", index=info["block_index"])
yield StreamEvent(type="message_stop")
+328
View File
@@ -0,0 +1,328 @@
"""Provider factory and model registry.
Two-tier system:
1. Built-in providers (Anthropic, OpenAI, Gemini) with curated model lists
2. User-configured custom providers (any OpenAI-compatible endpoint)
- Includes built-in OpenRouter integration for 300+ models
"""
from __future__ import annotations
import logging
from typing import Any, TYPE_CHECKING
from backend.apps.agents.providers.base import BaseProvider
if TYPE_CHECKING:
from backend.apps.settings.models import AppSettings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tier 1: Built-in models (curated, we know their quirks)
# ---------------------------------------------------------------------------
BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
"Anthropic": [
{"value": "sonnet", "label": "Claude Sonnet 4.6", "context_window": 1_000_000, "model_id": "claude-sonnet-4-6", "api": "anthropic"},
{"value": "opus", "label": "Claude Opus 4.6", "context_window": 1_000_000, "model_id": "claude-opus-4-6", "api": "anthropic"},
{"value": "haiku", "label": "Claude Haiku 4.5", "context_window": 200_000, "model_id": "claude-haiku-4-5", "api": "anthropic"},
],
}
# ---------------------------------------------------------------------------
# OpenRouter: built-in integration for 300+ models
# ---------------------------------------------------------------------------
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
_9router_cache: dict = {"available": None, "checked_at": 0}
def _is_9router_available() -> bool:
"""Check if 9Router is running on localhost:20128. Caches for 30 seconds."""
import time as _time
now = _time.time()
if _9router_cache["available"] is not None and now - _9router_cache["checked_at"] < 30:
return _9router_cache["available"]
try:
import httpx
r = httpx.get("http://localhost:20128/v1/models", timeout=2.0)
available = r.status_code == 200
except Exception:
available = False
_9router_cache["available"] = available
_9router_cache["checked_at"] = now
return available
# ---------------------------------------------------------------------------
# Provider factory
# ---------------------------------------------------------------------------
def create_provider(
provider_name: str,
settings: AppSettings,
provider_config: dict | None = None,
) -> BaseProvider:
"""Create a provider adapter.
Routes based on the 'api' field in BUILTIN_MODELS:
- "anthropic" → native Anthropic SDK
- "openai" → native OpenAI SDK (direct API)
- "gemini" → native Google GenAI SDK
- "openrouter" → OpenAI-compat via openrouter.ai (Meta, Mistral, DeepSeek, Qwen, xAI, etc.)
Custom providers use OpenAI-compat with user's base_url.
"""
api_type = _get_api_type(provider_name)
# Check for 9Router first
if provider_name in ("9Router", "9router"):
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
# Check for GitHub Copilot
if provider_name in ("GitHub Copilot", "copilot"):
from backend.apps.agents.providers.copilot import CopilotProvider
copilot_token = getattr(settings, "copilot_token", None)
if not copilot_token:
raise ValueError("GitHub Copilot not connected. Sign in via Settings → Models.")
# Auto-refresh if expired
import time as _time
expires = getattr(settings, "copilot_token_expires", None)
if expires and _time.time() > expires - 120:
github_token = getattr(settings, "copilot_github_token", None)
if github_token:
import asyncio
from backend.apps.agents.copilot_auth import exchange_for_copilot_token
try:
loop = asyncio.get_event_loop()
result = loop.run_until_complete(exchange_for_copilot_token(github_token))
copilot_token = result["token"]
settings.copilot_token = copilot_token
settings.copilot_token_expires = result["expires_at"]
from backend.apps.settings.settings import _save_settings
_save_settings(settings)
except Exception as e:
logger.warning(f"Copilot token refresh failed: {e}")
return CopilotProvider(copilot_token=copilot_token)
if api_type == "anthropic":
from backend.apps.agents.providers.anthropic import AnthropicProvider
if getattr(settings, "connection_mode", "own_key") == "managed":
return AnthropicProvider(
auth_token=getattr(settings, "openswarm_auth_token", None),
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.ai",
)
if settings.anthropic_api_key:
return AnthropicProvider(api_key=settings.anthropic_api_key)
# No API key — try 9Router as fallback
if _is_9router_available():
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
provider = OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
# Override get_model_id to map our short names to 9Router's cc/ prefixed IDs
_original_get_model = provider.get_model_id
_9r_model_map = {
"sonnet": "cc/claude-sonnet-4-6",
"opus": "cc/claude-opus-4-6",
"haiku": "cc/claude-haiku-4-5-20251001",
}
provider.get_model_id = lambda name: _9r_model_map.get(name, f"cc/{name}" if not name.startswith("cc/") else name)
return provider
raise ValueError("Anthropic API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "openai":
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
if settings.openai_api_key:
return OpenAICompatProvider(api_key=settings.openai_api_key, base_url="https://api.openai.com/v1")
# No API key — try 9Router as fallback
if _is_9router_available():
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError("OpenAI API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "gemini":
from backend.apps.agents.providers.gemini import GeminiProvider
if settings.google_api_key:
return GeminiProvider(api_key=settings.google_api_key)
# No API key — try 9Router as fallback
if _is_9router_available():
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError("Google API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "openrouter":
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
openrouter_key = getattr(settings, "openrouter_api_key", None)
if openrouter_key:
return OpenAICompatProvider(api_key=openrouter_key, base_url=OPENROUTER_BASE_URL)
# No OpenRouter key — try 9Router as fallback
if _is_9router_available():
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError(f"OpenRouter API key not configured for {provider_name}. Set it in Settings, or connect a subscription.")
# Custom provider — look up in settings.custom_providers
if provider_config:
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=provider_config.get("api_key", ""),
base_url=provider_config.get("base_url", ""),
)
for cp in getattr(settings, "custom_providers", []):
if cp.name == provider_name:
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=cp.api_key,
base_url=cp.base_url,
)
raise ValueError(f"Unknown provider: {provider_name}")
def _get_api_type(provider_name: str) -> str:
"""Get the API type for a provider from BUILTIN_MODELS.
Accepts both display names ('Anthropic') and lowercase API names ('anthropic').
"""
# Direct lookup first (display name like 'Anthropic', 'OpenAI', etc.)
models = BUILTIN_MODELS.get(provider_name, [])
if models:
return models[0].get("api", "openrouter")
# Lowercase API name mapping
_API_NAME_MAP = {
"anthropic": "anthropic",
"openai": "openai",
"gemini": "gemini",
"google": "gemini",
"openrouter": "openrouter",
}
if provider_name.lower() in _API_NAME_MAP:
return _API_NAME_MAP[provider_name.lower()]
# Case-insensitive lookup into BUILTIN_MODELS
lower = provider_name.lower()
for key, models in BUILTIN_MODELS.items():
if key.lower() == lower:
return models[0].get("api", "openrouter")
return "openrouter"
def _has_credentials(provider_name: str, settings: AppSettings) -> bool:
"""Check if a provider has credentials configured."""
api_type = _get_api_type(provider_name)
if api_type == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "managed":
return bool(getattr(settings, "openswarm_auth_token", None))
return bool(settings.anthropic_api_key)
if api_type == "openai":
return bool(settings.openai_api_key)
if api_type == "gemini":
return bool(getattr(settings, "google_api_key", None))
if api_type == "openrouter":
return bool(getattr(settings, "openrouter_api_key", None))
return False
def get_available_models(settings: AppSettings) -> dict[str, list[dict]]:
"""Return all models — always show everything, mark which have keys configured.
Like Cursor: show all models upfront, prompt for key when user tries to use one.
Returns: {"provider_name": [{"value": ..., "label": ..., "context_window": ..., "configured": bool}, ...]}
"""
result: dict[str, list[dict]] = {}
# Built-in providers — always show all
for provider_name, models in BUILTIN_MODELS.items():
configured = _has_credentials(provider_name, settings)
result[provider_name] = [
{**m, "configured": configured}
for m in models
]
# Custom providers
for cp in getattr(settings, "custom_providers", []):
if cp.models:
result[cp.name] = [
{
"value": m.get("value", m.get("id", "")),
"label": m.get("label", m.get("value", m.get("id", ""))),
"context_window": m.get("context_window", 128_000),
"configured": True,
}
for m in cp.models
]
return result
def get_context_window(provider: str, model: str, settings: AppSettings | None = None) -> int:
"""Look up context window for any model."""
# Check built-in models first
for models in BUILTIN_MODELS.values():
for m in models:
if m["value"] == model:
return m.get("context_window", 128_000)
# Check custom providers
if settings:
for cp in getattr(settings, "custom_providers", []):
for m in cp.models:
if m.get("value") == model or m.get("id") == model:
return m.get("context_window", 128_000)
return 128_000 # safe default
# ---------------------------------------------------------------------------
# Cost tracking
# ---------------------------------------------------------------------------
COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
# (provider, model): (input_cost_per_1M, output_cost_per_1M)
# Anthropic
("Anthropic", "sonnet"): (3.0, 15.0),
("Anthropic", "opus"): (5.0, 25.0),
("Anthropic", "haiku"): (1.0, 5.0),
# OpenAI
("OpenAI", "gpt-5.4"): (2.50, 15.0),
("OpenAI", "gpt-5.4-mini"): (0.75, 3.0),
("OpenAI", "o3"): (2.0, 8.0),
("OpenAI", "o4-mini"): (1.10, 4.40),
# Google
("Google", "gemini-2.5-flash"): (0.15, 0.60),
("Google", "gemini-2.5-pro"): (1.25, 10.0),
# OpenRouter-backed (approximate)
("xAI", "x-ai/grok-4-0214"): (3.0, 15.0),
("Meta", "meta-llama/llama-4-maverick"): (0.50, 0.70),
("Meta", "meta-llama/llama-4-scout"): (0.15, 0.40),
("DeepSeek", "deepseek/deepseek-chat-v3-0324"): (0.30, 0.90),
("DeepSeek", "deepseek/deepseek-r1"): (0.80, 2.40),
("Mistral", "mistralai/mistral-large-2501"): (2.0, 6.0),
("Mistral", "mistralai/mistral-small-3.1-24b-instruct"): (0.10, 0.30),
("Qwen", "qwen/qwen3-coder"): (0.0, 0.0),
("Qwen", "qwen/qwen3-235b-a22b"): (0.20, 0.70),
("Cohere", "cohere/command-a-03-2025"): (2.50, 10.0),
}
def calculate_cost(
provider: str, model: str,
input_tokens: int, output_tokens: int,
) -> float:
"""Calculate cost in USD from token counts."""
# Direct lookup first
rates = COST_PER_1M_TOKENS.get((provider, model))
if not rates:
# Case-insensitive provider lookup
lower = provider.lower()
for (p, m), r in COST_PER_1M_TOKENS.items():
if p.lower() == lower and m == model:
rates = r
break
if not rates:
return 0.0
input_rate, output_rate = rates
return (input_tokens * input_rate + output_tokens * output_rate) / 1_000_000
+47
View File
@@ -0,0 +1,47 @@
"""Base classes for builtin tool implementations."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
@dataclass
class ToolContext:
"""Runtime context passed to every tool execution."""
cwd: str
session_id: str
class BaseTool(ABC):
"""Abstract base for all builtin tools.
Subclasses must set ``name`` and ``description`` as class attributes and
implement ``get_schema`` (JSON Schema for tool input) and ``execute``.
"""
name: str
description: str
@abstractmethod
def get_schema(self) -> dict:
"""Return JSON Schema for this tool's input parameters."""
...
@abstractmethod
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
"""Execute the tool.
Returns a list of content blocks, e.g.
``[{"type": "text", "text": "..."}]``.
"""
...
def to_tool_schema(self):
"""Convert to the provider-agnostic ``ToolSchema`` used everywhere."""
from backend.apps.agents.providers.base import ToolSchema
return ToolSchema(
name=self.name,
description=self.description,
input_schema=self.get_schema(),
)
+476
View File
@@ -0,0 +1,476 @@
"""Filesystem tools: Read, Write, Edit, Glob, Grep."""
from __future__ import annotations
import asyncio
import base64
import mimetypes
import os
import re
from pathlib import Path
from typing import Any
from backend.apps.agents.tools.base import BaseTool, ToolContext
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
_MAX_OUTPUT_BYTES = 50 * 1024 # ~50 KB cap for grep output
def _resolve(file_path: str, cwd: str) -> Path:
"""Resolve *file_path* against *cwd* when it is relative."""
p = Path(file_path)
if not p.is_absolute():
p = Path(cwd) / p
return p.resolve()
def _text_block(text: str) -> list[dict]:
return [{"type": "text", "text": text}]
# ───────────────────────────────────────────────────────────────────────────
# ReadTool
# ───────────────────────────────────────────────────────────────────────────
class ReadTool(BaseTool):
name = "Read"
description = (
"Read a file from the filesystem. Returns lines with line numbers "
"(cat -n style). For image files returns base64 content. Supports "
"offset and limit parameters for reading portions of large files."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to read.",
},
"offset": {
"type": "integer",
"description": "1-based line number to start reading from.",
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to return (default 2000).",
},
},
"required": ["file_path"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
if not file_path.exists():
return _text_block(f"Error: file not found: {file_path}")
if not file_path.is_file():
return _text_block(f"Error: not a regular file: {file_path}")
# Binary / image files → base64
ext = file_path.suffix.lower()
if ext in _IMAGE_EXTENSIONS:
try:
raw = file_path.read_bytes()
b64 = base64.b64encode(raw).decode("ascii")
media = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
return [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media,
"data": b64,
},
}
]
except Exception as exc:
return _text_block(f"Error reading image {file_path}: {exc}")
# Text files
offset = max(input_data.get("offset", 1), 1)
limit = input_data.get("limit", 2000)
if limit <= 0:
limit = 2000
try:
with open(file_path, "r", errors="replace") as fh:
lines: list[str] = []
for lineno, line in enumerate(fh, start=1):
if lineno < offset:
continue
if len(lines) >= limit:
break
# cat -n style: right-justified line number + tab + content
lines.append(f"{lineno:>6}\t{line.rstrip()}")
if not lines:
return _text_block(f"(file is empty or offset beyond end of file: {file_path})")
return _text_block("\n".join(lines))
except Exception as exc:
return _text_block(f"Error reading {file_path}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# WriteTool
# ───────────────────────────────────────────────────────────────────────────
class WriteTool(BaseTool):
name = "Write"
description = (
"Write content to a file. Creates parent directories if they do not "
"exist. Overwrites the file if it already exists."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to write.",
},
"content": {
"type": "string",
"description": "The full content to write to the file.",
},
},
"required": ["file_path", "content"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
content: str = input_data["content"]
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return _text_block(f"Successfully wrote {len(content)} bytes to {file_path}")
except Exception as exc:
return _text_block(f"Error writing {file_path}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# EditTool
# ───────────────────────────────────────────────────────────────────────────
class EditTool(BaseTool):
name = "Edit"
description = (
"Perform exact string replacements in a file. By default the "
"old_string must appear exactly once (not unique → error). Pass "
"replace_all=true to replace every occurrence."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to edit.",
},
"old_string": {
"type": "string",
"description": "The exact text to find in the file.",
},
"new_string": {
"type": "string",
"description": "The text to replace old_string with.",
},
"replace_all": {
"type": "boolean",
"description": "If true, replace all occurrences. Default false.",
"default": False,
},
},
"required": ["file_path", "old_string", "new_string"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
old_string: str = input_data["old_string"]
new_string: str = input_data["new_string"]
replace_all: bool = input_data.get("replace_all", False)
if not file_path.exists():
return _text_block(f"Error: file not found: {file_path}")
if not file_path.is_file():
return _text_block(f"Error: not a regular file: {file_path}")
try:
content = file_path.read_text(encoding="utf-8")
except Exception as exc:
return _text_block(f"Error reading {file_path}: {exc}")
count = content.count(old_string)
if count == 0:
return _text_block(
f"Error: old_string not found in {file_path}. "
"Make sure the string matches exactly, including whitespace and indentation."
)
if not replace_all and count > 1:
return _text_block(
f"Error: old_string appears {count} times in {file_path}. "
"Provide more surrounding context to make the match unique, "
"or set replace_all=true to replace every occurrence."
)
if replace_all:
new_content = content.replace(old_string, new_string)
else:
# Replace only the first (and only) occurrence
new_content = content.replace(old_string, new_string, 1)
try:
file_path.write_text(new_content, encoding="utf-8")
except Exception as exc:
return _text_block(f"Error writing {file_path}: {exc}")
replacements = count if replace_all else 1
return _text_block(
f"Successfully edited {file_path} ({replacements} replacement{'s' if replacements != 1 else ''})."
)
# ───────────────────────────────────────────────────────────────────────────
# GlobTool
# ───────────────────────────────────────────────────────────────────────────
class GlobTool(BaseTool):
name = "Glob"
description = (
"Fast file pattern matching. Supports glob patterns like '**/*.py'. "
"Returns matching file paths sorted by modification time (newest first)."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match files (e.g. '**/*.py', 'src/**/*.ts').",
},
"path": {
"type": "string",
"description": "Directory to search in. Defaults to the working directory.",
},
},
"required": ["pattern"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
pattern: str = input_data["pattern"]
base = Path(input_data.get("path") or context.cwd)
if not base.is_dir():
return _text_block(f"Error: directory not found: {base}")
try:
matches: list[Path] = []
for p in base.glob(pattern):
if p.is_file():
matches.append(p)
if len(matches) >= 500:
break
# Sort by modification time, newest first
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
if not matches:
return _text_block(f"No files matched pattern '{pattern}' in {base}")
result = "\n".join(str(p) for p in matches)
return _text_block(result)
except Exception as exc:
return _text_block(f"Error during glob '{pattern}' in {base}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# GrepTool
# ───────────────────────────────────────────────────────────────────────────
class GrepTool(BaseTool):
name = "Grep"
description = (
"Search file contents using regular expressions. Uses ripgrep (rg) "
"when available, otherwise falls back to Python's re module. "
"Supports output modes: files_with_matches, content, count."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regular expression pattern to search for.",
},
"path": {
"type": "string",
"description": "File or directory to search in. Defaults to the working directory.",
},
"glob": {
"type": "string",
"description": "Glob pattern to filter files (e.g. '*.py', '*.{ts,tsx}').",
},
"output_mode": {
"type": "string",
"enum": ["files_with_matches", "content", "count"],
"description": "Output mode. Default: files_with_matches.",
"default": "files_with_matches",
},
},
"required": ["pattern"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
pattern: str = input_data["pattern"]
search_path: str = input_data.get("path") or context.cwd
file_glob: str | None = input_data.get("glob")
output_mode: str = input_data.get("output_mode", "files_with_matches")
# Try ripgrep first
try:
result = await self._run_rg(pattern, search_path, file_glob, output_mode)
if result is not None:
return result
except FileNotFoundError:
pass # rg not installed, fall through to Python fallback
# Python fallback
return await self._python_grep(pattern, search_path, file_glob, output_mode)
async def _run_rg(
self,
pattern: str,
search_path: str,
file_glob: str | None,
output_mode: str,
) -> list[dict] | None:
"""Run ripgrep and return results, or None if rg is not available."""
cmd = ["rg", "--no-heading", "--color=never"]
if output_mode == "files_with_matches":
cmd.append("--files-with-matches")
elif output_mode == "count":
cmd.append("--count")
else:
cmd.extend(["--line-number"])
if file_glob:
cmd.extend(["--glob", file_glob])
cmd.append(pattern)
cmd.append(search_path)
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
except FileNotFoundError:
raise # re-raise so caller knows rg is missing
except asyncio.TimeoutError:
return _text_block("Error: grep timed out after 30 seconds.")
except Exception as exc:
return _text_block(f"Error running ripgrep: {exc}")
output = stdout.decode("utf-8", errors="replace")
if proc.returncode not in (0, 1):
err = stderr.decode("utf-8", errors="replace").strip()
if err:
return _text_block(f"Grep error: {err}")
if not output.strip():
return _text_block(f"No matches found for pattern '{pattern}'.")
# Truncate if too large
if len(output) > _MAX_OUTPUT_BYTES:
output = output[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)"
return _text_block(output.rstrip())
async def _python_grep(
self,
pattern: str,
search_path: str,
file_glob: str | None,
output_mode: str,
) -> list[dict]:
"""Pure-Python grep fallback using the re module."""
try:
regex = re.compile(pattern)
except re.error as exc:
return _text_block(f"Invalid regex pattern: {exc}")
base = Path(search_path)
if base.is_file():
files = [base]
elif base.is_dir():
glob_pat = file_glob or "**/*"
files = [p for p in base.glob(glob_pat) if p.is_file()]
else:
return _text_block(f"Error: path not found: {search_path}")
lines_out: list[str] = []
total_bytes = 0
truncated = False
for fp in sorted(files):
try:
text = fp.read_text(encoding="utf-8", errors="replace")
except Exception:
continue
file_matches: list[tuple[int, str]] = []
for lineno, line in enumerate(text.splitlines(), start=1):
if regex.search(line):
file_matches.append((lineno, line))
if not file_matches:
continue
if output_mode == "files_with_matches":
entry = str(fp)
elif output_mode == "count":
entry = f"{fp}:{len(file_matches)}"
else:
parts = [f"{fp}:{ln}:{txt}" for ln, txt in file_matches]
entry = "\n".join(parts)
total_bytes += len(entry)
if total_bytes > _MAX_OUTPUT_BYTES:
truncated = True
break
lines_out.append(entry)
if not lines_out:
return _text_block(f"No matches found for pattern '{pattern}'.")
result = "\n".join(lines_out)
if truncated:
result += "\n... (output truncated)"
return _text_block(result)
+61
View File
@@ -0,0 +1,61 @@
"""Central tool registry.
Importing this module automatically registers all builtin tools.
"""
from __future__ import annotations
from backend.apps.agents.tools.base import BaseTool
from backend.apps.agents.providers.base import ToolSchema
_TOOLS: dict[str, BaseTool] = {}
def register_tool(tool: BaseTool) -> None:
"""Register a tool instance by its name."""
_TOOLS[tool.name] = tool
def get_tool(name: str) -> BaseTool | None:
"""Look up a registered tool by name. Returns None if not found."""
return _TOOLS.get(name)
def get_all_tools() -> list[BaseTool]:
"""Return all registered tool instances."""
return list(_TOOLS.values())
def get_all_tool_schemas() -> list[ToolSchema]:
"""Return provider-agnostic ToolSchema for every registered tool."""
return [t.to_tool_schema() for t in _TOOLS.values()]
def init_tools() -> None:
"""Import and register all builtin tools."""
from backend.apps.agents.tools.filesystem import (
ReadTool,
WriteTool,
EditTool,
GlobTool,
GrepTool,
)
from backend.apps.agents.tools.system import BashTool, AskUserQuestionTool
from backend.apps.agents.tools.web import WebSearchTool, WebFetchTool
for tool_cls in [
ReadTool,
WriteTool,
EditTool,
GlobTool,
GrepTool,
BashTool,
AskUserQuestionTool,
WebSearchTool,
WebFetchTool,
]:
register_tool(tool_cls())
# Auto-register on import
init_tools()
+125
View File
@@ -0,0 +1,125 @@
"""System tools: Bash and AskUserQuestion."""
from __future__ import annotations
import asyncio
from backend.apps.agents.tools.base import BaseTool, ToolContext
_MAX_OUTPUT_BYTES = 100 * 1024 # ~100 KB cap
class BashTool(BaseTool):
name = "Bash"
description = (
"Execute a shell command and return its output. The command runs in "
"the session's working directory. Supports an optional timeout "
"(default 120 000 ms). Stdout and stderr are captured and returned."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute.",
},
"timeout": {
"type": "integer",
"description": "Timeout in milliseconds (default 120000, max 600000).",
"default": 120000,
},
"description": {
"type": "string",
"description": "Optional human-readable description of what this command does.",
},
},
"required": ["command"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
command: str = input_data["command"]
timeout_ms: int = min(input_data.get("timeout", 120000), 600000)
timeout_s: float = timeout_ms / 1000.0
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=context.cwd,
)
except Exception as exc:
return [{"type": "text", "text": f"Error starting command: {exc}"}]
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
except asyncio.TimeoutError:
# Attempt to kill the process
try:
proc.kill()
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5)
except Exception:
stdout, stderr = b"", b""
partial = self._decode(stdout, stderr)
msg = (
f"Command timed out after {timeout_ms}ms.\n"
f"Partial output:\n{partial}"
)
return [{"type": "text", "text": self._truncate(msg)}]
except Exception as exc:
return [{"type": "text", "text": f"Error executing command: {exc}"}]
output = self._decode(stdout, stderr)
if proc.returncode != 0:
output = f"Exit code: {proc.returncode}\n{output}"
if not output.strip():
output = f"(command completed with exit code {proc.returncode})"
return [{"type": "text", "text": self._truncate(output)}]
@staticmethod
def _decode(stdout: bytes, stderr: bytes) -> str:
parts: list[str] = []
if stdout:
parts.append(stdout.decode("utf-8", errors="replace"))
if stderr:
parts.append(stderr.decode("utf-8", errors="replace"))
return "\n".join(parts)
@staticmethod
def _truncate(text: str) -> str:
if len(text) > _MAX_OUTPUT_BYTES:
return text[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)"
return text
class AskUserQuestionTool(BaseTool):
name = "AskUserQuestion"
description = (
"Ask the user a clarifying question. The actual blocking/HITL "
"interaction is handled by the agent loop's hitl_handler; this tool "
"simply surfaces the question text."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question to ask the user.",
},
},
"required": ["question"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
question: str = input_data.get("question", "")
return [{"type": "text", "text": question}]
+214
View File
@@ -0,0 +1,214 @@
"""Web tools: WebSearch and WebFetch."""
from __future__ import annotations
import html
import re
from typing import Any
import httpx
from backend.apps.agents.tools.base import BaseTool, ToolContext
_MAX_OUTPUT_BYTES = 100 * 1024 # ~100 KB
_HTTP_TIMEOUT = 30 # seconds
_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
def _truncate(text: str, limit: int = _MAX_OUTPUT_BYTES) -> str:
if len(text) > limit:
return text[:limit] + "\n... (output truncated)"
return text
def _strip_html(raw_html: str) -> str:
"""Naive but effective HTML → plain-text conversion."""
# Remove script/style blocks
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", raw_html, flags=re.DOTALL | re.IGNORECASE)
# Remove HTML tags
text = re.sub(r"<[^>]+>", " ", text)
# Decode HTML entities
text = html.unescape(text)
# Collapse whitespace
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# ───────────────────────────────────────────────────────────────────────────
# WebSearchTool
# ───────────────────────────────────────────────────────────────────────────
class WebSearchTool(BaseTool):
name = "WebSearch"
description = (
"Search the web using DuckDuckGo and return titles, URLs, and "
"snippets for the top results."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query.",
},
"num_results": {
"type": "integer",
"description": "Maximum number of results to return (default 5).",
"default": 5,
},
},
"required": ["query"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
query: str = input_data["query"]
num_results: int = input_data.get("num_results", 5)
try:
results = await self._search_ddg(query, num_results)
if not results:
return [{"type": "text", "text": f"No search results found for: {query}"}]
return [{"type": "text", "text": results}]
except Exception as exc:
return [{"type": "text", "text": f"Web search error: {exc}"}]
@staticmethod
async def _search_ddg(query: str, num_results: int) -> str:
"""Query DuckDuckGo HTML endpoint and parse results."""
async with httpx.AsyncClient(
timeout=_HTTP_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": _USER_AGENT},
) as client:
resp = await client.post(
"https://html.duckduckgo.com/html/",
data={"q": query},
)
resp.raise_for_status()
body = resp.text
# Parse result blocks – DuckDuckGo wraps each result in
# <div class="result ..."> ... </div>
result_blocks = re.findall(
r'<div[^>]*class="[^"]*result[^"]*"[^>]*>(.*?)</div>\s*(?=<div[^>]*class="[^"]*result|$)',
body,
flags=re.DOTALL,
)
entries: list[str] = []
for block in result_blocks:
if len(entries) >= num_results:
break
# Title + URL — handle both class-before-href and href-before-class
link_match = re.search(
r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
block,
flags=re.DOTALL,
)
if not link_match:
# Try reversed attribute order
link_match = re.search(
r'<a[^>]*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)</a>',
block,
flags=re.DOTALL,
)
if not link_match:
continue
raw_url = html.unescape(link_match.group(1))
title = _strip_html(link_match.group(2)).strip()
# Snippet
snippet_match = re.search(
r'<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>',
block,
flags=re.DOTALL,
)
snippet = _strip_html(snippet_match.group(1)).strip() if snippet_match else ""
# DuckDuckGo wraps URLs through a redirect; try to extract the real URL
real_url_match = re.search(r"uddg=([^&]+)", raw_url)
if real_url_match:
from urllib.parse import unquote
url = unquote(real_url_match.group(1))
else:
url = raw_url
entry = f"[{len(entries) + 1}] {title}\n {url}"
if snippet:
entry += f"\n {snippet}"
entries.append(entry)
return "\n\n".join(entries)
# ───────────────────────────────────────────────────────────────────────────
# WebFetchTool
# ───────────────────────────────────────────────────────────────────────────
class WebFetchTool(BaseTool):
name = "WebFetch"
description = (
"Fetch the contents of a URL and return the extracted text. "
"HTML is stripped to plain text. Output is truncated to ~100 KB."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to fetch.",
},
"prompt": {
"type": "string",
"description": "Optional prompt/context describing what information to look for.",
},
},
"required": ["url"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
url: str = input_data["url"]
prompt: str | None = input_data.get("prompt")
try:
async with httpx.AsyncClient(
timeout=_HTTP_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": _USER_AGENT},
) as client:
resp = await client.get(url)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
return [{"type": "text", "text": f"HTTP error {exc.response.status_code} fetching {url}"}]
except Exception as exc:
return [{"type": "text", "text": f"Error fetching {url}: {exc}"}]
content_type = resp.headers.get("content-type", "")
if "html" in content_type or resp.text.strip().startswith("<!"):
text = _strip_html(resp.text)
else:
text = resp.text
text = _truncate(text)
header = f"Contents of {url}:"
if prompt:
header += f"\n(Looking for: {prompt})"
return [{"type": "text", "text": f"{header}\n\n{text}"}]
+90 -2
View File
@@ -26,6 +26,14 @@ async def analytics_lifespan():
providers = []
if getattr(settings, "anthropic_api_key", None):
providers.append("anthropic")
if getattr(settings, "openai_api_key", None):
providers.append("openai")
if getattr(settings, "google_api_key", None):
providers.append("gemini")
if getattr(settings, "openrouter_api_key", None):
providers.append("openrouter")
for cp in getattr(settings, "custom_providers", []):
providers.append(cp.name)
record("app.opened", {
"os": platform.system(),
@@ -41,8 +49,22 @@ async def analytics_lifespan():
except Exception as e:
logger.debug(f"Analytics startup event failed (non-critical): {e}")
# Auto-start 9Router for subscription access
try:
from backend.apps.nine_router import ensure_running as ensure_9router
await ensure_9router()
except Exception as e:
logger.debug(f"9Router auto-start skipped: {e}")
yield
# Stop 9Router
try:
from backend.apps.nine_router import stop as stop_9router
stop_9router()
except Exception:
pass
shutdown_collector()
logger.info("PostHog analytics shut down")
@@ -102,7 +124,6 @@ async def usage_summary():
if created and closed:
try:
from datetime import datetime
fmt = "%Y-%m-%dT%H:%M:%S"
c_str = created[:19]
cl_str = closed[:19]
dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
@@ -120,10 +141,48 @@ async def usage_summary():
tool_counts[tool_name] += 1
avg_duration = total_duration / total_sessions if total_sessions > 0 else 0
avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
completed = status_counts.get("completed", 0)
completion_rate = completed / total_sessions if total_sessions > 0 else 0
# Fetch 9Router usage data for accurate cost/token tracking
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
nine_router_stats = await get_usage_stats() if _9r_running() else None
# Determine best cost source
if nine_router_stats and nine_router_stats.get("totalCost", 0) > 0:
cost_source = "9router"
total_cost = nine_router_stats["totalCost"]
elif total_cost > 0:
cost_source = "sdk"
else:
cost_source = "none"
avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
# Extract 9Router breakdowns
cost_by_model = {}
cost_by_provider = {}
total_prompt_tokens = 0
total_completion_tokens = 0
total_requests = 0
if nine_router_stats:
total_prompt_tokens = nine_router_stats.get("totalPromptTokens", 0)
total_completion_tokens = nine_router_stats.get("totalCompletionTokens", 0)
total_requests = nine_router_stats.get("totalRequests", 0)
for key, val in (nine_router_stats.get("byModel") or {}).items():
cost_by_model[key] = {
"cost": val.get("cost", 0),
"requests": val.get("count", 0),
"prompt_tokens": val.get("promptTokens", 0),
"completion_tokens": val.get("completionTokens", 0),
}
for key, val in (nine_router_stats.get("byProvider") or {}).items():
cost_by_provider[key] = {
"cost": val.get("cost", 0),
"requests": val.get("count", 0),
}
return {
"total_sessions": total_sessions,
"total_cost_usd": round(total_cost, 4),
@@ -136,6 +195,35 @@ async def usage_summary():
"providers_used": dict(provider_counts.most_common(10)),
"top_tools": dict(tool_counts.most_common(15)),
"status_breakdown": dict(status_counts),
# 9Router enrichment
"total_prompt_tokens": total_prompt_tokens,
"total_completion_tokens": total_completion_tokens,
"cost_by_model": cost_by_model,
"cost_by_provider": cost_by_provider,
"cost_source": cost_source,
"nine_router_available": nine_router_stats is not None,
"total_requests": total_requests,
}
@analytics.router.get("/cost-breakdown")
async def cost_breakdown(period: str = "7d"):
"""Get detailed cost breakdown from 9Router."""
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
if not _9r_running():
return {"available": False, "by_model": {}, "by_provider": {}}
stats = await get_usage_stats(period)
if not stats:
return {"available": False, "by_model": {}, "by_provider": {}}
return {
"available": True,
"period": period,
"total_cost": stats.get("totalCost", 0),
"total_requests": stats.get("totalRequests", 0),
"total_prompt_tokens": stats.get("totalPromptTokens", 0),
"total_completion_tokens": stats.get("totalCompletionTokens", 0),
"by_model": stats.get("byModel", {}),
"by_provider": stats.get("byProvider", {}),
}
+4 -5
View File
@@ -123,8 +123,10 @@ async def list_dashboards():
@dashboards.router.post("/create")
async def create_dashboard(body: DashboardCreate):
from backend.apps.analytics.collector import record as _analytics
dashboard = Dashboard(name=body.name)
_save(dashboard)
_analytics("dashboard.created", {}, dashboard_id=dashboard.id)
return dashboard.model_dump(mode="json")
@@ -151,13 +153,10 @@ async def generate_name(dashboard_id: str):
fallback = prompts[0][:40]
try:
import anthropic
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import get_anthropic_client
global_settings = load_settings()
if not global_settings.anthropic_api_key:
raise ValueError("API key not configured")
client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key)
client = get_anthropic_client(global_settings)
if len(prompts) == 1:
system = (
+211
View File
@@ -0,0 +1,211 @@
"""Auto-start and manage 9Router subprocess.
9Router is a free AI subscription proxy that lets users connect their
Claude/ChatGPT/Gemini subscriptions to OpenSwarm without API keys.
It runs silently in the background on port 20128 and exposes an
OpenAI-compatible API at localhost:20128/v1.
"""
import asyncio
import logging
import os
import shutil
import subprocess
import httpx
logger = logging.getLogger(__name__)
NINE_ROUTER_PORT = 20128
NINE_ROUTER_URL = f"http://localhost:{NINE_ROUTER_PORT}"
NINE_ROUTER_API = f"{NINE_ROUTER_URL}/api"
NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
_process: subprocess.Popen | None = None
def is_running() -> bool:
"""Check if 9Router is running."""
try:
r = httpx.get(f"{NINE_ROUTER_V1}/models", timeout=2.0)
return r.status_code == 200
except Exception:
return False
async def ensure_running():
"""Start 9Router if not already running."""
global _process
if is_running():
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
return
npx = shutil.which("npx")
if not npx:
logger.warning("npx not found — cannot auto-start 9Router. Install Node.js or run 9Router manually.")
return
logger.info("Starting 9Router on port %d...", NINE_ROUTER_PORT)
try:
env = {**os.environ, "PORT": str(NINE_ROUTER_PORT)}
_process = subprocess.Popen(
[npx, "9router"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
# Wait up to 15 seconds for it to start
for _ in range(30):
await asyncio.sleep(0.5)
if is_running():
logger.info("9Router started successfully")
return
logger.warning("9Router did not start within 15 seconds")
except Exception as e:
logger.warning(f"Failed to start 9Router: {e}")
def stop():
"""Stop the 9Router subprocess."""
global _process
if _process:
try:
_process.terminate()
_process.wait(timeout=5)
except Exception:
try:
_process.kill()
except Exception:
pass
_process = None
logger.info("9Router stopped")
# ---------------------------------------------------------------------------
# API proxy helpers — call 9Router's API from OpenSwarm
# ---------------------------------------------------------------------------
async def get_usage_stats(period: str = "all") -> dict | None:
"""Get usage statistics from 9Router."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{NINE_ROUTER_API}/usage/stats", params={"period": period})
if r.status_code == 200:
return r.json()
except Exception as e:
logger.debug(f"9Router usage stats fetch failed: {e}")
return None
async def get_providers() -> list[dict]:
"""Get all providers and their connection status from 9Router."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{NINE_ROUTER_API}/providers")
if r.status_code == 200:
return r.json()
except Exception as e:
logger.debug(f"9Router providers fetch failed: {e}")
return []
async def start_oauth(provider: str) -> dict:
"""Start OAuth flow for a provider.
For device_code providers (github, qwen, kiro): returns {user_code, verification_uri, device_code}
For authorization_code providers (claude, codex, gemini-cli): returns {authUrl, codeVerifier, state}
"""
async with httpx.AsyncClient(timeout=15.0) as client:
# Try device-code flow first
try:
r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code")
if r.status_code == 200:
data = r.json()
return {
"flow": "device_code",
"user_code": data.get("user_code", ""),
"verification_uri": data.get("verification_uri", data.get("verification_uri_complete", "")),
"device_code": data.get("device_code", ""),
"code_verifier": data.get("codeVerifier", ""),
"extra_data": {k: v for k, v in data.items() if k.startswith("_")},
}
except Exception:
pass
# Authorization code flow — redirect to 9Router's own callback page
# (Anthropic only accepts redirect URIs registered with 9Router's client ID)
callback_url = f"http://localhost:{NINE_ROUTER_PORT}/callback"
r = await client.get(
f"{NINE_ROUTER_API}/oauth/{provider}/authorize",
params={"redirect_uri": callback_url},
)
r.raise_for_status()
data = r.json()
return {
"flow": "authorization_code",
"auth_url": data.get("authUrl", ""),
"code_verifier": data.get("codeVerifier", ""),
"state": data.get("state", ""),
"redirect_uri": callback_url,
}
async def poll_oauth(provider: str, device_code: str, code_verifier: str | None = None, extra_data: dict | None = None) -> dict:
"""Poll for OAuth completion.
Returns: {success: true, connection: {...}} or {success: false, pending: true}
"""
body: dict = {"deviceCode": device_code}
if code_verifier:
body["codeVerifier"] = code_verifier
if extra_data:
body["extraData"] = extra_data
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.post(
f"{NINE_ROUTER_API}/oauth/{provider}/poll",
json=body,
)
r.raise_for_status()
return r.json()
async def exchange_oauth(provider: str, code: str, redirect_uri: str, code_verifier: str, state: str = "") -> dict:
"""Exchange OAuth code for tokens via 9Router."""
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.post(
f"{NINE_ROUTER_API}/oauth/{provider}/exchange",
json={
"code": code,
"redirectUri": redirect_uri,
"codeVerifier": code_verifier,
"state": state,
},
)
r.raise_for_status()
return r.json()
async def get_models() -> list[dict]:
"""Get all available models from 9Router."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{NINE_ROUTER_V1}/models")
if r.status_code == 200:
data = r.json()
models = data.get("data", [])
return [
{
"value": m.get("id", ""),
"label": m.get("id", "").split("/")[-1] if "/" in m.get("id", "") else m.get("id", ""),
"context_window": 200_000,
"provider": m.get("owned_by", "subscription"),
}
for m in models
]
except Exception as e:
logger.debug(f"9Router models fetch failed: {e}")
return []
+2 -5
View File
@@ -33,12 +33,9 @@ def _resolve_model(short_name: str) -> str:
def _get_anthropic_client():
"""Create an AsyncAnthropic client using the API key from app settings."""
import anthropic
from backend.apps.settings.credentials import get_anthropic_client
settings = load_settings()
if not settings.anthropic_api_key:
raise ValueError("Anthropic API key not configured. Set it in Settings.")
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
return get_anthropic_client(settings)
def _validate_against_schema(data: dict, schema: dict) -> str | None:
+141
View File
@@ -0,0 +1,141 @@
"""Centralized credential resolution for LLM API calls.
Supports multiple providers: Anthropic (native), OpenAI, Gemini,
OpenRouter, and user-configured custom providers.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import anthropic
from backend.apps.settings.models import AppSettings
OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.ai"
def _check_9router() -> bool:
"""Check if 9Router is running locally."""
try:
import httpx
r = httpx.get("http://localhost:20128/v1/models", timeout=2.0)
return r.status_code == 200
except Exception:
return False
def validate_credentials(settings: AppSettings, provider: str = "anthropic") -> None:
"""Raise ValueError if credentials are missing for the given provider.
Allows through if 9Router is running as a fallback.
Handles both display names ('Anthropic') and lowercase ('anthropic').
"""
p = provider.lower().strip()
# 9Router or GitHub Copilot providers don't need traditional credentials
if p in ("9router", "github copilot", "copilot"):
return
# If 9Router is running, all providers are accessible
if _check_9router():
return
if p == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "managed":
if not getattr(settings, "openswarm_auth_token", None):
raise ValueError("Open Swarm account not connected. Sign in via Settings -> API.")
return
if settings.anthropic_api_key:
return
raise ValueError("Anthropic API key not configured. Set it in Settings, or connect a subscription.")
elif p == "openai":
if settings.openai_api_key:
return
raise ValueError("OpenAI API key not configured. Set it in Settings, or connect a subscription.")
elif p in ("gemini", "google"):
if getattr(settings, "google_api_key", None):
return
raise ValueError("Google API key not configured. Set it in Settings, or connect a subscription.")
elif p == "openrouter":
if getattr(settings, "openrouter_api_key", None):
return
raise ValueError("OpenRouter API key not configured. Set it in Settings.")
elif p in ("xai", "meta", "deepseek", "mistral", "qwen", "cohere"):
# These route through OpenRouter — need either OpenRouter key or 9Router
if getattr(settings, "openrouter_api_key", None):
return
raise ValueError(f"{provider} requires an OpenRouter API key, or connect a subscription via 9Router.")
else:
# Custom provider — check if it exists in custom_providers
for cp in getattr(settings, "custom_providers", []):
if cp.name.lower() == p:
return
# Unknown provider — allow through (create_provider will handle the error)
return
def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str, str]:
"""Return credential dict for a specific provider."""
validate_credentials(settings, provider)
if provider == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "managed":
return {
"auth_token": getattr(settings, "openswarm_auth_token", "") or "",
"base_url": getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL,
}
return {"api_key": settings.anthropic_api_key or ""}
if provider == "openai":
return {"api_key": settings.openai_api_key or ""}
if provider == "gemini":
return {"api_key": getattr(settings, "google_api_key", "") or ""}
if provider == "openrouter":
return {"api_key": getattr(settings, "openrouter_api_key", "") or ""}
# Custom provider
for cp in getattr(settings, "custom_providers", []):
if cp.name == provider:
return {"api_key": cp.api_key, "base_url": cp.base_url}
raise ValueError(f"No credentials for provider: {provider}")
# ---------------------------------------------------------------------------
# Legacy helpers (kept for backward compat during migration)
# ---------------------------------------------------------------------------
def get_agent_sdk_env(settings: AppSettings) -> dict[str, str]:
"""Return the env dict for ClaudeAgentOptions based on connection mode.
DEPRECATED: Use create_provider() from providers.registry instead.
"""
validate_credentials(settings, "anthropic")
if getattr(settings, "connection_mode", "own_key") == "managed":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
return {
"ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_auth_token", ""),
"ANTHROPIC_BASE_URL": proxy_url,
}
return {"ANTHROPIC_API_KEY": settings.anthropic_api_key}
def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
"""Return a configured AsyncAnthropic client based on connection mode."""
import anthropic
validate_credentials(settings, "anthropic")
if getattr(settings, "connection_mode", "own_key") == "managed":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
return anthropic.AsyncAnthropic(
auth_token=getattr(settings, "openswarm_auth_token", None),
base_url=proxy_url,
)
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
+38 -7
View File
@@ -1,12 +1,22 @@
from pydantic import BaseModel
from typing import Optional
from pydantic import BaseModel, Field
from typing import Optional, Any
DEFAULT_SYSTEM_PROMPT = (
'"Ask the user as many follow ups as needed in order to eliminate any possible ambiguity. '
"When asking the user questions, use the AskUserQuestion tool.\n\n"
"You are an unstopable Agent that does whatever is needed to achieve the task. "
"You are particularly gifted at coding, so when needed, transpose ordinary tasks into coding tasks.\n\n"
'If a Browser is selected, prioritize this over other tools when it makes sense (so the user also has observability)."'
"You are a personal AI assistant running inside OpenSwarm.\n\n"
"## Tool Priority\n"
"When a dedicated MCP tool exists for a task, use it directly — do not use the browser for things MCP tools can handle.\n"
"Priority order:\n"
"1. MCP tools first (Reddit, Google Workspace, Twitter, etc.) — fastest and most reliable\n"
"2. WebSearch / WebFetch — for general web lookups without a dedicated MCP\n"
"3. BrowserAgent — only when you need to visually interact with a website, fill forms, or do something no other tool can handle\n\n"
"## Tool Call Style\n"
"Default: do not narrate routine tool calls — just call the tool.\n"
"Narrate only when it helps: multi-step work, complex problems, or when the user explicitly asks.\n"
"Keep narration brief. Use plain language.\n\n"
"## Interaction Style\n"
"Be direct and action-oriented. Do not ask clarifying questions unless genuinely ambiguous — "
"make reasonable assumptions and act. If you need to ask, use the AskUserQuestion tool.\n"
"Do not over-explain what you are about to do. Just do it and show the results.\n"
)
@@ -21,10 +31,31 @@ class AppSettings(BaseModel):
new_agent_shortcut: str = "Meta+l"
anthropic_api_key: Optional[str] = None
browser_homepage: str = "https://www.google.com"
# Multi-provider API keys
openai_api_key: Optional[str] = None
google_api_key: Optional[str] = None
openrouter_api_key: Optional[str] = None
custom_providers: list["CustomProvider"] = Field(default_factory=list)
# Dashboard / UI preferences
auto_select_mode_on_new_agent: bool = False
expand_new_chats_in_dashboard: bool = False
auto_reveal_sub_agents: bool = True
dev_mode: bool = False
# Subscription tokens (from CLI tools — alternative to API keys)
claude_subscription_token: Optional[str] = None
openai_subscription_token: Optional[str] = None
gemini_subscription_token: Optional[str] = None
# GitHub Copilot
copilot_github_token: Optional[str] = None
copilot_token: Optional[str] = None
copilot_token_expires: Optional[float] = None
# Analytics: opted in by default, user can toggle off
analytics_opt_in: bool = True
installation_id: Optional[str] = None
class CustomProvider(BaseModel):
name: str
base_url: str
api_key: str = ""
models: list[dict[str, Any]] = Field(default_factory=list)
+69 -4
View File
@@ -4,8 +4,11 @@ from uuid import uuid4
logger = logging.getLogger(__name__)
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi import Request
# In-memory store for pending OAuth flows (state -> {provider, code_verifier, redirect_uri})
_pending_oauth: dict[str, dict] = {}
from backend.config.Apps import MainApp
from backend.apps.health.health import health
from backend.apps.agents.agents import agents
@@ -52,6 +55,7 @@ async def websocket_session(websocket: WebSocket, session_id: str):
payload.get("prompt", ""),
mode=payload.get("mode"),
model=payload.get("model"),
provider=payload.get("provider"),
images=payload.get("images"),
)
elif event == "agent:approval_response":
@@ -118,6 +122,53 @@ async def browser_command(request: Request):
return JSONResponse(result)
@app.get("/api/subscriptions/pending/{state}")
async def subscriptions_pending(state: str):
"""Return pending OAuth data for a state param. Called by 9Router's callback page."""
pending = _pending_oauth.get(state)
if not pending:
return JSONResponse({"error": "not found"}, status_code=404,
headers={"Access-Control-Allow-Origin": "*"})
return JSONResponse({
"provider": pending["provider"],
"code_verifier": pending["code_verifier"],
"redirect_uri": pending["redirect_uri"],
}, headers={"Access-Control-Allow-Origin": "*"})
@app.get("/api/subscriptions/callback")
async def subscriptions_callback(request: Request):
"""Catch OAuth redirect from provider, exchange code via 9Router, close window."""
code = request.query_params.get("code", "")
state = request.query_params.get("state", "")
error = request.query_params.get("error", "")
if error:
desc = request.query_params.get("error_description", error)
return HTMLResponse(f'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Authorization failed</h2><p style="color:#888">{desc}</p></div></body></html>')
pending = _pending_oauth.pop(state, None)
if not pending:
return HTMLResponse('<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Session expired</h2><p style="color:#888">Please try connecting again.</p></div></body></html>')
from backend.apps.nine_router import exchange_oauth
try:
await exchange_oauth(pending["provider"], code, pending["redirect_uri"], pending["code_verifier"], state)
except Exception as e:
return HTMLResponse(f'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Connection failed</h2><p style="color:#888">{e}</p></div></body></html>')
return HTMLResponse(
'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">'
'<div style="text-align:center">'
'<div style="width:64px;height:64px;border-radius:50%;background:#22c55e20;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:32px">&#10003;</div>'
'<h2 style="margin:0 0 8px">Connected!</h2>'
'<p style="color:#888;margin:0">You can close this window</p>'
'</div>'
'<script>setTimeout(()=>window.close(),1500)</script>'
'</body></html>'
)
@app.post("/api/browser-agent/run")
async def browser_agent_run(request: Request):
"""Run one or more browser sub-agents in parallel.
@@ -136,16 +187,30 @@ async def browser_agent_run(request: Request):
return JSONResponse({"error": "tasks array is required"}, status_code=400)
settings = load_settings()
if not settings.anthropic_api_key:
return JSONResponse({"error": "Anthropic API key not configured"}, status_code=400)
# Determine API credentials — check API key, then 9Router
api_key = settings.anthropic_api_key
auth_token = None
base_url = None
if not api_key:
# Try 9Router
from backend.apps.nine_router import is_running as _9r_running
if _9r_running():
api_key = "9router"
base_url = "http://localhost:20128/v1"
else:
return JSONResponse({"error": "No AI provider configured. Set an API key or connect a subscription."}, status_code=400)
results = await run_browser_agents(
tasks=tasks,
model=model,
api_key=settings.anthropic_api_key,
api_key=api_key,
dashboard_id=dashboard_id or None,
pre_selected_browser_ids=pre_selected_browser_ids,
parent_session_id=parent_session_id or None,
auth_token=auth_token,
base_url=base_url,
)
return JSONResponse({"results": results})
+2 -1
View File
@@ -10,4 +10,5 @@ pytest-asyncio==0.25.2
typeguard==4.4.2
python-dotenv==1.1.1
Pillow
posthog
posthog
httpx>=0.27.0
+2 -2
View File
@@ -28,7 +28,7 @@ trap cleanup EXIT INT TERM
VENV_DIR="$BACKEND_DIR_ABSPATH/.venv"
if [[ ! -d "$VENV_DIR" ]]; then
echo "Creating virtual environment..."
python -m venv "$VENV_DIR"
python3 -m venv "$VENV_DIR"
fi
source "$VENV_DIR/bin/activate"
@@ -56,6 +56,6 @@ fi
# --- Start the backend server ---
echo "Starting backend server on http://0.0.0.0:8324 ..."
cd "$PROJECT_ROOT_ABSPATH"
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8324 --reload \
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port 8324 --reload \
--reload-dir "$BACKEND_DIR_ABSPATH" \
--reload-exclude '*.pyc'