mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
Modified 9Router's callback page to call the exchange endpoint directly
instead of relying on postMessage/BroadcastChannel relay (which fails
in Electron where window.opener is null).
Flow: click Connect → sign in → callback page fetches pending state
from our backend → calls 9Router exchange → connection saved → done.
Backend:
- Added GET /api/subscriptions/pending/{state} with CORS for callback
- Added GET /api/subscriptions/callback as fallback
- Store pending OAuth state (codeVerifier, redirectUri) keyed by state
- redirect_uri stays on localhost:20128 (required by Anthropic)
Frontend:
- Popup window for auth URL + postMessage listener + status polling
- Works in both Electron and regular browser
378 lines
14 KiB
Python
378 lines
14 KiB
Python
from backend.config.Apps import SubApp
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
from backend.apps.agents.ws_manager import ws_manager
|
|
from backend.apps.agents.models import AgentConfig, ApprovalResponse
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import WebSocket, WebSocketDisconnect, HTTPException
|
|
from fastapi.responses import JSONResponse
|
|
import json
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
@asynccontextmanager
|
|
async def agents_lifespan():
|
|
logger.info("Agents sub-app starting")
|
|
await agent_manager.reconcile_on_startup()
|
|
await agent_manager.restore_all_sessions()
|
|
yield
|
|
logger.info("Agents sub-app shutting down")
|
|
for session_id in list(agent_manager.tasks.keys()):
|
|
await agent_manager.stop_agent(session_id)
|
|
await agent_manager.persist_all_sessions()
|
|
|
|
agents = SubApp("agents", agents_lifespan)
|
|
|
|
# REST Endpoints
|
|
|
|
@agents.router.get("/sessions")
|
|
async def list_sessions(dashboard_id: str = ""):
|
|
sessions = agent_manager.get_all_sessions(dashboard_id=dashboard_id or None)
|
|
return {"sessions": [s.model_dump(mode="json") for s in sessions]}
|
|
|
|
@agents.router.get("/sessions/{session_id}")
|
|
async def get_session(session_id: str):
|
|
session = agent_manager.get_session(session_id)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
return session.model_dump(mode="json")
|
|
|
|
@agents.router.post("/launch")
|
|
async def launch_agent(config: AgentConfig):
|
|
session = await agent_manager.launch_agent(config)
|
|
return {"session_id": session.id, "session": session.model_dump(mode="json")}
|
|
|
|
@agents.router.post("/sessions/{session_id}/message")
|
|
async def send_message(session_id: str, body: dict):
|
|
prompt = body.get("prompt", "")
|
|
if not prompt:
|
|
raise HTTPException(status_code=400, detail="prompt is required")
|
|
await agent_manager.send_message(
|
|
session_id,
|
|
prompt,
|
|
mode=body.get("mode"),
|
|
model=body.get("model"),
|
|
provider=body.get("provider"),
|
|
images=body.get("images"),
|
|
context_paths=body.get("context_paths"),
|
|
forced_tools=body.get("forced_tools"),
|
|
attached_skills=body.get("attached_skills"),
|
|
hidden=body.get("hidden", False),
|
|
selected_browser_ids=body.get("selected_browser_ids"),
|
|
)
|
|
return {"ok": True}
|
|
|
|
@agents.router.post("/sessions/{session_id}/stop")
|
|
async def stop_agent(session_id: str):
|
|
await agent_manager.stop_agent(session_id)
|
|
return {"ok": True}
|
|
|
|
@agents.router.post("/approval")
|
|
async def handle_approval(response: ApprovalResponse):
|
|
agent_manager.handle_approval(response.request_id, {
|
|
"behavior": response.behavior,
|
|
"message": response.message,
|
|
"updated_input": response.updated_input,
|
|
})
|
|
return {"ok": True}
|
|
|
|
@agents.router.post("/sessions/{session_id}/edit_message")
|
|
async def edit_message(session_id: str, body: dict):
|
|
message_id = body.get("message_id")
|
|
new_content = body.get("content", "")
|
|
if not message_id or not new_content:
|
|
raise HTTPException(status_code=400, detail="message_id and content are required")
|
|
await agent_manager.edit_message(session_id, message_id, new_content)
|
|
return {"ok": True}
|
|
|
|
@agents.router.post("/sessions/{session_id}/switch_branch")
|
|
async def switch_branch(session_id: str, body: dict):
|
|
branch_id = body.get("branch_id", "")
|
|
if not branch_id:
|
|
raise HTTPException(status_code=400, detail="branch_id is required")
|
|
await agent_manager.switch_branch(session_id, branch_id)
|
|
return {"ok": True}
|
|
|
|
@agents.router.post("/sessions/{session_id}/generate-title")
|
|
async def generate_title(session_id: str, body: dict):
|
|
prompt = body.get("prompt", "")
|
|
if not prompt:
|
|
raise HTTPException(status_code=400, detail="prompt is required")
|
|
title = await agent_manager.generate_title(session_id, prompt)
|
|
return {"title": title}
|
|
|
|
@agents.router.post("/sessions/{session_id}/generate-group-meta")
|
|
async def generate_group_meta(session_id: str, body: dict):
|
|
group_id = body.get("group_id", "")
|
|
tool_calls = body.get("tool_calls", [])
|
|
if not group_id or not tool_calls:
|
|
raise HTTPException(status_code=400, detail="group_id and tool_calls are required")
|
|
result = await agent_manager.generate_group_meta(
|
|
session_id,
|
|
group_id,
|
|
tool_calls,
|
|
results_summary=body.get("results_summary"),
|
|
is_refinement=body.get("is_refinement", False),
|
|
)
|
|
return result
|
|
|
|
@agents.router.patch("/sessions/{session_id}")
|
|
async def update_session(session_id: str, body: dict):
|
|
session = agent_manager.get_session(session_id)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
await agent_manager.update_session(session_id, **body)
|
|
return {"ok": True}
|
|
|
|
@agents.router.get("/sessions/{session_id}/branches")
|
|
async def get_branches(session_id: str):
|
|
session = agent_manager.get_session(session_id)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
return {
|
|
"branches": {k: v.model_dump(mode="json") for k, v in session.branches.items()},
|
|
"active_branch_id": session.active_branch_id,
|
|
}
|
|
|
|
@agents.router.post("/sessions/{session_id}/duplicate")
|
|
async def duplicate_session(session_id: str, body: dict = {}):
|
|
try:
|
|
session = await agent_manager.duplicate_session(
|
|
session_id,
|
|
dashboard_id=body.get("dashboard_id"),
|
|
up_to_message_id=body.get("up_to_message_id"),
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
return {"session": session.model_dump(mode="json")}
|
|
|
|
@agents.router.post("/sessions/{session_id}/close")
|
|
async def close_session(session_id: str):
|
|
try:
|
|
await agent_manager.close_session(session_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
return {"ok": True}
|
|
|
|
@agents.router.delete("/sessions/{session_id}")
|
|
async def delete_session(session_id: str):
|
|
await agent_manager.delete_session(session_id)
|
|
return {"ok": True}
|
|
|
|
@agents.router.get("/history")
|
|
async def get_history(q: str = "", limit: int = 20, offset: int = 0, dashboard_id: str = ""):
|
|
return agent_manager.get_history(
|
|
q=q, limit=limit, offset=offset,
|
|
dashboard_id=dashboard_id or None,
|
|
)
|
|
|
|
@agents.router.get("/sessions/{session_id}/browser-agents")
|
|
async def get_browser_agent_children(session_id: str):
|
|
children = agent_manager.get_browser_agent_children(session_id)
|
|
return {"sessions": children}
|
|
|
|
@agents.router.post("/sessions/{session_id}/resume")
|
|
async def resume_session(session_id: str):
|
|
try:
|
|
session = await agent_manager.resume_session(session_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
return {"session": session.model_dump(mode="json")}
|
|
|
|
|
|
@agents.router.get("/models")
|
|
async def list_models():
|
|
"""Return available models grouped by provider, filtered by configured credentials."""
|
|
from backend.apps.agents.providers.registry import get_available_models
|
|
from backend.apps.settings.settings import load_settings
|
|
settings = load_settings()
|
|
return {"models": get_available_models(settings)}
|
|
|
|
|
|
# ── GitHub Copilot Auth ──
|
|
|
|
@agents.router.post("/copilot/start-auth")
|
|
async def copilot_start_auth():
|
|
"""Start GitHub device flow for Copilot auth."""
|
|
from backend.apps.agents.copilot_auth import start_device_flow
|
|
try:
|
|
result = await start_device_flow()
|
|
return result
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@agents.router.post("/copilot/poll-auth")
|
|
async def copilot_poll_auth(body: dict):
|
|
"""Poll for GitHub auth completion. Returns token on success."""
|
|
from backend.apps.agents.copilot_auth import poll_for_token, exchange_for_copilot_token, get_github_username, list_copilot_models
|
|
from backend.apps.settings.settings import load_settings, _save_settings
|
|
|
|
device_code = body.get("device_code", "")
|
|
if not device_code:
|
|
raise HTTPException(status_code=400, detail="device_code required")
|
|
|
|
try:
|
|
github_token = await poll_for_token(device_code)
|
|
if github_token is None:
|
|
return {"status": "pending"}
|
|
|
|
# Got GitHub token — exchange for Copilot token
|
|
copilot_result = await exchange_for_copilot_token(github_token)
|
|
username = await get_github_username(github_token)
|
|
|
|
# Fetch available models
|
|
models = await list_copilot_models(copilot_result["token"])
|
|
|
|
# Save to settings
|
|
settings = load_settings()
|
|
settings.copilot_github_token = github_token
|
|
settings.copilot_token = copilot_result["token"]
|
|
settings.copilot_token_expires = copilot_result["expires_at"]
|
|
_save_settings(settings)
|
|
|
|
return {
|
|
"status": "connected",
|
|
"username": username,
|
|
"models": models,
|
|
}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@agents.router.get("/copilot/models")
|
|
async def copilot_models():
|
|
"""List models available through Copilot."""
|
|
from backend.apps.agents.copilot_auth import list_copilot_models, get_copilot_token
|
|
from backend.apps.settings.settings import load_settings, _save_settings
|
|
|
|
settings = load_settings()
|
|
github_token = getattr(settings, "copilot_github_token", None)
|
|
if not github_token:
|
|
return {"models": []}
|
|
|
|
try:
|
|
result = await get_copilot_token(
|
|
github_token,
|
|
getattr(settings, "copilot_token", None),
|
|
getattr(settings, "copilot_token_expires", None),
|
|
)
|
|
settings.copilot_token = result["token"]
|
|
settings.copilot_token_expires = result["expires_at"]
|
|
_save_settings(settings)
|
|
|
|
models = await list_copilot_models(result["token"])
|
|
return {"models": models}
|
|
except Exception as e:
|
|
return {"models": [], "error": str(e)}
|
|
|
|
|
|
@agents.router.post("/copilot/disconnect")
|
|
async def copilot_disconnect():
|
|
"""Clear Copilot tokens."""
|
|
from backend.apps.settings.settings import load_settings, _save_settings
|
|
|
|
settings = load_settings()
|
|
settings.copilot_github_token = None
|
|
settings.copilot_token = None
|
|
settings.copilot_token_expires = None
|
|
_save_settings(settings)
|
|
return {"ok": True}
|
|
|
|
|
|
# ── Subscription Management (via 9Router) ──
|
|
|
|
@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():
|
|
import asyncio
|
|
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}
|
|
|