From ebea25f0c26129e7fb59770cd71f0d2bb52f637e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 24 Mar 2026 14:02:58 -0700 Subject: [PATCH] [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 --- backend/apps/agents/agent_loop.py | 331 ++++++++ backend/apps/agents/agent_manager.py | 52 +- backend/apps/agents/agents.py | 95 +++ backend/apps/agents/mcp_client.py | 360 +++++++++ backend/apps/agents/models.py | 2 + backend/apps/agents/providers/__init__.py | 0 backend/apps/agents/providers/anthropic.py | 260 +++++++ backend/apps/agents/providers/base.py | 135 ++++ .../apps/agents/providers/openai_compat.py | 330 ++++++++ backend/apps/agents/providers/registry.py | 328 ++++++++ backend/apps/agents/tools/__init__.py | 0 backend/apps/agents/tools/base.py | 47 ++ backend/apps/agents/tools/filesystem.py | 476 ++++++++++++ backend/apps/agents/tools/registry.py | 61 ++ backend/apps/agents/tools/system.py | 125 +++ backend/apps/agents/tools/web.py | 214 ++++++ backend/apps/analytics/analytics.py | 92 ++- backend/apps/dashboards/dashboards.py | 9 +- backend/apps/nine_router.py | 211 ++++++ backend/apps/outputs/outputs.py | 7 +- backend/apps/settings/credentials.py | 141 ++++ backend/apps/settings/models.py | 45 +- backend/main.py | 73 +- backend/requirements.txt | 3 +- backend/run.sh | 4 +- frontend/src/app/Main.tsx | 4 + .../src/app/components/OnboardingModal.tsx | 217 ++++++ .../src/app/pages/AgentChat/ChatInput.tsx | 89 ++- .../src/app/pages/Dashboard/AgentCard.tsx | 9 +- frontend/src/app/pages/Settings/Settings.tsx | 712 +++++++++++++++--- frontend/src/shared/config.ts | 1 + frontend/src/shared/state/agentsSlice.ts | 21 +- frontend/src/shared/state/analyticsSlice.ts | 227 +----- frontend/src/shared/state/modelsSlice.ts | 49 ++ frontend/src/shared/state/settingsSlice.ts | 11 + frontend/src/shared/state/store.ts | 2 + frontend/src/shared/ws/WebSocketManager.ts | 2 +- run.sh | 10 +- 38 files changed, 4382 insertions(+), 373 deletions(-) create mode 100644 backend/apps/agents/agent_loop.py create mode 100644 backend/apps/agents/mcp_client.py create mode 100644 backend/apps/agents/providers/__init__.py create mode 100644 backend/apps/agents/providers/anthropic.py create mode 100644 backend/apps/agents/providers/base.py create mode 100644 backend/apps/agents/providers/openai_compat.py create mode 100644 backend/apps/agents/providers/registry.py create mode 100644 backend/apps/agents/tools/__init__.py create mode 100644 backend/apps/agents/tools/base.py create mode 100644 backend/apps/agents/tools/filesystem.py create mode 100644 backend/apps/agents/tools/registry.py create mode 100644 backend/apps/agents/tools/system.py create mode 100644 backend/apps/agents/tools/web.py create mode 100644 backend/apps/nine_router.py create mode 100644 backend/apps/settings/credentials.py create mode 100644 frontend/src/app/components/OnboardingModal.tsx create mode 100644 frontend/src/shared/state/modelsSlice.ts diff --git a/backend/apps/agents/agent_loop.py b/backend/apps/agents/agent_loop.py new file mode 100644 index 00000000..92410408 --- /dev/null +++ b/backend/apps/agents/agent_loop.py @@ -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 diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index b2ed4a7f..6b1b9bfa 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -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, diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 070e29c5..04765a31 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -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} + diff --git a/backend/apps/agents/mcp_client.py b/backend/apps/agents/mcp_client.py new file mode 100644 index 00000000..303078ab --- /dev/null +++ b/backend/apps/agents/mcp_client.py @@ -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____. + """ + 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____ 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 diff --git a/backend/apps/agents/models.py b/backend/apps/agents/models.py index 948a007c..524907d3 100644 --- a/backend/apps/agents/models.py +++ b/backend/apps/agents/models.py @@ -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 diff --git a/backend/apps/agents/providers/__init__.py b/backend/apps/agents/providers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/agents/providers/anthropic.py b/backend/apps/agents/providers/anthropic.py new file mode 100644 index 00000000..bd347ead --- /dev/null +++ b/backend/apps/agents/providers/anthropic.py @@ -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.") diff --git a/backend/apps/agents/providers/base.py b/backend/apps/agents/providers/base.py new file mode 100644 index 00000000..ef2f746e --- /dev/null +++ b/backend/apps/agents/providers/base.py @@ -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, + } diff --git a/backend/apps/agents/providers/openai_compat.py b/backend/apps/agents/providers/openai_compat.py new file mode 100644 index 00000000..bdc4a3b6 --- /dev/null +++ b/backend/apps/agents/providers/openai_compat.py @@ -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") diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py new file mode 100644 index 00000000..947794b9 --- /dev/null +++ b/backend/apps/agents/providers/registry.py @@ -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 diff --git a/backend/apps/agents/tools/__init__.py b/backend/apps/agents/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/agents/tools/base.py b/backend/apps/agents/tools/base.py new file mode 100644 index 00000000..6190a170 --- /dev/null +++ b/backend/apps/agents/tools/base.py @@ -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(), + ) diff --git a/backend/apps/agents/tools/filesystem.py b/backend/apps/agents/tools/filesystem.py new file mode 100644 index 00000000..5ea3c253 --- /dev/null +++ b/backend/apps/agents/tools/filesystem.py @@ -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) diff --git a/backend/apps/agents/tools/registry.py b/backend/apps/agents/tools/registry.py new file mode 100644 index 00000000..91cd6b5b --- /dev/null +++ b/backend/apps/agents/tools/registry.py @@ -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() diff --git a/backend/apps/agents/tools/system.py b/backend/apps/agents/tools/system.py new file mode 100644 index 00000000..3a394328 --- /dev/null +++ b/backend/apps/agents/tools/system.py @@ -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}] diff --git a/backend/apps/agents/tools/web.py b/backend/apps/agents/tools/web.py new file mode 100644 index 00000000..d1eec3b0 --- /dev/null +++ b/backend/apps/agents/tools/web.py @@ -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)[^>]*>.*?", "", 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 + #
...
+ result_blocks = re.findall( + r']*class="[^"]*result[^"]*"[^>]*>(.*?)\s*(?=]*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']*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)', + block, + flags=re.DOTALL, + ) + if not link_match: + # Try reversed attribute order + link_match = re.search( + r']*href="([^"]*)"[^>]*class="[^"]*result__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']*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)', + 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(" 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", {}), } diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index d274895f..5a21d4a6 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -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 = ( diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py new file mode 100644 index 00000000..ce7a3a90 --- /dev/null +++ b/backend/apps/nine_router.py @@ -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 [] diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 27af9139..8a6c4d07 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -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: diff --git a/backend/apps/settings/credentials.py b/backend/apps/settings/credentials.py new file mode 100644 index 00000000..7bed6dff --- /dev/null +++ b/backend/apps/settings/credentials.py @@ -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) diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 172e8337..91a9ba56 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -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) diff --git a/backend/main.py b/backend/main.py index 9dbfd4b7..f3af2e35 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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'

Authorization failed

{desc}

') + + pending = _pending_oauth.pop(state, None) + if not pending: + return HTMLResponse('

Session expired

Please try connecting again.

') + + 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'

Connection failed

{e}

') + + return HTMLResponse( + '' + '
' + '
' + '

Connected!

' + '

You can close this window

' + '
' + '' + '' + ) + + @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}) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9cc94efe..909d7685 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,4 +10,5 @@ pytest-asyncio==0.25.2 typeguard==4.4.2 python-dotenv==1.1.1 Pillow -posthog \ No newline at end of file +posthog +httpx>=0.27.0 \ No newline at end of file diff --git a/backend/run.sh b/backend/run.sh index 689ecc1f..a94240ba 100755 --- a/backend/run.sh +++ b/backend/run.sh @@ -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' diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index e1ac50dc..0e378734 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -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 = () => { + diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx new file mode 100644 index 00000000..efc2c9f0 --- /dev/null +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -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(null); + const [nineRouterStatus, setNineRouterStatus] = useState(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 ( + + + + Welcome to OpenSwarm + + + Connect an AI model to get started + + + {/* Subscription options */} + + Use your existing subscription + + + {SUBSCRIPTION_PROVIDERS.map((p) => ( + !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` } }), + }} + > + + {p.name} + {p.desc} + + + {p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : 'Connect \u2192'} + + + ))} + + + {/* API key option */} + + Or use an API key + + + + I have an API key + + + Go to Settings → Models to enter your key + + + + {/* Skip */} + + + + ); +}; + +export default OnboardingModal; diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index d07e4cb1..fd0f058e 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -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 = { 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(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => { +const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => { const c = useClaudeTokens(); const editorRef = useRef(null); const containerRef = useRef(null); @@ -158,6 +160,24 @@ const ChatInput = forwardRef(({ 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> = {}; + 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(({ 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(({ onSend, disabled, mode, }} > - {(() => { 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; })()} @@ -992,21 +1013,45 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} slotProps={{ paper: menuPaperProps }} > - {MODEL_OPTIONS.map((opt) => ( - { - onModelChange(opt.value); - setModelAnchor(null); - }} - > - - - ))} + {Object.entries(allModelOptions.grouped).map(([prov, models]) => [ + + + {prov} + + , + ...models.map((opt) => ( + { + onModelChange(opt.value); + if (onProviderChange) { + // Derive API-level provider key from the display group name + const provLower = prov.toLowerCase(); + const providerMap: Record = { + 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); + }} + > + + + )), + ]).flat()} diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx index a88a44f3..9b8b5f6b 100644 --- a/frontend/src/app/pages/Dashboard/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx @@ -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 = ({ {session.mode} - {formatDuration(session.created_at)} + {formatDuration(session.created_at, (session as any).closed_at, session.status)} {session.cost_usd > 0 && ( diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 79f743bc..ed902109 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -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 ( + + + + Connected{username ? ` as @${username}` : ''} + + + Disconnect + + + ); + } + + if (status === 'waiting') { + return ( + + + Enter code {userCode} at github.com/login/device + + Waiting for authorization... + + ); + } + + return ( + + + {error && {error}} + + ); +}; + +// ── 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 ( + + + + + + {provider.name} + {provider.desc} + + + {isPreview ? ( + + Coming soon + + ) : connected ? ( + + Disconnect + + ) : connecting && userCode ? ( + + Enter code: + {userCode} + + ) : ( + + )} + + + ); +}; + +const SubscriptionCards: React.FC = () => { + const c = useClaudeTokens(); + const [status, setStatus] = useState(null); + const [connecting, setConnecting] = useState(null); + const [userCode, setUserCode] = useState(''); + const [pollTimer, setPollTimer] = useState(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 ( + + + Starting subscription service... + + + This connects your existing AI subscriptions. If this doesn't load, make sure Node.js is installed. + + + ); + } + + return ( + + {SUBSCRIPTION_PROVIDERS.map(p => ( + handleConnect(p.id)} + onDisconnect={() => handleDisconnect(p.id)} + connecting={connecting === p.id} + userCode={connecting === p.id ? userCode : undefined} + /> + ))} + + ); +}; + +// ── 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 ( + + {Array.from({ length: width }, (_, i) => ( + + ))} + + ); +}; + +// ── Usage Stats Component ── +const UsageStats: React.FC = () => { + const c = useClaudeTokens(); + const [stats, setStats] = useState(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) => ( + + ); + + 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 ( + + {/* Row 1: Core metrics */} + + + Total Sessions + {stats.total_sessions.toLocaleString()} + + {statusEntries.map(([s, n]) => `${n} ${s}`).join(', ') || 'no sessions'} + + + + Total Cost + {formatCost(stats.total_cost_usd)} + + {costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'} + + + + Total Messages + {stats.total_messages.toLocaleString()} + + {msgsPerSession} avg per session + + + + Total Tool Calls + {stats.total_tool_calls.toLocaleString()} + + {toolsPerSession} avg per session + + + + + {/* Row 2: Time + efficiency + tokens */} + + + Total Run Time + {formatTotalTime(totalTime)} + across all sessions + + + Avg Session + {formatDuration(stats.avg_duration_seconds)} + per session duration + + + Completion Rate + {(stats.completion_rate * 100).toFixed(1)}% + + sessions finished successfully + + + + Tokens Used + + {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} + + + {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'} + + + + + {/* Model + Provider + Tool breakdown */} + + {/* Models & Providers */} + + Models Used + {modelEntries.length > 0 ? modelEntries.map(([model, count]) => { + const pct = stats.total_sessions > 0 ? ((count / stats.total_sessions) * 100).toFixed(0) : '0'; + return ( + + + {model} + + {count} ({pct}%) + + + + + ); + }) : No sessions yet} + + + {/* Tools */} + + Top Tools + {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 ( + + + {shortName} + + {count} call{count !== 1 ? 's' : ''} ({pct}%) + + + + + ); + }) : No tool calls yet} + + + + ); +}; 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({ ...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 = () => { }} > + + @@ -626,125 +1127,6 @@ const Settings: React.FC = () => { - {/* ── API ── */} - API - - - Anthropic API key - - - Stored securely in the local database. - - 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?'} - - - - - - {API_KEY_STEPS.map((step, i) => ( - - - {i + 1} - - - - {step.title} - {step.link && ( - - Open - - - )} - - - {step.detail} - - - - ))} - - - - 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: ( - - setShowApiKey(!showApiKey)} - edge="end" - size="small" - sx={{ color: c.text.tertiary }} - > - {showApiKey ? : } - - - ), - }} - /> - - {/* ── Advanced ── */} Advanced @@ -873,6 +1255,94 @@ const Settings: React.FC = () => { + ) : activeTab === 'models' ? ( + + + {/* ── USE EXISTING SUBSCRIPTIONS ── */} + + Use Your Existing Subscriptions + + + + Already paying for Claude, ChatGPT, or Gemini? Connect your subscription — no API key needed, no extra cost. + + + + + {/* ── API KEYS ── */} + + Or Connect With API Keys + + + + Pay per use. Each key is stored locally on your device. + + + {/* Anthropic */} + + + Anthropic + {form.anthropic_api_key ? ( + CONNECTED + ) : null} + + Claude Sonnet, Opus, Haiku. + + 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: ( + + setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}> + {showApiKey ? : } + + + ), + }} + /> + + Get key + + + + + + ) : activeTab === 'usage' ? ( + + + + {/* ── Analytics ── */} + Analytics + + + + Share anonymous usage data + + 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. + + + 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 }, + }} + /> + + ) : ( @@ -880,7 +1350,7 @@ const Settings: React.FC = () => { )} - {activeTab === 'general' && ( + {(activeTab === 'general' || activeTab === 'models') && (