mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
855 lines
35 KiB
Python
855 lines
35 KiB
Python
from backend.config.Apps import SubApp
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
from backend.apps.agents.core.models import AgentConfig, ApprovalResponse
|
|
from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import WebSocket, WebSocketDisconnect, HTTPException
|
|
from fastapi.responses import JSONResponse
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Dedup concurrent generate-group-meta calls; collapses the 429 thundering herd by sharing one upstream Future per (session, group).
|
|
p_group_meta_inflight: dict[tuple[str, str], asyncio.Future] = {}
|
|
|
|
@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)
|
|
|
|
|
|
@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("/activity")
|
|
async def agent_activity():
|
|
"""How many agent tasks are live right now. Drives the desktop's idle-update gate so a
|
|
silent update-on-idle never lands on top of a running agent."""
|
|
active = sum(1 for t in agent_manager.tasks.values() if not t.done())
|
|
return {"active": active}
|
|
|
|
@agents.router.get("/sessions/{session_id}")
|
|
async def get_session(session_id: str):
|
|
"""Returns the session by id.
|
|
|
|
Falls back to a disk load when the session isn't in the in-memory
|
|
dict. Without this, any surface that queries a session before the
|
|
dashboard has restored it (Apps editor opened cold, deep link to a
|
|
chat, a workflow step inspecting an old session) hits a 404 even
|
|
though the JSON file is sitting on disk. The disk-load path is
|
|
O(1) memory hit after the first call: resume_session moves the
|
|
session into agent_manager.sessions and the next GET short-circuits
|
|
on the in-memory check.
|
|
"""
|
|
session = agent_manager.get_session(session_id)
|
|
if not session:
|
|
try:
|
|
session = await agent_manager.resume_session(session_id)
|
|
except ValueError:
|
|
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")
|
|
|
|
# Run MCP-suggestion classifier in parallel with the agent launch; fails open.
|
|
try:
|
|
from backend.apps.agents.core.mcp_preflight import run_preflight
|
|
from backend.apps.agents.core.ws_manager import ws_manager as p_ws
|
|
|
|
async def p_emit_preflight():
|
|
try:
|
|
result = await run_preflight(prompt, task_id=session_id)
|
|
if result.get("suggestions") or result.get("is_vague"):
|
|
await p_ws.send_to_session(session_id, "agent:mcp_suggestions", {
|
|
"session_id": session_id,
|
|
"suggestions": result.get("suggestions", []),
|
|
"is_vague": bool(result.get("is_vague")),
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
import asyncio as p_asyncio
|
|
p_asyncio.create_task(p_emit_preflight())
|
|
except Exception:
|
|
pass
|
|
|
|
await agent_manager.send_message(
|
|
session_id,
|
|
prompt,
|
|
mode=body.get("mode"),
|
|
model=body.get("model"),
|
|
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"),
|
|
selected_app_output_ids=body.get("selected_app_output_ids"),
|
|
selected_setting_ids=body.get("selected_setting_ids"),
|
|
client_message_id=body.get("client_message_id"),
|
|
)
|
|
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,
|
|
"trust_pattern": response.trust_pattern,
|
|
"set_always_allow": response.set_always_allow,
|
|
})
|
|
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")
|
|
|
|
# Dedup: share an in-flight Future across callers; refinement requests bypass since they may want fresh results.
|
|
is_refinement = body.get("is_refinement", False)
|
|
key = (session_id, group_id)
|
|
if not is_refinement:
|
|
existing = p_group_meta_inflight.get(key)
|
|
if existing is not None and not existing.done():
|
|
try:
|
|
return await existing
|
|
except Exception:
|
|
# In-flight call failed; retry ourselves rather than propagate someone else's error.
|
|
pass
|
|
|
|
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
if not is_refinement:
|
|
p_group_meta_inflight[key] = future
|
|
try:
|
|
result = await agent_manager.generate_group_meta(
|
|
session_id,
|
|
group_id,
|
|
tool_calls,
|
|
results_summary=body.get("results_summary"),
|
|
is_refinement=is_refinement,
|
|
)
|
|
if not future.done():
|
|
future.set_result(result)
|
|
return result
|
|
except Exception as e:
|
|
if not future.done():
|
|
future.set_exception(e)
|
|
raise
|
|
finally:
|
|
if not is_refinement and p_group_meta_inflight.get(key) is future:
|
|
p_group_meta_inflight.pop(key, None)
|
|
|
|
@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.get("/browser-memory")
|
|
async def list_browser_memory():
|
|
"""Everything the browser agent has learned, per site, so the user can see it
|
|
and clear it: tier-1 skills (replayable shortcuts) + tier-2 playbook (strategy
|
|
text). Read-only; pure introspection."""
|
|
from backend.apps.agents.browser import browser_playbook, browser_skills
|
|
sites: dict[str, dict] = {}
|
|
for entry in browser_playbook.list_hosts():
|
|
sites.setdefault(entry["host"], {"host": entry["host"], "skills": [], "strategy": []})
|
|
sites[entry["host"]]["strategy"] = entry["bullets"]
|
|
sites[entry["host"]]["updated_at"] = entry.get("updated_at", 0)
|
|
for host in list(sites.keys()):
|
|
sites[host]["skills"] = browser_skills.list_skills(host)
|
|
return {"sites": sorted(sites.values(), key=lambda s: -s.get("updated_at", 0))}
|
|
|
|
|
|
@agents.router.delete("/browser-memory/{host}")
|
|
async def forget_browser_memory(host: str):
|
|
"""Clear what the browser agent learned about one site (strategy + skills); it
|
|
re-learns on the next successful run."""
|
|
from backend.apps.agents.browser import browser_playbook, browser_skills
|
|
forgot_strategy = browser_playbook.forget(host)
|
|
forgot_skills = browser_skills.forget_host(host)
|
|
return {"ok": True, "host": host, "forgot_strategy": forgot_strategy, "forgot_skills": forgot_skills}
|
|
|
|
|
|
@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.post("/sessions/{session_id}/warm-cache")
|
|
async def warm_session_cache(session_id: str):
|
|
"""Fire a max_tokens=1 dummy request to prime the Anthropic prompt cache; best-effort."""
|
|
try:
|
|
await agent_manager.warm_prompt_cache(session_id)
|
|
except Exception:
|
|
pass
|
|
return {"ok": True}
|
|
|
|
|
|
@agents.router.post("/sessions/{session_id}/compact")
|
|
async def compact_session(session_id: str):
|
|
"""Run the summarizer over older turns to free up context.
|
|
|
|
Wired to the 'Compact memory' button in the pre-send overflow banner
|
|
and the /compact slash command. Sets compacted_through_msg_id so the
|
|
next turn's history-builder uses the summary in place of the
|
|
original messages.
|
|
"""
|
|
session = agent_manager.sessions.get(session_id)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="session not found")
|
|
fired = agent_manager.maybe_compact(session, force=True)
|
|
if fired:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
try:
|
|
await ws_manager.send_to_session(session_id, "agent:context_status", {
|
|
"session_id": session_id,
|
|
"reason": "compacted",
|
|
"compacted_through_msg_id": session.compacted_through_msg_id,
|
|
})
|
|
await agent_manager.emit_context_update(
|
|
session_id,
|
|
session,
|
|
input_tokens=estimate_post_compact_input(session),
|
|
output_tokens=session.tokens.get("output", 0),
|
|
)
|
|
except Exception:
|
|
pass
|
|
return {"ok": True, "compacted": fired}
|
|
|
|
|
|
@agents.router.post("/sessions/{session_id}/clear")
|
|
async def clear_session(session_id: str):
|
|
"""Drop all messages from the session, keep MCPs/model/tools.
|
|
|
|
Wired to the /clear slash command. Quickest path to recover from an
|
|
overflow short of starting a fresh chat."""
|
|
session = agent_manager.sessions.get(session_id)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="session not found")
|
|
session.messages = []
|
|
session.compacted_through_msg_id = None
|
|
session.tokens = {"input": 0, "output": 0}
|
|
session.needs_fresh_session = True
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
try:
|
|
await ws_manager.send_to_session(session_id, "agent:status", {
|
|
"session_id": session_id,
|
|
"status": session.status,
|
|
"session": session.model_dump(mode="json"),
|
|
})
|
|
await agent_manager.emit_context_update(
|
|
session_id,
|
|
session,
|
|
input_tokens=0,
|
|
output_tokens=0,
|
|
)
|
|
except Exception:
|
|
pass
|
|
return {"ok": True}
|
|
|
|
|
|
@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": []}
|
|
connections = await get_providers()
|
|
models = await get_models()
|
|
# Frontend reads data.providers.connections; preserve the envelope.
|
|
return {"running": True, "providers": {"connections": connections}, "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.")
|
|
|
|
# Reconnecting gemini-cli must wipe antigravity; registry prefers AG and a stale AG token would 400 after gemini-cli refreshes.
|
|
cascade = P_PROVIDER_CASCADE_REMOVES.get(provider, [])
|
|
if cascade:
|
|
try:
|
|
await p_delete_provider_connections(cascade)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
result = await start_oauth(provider)
|
|
|
|
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"),
|
|
)
|
|
if result.get("success"):
|
|
from backend.apps.service.client import sync as p_sync
|
|
from backend.apps.settings.settings import load_settings
|
|
p_sync(load_settings().model_dump())
|
|
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
|
|
from backend.apps.oauth_state import (
|
|
pending_oauth as pending_oauth,
|
|
completed_oauth as completed_oauth,
|
|
mark_oauth_completed as mark_completed,
|
|
)
|
|
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)
|
|
if result.get("success"):
|
|
# Claude races this path against /api/subscriptions/callback (popup + 9router patch
|
|
# 302 to backend); dedup so the loser sees the success page, not "Session expired".
|
|
if state:
|
|
pending_oauth.pop(state, None)
|
|
mark_completed(state)
|
|
from backend.apps.service.client import sync as do_sync
|
|
from backend.apps.settings.settings import load_settings
|
|
do_sync(load_settings().model_dump())
|
|
return result
|
|
except Exception as e:
|
|
if state and state in completed_oauth:
|
|
return {"success": True, "deduped": True}
|
|
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}
|
|
|
|
|
|
@agents.router.post("/probe-model")
|
|
async def probe_model(body: dict):
|
|
"""1-token health probe; returns latency or skipped when the route is ambiguous (silent beats wrong)."""
|
|
import time as p_time
|
|
short_name = (body or {}).get("model") or ""
|
|
if not short_name:
|
|
return {"ok": False, "error": "model required"}
|
|
try:
|
|
from backend.apps.agents.providers.registry import (
|
|
resolve_model_id_for_sdk,
|
|
get_api_type,
|
|
find_builtin_model,
|
|
_NINEROUTER_MODEL_PREFIXES,
|
|
)
|
|
from backend.apps.settings.settings import load_settings
|
|
from backend.apps.nine_router import is_running as p_9r_running
|
|
settings = load_settings()
|
|
api_type = get_api_type(short_name)
|
|
resolved = resolve_model_id_for_sdk(short_name, settings)
|
|
entry = find_builtin_model(short_name) or {}
|
|
route = entry.get("route")
|
|
connection_mode = getattr(settings, "connection_mode", "own_key")
|
|
|
|
import anthropic
|
|
client = None
|
|
|
|
# Routing mirrors agent_manager: prefix takes precedence over Pro.
|
|
resolved_is_9router = (
|
|
isinstance(resolved, str)
|
|
and resolved.startswith(_NINEROUTER_MODEL_PREFIXES)
|
|
)
|
|
|
|
if resolved_is_9router:
|
|
if not p_9r_running():
|
|
return {"ok": True, "skipped": True}
|
|
client = anthropic.AsyncAnthropic(api_key="9router", base_url="http://localhost:20128")
|
|
elif route == "api" and api_type == "anthropic" and getattr(settings, "anthropic_api_key", None):
|
|
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
|
elif api_type == "anthropic" and connection_mode == "openswarm-pro":
|
|
bearer = getattr(settings, "openswarm_bearer_token", "") or ""
|
|
proxy_url = (getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com").rstrip("/")
|
|
if not bearer:
|
|
return {"ok": True, "skipped": True}
|
|
client = anthropic.AsyncAnthropic(auth_token=bearer, base_url=proxy_url)
|
|
elif api_type == "anthropic" and getattr(settings, "anthropic_api_key", None):
|
|
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
|
else:
|
|
if not p_9r_running():
|
|
return {"ok": True, "skipped": True}
|
|
client = anthropic.AsyncAnthropic(api_key="9router", base_url="http://localhost:20128")
|
|
|
|
t0 = p_time.monotonic()
|
|
await client.messages.create(
|
|
model=resolved,
|
|
max_tokens=1,
|
|
messages=[{"role": "user", "content": "ping"}],
|
|
timeout=10.0,
|
|
)
|
|
return {"ok": True, "latency_ms": int((p_time.monotonic() - t0) * 1000)}
|
|
except Exception as e:
|
|
msg = str(e).splitlines()[0] if str(e) else type(e).__name__
|
|
low = msg.lower()
|
|
# Suppress transients: chat retries naturally and probe-time alias 404s often differ from chat resolution.
|
|
if any(s in low for s in (
|
|
"timeout", "timed out",
|
|
"connection reset", "connection aborted",
|
|
"rate_limit", "rate limit", "429",
|
|
"internal server error", "503", "502", "504",
|
|
"reset after",
|
|
"provider returned error",
|
|
"404", "not_found", "not found",
|
|
)):
|
|
return {"ok": True, "skipped": True}
|
|
return {"ok": False, "error": msg[:240]}
|
|
|
|
|
|
@agents.router.get("/models")
|
|
async def list_models():
|
|
"""Picker model list, grouped by provider, intersected with available creds."""
|
|
from backend.apps.agents.providers.registry import BUILTIN_MODELS
|
|
from backend.apps.nine_router import is_running as p_9r_running, get_providers as p_9r_providers
|
|
from backend.apps.settings.settings import load_settings
|
|
|
|
settings = load_settings()
|
|
nine_router_up = p_9r_running()
|
|
|
|
connected: set[str] = set()
|
|
if nine_router_up:
|
|
try:
|
|
conns = await p_9r_providers()
|
|
raw_providers = {c.get("provider", "") for c in conns if c.get("isActive") or c.get("testStatus") == "active"}
|
|
# 9Router uses "claude"; our models use api="anthropic". Map across.
|
|
p_9R_TO_API = {
|
|
"claude": "anthropic",
|
|
"codex": "codex",
|
|
"gemini-cli": "gemini-cli",
|
|
"antigravity": "gemini-cli", # AG = same Gemini models, separate OAuth.
|
|
}
|
|
connected = raw_providers | {p_9R_TO_API.get(p, p) for p in raw_providers}
|
|
except Exception as e:
|
|
logger.debug(f"Failed to fetch 9Router providers: {e}")
|
|
|
|
def p_serialize(models: list[dict]) -> list[dict]:
|
|
# Tiers describe the model; billing_kind describes the wallet. Pricing shown only for paid.
|
|
from backend.apps.agents.providers.registry import (
|
|
COST_PER_1M_TOKENS,
|
|
compute_tiers,
|
|
compute_billing_kind,
|
|
)
|
|
out = []
|
|
for m in models:
|
|
input_cost = output_cost = 0.0
|
|
for (p_p, p_v), rates in COST_PER_1M_TOKENS.items():
|
|
if p_v == m["value"]:
|
|
input_cost, output_cost = rates
|
|
break
|
|
api = m.get("api", "")
|
|
route = m.get("route")
|
|
billing_kind = compute_billing_kind(
|
|
api=api, route=route, is_or_free=False, settings=settings,
|
|
)
|
|
tiers = compute_tiers(
|
|
m.get("model_id", m["value"]),
|
|
m["label"],
|
|
output_cost,
|
|
bool(m.get("reasoning", False)),
|
|
)
|
|
out.append({
|
|
"value": m["value"],
|
|
"label": m["label"],
|
|
"context_window": m.get("context_window", 128_000),
|
|
"reasoning": bool(m.get("reasoning", False)),
|
|
"input_cost_per_1m": input_cost,
|
|
"output_cost_per_1m": output_cost,
|
|
# Strict free; subscriptions show via the picker's Subscription chip.
|
|
"is_free": billing_kind == "free",
|
|
"billing_kind": billing_kind,
|
|
"tiers": list(tiers),
|
|
})
|
|
return out
|
|
|
|
has_api_key = bool(getattr(settings, "anthropic_api_key", None))
|
|
is_openswarm_pro = (
|
|
getattr(settings, "connection_mode", "own_key") == "openswarm-pro"
|
|
and bool(getattr(settings, "openswarm_bearer_token", None))
|
|
)
|
|
has_claude_sub = "claude" in connected
|
|
|
|
result: dict[str, list[dict]] = {}
|
|
|
|
anthropic_models = BUILTIN_MODELS.get("Anthropic", [])
|
|
adaptive = [m for m in anthropic_models if m.get("route") not in ("cc", "api")]
|
|
cc_variants = [m for m in anthropic_models if m.get("route") == "cc"]
|
|
api_variants = [m for m in anthropic_models if m.get("route") == "api"]
|
|
|
|
# Pro mode splits into Pro proxy + Anthropic alternates; own-key collapses to one adaptive group.
|
|
notes: list[dict] = []
|
|
if is_openswarm_pro:
|
|
result["OpenSwarm Pro"] = p_serialize(adaptive)
|
|
anth_alternates: list[dict] = []
|
|
if has_claude_sub:
|
|
anth_alternates += cc_variants
|
|
if has_api_key:
|
|
anth_alternates += api_variants
|
|
if anth_alternates:
|
|
result["Anthropic"] = p_serialize(anth_alternates)
|
|
elif has_api_key or has_claude_sub:
|
|
rows = p_serialize(adaptive)
|
|
# When an Anthropic key is set, these adaptive rows run on it: own-key routing prefers the
|
|
# user's key over any sub (agent_manager + anthropic_proxy._pick_upstream), so it holds even
|
|
# with a Claude sub connected. Label + bucket as API key (not 9router-state dependent).
|
|
if has_api_key:
|
|
for r in rows:
|
|
if not r["label"].endswith("(API key)"):
|
|
r["label"] += " (API key)"
|
|
r["billing_kind"] = "api_key"
|
|
r["is_free"] = False
|
|
# Models that only exist on the API-key route (Fable 5, whose sub route 404s
|
|
# on our pinned 9Router) have no adaptive twin to relabel, so add them or they vanish.
|
|
adaptive_ids = {m.get("model_id") for m in adaptive}
|
|
api_only = [m for m in api_variants if m.get("model_id") not in adaptive_ids]
|
|
rows = p_serialize(api_only) + rows
|
|
elif has_claude_sub:
|
|
# Only a sub: the adaptive rows route through 9router's cc/ lane, so they're covered
|
|
# by the subscription, not pay-per-use.
|
|
for r in rows:
|
|
r["billing_kind"] = "subscription"
|
|
# Sub-only models with no adaptive twin (Fable 5) won't ride the relabeled rows, so add their cc/ entry.
|
|
adaptive_ids = {m.get("model_id") for m in adaptive}
|
|
cc_only = [m for m in cc_variants if m.get("model_id") not in adaptive_ids]
|
|
rows = p_serialize(cc_only) + rows
|
|
# With BOTH a key and a sub the adaptive rows above run on the key, so also surface the
|
|
# subscription (cc) variants; they route via 9router's cc/ lane and stay selectable, the
|
|
# way OpenAI/Gemini show both a subscription row and an API-key row.
|
|
if has_api_key and has_claude_sub:
|
|
rows += p_serialize(cc_variants)
|
|
result["Anthropic"] = rows
|
|
|
|
has_openai_key = bool(getattr(settings, "openai_api_key", None))
|
|
has_google_key = bool(getattr(settings, "google_api_key", None))
|
|
has_openrouter_key = bool(getattr(settings, "openrouter_api_key", None))
|
|
from backend.apps.agents.providers.registry import (
|
|
COST_PER_1M_TOKENS as P_CPM,
|
|
compute_tiers as p_ct_native,
|
|
compute_billing_kind as p_cbk_native,
|
|
)
|
|
for provider_name, models in BUILTIN_MODELS.items():
|
|
if provider_name == "Anthropic":
|
|
continue
|
|
visible = []
|
|
for m in models:
|
|
api = m.get("api", "")
|
|
route = m.get("route")
|
|
if route == "api":
|
|
if api == "openai" and not has_openai_key:
|
|
continue
|
|
if api == "gemini" and not has_google_key:
|
|
continue
|
|
elif m.get("subscription_only"):
|
|
if not nine_router_up or api not in connected:
|
|
continue
|
|
in_cost = out_cost = 0.0
|
|
for (p_p, p_v), rates in P_CPM.items():
|
|
if p_v == m["value"]:
|
|
in_cost, out_cost = rates
|
|
break
|
|
billing_kind = p_cbk_native(
|
|
api=api, route=route, is_or_free=False, settings=settings,
|
|
)
|
|
tiers = p_ct_native(
|
|
m.get("model_id", m["value"]),
|
|
m["label"],
|
|
out_cost,
|
|
bool(m.get("reasoning", False)),
|
|
)
|
|
visible.append({
|
|
"value": m["value"],
|
|
"label": m["label"],
|
|
"context_window": m.get("context_window", 128_000),
|
|
"reasoning": bool(m.get("reasoning", False)),
|
|
"input_cost_per_1m": in_cost,
|
|
"output_cost_per_1m": out_cost,
|
|
"is_free": billing_kind == "free",
|
|
"billing_kind": billing_kind,
|
|
"tiers": list(tiers),
|
|
})
|
|
if visible:
|
|
result[provider_name] = visible
|
|
|
|
# Fetch OpenRouter catalog directly (independent of 9Router) so picker fills the moment a key lands.
|
|
if has_openrouter_key:
|
|
try:
|
|
from backend.apps.agents.providers.registry import fetch_openrouter_models
|
|
or_models = await fetch_openrouter_models(settings.openrouter_api_key)
|
|
except Exception as e:
|
|
logger.debug(f"OpenRouter catalog fetch failed: {e}")
|
|
or_models = []
|
|
if or_models:
|
|
by_vendor: dict[str, list[dict]] = {}
|
|
from backend.apps.agents.providers.registry import (
|
|
compute_tiers as p_ct,
|
|
compute_billing_kind as p_cbk,
|
|
)
|
|
for m in or_models:
|
|
v = m.get("vendor") or "Other"
|
|
in_cost = float(m.get("input_cost_per_1m", 0.0))
|
|
out_cost = float(m.get("output_cost_per_1m", 0.0))
|
|
is_free = bool(m.get("is_free", False))
|
|
billing_kind = p_cbk(
|
|
api="openrouter", route="openrouter", is_or_free=is_free,
|
|
settings=settings,
|
|
)
|
|
tiers = p_ct(
|
|
m.get("model_id", m["value"]),
|
|
m["label"],
|
|
out_cost,
|
|
bool(m.get("reasoning", False)),
|
|
)
|
|
by_vendor.setdefault(v, []).append({
|
|
"value": m["value"],
|
|
"label": m["label"],
|
|
"context_window": m.get("context_window", 128_000),
|
|
"reasoning": bool(m.get("reasoning", False)),
|
|
"input_cost_per_1m": in_cost,
|
|
"output_cost_per_1m": out_cost,
|
|
"is_free": is_free,
|
|
"billing_kind": billing_kind,
|
|
"tiers": list(tiers),
|
|
"max_completion_tokens": m.get("max_completion_tokens"),
|
|
})
|
|
for vendor in sorted(by_vendor.keys()):
|
|
pretty = (
|
|
vendor.replace("-", " ").replace("_", " ").title().replace("Ai", "AI")
|
|
)
|
|
entries = sorted(by_vendor[vendor], key=lambda x: x["label"].lower())
|
|
result[f"OpenRouter · {pretty}"] = entries
|
|
|
|
# Custom OpenAI-compatible providers (Ollama Cloud, Together, etc); addressed via custom/<slug>/<model_id>.
|
|
from backend.apps.agents.providers.registry import custom_provider_slug_for_lookup
|
|
for cp in (getattr(settings, "custom_providers", None) or []):
|
|
cp_name = (getattr(cp, "name", "") or "").strip()
|
|
cp_base_url = (getattr(cp, "base_url", "") or "").strip()
|
|
cp_models = getattr(cp, "models", None) or []
|
|
if not cp_name or not cp_base_url or not cp_models:
|
|
continue
|
|
slug = custom_provider_slug_for_lookup(cp_name)
|
|
entries: list[dict] = []
|
|
for m in cp_models:
|
|
bare = (m.get("value") or m.get("id") or "").strip()
|
|
if not bare:
|
|
continue
|
|
label = (m.get("label") or bare).strip() or bare
|
|
ctx = m.get("context_window")
|
|
if not isinstance(ctx, int) or ctx <= 0:
|
|
ctx = 128_000
|
|
entries.append({
|
|
"value": f"custom/{slug}/{bare}",
|
|
"label": label,
|
|
"context_window": ctx,
|
|
"reasoning": bool(m.get("reasoning", False)),
|
|
"input_cost_per_1m": 0.0,
|
|
"output_cost_per_1m": 0.0,
|
|
"is_free": False,
|
|
"billing_kind": "api_key",
|
|
"tiers": [3, 3, 1],
|
|
})
|
|
if entries:
|
|
result[cp_name] = entries
|
|
|
|
return {"models": result, "notes": notes}
|
|
|
|
|
|
# gemini-cli and antigravity are two Google OAuth lanes; registry prefers AG, so we cascade-wipe AG when reconnecting gemini-cli to avoid stale-AG 400s. One-directional: AG operations MUST NOT cascade back.
|
|
P_PROVIDER_CASCADE_REMOVES: dict[str, list[str]] = {
|
|
"gemini-cli": ["antigravity"],
|
|
}
|
|
|
|
|
|
async def p_delete_provider_connections(providers: list[str]) -> int:
|
|
"""Delete 9Router connections in `providers`; returns count removed, silent on 9Router unreachable."""
|
|
import httpx
|
|
from backend.apps.nine_router import NINE_ROUTER_API, get_providers
|
|
try:
|
|
connections = await get_providers()
|
|
except Exception:
|
|
return 0
|
|
targets = [c for c in connections if c.get("provider") in providers and c.get("id")]
|
|
removed = 0
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
for c in targets:
|
|
try:
|
|
await client.delete(f"{NINE_ROUTER_API}/providers/{c['id']}")
|
|
removed += 1
|
|
except Exception:
|
|
pass
|
|
return removed
|
|
|
|
|
|
@agents.router.post("/subscriptions/disconnect")
|
|
async def subscriptions_disconnect(body: dict):
|
|
"""Disconnect a subscription provider via 9Router; cascades-wipe Google's paired lanes."""
|
|
provider = body.get("provider", "")
|
|
if not provider:
|
|
raise HTTPException(status_code=400, detail="provider required")
|
|
|
|
try:
|
|
to_remove = [provider, *P_PROVIDER_CASCADE_REMOVES.get(provider, [])]
|
|
removed = await p_delete_provider_connections(to_remove)
|
|
if removed:
|
|
from backend.apps.service.client import sync as p_sync
|
|
from backend.apps.settings.settings import load_settings
|
|
p_sync(load_settings().model_dump())
|
|
return {"ok": True}
|
|
return {"ok": False, "error": "Connection not found"}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|