mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-21 12:12:22 +02:00
* [eric] ci: gitleaks-ignore the known historical secrets so our branch stops failing on leaks it didnt add * [eric] workflows: restore scheduled-tasks on the workflow line (revert removal, keep windows fixes + 1.1.69) * [eric] workflows: re-apply uncommitted scheduling wip (schedule pill, calendar view, slice) * [eric] ops: gitignore dev-team local state files * [eric] ops: backlog item for download-tracking visibility * [eric] ci: allowlist the cdp-routes redaction-test token in gitleaks * [aidan] feat/scheduled-tasks: keep step labels in sync on edit and show chevron on every step * [aidan] fix: schedule time in chat * [aidan] ux/workflows: add workflow step removal (#91) * [aidan] feat/scheduled-tasks: remember workflow tool permissions across runs * [aidan] feat/task-scheduling: add hourly and minute (15-min minimum) schedule intervals (#93) * [aidan] feat/scheduled-tasks: calendar, rename, and edit workflows (#94) * [aidan] bug: fix schedule button * [aidan] fix/agent-errors: surface provider rate limits * [aidan] ux/cards: click-to-rename for chat and workflow titles Single-click a card's title to enter edit mode inline. Commit on Enter/blur, cancel on Escape. Rename persists via PATCH for workflows and sessions. * [aidan] feat/workflows: seed build prompt for zero-step workflows When a new workflow has no steps, seed the agent with a prompt asking the user to describe what the workflow should do, rather than starting blank. * [aidan] feat/workflows: add-to-schedule popover for unscheduled workflows Clicking the "+" on an unscheduled workflow row opens a popover with two options: - Keep this schedule: enables the workflow's existing cadence and moves it to Scheduled - Change schedule: opens the scheduling editor to pick a different time * [aidan] ux/workflows: wire add-to-schedule popover and simplify New button - Made the "+" icon on unscheduled workflow rows clickable, opening a popover to keep or change the schedule - Removed AddIcon from toolbar "New" button (now reads "New" instead of "+ New") * [aidan] fix/scheduled-tasks: open schedule calendar when Schedule pill clicked Fixed the Schedule pill click being swallowed by the toolbar's dismiss handler. Exempted the toolbar pills via data-toolbar-pills so their click handlers fire. * [aidan] ux/workflows: open New workflow in agent build chat instead of empty card When creating a new workflow from the hub, open it in edit_agent view (with the agent builder chat) instead of a preview card. The workflow is created on the backend first so the embedded session has a real ID. * [aidan] feat/workflow-edit: add draft testing save flow * [aidan] ux/chat: remove continue chat button * [aidan] ux/workflows: polish workflow card interactions * [aidan] fix/workflow-scheduling: save unscheduled workflows as drafts * aidan ui: schedule naming changes * [aidan] ui: tool calling desc/naming * [aidan] ui: calendar sidebar naming * [aidan] ui: fix stop viewing closing chat * [aidan] feat/workflows: auto-name workflows and polish the build flow (#95) * [aidan] feat/workflow-auto-naming: auto-generate workflow titles from steps Generate a title + description from a workflow's steps (one aux call, reused for step labels) whenever it is still auto_named, so a workflow built in the Edit Agent names itself on commit instead of staying "New workflow". A manual rename sets auto_named=False and is never overwritten. Stream the aux call (non-streaming drops content on some 9router lanes) and fall back to a step-derived title when the model is unavailable. * [aidan] feat/workflows: hide unsaved new workflows until first save A brand-new "+ New" workflow is created with unsaved=true and kept out of the hub's scheduled/unscheduled lists while the user is still building it in the Edit Agent. The first commit (Save) clears the flag and the workflow appears. Every other create path stays visible immediately. * [aidan] ux/workflows: remove redundant save workflow button The Edit Agent already has Discard/Save controls in its strip, so the header "Save Workflow" button was a duplicate save path. Remove it and its pulse/edit-session-id wiring; the model/time subtitle stays. * [aidan] ux/workflows: animate title on auto-rename Wrap the workflow card title in the same Typewriter the chat card uses, so when the auto-generated name replaces the placeholder after Save it retypes letter-by-letter. Gated on a real (non-placeholder) title so it never animates on mount or for already-named workflows. * [aidan] ux/workflows: animate sidebar title on auto-rename Wrap the calendar hub's sidebar row title in the same Typewriter the workflow card uses, so a title that auto-renames retypes letter-by-letter in the sidebar too. Extract the placeholder/isRealTitle guard into the shared workflowVisuals so the card and sidebar stay in sync. * [aidan] fix/workflows: connect watch tether, keep watched chat open, wire draft run/history * [aidan] ui: grey out chat pill when not selected * [aidan] ui: fix running agent display * [aidan] feat/history-popover: add chat history and scheduled tasks run log tabs (#96) * [aidan] ux/schedule: toast when calendar view already open on expand * [aidan] ui: fix popover descs * [aidan] feat/workflow-runs: add pause, resume, and stop controls for live runs * [aidan] feat/schedule-calendar: add calendar occurrences endpoint and concrete timezones * [aidan] feat/workflows: require at least one step to save a workflow * [aidan] ux/edit-agent: hide Discard for an unsaved new workflow * [aidan] ux/edit-agent: move fix-prefix card below the step list * [aidan] feat/workflows: toast when an unattended scheduled run starts * [aidan] fix/dashboard-tethers: anchor workflow-sidecar tethers to measured card rects * [aidan] feat/workflows: validate steps before scheduling and keep chat tool memory * [aidan] feat/mcp-suggestions: dismissable integration banner with per-session cooldown * [aidan] feat/workflows: add scheduled-run "running now" toast with click-to-view (#97) * [aidan] ux/workflows: surface paused state on card, sidebar, and calendar; tidy run history * [aidan] feat/mcp-suggestions: suggest both Google and Microsoft when provider is ambiguous * [aidan] fix/agent-tokens: friendly out-of-tokens card across all agent surfaces * [aidan] feat/workflow-model: persist edit-agent model on save with switch notice and fresh drafts * [aidan] fix/workflow-chat: force stop on watched run mirrors workflow card stop * [aidan] fix/workflow-cards: keep watched run tethered on finish to avoid duplicate chat * [aidan] feat/schedule-calendar: mark current time with a now line in week view * [aidan] refactor/private-names: rename error and schedule classifiers from _ to p_ * [aidan] feat/scheduled-tasks: agent workflow scheduling and in-chat convert (#98) * [aidan] feat/workflow-suggest: nudge user to convert repeatable chat to workflow Add SuggestConvertToWorkflow MCP tool that agents call at the end of a task when they've completed something worth repeating (daily report, weekly check, recurring data pull). Frontend detects the tool call and glows the "Convert to workflow" button 3 times to draw the eye. When user clicks it, the suggested cadence (e.g. "every weekday at 9am") is stored in the draft and seeded into the scheduling agent's first prompt, so the agent can act on the suggestion rather than asking the user again. Tool is never auto-called — agents decide when a task is genuinely repeatable (not debugging, creative work, one-off lookup). Tool description emphasizes sparse, high-confidence use only (once per session max). Files changed: - backend/apps/agents/schedule_mcp_server.py: add SuggestConvertToWorkflow tool - frontend/src/shared/mcpToolMeta.ts: add label for new tool - frontend/src/app/pages/Dashboard/cards/AgentCard.tsx: detect suggestion in session messages, show+glow "Convert to workflow" button, pass cadence to draft - frontend/src/shared/state/workflowsSlice.ts: add suggested_cadence field to Workflow interface - frontend/src/app/pages/Workflows/SchedulingView.tsx: seed scheduling agent prompt with suggested cadence hint * [aidan] feat/agent-scheduling: route recurring asks through native workflows, deny claude cron skill * [aidan] feat/workflow-convert: in-chat convert popup and auto-open scheduled workflow card * [aidan] ux/calendar-page: schedule calendar restyle + popover fixes (#99) * [aidan] fix/dashboard-delete: remove workflows calendar panel on delete key * [aidan] ux/workflows-calendar: restyle hub, fix today highlight, add toolbar toggle * [aidan] ux/schedule-popover: compact density, fix sticky header bleed, add header spacing * [aidan] ux/schedule-calendar: hollow ring dot for past fires in month view * [aidan] ux/schedule-calendar: clickable +N more opens day's full run list * [aidan] feat/run-log-filters: add success and skipped pills to scheduled task history * [aidan] fix/convert-button: stop drag capture so convert-to-workflow click fires * [aidan] ux/calendar-card: match border color and radius to chat and workflow cards * [aidan] fix/minimap: render missed-runs card on the minimap * [aidan] ux/run-sparkline: simplify tooltip to plain run tally * [aidan] ux/run-history: collapse expanded run view to one clickable line * [aidan] ux/calendar-card: match corner radius to browser cards * [aidan] feat/workflows: launch-time scheduling UX and workflow-card polish (#101) * [aidan] feat/schedule-list: lazy-load list view via scroll sentinel * [aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card * [aidan] fix/dashboard-tethers: keep watching line anchored on canvas zoom * [aidan] feat/scheduled-tasks: review missed runs at launch instead of auto-firing on_missed * [aidan] refactor/workflow-cards: use radius and status design tokens, polish card chrome * [aidan] ux/agent-card: keep convert-to-workflow visible during runs with mid-turn toast * [aidan] ux/mcp-bubble: drop redundant verb label when a workflow label is shown * [aidan] chore/backend: remove stale explanatory comments * [aidan] fix/workflows-hub: load workflows on hub mount so calendar fills at launch * [aidan] feat/workflows: generate title, description, step labels at convert time * [aidan] fix/tidy-layout: include workflows hub in tidy and fit-to-view * [aidan] feat/schedule-list: window long list via measured-height virtualizer * [aidan] ux/workflows-hub: remove time-saved badge from calendar header * [aidan] fix/types: add missing semantic-type labels and drop stray fade arg * [aidan] feat/schedule: pin monthly day-of-month and honor repeat-every intervals * [aidan] feat/schedule: inherit source-session tool surface for scheduled runs * [aidan] ux/calendar: restack hour-cell events as bars with overflow affordance * [aidan] feat/calendar: open the run card when clicking a scheduled occurrence * [aidan] ux/missed-runs: add per-group select-all toggle and rename skip action * [aidan] feat: new scheduled task design ported * [aidan] ui: sidebar reorder, repeat controls on schedule card * [aidan] ui: sidebar, scheduling time * [aidan] feat/schedule: pin monthly last-day-of-month * [aidan] feat/steps: per-step enable toggle * [aidan] feat/workflows: per-workflow color swatch * [aidan] feat/trash: soft-delete workflows with restore and purge * [aidan] feat/run-monitor: live run monitor card on the canvas * [aidan] feat/run-context: attach a run as removable chat context * [aidan] feat/compose: new-workflow landing page and auto-commit build flow * [aidan] ui/workflows: dark mode and design-system cohesion * [aidan] ui/calendar: overflow popover, condensed week view, scroll fix * [aidan] feat/home: ongoing runs, missed review, and accurate Coming-up counts * [aidan] fix/run-status: sync ongoing runs and heal stuck/interrupted runs * [aidan] ux/schedule: last-day-of-month UI, Run-at time typing, interval input * [aidan] ux/workflows: default window size and toolbar icon * [aidan] fix/schedule: measure ran_late from start and anchor recurrences to created_at * [aidan] feat/calendar: render fire times from backend, drop JS recurrence reimpl * [aidan] chore/dashboard: drop dead configure/missed-run cards, refetch on reconnect * [aidan] chore/agent-card: remove unreachable convert-to-workflow action * [aidan] fix/workflows: don't bump updated_at on a no-op draft commit so viewing a workflow doesn't reorder the sidebar * [aidan] feat/schedule: warn when scheduling a workflow that has no steps * [aidan] fix/selection-tool: never select the workflows app, and exit the tool on Escape without dropping selections * [aidan] ux/compose: diversify new-workflow starter prompts across personas * [aidan] ux/run-monitor: spawn the run card a bit farther right of the workflows app * [aidan] fix/schedule: harden run recovery and storage writes against crashes * [aidan] ux/compose: restyle new-workflow starters as a clean pill cluster with rich prompts * [aidan] fix/workflows: optimistically apply edits so the schedule banner updates instantly * [aidan] ui/workflows: three-tone surface depth so the window lifts off the canvas in both themes * [aidan] ui/workflows: close buttons turn red on hover, matching the chat card * [aidan] test/schedule: cover executor pipeline, storage durability, and recurrence gaps * [aidan] fix: remove package-lock json * [aidan] fix/workflows-compose: keep compose view until edit agent replies * [aidan] feat/workflows: auto-generate workflow + step titles with typewriter animation * [eric] deps: restore frontend/package-lock.json (PR #105 deletion broke npm ci) --------- Co-authored-by: Eric <ciregenz@berkeley.edu> Co-authored-by: cire <134991075+ciregenz@users.noreply.github.com>
862 lines
36 KiB
Python
862 lines
36 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
|
|
import time
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MCP_SUGGEST_COOLDOWN_S = 300.0
|
|
p_mcp_suggest_cooldown: dict[str, float] = {}
|
|
|
|
# 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:
|
|
last_suggested = p_mcp_suggest_cooldown.get(session_id, 0.0)
|
|
if time.monotonic() - last_suggested >= MCP_SUGGEST_COOLDOWN_S:
|
|
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"):
|
|
p_mcp_suggest_cooldown[session_id] = time.monotonic()
|
|
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 p_pending_oauth
|
|
p_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))
|