mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[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:
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}]
|
||||
@@ -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}"}]
|
||||
@@ -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", {}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 []
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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">✓</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})
|
||||
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mu
|
||||
import { store } from '../shared/state/store';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchSettings } from '@/shared/state/settingsSlice';
|
||||
import { fetchModels } from '@/shared/state/modelsSlice';
|
||||
import {
|
||||
setAppVersion,
|
||||
setUpdateAvailable,
|
||||
@@ -24,6 +25,7 @@ import Views from './pages/Views/Views';
|
||||
import Customization from './pages/Customization/Customization';
|
||||
import Analytics from './pages/Analytics/Analytics';
|
||||
import AnalyticsOptIn from './components/AnalyticsOptIn';
|
||||
import OnboardingModal from './components/OnboardingModal';
|
||||
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
|
||||
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
|
||||
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
@@ -162,6 +164,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
const loaded = useAppSelector((s) => s.settings.loaded);
|
||||
useEffect(() => {
|
||||
dispatch(fetchSettings());
|
||||
dispatch(fetchModels());
|
||||
}, [dispatch]);
|
||||
useEffect(() => {
|
||||
if (loaded) setThemeMode(theme as 'light' | 'dark');
|
||||
@@ -234,6 +237,7 @@ const ThemedApp: React.FC = () => {
|
||||
</Route>
|
||||
</Routes>
|
||||
<AnalyticsOptIn />
|
||||
<OnboardingModal />
|
||||
</UpdateListener>
|
||||
</SettingsLoader>
|
||||
</ShortcutsProvider>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Modal, Button } from '@mui/material';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const SUBSCRIPTION_PROVIDERS = [
|
||||
{ id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false },
|
||||
{ id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4', preview: true },
|
||||
{ id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, o3, o4-mini', color: '#74AA9C', preview: true },
|
||||
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models', color: '#8B949E', preview: true },
|
||||
];
|
||||
|
||||
const OnboardingModal: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const settings = useAppSelector((s) => s.settings);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [connecting, setConnecting] = useState<string | null>(null);
|
||||
const [nineRouterStatus, setNineRouterStatus] = useState<any>(null);
|
||||
|
||||
// Check if user has any credentials configured
|
||||
const hasAnyKey = !!(
|
||||
settings.data.anthropic_api_key ||
|
||||
settings.data.openai_api_key ||
|
||||
settings.data.google_api_key ||
|
||||
settings.data.openrouter_api_key
|
||||
);
|
||||
|
||||
// Check 9Router subscription status
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/agents/subscriptions/status`)
|
||||
.then((r) => r.json())
|
||||
.then(setNineRouterStatus)
|
||||
.catch(() => setNineRouterStatus(null));
|
||||
}, []);
|
||||
|
||||
const hasSubscription = (() => {
|
||||
if (!nineRouterStatus?.running) return false;
|
||||
const connections = nineRouterStatus?.providers?.connections || [];
|
||||
return connections.some((p: any) => p.isActive);
|
||||
})();
|
||||
|
||||
// Show modal if no keys AND no subscriptions AND not dismissed
|
||||
useEffect(() => {
|
||||
if (!hasAnyKey && !hasSubscription && !dismissed && nineRouterStatus !== null) {
|
||||
setOpen(true);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [hasAnyKey, hasSubscription, dismissed, nineRouterStatus]);
|
||||
|
||||
const handleConnect = async (providerId: string) => {
|
||||
setConnecting(providerId);
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: providerId }),
|
||||
});
|
||||
const data = await r.json();
|
||||
|
||||
if (data.flow === 'device_code') {
|
||||
const verifyUrl = data.verification_uri;
|
||||
if (verifyUrl) window.open(verifyUrl, '_blank');
|
||||
// Poll for completion
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: providerId, device_code: data.device_code, code_verifier: data.code_verifier, extra_data: data.extra_data }),
|
||||
});
|
||||
const pd = await pr.json();
|
||||
if (pd.success) {
|
||||
clearInterval(timer);
|
||||
setConnecting(null);
|
||||
setOpen(false);
|
||||
}
|
||||
} catch {}
|
||||
}, 5000);
|
||||
setTimeout(() => { clearInterval(timer); setConnecting(null); }, 300000);
|
||||
} else if (data.flow === 'authorization_code') {
|
||||
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
|
||||
|
||||
const msgHandler = async (event: MessageEvent) => {
|
||||
const d = event.data;
|
||||
const callbackData = d?.type === 'oauth_callback' ? d.data : d;
|
||||
if (callbackData?.code) {
|
||||
window.removeEventListener('message', msgHandler);
|
||||
clearInterval(statusPoller);
|
||||
if (popup && !popup.closed) popup.close();
|
||||
try {
|
||||
await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider: providerId, code: callbackData.code,
|
||||
redirect_uri: data.redirect_uri, code_verifier: data.code_verifier,
|
||||
state: callbackData.state || data.state,
|
||||
}),
|
||||
});
|
||||
} catch {}
|
||||
setConnecting(null);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', msgHandler);
|
||||
|
||||
const statusPoller = setInterval(async () => {
|
||||
try {
|
||||
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
|
||||
const sd = await sr.json();
|
||||
const conns = sd.providers?.connections || [];
|
||||
if (conns.some((p: any) => p.provider === providerId && p.isActive)) {
|
||||
clearInterval(statusPoller);
|
||||
window.removeEventListener('message', msgHandler);
|
||||
setConnecting(null);
|
||||
setOpen(false);
|
||||
}
|
||||
} catch {}
|
||||
}, 2000);
|
||||
setTimeout(() => { clearInterval(statusPoller); window.removeEventListener('message', msgHandler); setConnecting(null); }, 300000);
|
||||
}
|
||||
} catch {
|
||||
setConnecting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKey = () => {
|
||||
setDismissed(true);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
setDismissed(true);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleSkip} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Box sx={{
|
||||
width: 480, maxWidth: '90vw', bgcolor: c.bg.surface, borderRadius: `${c.radius.xl}px`,
|
||||
border: `1px solid ${c.border.subtle}`, p: 3.5, outline: 'none',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
|
||||
Welcome to OpenSwarm
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 3, textAlign: 'center' }}>
|
||||
Connect an AI model to get started
|
||||
</Typography>
|
||||
|
||||
{/* Subscription options */}
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
|
||||
Use your existing subscription
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
|
||||
{SUBSCRIPTION_PROVIDERS.map((p) => (
|
||||
<Box
|
||||
key={p.id}
|
||||
onClick={() => !p.preview && !connecting && handleConnect(p.id)}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
|
||||
cursor: p.preview ? 'default' : connecting ? 'wait' : 'pointer',
|
||||
opacity: p.preview ? 0.5 : 1,
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
...(!p.preview && { '&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` } }),
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{p.name}</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{p.desc}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: p.preview ? c.text.ghost : connecting === p.id ? c.accent.primary : c.text.tertiary, fontStyle: p.preview ? 'italic' : 'normal' }}>
|
||||
{p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : 'Connect \u2192'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* API key option */}
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
|
||||
Or use an API key
|
||||
</Typography>
|
||||
<Box
|
||||
onClick={handleApiKey}
|
||||
sx={{
|
||||
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
|
||||
cursor: 'pointer', mb: 2.5,
|
||||
'&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` },
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
|
||||
I have an API key
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>
|
||||
Go to Settings → Models to enter your key
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Skip */}
|
||||
<Button
|
||||
onClick={handleSkip}
|
||||
fullWidth
|
||||
sx={{ textTransform: 'none', fontSize: '0.72rem', color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingModal;
|
||||
@@ -67,6 +67,8 @@ interface Props {
|
||||
onModeChange: (mode: string) => void;
|
||||
model: string;
|
||||
onModelChange: (model: string) => void;
|
||||
provider?: string;
|
||||
onProviderChange?: (provider: string) => void;
|
||||
isRunning?: boolean;
|
||||
onStop?: () => void;
|
||||
autoRunMode?: boolean;
|
||||
@@ -92,10 +94,10 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
|
||||
const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy };
|
||||
|
||||
const MODEL_OPTIONS = [
|
||||
{ value: 'sonnet', label: 'Sonnet', version: '4.6' },
|
||||
{ value: 'opus', label: 'Opus', version: '4.6' },
|
||||
{ value: 'haiku', label: 'Haiku', version: '3.5' },
|
||||
const FALLBACK_MODELS = [
|
||||
{ value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000 },
|
||||
{ value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000 },
|
||||
{ value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000 },
|
||||
];
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
@@ -132,7 +134,7 @@ const ContextRing: React.FC<{ used: number; limit: number; accentColor: string;
|
||||
);
|
||||
};
|
||||
|
||||
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => {
|
||||
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => {
|
||||
const c = useClaudeTokens();
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -158,6 +160,24 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
const skills = useAppSelector((state) => state.skills.items);
|
||||
const modesMap = useAppSelector((state) => state.modes.items);
|
||||
const modesArr = useMemo(() => Object.values(modesMap), [modesMap]);
|
||||
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
|
||||
const modelsLoaded = useAppSelector((state) => state.models.loaded);
|
||||
|
||||
// Build flat model list with provider grouping
|
||||
const allModelOptions = useMemo(() => {
|
||||
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
|
||||
return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: 'Anthropic' })), grouped: { Anthropic: FALLBACK_MODELS } };
|
||||
}
|
||||
const flat: Array<{ value: string; label: string; context_window: number; provider: string }> = [];
|
||||
const grouped: Record<string, Array<{ value: string; label: string; context_window: number }>> = {};
|
||||
for (const [prov, models] of Object.entries(modelsByProvider)) {
|
||||
grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000 }));
|
||||
for (const m of models) {
|
||||
flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov });
|
||||
}
|
||||
}
|
||||
return { flat, grouped };
|
||||
}, [modelsByProvider, modelsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (modesArr.length === 0) dispatch(fetchModes());
|
||||
@@ -566,7 +586,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: '10px',
|
||||
minWidth: 140,
|
||||
minWidth: 180,
|
||||
maxHeight: 400,
|
||||
boxShadow: c.shadow.lg,
|
||||
'& .MuiMenuItem-root': {
|
||||
fontSize: '0.8rem',
|
||||
@@ -979,7 +1000,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500, color: 'inherit', lineHeight: 1 }}>
|
||||
{(() => { const m = MODEL_OPTIONS.find((m) => m.value === model); return m ? `${m.label} ${m.version}` : model; })()}
|
||||
{(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()}
|
||||
</Typography>
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: 'inherit', opacity: 0.7 }} />
|
||||
</Box>
|
||||
@@ -992,21 +1013,45 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
slotProps={{ paper: menuPaperProps }}
|
||||
>
|
||||
{MODEL_OPTIONS.map((opt) => (
|
||||
<MenuItem
|
||||
key={opt.value}
|
||||
selected={model === opt.value}
|
||||
onClick={() => {
|
||||
onModelChange(opt.value);
|
||||
setModelAnchor(null);
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={`${opt.label} ${opt.version}`}
|
||||
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
{Object.entries(allModelOptions.grouped).map(([prov, models]) => [
|
||||
<MenuItem key={`header-${prov}`} disabled sx={{ opacity: '0.7 !important', py: 0.5, px: 1.5, minHeight: 'auto' }}>
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.text.tertiary }}>
|
||||
{prov}
|
||||
</Typography>
|
||||
</MenuItem>,
|
||||
...models.map((opt) => (
|
||||
<MenuItem
|
||||
key={opt.value}
|
||||
selected={model === opt.value}
|
||||
onClick={() => {
|
||||
onModelChange(opt.value);
|
||||
if (onProviderChange) {
|
||||
// Derive API-level provider key from the display group name
|
||||
const provLower = prov.toLowerCase();
|
||||
const providerMap: Record<string, string> = {
|
||||
anthropic: 'anthropic',
|
||||
openai: 'openai',
|
||||
google: 'gemini',
|
||||
// OpenRouter-backed providers
|
||||
xai: 'openrouter',
|
||||
meta: 'openrouter',
|
||||
deepseek: 'openrouter',
|
||||
mistral: 'openrouter',
|
||||
qwen: 'openrouter',
|
||||
cohere: 'openrouter',
|
||||
};
|
||||
onProviderChange(providerMap[provLower] || provLower);
|
||||
}
|
||||
setModelAnchor(null);
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={opt.label}
|
||||
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }}
|
||||
/>
|
||||
</MenuItem>
|
||||
)),
|
||||
]).flat()}
|
||||
</Menu>
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
@@ -72,8 +72,11 @@ const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ servi
|
||||
return null;
|
||||
};
|
||||
|
||||
function formatDuration(createdAt: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000);
|
||||
function formatDuration(createdAt: string, closedAt?: string | null, status?: string): string {
|
||||
const start = new Date(createdAt).getTime();
|
||||
const end = (closedAt ? new Date(closedAt).getTime() : null)
|
||||
|| (status === 'running' || status === 'waiting_approval' ? Date.now() : Date.now());
|
||||
const seconds = Math.max(0, Math.floor((end - start) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
||||
@@ -778,7 +781,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
{session.mode}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{formatDuration(session.created_at)}
|
||||
{formatDuration(session.created_at, (session as any).closed_at, session.status)}
|
||||
</Typography>
|
||||
{session.cost_usd > 0 && (
|
||||
<Typography variant="caption" sx={{ color: c.accent.primary }}>
|
||||
|
||||
@@ -40,11 +40,508 @@ import Collapse from '@mui/material/Collapse';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { updateSettings, closeSettingsModal, resetSystemPrompt, AppSettings, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice';
|
||||
import { fetchModels } from '@/shared/state/modelsSlice';
|
||||
import { setChecking, setUpdateError } from '@/shared/state/updateSlice';
|
||||
import { fetchModes } from '@/shared/state/modesSlice';
|
||||
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
import DirectoryBrowser from '@/app/components/DirectoryBrowser';
|
||||
import { CommandsContent } from '@/app/pages/Commands/Commands';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
// ── Copilot Auth Button ──
|
||||
const CopilotAuthButton: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const [status, setStatus] = useState<'idle' | 'waiting' | 'connected' | 'error'>('idle');
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Check if already connected
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/agents/copilot/models`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.models && d.models.length > 0) setStatus('connected');
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const startAuth = async () => {
|
||||
setStatus('waiting');
|
||||
setError('');
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/agents/copilot/start-auth`, { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
setUserCode(data.user_code);
|
||||
window.open(data.verification_uri, '_blank');
|
||||
|
||||
// Poll for completion
|
||||
const deviceCode = data.device_code;
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/agents/copilot/poll-auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_code: deviceCode }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'connected') {
|
||||
clearInterval(poll);
|
||||
setStatus('connected');
|
||||
setUsername(d.username || '');
|
||||
}
|
||||
} catch {}
|
||||
}, 5000);
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => { clearInterval(poll); if (status === 'waiting') { setStatus('error'); setError('Auth timed out'); } }, 300000);
|
||||
} catch (e: any) {
|
||||
setStatus('error');
|
||||
setError(e.message || 'Failed to start auth');
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = async () => {
|
||||
await fetch(`${API_BASE}/agents/copilot/disconnect`, { method: 'POST' });
|
||||
setStatus('idle');
|
||||
setUsername('');
|
||||
};
|
||||
|
||||
if (status === 'connected') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.status.success, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
|
||||
Connected{username ? ` as @${username}` : ''}
|
||||
</Typography>
|
||||
<Typography
|
||||
onClick={disconnect}
|
||||
sx={{ fontSize: '0.72rem', color: c.text.tertiary, cursor: 'pointer', ml: 'auto', '&:hover': { color: c.status.error } }}
|
||||
>
|
||||
Disconnect
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'waiting') {
|
||||
return (
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary, mb: 0.5 }}>
|
||||
Enter code <strong style={{ fontFamily: 'monospace', fontSize: '0.9rem', letterSpacing: '0.1em' }}>{userCode}</strong> at github.com/login/device
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary }}>Waiting for authorization...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
onClick={startAuth}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.78rem',
|
||||
color: c.text.primary,
|
||||
borderColor: c.border.medium,
|
||||
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary },
|
||||
}}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</Button>
|
||||
{error && <Typography sx={{ fontSize: '0.7rem', color: c.status.error, mt: 0.5 }}>{error}</Typography>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Subscription Provider Card ──
|
||||
const SUBSCRIPTION_PROVIDERS = [
|
||||
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet, Opus, Haiku — use your Anthropic subscription', color: '#E8927A', preview: false },
|
||||
{ id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 2.5 Pro and Flash — use your Google subscription', color: '#4285F4', preview: true },
|
||||
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, o3, o4-mini — use your OpenAI subscription', color: '#74AA9C', preview: true },
|
||||
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E', preview: true },
|
||||
];
|
||||
|
||||
const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode }) => {
|
||||
const c = useClaudeTokens();
|
||||
const isPreview = (provider as any).preview;
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${connected ? c.status.success + '30' : c.border.subtle}`, bgcolor: connected ? `${c.status.success}04` : 'transparent', opacity: isPreview ? 0.5 : 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: connected ? c.status.success : c.border.medium, flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.text.primary }}>{provider.name}</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{provider.desc}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{isPreview ? (
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost, fontStyle: 'italic' }}>
|
||||
Coming soon
|
||||
</Typography>
|
||||
) : connected ? (
|
||||
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error } }}>
|
||||
Disconnect
|
||||
</Typography>
|
||||
) : connecting && userCode ? (
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>Enter code:</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.accent.primary, fontFamily: 'monospace', letterSpacing: '0.1em' }}>{userCode}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Button onClick={onConnect} disabled={connecting} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary } }}>
|
||||
{connecting ? 'Waiting...' : 'Connect'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const SubscriptionCards: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
const [connecting, setConnecting] = useState<string | null>(null);
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [pollTimer, setPollTimer] = useState<any>(null);
|
||||
|
||||
const fetchStatus = () => {
|
||||
fetch(`${API_BASE}/agents/subscriptions/status`)
|
||||
.then(r => r.json())
|
||||
.then(setStatus)
|
||||
.catch(() => setStatus({ running: false, providers: [], models: [] }));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchStatus(); }, []);
|
||||
|
||||
const isConnected = (providerId: string) => {
|
||||
if (!status?.providers) return false;
|
||||
const connections = status.providers?.connections || (Array.isArray(status.providers) ? status.providers : []);
|
||||
return connections.some((p: any) => p.provider === providerId && p.isActive);
|
||||
};
|
||||
|
||||
const handleConnect = async (providerId: string) => {
|
||||
setConnecting(providerId);
|
||||
setUserCode('');
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: providerId }),
|
||||
});
|
||||
const data = await r.json();
|
||||
|
||||
if (data.flow === 'device_code') {
|
||||
// Device code flow (GitHub, Qwen, etc.) — show code, poll
|
||||
const code = data.user_code || '';
|
||||
setUserCode(code);
|
||||
if (data.verification_uri) window.open(data.verification_uri, '_blank');
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: providerId, device_code: data.device_code, code_verifier: data.code_verifier, extra_data: data.extra_data }),
|
||||
});
|
||||
const pd = await pr.json();
|
||||
if (pd.success) {
|
||||
clearInterval(timer);
|
||||
setConnecting(null);
|
||||
setUserCode('');
|
||||
fetchStatus();
|
||||
}
|
||||
} catch {}
|
||||
}, 5000);
|
||||
setPollTimer(timer);
|
||||
setTimeout(() => { clearInterval(timer); setConnecting(null); setUserCode(''); }, 300000);
|
||||
|
||||
} else if (data.flow === 'authorization_code') {
|
||||
// Open auth URL as popup — window.opener lets callback page postMessage back
|
||||
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
|
||||
|
||||
const msgHandler = async (event: MessageEvent) => {
|
||||
const d = event.data;
|
||||
const callbackData = d?.type === 'oauth_callback' ? d.data : d;
|
||||
if (callbackData?.code) {
|
||||
window.removeEventListener('message', msgHandler);
|
||||
clearInterval(statusPoller);
|
||||
if (popup && !popup.closed) popup.close();
|
||||
try {
|
||||
await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider: providerId, code: callbackData.code,
|
||||
redirect_uri: data.redirect_uri, code_verifier: data.code_verifier,
|
||||
state: callbackData.state || data.state,
|
||||
}),
|
||||
});
|
||||
} catch {}
|
||||
setConnecting(null);
|
||||
fetchStatus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', msgHandler);
|
||||
|
||||
const statusPoller = setInterval(async () => {
|
||||
try {
|
||||
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
|
||||
const sd = await sr.json();
|
||||
const connections = sd.providers?.connections || [];
|
||||
if (connections.some((p: any) => p.provider === providerId && p.isActive)) {
|
||||
clearInterval(statusPoller);
|
||||
window.removeEventListener('message', msgHandler);
|
||||
setConnecting(null);
|
||||
fetchStatus();
|
||||
}
|
||||
} catch {}
|
||||
}, 2000);
|
||||
setPollTimer(statusPoller);
|
||||
setTimeout(() => { clearInterval(statusPoller); window.removeEventListener('message', msgHandler); setConnecting(null); }, 300000);
|
||||
|
||||
} else {
|
||||
setConnecting(null);
|
||||
}
|
||||
} catch { setConnecting(null); }
|
||||
};
|
||||
|
||||
const handleDisconnect = async (providerId: string) => {
|
||||
// TODO: implement disconnect via 9Router API
|
||||
fetchStatus();
|
||||
};
|
||||
|
||||
if (!status?.running) {
|
||||
return (
|
||||
<Box sx={{ p: 2, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, textAlign: 'center' }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1 }}>
|
||||
Starting subscription service...
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost }}>
|
||||
This connects your existing AI subscriptions. If this doesn't load, make sure Node.js is installed.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{SUBSCRIPTION_PROVIDERS.map(p => (
|
||||
<SubscriptionCard
|
||||
key={p.id}
|
||||
provider={p}
|
||||
connected={isConnected(p.id)}
|
||||
onConnect={() => handleConnect(p.id)}
|
||||
onDisconnect={() => handleDisconnect(p.id)}
|
||||
connecting={connecting === p.id}
|
||||
userCode={connecting === p.id ? userCode : undefined}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Pixel Bar ──
|
||||
const PIXEL_SALMON = ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'];
|
||||
const PIXEL_BLUE = ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'];
|
||||
|
||||
const PixelBarOuter: React.FC<{ value: number; max: number; width?: number; palette?: string[]; tokens: any }> = ({ value, max, width = 16, palette = PIXEL_SALMON, tokens: c }) => {
|
||||
const filled = max > 0 ? Math.max(value > 0 ? 1 : 0, Math.round((value / max) * width)) : 0;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: '1px', mt: 0.25 }}>
|
||||
{Array.from({ length: width }, (_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 5,
|
||||
height: 5,
|
||||
bgcolor: i < filled
|
||||
? palette[Math.min(palette.length - 1, Math.floor((i / Math.max(filled - 1, 1)) * (palette.length - 1)))]
|
||||
: c.border.subtle,
|
||||
opacity: i < filled ? 1 : 0.3,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Usage Stats Component ──
|
||||
const UsageStats: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/analytics/usage-summary`)
|
||||
.then(r => r.json())
|
||||
.then(setStats)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const formatCost = (v: number) => {
|
||||
if (v === 0) return '$0.00';
|
||||
if (v < 0.001) return `$${v.toFixed(6)}`;
|
||||
if (v < 0.01) return `$${v.toFixed(5)}`;
|
||||
if (v < 1) return `$${v.toFixed(4)}`;
|
||||
return `$${v.toFixed(2)}`;
|
||||
};
|
||||
const formatDuration = (s: number) => {
|
||||
if (s === 0) return '0s';
|
||||
if (s < 60) return `${s.toFixed(1)}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
|
||||
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
||||
};
|
||||
const formatTotalTime = (s: number) => {
|
||||
if (s < 60) return `${s.toFixed(1)}s`;
|
||||
if (s < 3600) return `${(s / 60).toFixed(1)} min`;
|
||||
return `${(s / 3600).toFixed(1)} hrs`;
|
||||
};
|
||||
|
||||
const cardSx = {
|
||||
p: 1.5,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
};
|
||||
const labelSx = { fontSize: '0.58rem', fontWeight: 700, color: c.text.ghost, textTransform: 'uppercase' as const, letterSpacing: '0.06em', mb: 0.25 };
|
||||
const valueSx = { fontSize: '1.05rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.2 };
|
||||
const subSx = { fontSize: '0.62rem', color: c.text.tertiary, mt: 0.25 };
|
||||
|
||||
const modelEntries = Object.entries(stats.models_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
|
||||
const providerEntries = Object.entries(stats.providers_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
|
||||
const toolEntries = Object.entries(stats.top_tools || {}).slice(0, 10) as [string, number][];
|
||||
const maxToolCount = toolEntries.length > 0 ? Math.max(...toolEntries.map(([, c]) => c)) : 1;
|
||||
const statusEntries = Object.entries(stats.status_breakdown || {}) as [string, string][];
|
||||
|
||||
// Pixel bar helper that passes tokens
|
||||
const PixelBar: React.FC<{ value: number; max: number; width?: number; palette?: string[] }> = (props) => (
|
||||
<PixelBarOuter {...props} tokens={c} />
|
||||
);
|
||||
|
||||
const totalTime = stats.avg_duration_seconds * stats.total_sessions;
|
||||
const msgsPerSession = stats.total_sessions > 0 ? (stats.total_messages / stats.total_sessions).toFixed(1) : '0';
|
||||
const toolsPerSession = stats.total_sessions > 0 ? (stats.total_tool_calls / stats.total_sessions).toFixed(1) : '0';
|
||||
const formatTokens = (n: number) => {
|
||||
if (n === 0) return '0';
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
||||
return `${(n / 1_000_000).toFixed(2)}M`;
|
||||
};
|
||||
const costSourceLabel = stats.cost_source === '9router' ? 'via subscription' : stats.cost_source === 'sdk' ? 'via API' : '';
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
{/* Row 1: Core metrics */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Sessions</Typography>
|
||||
<Typography sx={valueSx}>{stats.total_sessions.toLocaleString()}</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{statusEntries.map(([s, n]) => `${n} ${s}`).join(', ') || 'no sessions'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Cost</Typography>
|
||||
<Typography sx={valueSx}>{formatCost(stats.total_cost_usd)}</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Messages</Typography>
|
||||
<Typography sx={valueSx}>{stats.total_messages.toLocaleString()}</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{msgsPerSession} avg per session
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Tool Calls</Typography>
|
||||
<Typography sx={valueSx}>{stats.total_tool_calls.toLocaleString()}</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{toolsPerSession} avg per session
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Row 2: Time + efficiency + tokens */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Run Time</Typography>
|
||||
<Typography sx={valueSx}>{formatTotalTime(totalTime)}</Typography>
|
||||
<Typography sx={subSx}>across all sessions</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Avg Session</Typography>
|
||||
<Typography sx={valueSx}>{formatDuration(stats.avg_duration_seconds)}</Typography>
|
||||
<Typography sx={subSx}>per session duration</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Completion Rate</Typography>
|
||||
<Typography sx={valueSx}>{(stats.completion_rate * 100).toFixed(1)}%</Typography>
|
||||
<Typography sx={subSx}>
|
||||
sessions finished successfully
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Tokens Used</Typography>
|
||||
<Typography sx={valueSx}>
|
||||
{stats.total_prompt_tokens || stats.total_completion_tokens
|
||||
? formatTokens((stats.total_prompt_tokens || 0) + (stats.total_completion_tokens || 0))
|
||||
: Object.keys(stats.providers_used || {}).length}
|
||||
</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{stats.total_prompt_tokens || stats.total_completion_tokens
|
||||
? `${formatTokens(stats.total_prompt_tokens || 0)} in · ${formatTokens(stats.total_completion_tokens || 0)} out`
|
||||
: providerEntries.map(([p]) => p).join(', ') || 'none'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Model + Provider + Tool breakdown */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
{/* Models & Providers */}
|
||||
<Box sx={{ ...cardSx, p: 2 }}>
|
||||
<Typography sx={{ ...labelSx, mb: 1.5 }}>Models Used</Typography>
|
||||
{modelEntries.length > 0 ? modelEntries.map(([model, count]) => {
|
||||
const pct = stats.total_sessions > 0 ? ((count / stats.total_sessions) * 100).toFixed(0) : '0';
|
||||
return (
|
||||
<Box key={model} sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, fontWeight: 500 }}>{model}</Typography>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
|
||||
{count} ({pct}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
<PixelBar value={count} max={stats.total_sessions} palette={PIXEL_BLUE} />
|
||||
</Box>
|
||||
);
|
||||
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No sessions yet</Typography>}
|
||||
</Box>
|
||||
|
||||
{/* Tools */}
|
||||
<Box sx={{ ...cardSx, p: 2 }}>
|
||||
<Typography sx={{ ...labelSx, mb: 1.5 }}>Top Tools</Typography>
|
||||
{toolEntries.length > 0 ? toolEntries.map(([tool, count]) => {
|
||||
const shortName = tool.includes('__') ? tool.split('__').pop() : tool;
|
||||
const pct = stats.total_tool_calls > 0 ? ((count / stats.total_tool_calls) * 100).toFixed(0) : '0';
|
||||
return (
|
||||
<Box key={tool} sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, fontWeight: 500 }}>{shortName}</Typography>
|
||||
<Typography sx={{ fontSize: '0.62rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
|
||||
{count} call{count !== 1 ? 's' : ''} ({pct}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
<PixelBar value={count} max={maxToolCount} />
|
||||
</Box>
|
||||
);
|
||||
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No tool calls yet</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const API_KEY_STEPS = [
|
||||
{
|
||||
@@ -87,7 +584,7 @@ const Settings: React.FC = () => {
|
||||
const downloadPercent = useAppSelector((s) => s.update.downloadPercent);
|
||||
const updateError = useAppSelector((s) => s.update.error);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'commands'>('general');
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'models' | 'usage' | 'commands'>('general');
|
||||
const [form, setForm] = useState<AppSettings>({ ...settings });
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [browseOpen, setBrowseOpen] = useState(false);
|
||||
@@ -143,6 +640,7 @@ const Settings: React.FC = () => {
|
||||
if (form.theme !== settings.theme) {
|
||||
setThemeMode(form.theme);
|
||||
}
|
||||
dispatch(fetchModels());
|
||||
setSaved(true);
|
||||
};
|
||||
|
||||
@@ -165,6 +663,7 @@ const Settings: React.FC = () => {
|
||||
if (form.theme !== settings.theme) {
|
||||
setThemeMode(form.theme);
|
||||
}
|
||||
dispatch(fetchModels());
|
||||
setSaved(true);
|
||||
setConfirmDiscard(false);
|
||||
dispatch(closeSettingsModal());
|
||||
@@ -272,6 +771,8 @@ const Settings: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Tab label="General" value="general" disableRipple />
|
||||
<Tab label="Models" value="models" disableRipple />
|
||||
<Tab label="Usage" value="usage" disableRipple />
|
||||
<Tab label="Commands" value="commands" disableRipple />
|
||||
</Tabs>
|
||||
</DialogTitle>
|
||||
@@ -626,125 +1127,6 @@ const Settings: React.FC = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── API ── */}
|
||||
<Typography sx={{ ...sectionSx, mt: 3 }}>API</Typography>
|
||||
|
||||
<Box sx={rowLastSx}>
|
||||
<Typography sx={labelSx}>Anthropic API key</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography sx={descSx}>
|
||||
Stored securely in the local database.
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
onClick={() => setShowApiHelp((v) => !v)}
|
||||
sx={{
|
||||
color: c.accent.primary,
|
||||
fontSize: '0.75rem',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.4,
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{showApiHelp ? 'Hide guide' : 'How do I get a key?'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showApiHelp} timeout={250}>
|
||||
<Box sx={{
|
||||
mb: 1.5,
|
||||
p: 2,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: `${c.accent.primary}08`,
|
||||
border: `1px solid ${c.accent.primary}20`,
|
||||
}}>
|
||||
{API_KEY_STEPS.map((step, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1.5, mb: i < API_KEY_STEPS.length - 1 ? 1.5 : 0 }}>
|
||||
<Box sx={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${c.accent.primary}15`,
|
||||
color: c.accent.primary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
mt: 0.1,
|
||||
}}>
|
||||
{i + 1}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500, lineHeight: 1.4 }}>
|
||||
{step.title}
|
||||
{step.link && (
|
||||
<Typography
|
||||
component="a"
|
||||
href={step.link}
|
||||
sx={{
|
||||
color: c.accent.primary,
|
||||
fontSize: '0.75rem',
|
||||
ml: 0.75,
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.3,
|
||||
verticalAlign: 'middle',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
Open
|
||||
<OpenInNewIcon sx={{ fontSize: 12 }} />
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.75rem', lineHeight: 1.4 }}>
|
||||
{step.detail}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
<TextField
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={form.anthropic_api_key ?? ''}
|
||||
onChange={(e) => setForm({ ...form, anthropic_api_key: e.target.value || null })}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="sk-ant-..."
|
||||
sx={{
|
||||
...fieldSx,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
...fieldSx['& .MuiOutlinedInput-root'],
|
||||
fontFamily: c.font.mono,
|
||||
},
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
edge="end"
|
||||
size="small"
|
||||
sx={{ color: c.text.tertiary }}
|
||||
>
|
||||
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* ── Advanced ── */}
|
||||
<Typography sx={{ ...sectionSx, mt: 3 }}>Advanced</Typography>
|
||||
|
||||
@@ -873,6 +1255,94 @@ const Settings: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
) : activeTab === 'models' ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5 }}>
|
||||
|
||||
{/* ── USE EXISTING SUBSCRIPTIONS ── */}
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
|
||||
Use Your Existing Subscriptions
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ ...descSx, mb: 0 }}>
|
||||
Already paying for Claude, ChatGPT, or Gemini? Connect your subscription — no API key needed, no extra cost.
|
||||
</Typography>
|
||||
|
||||
<SubscriptionCards />
|
||||
|
||||
{/* ── API KEYS ── */}
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
|
||||
Or Connect With API Keys
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ ...descSx, mb: -1 }}>
|
||||
Pay per use. Each key is stored locally on your device.
|
||||
</Typography>
|
||||
|
||||
{/* Anthropic */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={labelSx}>Anthropic</Typography>
|
||||
{form.anthropic_api_key ? (
|
||||
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 0.75, py: 0.15, borderRadius: '3px' }}>CONNECTED</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<Typography sx={{ ...descSx, mb: 1 }}>Claude Sonnet, Opus, Haiku.</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<TextField
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={form.anthropic_api_key ?? ''}
|
||||
onChange={(e) => setForm({ ...form, anthropic_api_key: e.target.value || null })}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="sk-ant-..."
|
||||
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}>
|
||||
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
component="a"
|
||||
href="https://console.anthropic.com/settings/keys"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{ color: c.accent.primary, fontSize: '0.72rem', whiteSpace: 'nowrap', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 0.3, '&:hover': { textDecoration: 'underline' } }}
|
||||
>
|
||||
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
) : activeTab === 'usage' ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1 }}>
|
||||
<UsageStats />
|
||||
|
||||
{/* ── Analytics ── */}
|
||||
<Typography sx={{ ...sectionSx, mt: 1 }}>Analytics</Typography>
|
||||
|
||||
<Box sx={inlineRowLastSx}>
|
||||
<Box sx={{ mr: 3 }}>
|
||||
<Typography sx={labelSx}>Share anonymous usage data</Typography>
|
||||
<Typography sx={descSx}>
|
||||
Help improve OpenSwarm by sharing anonymous statistics like session counts, model usage, and feature adoption. No conversations, file paths, or personal information is ever collected.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={(form as any).analytics_opt_in ?? true}
|
||||
onChange={(e) => setForm({ ...form, analytics_opt_in: e.target.checked } as any)}
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ pt: 2.5, pb: 1 }}>
|
||||
<CommandsContent />
|
||||
@@ -880,7 +1350,7 @@ const Settings: React.FC = () => {
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
{activeTab === 'general' && (
|
||||
{(activeTab === 'general' || activeTab === 'models') && (
|
||||
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 1.5, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={handleRequestClose}
|
||||
|
||||
@@ -3,3 +3,4 @@ const host = window.location.hostname || 'localhost';
|
||||
|
||||
export const API_BASE = `http://${host}:${port}/api`;
|
||||
export const WS_BASE = `ws://${host}:${port}`;
|
||||
export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.ai';
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface AgentSession {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'draft' | 'running' | 'waiting_approval' | 'completed' | 'error' | 'stopped';
|
||||
provider: string;
|
||||
model: string;
|
||||
mode: string;
|
||||
worktree_path: string | null;
|
||||
@@ -75,6 +76,7 @@ export interface AgentSession {
|
||||
|
||||
export interface AgentConfig {
|
||||
name?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mode?: string;
|
||||
system_prompt?: string;
|
||||
@@ -151,6 +153,7 @@ export interface SendMessagePayload {
|
||||
prompt: string;
|
||||
mode?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
images?: Array<{ data: string; media_type: string }>;
|
||||
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
|
||||
forcedTools?: string[];
|
||||
@@ -161,11 +164,11 @@ export interface SendMessagePayload {
|
||||
|
||||
export const sendMessage = createAsyncThunk(
|
||||
'agents/sendMessage',
|
||||
async ({ sessionId, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds }: SendMessagePayload) => {
|
||||
async ({ sessionId, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds }: SendMessagePayload) => {
|
||||
await fetch(`${AGENTS_API}/sessions/${sessionId}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds }),
|
||||
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds }),
|
||||
});
|
||||
return { sessionId, prompt };
|
||||
}
|
||||
@@ -213,6 +216,7 @@ export interface LaunchAndSendPayload {
|
||||
prompt: string;
|
||||
mode: string;
|
||||
model: string;
|
||||
provider?: string;
|
||||
images?: Array<{ data: string; media_type: string }>;
|
||||
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
|
||||
forcedTools?: string[];
|
||||
@@ -232,7 +236,7 @@ export const fetchSession = createAsyncThunk(
|
||||
|
||||
export const launchAndSendFirstMessage = createAsyncThunk(
|
||||
'agents/launchAndSendFirstMessage',
|
||||
async ({ draftId, config, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }: LaunchAndSendPayload) => {
|
||||
async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }: LaunchAndSendPayload) => {
|
||||
const launchRes = await fetch(`${AGENTS_API}/launch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -244,7 +248,7 @@ export const launchAndSendFirstMessage = createAsyncThunk(
|
||||
await fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds }),
|
||||
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds }),
|
||||
});
|
||||
|
||||
const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
|
||||
@@ -424,6 +428,7 @@ const agentsSlice = createSlice({
|
||||
id: draftId,
|
||||
name: 'New chat',
|
||||
status: 'draft',
|
||||
provider: 'anthropic',
|
||||
model: 'sonnet',
|
||||
mode,
|
||||
worktree_path: null,
|
||||
@@ -664,6 +669,13 @@ const agentsSlice = createSlice({
|
||||
}
|
||||
},
|
||||
|
||||
updateSessionProvider(state, action: PayloadAction<{ sessionId: string; provider: string }>) {
|
||||
const session = state.sessions[action.payload.sessionId];
|
||||
if (session) {
|
||||
session.provider = action.payload.provider;
|
||||
}
|
||||
},
|
||||
|
||||
updateSessionModel(state, action: PayloadAction<{ sessionId: string; model: string }>) {
|
||||
const session = state.sessions[action.payload.sessionId];
|
||||
if (session) {
|
||||
@@ -1004,6 +1016,7 @@ export const {
|
||||
updateSessionCost,
|
||||
addBranch,
|
||||
setActiveBranch,
|
||||
updateSessionProvider,
|
||||
updateSessionModel,
|
||||
updateSessionMode,
|
||||
closeSessionFromWs,
|
||||
|
||||
@@ -3,208 +3,64 @@ import { API_BASE } from '@/shared/config';
|
||||
|
||||
const ANALYTICS_API = `${API_BASE}/analytics`;
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
export interface UsageSummary {
|
||||
total_sessions: number;
|
||||
total_cost_usd: number;
|
||||
total_messages: number;
|
||||
total_tool_calls: number;
|
||||
avg_session_duration_seconds: number;
|
||||
session_completion_rate: number;
|
||||
approval_rate: number;
|
||||
models_used: Record<string, number>;
|
||||
modes_used: Record<string, number>;
|
||||
top_tools: [string, number][];
|
||||
}
|
||||
|
||||
export interface UsagePoint {
|
||||
date: string;
|
||||
sessions: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface CostPoint {
|
||||
date: string;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface ToolRank {
|
||||
tool: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ApprovalStats {
|
||||
allow: number;
|
||||
deny: number;
|
||||
total: number;
|
||||
rate: number;
|
||||
avg_latency_ms: number;
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
completed: number;
|
||||
stopped: number;
|
||||
error: number;
|
||||
total: number;
|
||||
completion_rate: number;
|
||||
avg_duration_seconds: number;
|
||||
avg_cost_per_session: number;
|
||||
completion_rate: number;
|
||||
models_used: Record<string, number>;
|
||||
providers_used: Record<string, number>;
|
||||
top_tools: Record<string, number>;
|
||||
status_breakdown: Record<string, number>;
|
||||
// 9Router enrichment
|
||||
total_prompt_tokens: number;
|
||||
total_completion_tokens: number;
|
||||
cost_by_model: Record<string, { cost: number; requests: number; prompt_tokens: number; completion_tokens: number }>;
|
||||
cost_by_provider: Record<string, { cost: number; requests: number }>;
|
||||
cost_source: ' 9router' | 'sdk' | 'none';
|
||||
nine_router_available: boolean;
|
||||
total_requests: number;
|
||||
}
|
||||
|
||||
export interface HourlyPoint {
|
||||
hour: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DurationBucket {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CostByModel {
|
||||
model: string;
|
||||
cost: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
export interface CumulativeCostPoint {
|
||||
date: string;
|
||||
cumulative: number;
|
||||
daily: number;
|
||||
}
|
||||
|
||||
export interface ToolDuration {
|
||||
tool: string;
|
||||
calls: number;
|
||||
avg_ms: number;
|
||||
max_ms: number;
|
||||
}
|
||||
|
||||
export interface SessionCost {
|
||||
timestamp: string;
|
||||
model: string;
|
||||
cost: number;
|
||||
duration: number;
|
||||
messages: number;
|
||||
export interface CostBreakdown {
|
||||
available: boolean;
|
||||
period: string;
|
||||
total_cost: number;
|
||||
total_requests: number;
|
||||
total_prompt_tokens: number;
|
||||
total_completion_tokens: number;
|
||||
by_model: Record<string, any>;
|
||||
by_provider: Record<string, any>;
|
||||
}
|
||||
|
||||
interface AnalyticsState {
|
||||
summary: AnalyticsSummary | null;
|
||||
usage: UsagePoint[];
|
||||
cost: CostPoint[];
|
||||
tools: ToolRank[];
|
||||
approvals: ApprovalStats | null;
|
||||
sessionStats: SessionStats | null;
|
||||
hourly: HourlyPoint[];
|
||||
durationDist: DurationBucket[];
|
||||
costByModel: CostByModel[];
|
||||
cumulativeCost: CumulativeCostPoint[];
|
||||
toolDurations: ToolDuration[];
|
||||
sessionCosts: SessionCost[];
|
||||
exportPreview: any | null;
|
||||
summary: UsageSummary | null;
|
||||
costBreakdown: CostBreakdown | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const initialState: AnalyticsState = {
|
||||
summary: null,
|
||||
usage: [],
|
||||
cost: [],
|
||||
tools: [],
|
||||
approvals: null,
|
||||
sessionStats: null,
|
||||
hourly: [],
|
||||
durationDist: [],
|
||||
costByModel: [],
|
||||
cumulativeCost: [],
|
||||
toolDurations: [],
|
||||
sessionCosts: [],
|
||||
exportPreview: null,
|
||||
costBreakdown: null,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
export const fetchAnalyticsSummary = createAsyncThunk('analytics/fetchSummary', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/summary`);
|
||||
return (await res.json()) as AnalyticsSummary;
|
||||
const res = await fetch(`${ANALYTICS_API}/usage-summary`);
|
||||
return (await res.json()) as UsageSummary;
|
||||
});
|
||||
|
||||
export const fetchUsage = createAsyncThunk(
|
||||
'analytics/fetchUsage',
|
||||
async ({ period, range }: { period: string; range: number }) => {
|
||||
const res = await fetch(`${ANALYTICS_API}/usage?period=${period}&range=${range}`);
|
||||
const data = await res.json();
|
||||
return data.data as UsagePoint[];
|
||||
export const fetchCostBreakdown = createAsyncThunk(
|
||||
'analytics/fetchCostBreakdown',
|
||||
async (period: string = '7d') => {
|
||||
const res = await fetch(`${ANALYTICS_API}/cost-breakdown?period=${period}`);
|
||||
return (await res.json()) as CostBreakdown;
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchCost = createAsyncThunk(
|
||||
'analytics/fetchCost',
|
||||
async ({ period, range }: { period: string; range: number }) => {
|
||||
const res = await fetch(`${ANALYTICS_API}/cost?period=${period}&range=${range}`);
|
||||
const data = await res.json();
|
||||
return data.data as CostPoint[];
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchTools = createAsyncThunk('analytics/fetchTools', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/tools?limit=20`);
|
||||
const data = await res.json();
|
||||
return data.data as ToolRank[];
|
||||
});
|
||||
|
||||
export const fetchApprovals = createAsyncThunk('analytics/fetchApprovals', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/approvals`);
|
||||
return (await res.json()) as ApprovalStats;
|
||||
});
|
||||
|
||||
export const fetchSessionStats = createAsyncThunk('analytics/fetchSessionStats', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/sessions-stats`);
|
||||
return (await res.json()) as SessionStats;
|
||||
});
|
||||
|
||||
export const fetchHourlyActivity = createAsyncThunk('analytics/fetchHourly', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/hourly-activity`);
|
||||
const data = await res.json();
|
||||
return data.data as HourlyPoint[];
|
||||
});
|
||||
|
||||
export const fetchDurationDistribution = createAsyncThunk('analytics/fetchDurationDist', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/duration-distribution`);
|
||||
const data = await res.json();
|
||||
return data.data as DurationBucket[];
|
||||
});
|
||||
|
||||
export const fetchCostByModel = createAsyncThunk('analytics/fetchCostByModel', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/cost-by-model`);
|
||||
const data = await res.json();
|
||||
return data.data as CostByModel[];
|
||||
});
|
||||
|
||||
export const fetchCumulativeCost = createAsyncThunk('analytics/fetchCumulativeCost', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/cumulative-cost?range=90`);
|
||||
const data = await res.json();
|
||||
return data.data as CumulativeCostPoint[];
|
||||
});
|
||||
|
||||
export const fetchToolDurations = createAsyncThunk('analytics/fetchToolDurations', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/tool-durations`);
|
||||
const data = await res.json();
|
||||
return data.data as ToolDuration[];
|
||||
});
|
||||
|
||||
export const fetchSessionCosts = createAsyncThunk('analytics/fetchSessionCosts', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/cost-per-session?limit=50`);
|
||||
const data = await res.json();
|
||||
return data.data as SessionCost[];
|
||||
});
|
||||
|
||||
export const fetchExportPreview = createAsyncThunk('analytics/fetchExportPreview', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/export/preview`);
|
||||
return await res.json();
|
||||
});
|
||||
|
||||
export const doExport = createAsyncThunk('analytics/doExport', async () => {
|
||||
const res = await fetch(`${ANALYTICS_API}/export`, { method: 'POST' });
|
||||
return await res.json();
|
||||
});
|
||||
|
||||
const analyticsSlice = createSlice({
|
||||
name: 'analytics',
|
||||
initialState,
|
||||
@@ -217,18 +73,9 @@ const analyticsSlice = createSlice({
|
||||
state.summary = action.payload;
|
||||
})
|
||||
.addCase(fetchAnalyticsSummary.rejected, (state) => { state.loading = false; })
|
||||
.addCase(fetchUsage.fulfilled, (state, action) => { state.usage = action.payload; })
|
||||
.addCase(fetchCost.fulfilled, (state, action) => { state.cost = action.payload; })
|
||||
.addCase(fetchTools.fulfilled, (state, action) => { state.tools = action.payload; })
|
||||
.addCase(fetchApprovals.fulfilled, (state, action) => { state.approvals = action.payload; })
|
||||
.addCase(fetchSessionStats.fulfilled, (state, action) => { state.sessionStats = action.payload; })
|
||||
.addCase(fetchHourlyActivity.fulfilled, (state, action) => { state.hourly = action.payload; })
|
||||
.addCase(fetchDurationDistribution.fulfilled, (state, action) => { state.durationDist = action.payload; })
|
||||
.addCase(fetchCostByModel.fulfilled, (state, action) => { state.costByModel = action.payload; })
|
||||
.addCase(fetchCumulativeCost.fulfilled, (state, action) => { state.cumulativeCost = action.payload; })
|
||||
.addCase(fetchToolDurations.fulfilled, (state, action) => { state.toolDurations = action.payload; })
|
||||
.addCase(fetchSessionCosts.fulfilled, (state, action) => { state.sessionCosts = action.payload; })
|
||||
.addCase(fetchExportPreview.fulfilled, (state, action) => { state.exportPreview = action.payload; });
|
||||
.addCase(fetchCostBreakdown.fulfilled, (state, action) => {
|
||||
state.costBreakdown = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const AGENTS_API = `${API_BASE}/agents`;
|
||||
|
||||
export interface ModelOption {
|
||||
value: string;
|
||||
label: string;
|
||||
version?: string;
|
||||
context_window: number;
|
||||
}
|
||||
|
||||
interface ModelsState {
|
||||
byProvider: Record<string, ModelOption[]>;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const initialState: ModelsState = {
|
||||
byProvider: {},
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
export const fetchModels = createAsyncThunk('models/fetchModels', async () => {
|
||||
const res = await fetch(`${AGENTS_API}/models`);
|
||||
if (!res.ok) throw new Error('Failed to fetch models');
|
||||
const data = await res.json();
|
||||
// API returns { models: { provider: [...] } }
|
||||
const models = data.models || data;
|
||||
return models as Record<string, ModelOption[]>;
|
||||
});
|
||||
|
||||
const modelsSlice = createSlice({
|
||||
name: 'models',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchModels.fulfilled, (state, action) => {
|
||||
state.byProvider = action.payload;
|
||||
state.loaded = true;
|
||||
})
|
||||
.addCase(fetchModels.rejected, (state) => {
|
||||
// Mark as loaded even on failure so we fall back to hardcoded options
|
||||
state.loaded = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default modelsSlice.reducer;
|
||||
@@ -11,6 +11,13 @@ export const DEFAULT_SYSTEM_PROMPT =
|
||||
`If a Browser is selected, prioritize this over other tools when it makes sense (so the user also has observability).\n\n` +
|
||||
`If multiple Browsers are selected, parallelize the tasks across them.`;
|
||||
|
||||
export interface CustomProvider {
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Array<{ value: string; label: string; context_window?: number }>;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
default_system_prompt: string | null;
|
||||
default_folder: string | null;
|
||||
@@ -21,6 +28,10 @@ export interface AppSettings {
|
||||
theme: 'light' | 'dark';
|
||||
new_agent_shortcut: string;
|
||||
anthropic_api_key: string | null;
|
||||
openai_api_key?: string | null;
|
||||
google_api_key?: string | null;
|
||||
openrouter_api_key?: string | null;
|
||||
custom_providers?: CustomProvider[];
|
||||
browser_homepage: string;
|
||||
auto_select_mode_on_new_agent: boolean;
|
||||
expand_new_chats_in_dashboard: boolean;
|
||||
|
||||
@@ -13,6 +13,7 @@ import dashboardLayoutReducer from './dashboardLayoutSlice';
|
||||
import dashboardsReducer from './dashboardsSlice';
|
||||
import updateReducer from './updateSlice';
|
||||
import analyticsReducer from './analyticsSlice';
|
||||
import modelsReducer from './modelsSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@@ -30,6 +31,7 @@ export const store = configureStore({
|
||||
dashboards: dashboardsReducer,
|
||||
update: updateReducer,
|
||||
analytics: analyticsReducer,
|
||||
models: modelsReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ class WebSocketManager {
|
||||
sendMessage(
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
opts?: { mode?: string; model?: string; images?: Array<{ data: string; media_type: string }> },
|
||||
opts?: { mode?: string; model?: string; provider?: string; images?: Array<{ data: string; media_type: string }> },
|
||||
) {
|
||||
this.send('agent:send_message', {
|
||||
session_id: sessionId,
|
||||
|
||||
@@ -127,6 +127,15 @@ if (( frontend_elapsed >= FRONTEND_MAX_WAIT )); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Install Electron dependencies if needed ---
|
||||
MAGENTA='\033[0;35m'
|
||||
if [ ! -d "$PROJECT_ROOT/electron/node_modules" ]; then
|
||||
echo -e "${MAGENTA}${BOLD}[electron]${RESET} Installing dependencies..."
|
||||
(cd "$PROJECT_ROOT/electron" && npm install) 2>&1 | while IFS= read -r line; do
|
||||
printf "${MAGENTA}${BOLD}[electron]${RESET} %s\n" "$line"
|
||||
done
|
||||
fi
|
||||
|
||||
# --- Sign Electron VMP for DRM (if EVS account exists) ---
|
||||
if [ -f "$PROJECT_ROOT/electron/scripts/sign-vmp.sh" ]; then
|
||||
echo -e "${YELLOW}${BOLD}[vmp]${RESET} Checking VMP signature..."
|
||||
@@ -136,7 +145,6 @@ if [ -f "$PROJECT_ROOT/electron/scripts/sign-vmp.sh" ]; then
|
||||
fi
|
||||
|
||||
# --- Start Electron in dev mode ---
|
||||
MAGENTA='\033[0;35m'
|
||||
echo -e "${MAGENTA}${BOLD}[electron]${RESET} Launching Electron dev shell..."
|
||||
(cd "$PROJECT_ROOT/electron" && unset ELECTRON_RUN_AS_NODE && ELECTRON_DEV=1 npx electron .) > >(
|
||||
while IFS= read -r line; do
|
||||
|
||||
Reference in New Issue
Block a user