mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-24 13:32:22 +02:00
295 lines
11 KiB
Python
295 lines
11 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import HTTPException, Query, UploadFile, File
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
from typing import Optional
|
|
|
|
from backend.config.Apps import SubApp
|
|
from backend.apps.settings.models import AppSettings, DEFAULT_SYSTEM_PROMPT
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from backend.config.paths import SETTINGS_DIR as DATA_DIR
|
|
|
|
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def settings_lifespan():
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
try:
|
|
from backend.apps.nine_router import sync_gemini_api_key, sync_openswarm_pro_as_claude
|
|
s = load_settings()
|
|
import asyncio as _asyncio
|
|
if getattr(s, "google_api_key", None):
|
|
_asyncio.create_task(sync_gemini_api_key(s.google_api_key))
|
|
if getattr(s, "connection_mode", None) == "openswarm-pro":
|
|
bearer = getattr(s, "openswarm_bearer_token", None)
|
|
proxy = getattr(s, "openswarm_proxy_url", None) or "https://api.openswarm.com"
|
|
if bearer:
|
|
_asyncio.create_task(sync_openswarm_pro_as_claude(bearer, proxy))
|
|
except Exception as e:
|
|
logger.warning(f"9Router sync startup failed: {e}")
|
|
yield
|
|
|
|
|
|
settings = SubApp("settings", settings_lifespan)
|
|
|
|
|
|
def _migrate_legacy_fields(raw: dict) -> dict:
|
|
"""Translate deprecated field names/values so they survive into the new schema.
|
|
|
|
Pre-launch scaffolding used `connection_mode="managed"` and
|
|
`openswarm_auth_token`; production names are `"openswarm-pro"` and
|
|
`openswarm_bearer_token`. Zero known users are affected, but keep the
|
|
mapping for safety.
|
|
"""
|
|
if raw.get("connection_mode") == "managed":
|
|
raw["connection_mode"] = "openswarm-pro"
|
|
if "openswarm_auth_token" in raw and "openswarm_bearer_token" not in raw:
|
|
raw["openswarm_bearer_token"] = raw.pop("openswarm_auth_token")
|
|
return raw
|
|
|
|
|
|
def load_settings() -> AppSettings:
|
|
"""Load settings from JSON file, returning defaults if not found."""
|
|
if os.path.exists(SETTINGS_FILE):
|
|
with open(SETTINGS_FILE) as f:
|
|
raw = _migrate_legacy_fields(json.load(f))
|
|
settings = AppSettings(**raw)
|
|
if settings.default_system_prompt is None:
|
|
settings.default_system_prompt = DEFAULT_SYSTEM_PROMPT
|
|
return settings
|
|
return AppSettings()
|
|
|
|
|
|
# Single threading.Lock guards every write to SETTINGS_FILE — protects against
|
|
# corruption from two requests racing through the file system. Async callers
|
|
# offload the actual write to the default thread pool (run_in_executor), so
|
|
# the lock works for both sync and thread-pool execution paths.
|
|
_settings_write_lock = threading.Lock()
|
|
|
|
|
|
def _atomic_write_settings(payload: dict) -> None:
|
|
"""Internal: serialise payload to SETTINGS_FILE atomically.
|
|
Always called via save_settings* — don't invoke directly."""
|
|
with _settings_write_lock:
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(prefix=".settings.", suffix=".tmp", dir=DATA_DIR)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, indent=2)
|
|
# On Windows, os.replace can transiently fail with PermissionError
|
|
# if Defender or another reader holds the destination open. One
|
|
# retry after a short backoff handles every real-world case
|
|
# without masking genuine permission bugs.
|
|
for attempt in range(2):
|
|
try:
|
|
os.replace(tmp, SETTINGS_FILE)
|
|
return
|
|
except PermissionError:
|
|
if attempt == 1:
|
|
raise
|
|
time.sleep(0.05)
|
|
except Exception:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def save_settings(settings_obj: AppSettings) -> None:
|
|
"""Synchronously persist settings atomically. Thread-safe.
|
|
Use from sync paths (analytics collector, lifespans). Async callers should
|
|
prefer save_settings_async to avoid blocking the event loop on Windows
|
|
where Defender scans can stretch the write to 50-200ms."""
|
|
_atomic_write_settings(settings_obj.model_dump())
|
|
|
|
|
|
async def save_settings_async(settings_obj: AppSettings) -> None:
|
|
"""Async-safe atomic save. Runs the file I/O in the default thread pool
|
|
so the FastAPI event loop stays responsive while the write completes.
|
|
Shares the threading.Lock with the sync variant for safe interleaving."""
|
|
payload = settings_obj.model_dump()
|
|
loop = asyncio.get_running_loop()
|
|
await loop.run_in_executor(None, _atomic_write_settings, payload)
|
|
|
|
|
|
# Backward-compat alias. Existing sync callers (analytics collector, analytics
|
|
# lifespan) continue to work; new async callers should use save_settings_async.
|
|
def _save_settings(settings_obj: AppSettings) -> None:
|
|
save_settings(settings_obj)
|
|
|
|
|
|
@settings.router.get("")
|
|
async def get_settings():
|
|
return load_settings().model_dump()
|
|
|
|
|
|
@settings.router.put("")
|
|
async def update_settings(body: AppSettings):
|
|
from backend.apps.analytics.collector import record as _analytics
|
|
|
|
old = load_settings()
|
|
|
|
# Track provider key changes
|
|
provider_keys = {
|
|
"anthropic_api_key": "anthropic",
|
|
"openai_api_key": "openai",
|
|
"google_api_key": "gemini",
|
|
"openrouter_api_key": "openrouter",
|
|
}
|
|
for key, provider_name in provider_keys.items():
|
|
old_val = bool(getattr(old, key, None))
|
|
new_val = bool(getattr(body, key, None))
|
|
if old_val != new_val:
|
|
_analytics("provider.configured", {
|
|
"provider": provider_name,
|
|
"action": "added" if new_val else "removed",
|
|
})
|
|
|
|
# Track settings changes (key names only, not values)
|
|
old_dict = old.model_dump()
|
|
new_dict = body.model_dump()
|
|
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
|
|
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
|
|
"installation_id"}
|
|
safe_changed = [
|
|
k for k in new_dict
|
|
if k in old_dict and new_dict[k] != old_dict[k] and k not in secret_keys
|
|
]
|
|
if safe_changed:
|
|
_analytics("settings.changed", {"changed_keys": safe_changed})
|
|
|
|
# Identify user in PostHog when profile is set/changed
|
|
if (body.user_email and body.user_email != getattr(old, "user_email", None)) or \
|
|
(body.user_name and body.user_name != getattr(old, "user_name", None)):
|
|
from backend.apps.analytics.collector import identify as _identify
|
|
id_props = {}
|
|
if body.user_email:
|
|
id_props["email"] = body.user_email
|
|
if body.user_name:
|
|
id_props["name"] = body.user_name
|
|
if body.user_use_case:
|
|
id_props["use_case"] = body.user_use_case
|
|
if body.user_referral_source:
|
|
id_props["referral_source"] = body.user_referral_source
|
|
if id_props:
|
|
_identify(id_props)
|
|
|
|
await save_settings_async(body)
|
|
|
|
# When the user changes their Gemini AI Studio API key, mirror it into
|
|
# 9Router as a priority-0 apikey connection. This bypasses the Gemini
|
|
# CLI OAuth 429 quota (Code Assist free tier) by routing through the
|
|
# independent generativelanguage.googleapis.com quota instead.
|
|
if getattr(body, "google_api_key", None) != getattr(old, "google_api_key", None):
|
|
try:
|
|
from backend.apps.nine_router import sync_gemini_api_key
|
|
await sync_gemini_api_key(body.google_api_key or None)
|
|
except Exception as e:
|
|
logger.warning(f"Gemini API-key sync failed: {e}")
|
|
|
|
# When openswarm-pro mode or bearer token changes, register a `claude`
|
|
# apikey connection in 9Router that proxies through our cloud. This
|
|
# makes the CLI's built-in WebSearch work on non-Claude primaries for
|
|
# Pro users — the CLI's Anthropic delegation path now has a working
|
|
# Claude route via 9Router, instead of hitting "no credentials for
|
|
# provider: claude".
|
|
pro_mode_old = getattr(old, "connection_mode", None) == "openswarm-pro"
|
|
pro_mode_new = getattr(body, "connection_mode", None) == "openswarm-pro"
|
|
bearer_old = getattr(old, "openswarm_bearer_token", None)
|
|
bearer_new = getattr(body, "openswarm_bearer_token", None)
|
|
if pro_mode_old != pro_mode_new or bearer_old != bearer_new:
|
|
try:
|
|
from backend.apps.nine_router import sync_openswarm_pro_as_claude
|
|
proxy_url = getattr(body, "openswarm_proxy_url", None) or "https://api.openswarm.com"
|
|
await sync_openswarm_pro_as_claude(
|
|
bearer_new if pro_mode_new else None,
|
|
proxy_url if pro_mode_new else None,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"OpenSwarm-Pro → Claude sync failed: {e}")
|
|
|
|
return {"ok": True, "settings": body.model_dump()}
|
|
|
|
|
|
@settings.router.get("/default-system-prompt")
|
|
async def get_default_system_prompt():
|
|
return {"default_system_prompt": DEFAULT_SYSTEM_PROMPT}
|
|
|
|
|
|
@settings.router.post("/reset-system-prompt")
|
|
async def reset_system_prompt():
|
|
current = load_settings()
|
|
current.default_system_prompt = DEFAULT_SYSTEM_PROMPT
|
|
await save_settings_async(current)
|
|
return {"ok": True, "settings": current.model_dump()}
|
|
|
|
|
|
class BrowseResponse(BaseModel):
|
|
current: str
|
|
parent: Optional[str]
|
|
directories: list[str]
|
|
files: list[str]
|
|
|
|
|
|
UPLOAD_DIR = os.path.join(tempfile.gettempdir(), "self-swarm-uploads")
|
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
|
|
|
|
@settings.router.post("/upload-files")
|
|
async def upload_files(files: list[UploadFile] = File(...)):
|
|
"""Accept dropped files, save them, and return their server-side paths."""
|
|
results = []
|
|
for f in files:
|
|
safe_name = os.path.basename(f.filename or "untitled")
|
|
dest = os.path.join(UPLOAD_DIR, safe_name)
|
|
|
|
counter = 1
|
|
base, ext = os.path.splitext(safe_name)
|
|
while os.path.exists(dest):
|
|
dest = os.path.join(UPLOAD_DIR, f"{base}_{counter}{ext}")
|
|
counter += 1
|
|
|
|
contents = await f.read()
|
|
with open(dest, "wb") as fh:
|
|
fh.write(contents)
|
|
|
|
results.append({"path": dest, "name": safe_name, "size": len(contents)})
|
|
|
|
return JSONResponse({"files": results})
|
|
|
|
|
|
@settings.router.get("/browse-directories")
|
|
async def browse_directories(path: str = Query(default="")) -> BrowseResponse:
|
|
target = path.strip() if path.strip() else os.path.expanduser("~")
|
|
target = os.path.expanduser(target)
|
|
target = os.path.abspath(target)
|
|
|
|
if not os.path.exists(target):
|
|
raise HTTPException(status_code=404, detail=f"Path not found: {target}")
|
|
if not os.path.isdir(target):
|
|
raise HTTPException(status_code=400, detail=f"Not a directory: {target}")
|
|
|
|
try:
|
|
entries = sorted(os.listdir(target))
|
|
except PermissionError:
|
|
raise HTTPException(status_code=403, detail=f"Permission denied: {target}")
|
|
|
|
visible = [e for e in entries if not e.startswith(".")]
|
|
directories = [e for e in visible if os.path.isdir(os.path.join(target, e))]
|
|
files = [e for e in visible if os.path.isfile(os.path.join(target, e))]
|
|
|
|
parent = os.path.dirname(target) if target != "/" else None
|
|
|
|
return BrowseResponse(current=target, parent=parent, directories=directories, files=files)
|