mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[Haik]: fixed custom linter error msg in ide to be more verbose for num items in file. Also modded the exceptions a bit. Also abstracted nine_router into a normal subapp typa format. Also made the linter functionally prevent publishing or merging if theres any errors.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
structlint:
|
||||
name: Structure lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- run: python3 linter/structlint.py --root .
|
||||
|
||||
typecheck:
|
||||
name: Type check (backend)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- run: pip install pyright
|
||||
|
||||
- run: pyright --project linter/pyrightconfig.json
|
||||
@@ -110,12 +110,6 @@ async def analytics_lifespan():
|
||||
except Exception as e:
|
||||
logger.debug(f"Analytics startup event failed (non-critical): {e}")
|
||||
|
||||
try:
|
||||
from backend.apps.nine_router import ensure_running as ensure_9router
|
||||
await ensure_9router()
|
||||
except Exception as e:
|
||||
logger.warning(f"9Router auto-start failed: {e}")
|
||||
|
||||
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
|
||||
|
||||
yield
|
||||
@@ -128,12 +122,6 @@ async def analytics_lifespan():
|
||||
pass
|
||||
_heartbeat_task = None
|
||||
|
||||
try:
|
||||
from backend.apps.nine_router import stop as stop_9router
|
||||
stop_9router()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
shutdown_collector()
|
||||
logger.info("PostHog analytics shut down")
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""nine_router package — SubApp instance, lifespan, and re-exports.
|
||||
|
||||
9Router is a free AI subscription proxy that lets users connect their
|
||||
Claude/ChatGPT/Gemini subscriptions to OpenSwarm without API keys.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.ports import NINE_ROUTER_PORT
|
||||
|
||||
from backend.apps.nine_router.process import is_running, ensure_running, stop
|
||||
from backend.apps.nine_router.client import (
|
||||
get_usage_stats, get_providers, start_oauth,
|
||||
poll_oauth, exchange_oauth, get_models,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NINE_ROUTER_URL = f"http://localhost:{NINE_ROUTER_PORT}"
|
||||
NINE_ROUTER_API = f"{NINE_ROUTER_URL}/api"
|
||||
NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def nine_router_lifespan():
|
||||
try:
|
||||
await ensure_running()
|
||||
except Exception as e:
|
||||
logger.warning(f"9Router auto-start failed: {e}")
|
||||
yield
|
||||
try:
|
||||
stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
nine_router = SubApp("nine_router", nine_router_lifespan)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""HTTP API proxy — call 9Router's REST API from OpenSwarm."""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.ports import NINE_ROUTER_PORT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NINE_ROUTER_URL = f"http://localhost:{NINE_ROUTER_PORT}"
|
||||
NINE_ROUTER_API = f"{NINE_ROUTER_URL}/api"
|
||||
NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
|
||||
|
||||
|
||||
async def get_usage_stats(period: str = "all") -> dict | None:
|
||||
"""Get usage statistics from 9Router."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(f"{NINE_ROUTER_API}/usage/stats", params={"period": period})
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.debug(f"9Router usage stats fetch failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_providers() -> list[dict]:
|
||||
"""Get all providers and their connection status from 9Router."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(f"{NINE_ROUTER_API}/providers")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.debug(f"9Router providers fetch failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def start_oauth(provider: str) -> dict:
|
||||
"""Start OAuth flow for a provider.
|
||||
|
||||
For device_code providers (github, qwen, kiro): returns {user_code, verification_uri, device_code}
|
||||
For authorization_code providers (claude, codex, gemini-cli): returns {authUrl, codeVerifier, state}
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
try:
|
||||
r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
return {
|
||||
"flow": "device_code",
|
||||
"user_code": data.get("user_code", ""),
|
||||
"verification_uri": data.get("verification_uri", data.get("verification_uri_complete", "")),
|
||||
"device_code": data.get("device_code", ""),
|
||||
"code_verifier": data.get("codeVerifier", ""),
|
||||
"extra_data": {k: v for k, v in data.items() if k.startswith("_")},
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Authorization code flow — Anthropic only accepts redirect URIs
|
||||
# registered with 9Router's client ID
|
||||
callback_url = f"http://localhost:{NINE_ROUTER_PORT}/callback"
|
||||
r = await client.get(
|
||||
f"{NINE_ROUTER_API}/oauth/{provider}/authorize",
|
||||
params={"redirect_uri": callback_url},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
return {
|
||||
"flow": "authorization_code",
|
||||
"auth_url": data.get("authUrl", ""),
|
||||
"code_verifier": data.get("codeVerifier", ""),
|
||||
"state": data.get("state", ""),
|
||||
"redirect_uri": callback_url,
|
||||
}
|
||||
|
||||
|
||||
async def poll_oauth(
|
||||
provider: str,
|
||||
device_code: str,
|
||||
code_verifier: str | None = None,
|
||||
extra_data: dict | None = None,
|
||||
) -> dict:
|
||||
"""Poll for OAuth completion.
|
||||
|
||||
Returns: {success: true, connection: {...}} or {success: false, pending: true}
|
||||
"""
|
||||
body: dict = {"deviceCode": device_code}
|
||||
if code_verifier:
|
||||
body["codeVerifier"] = code_verifier
|
||||
if extra_data:
|
||||
body["extraData"] = extra_data
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.post(
|
||||
f"{NINE_ROUTER_API}/oauth/{provider}/poll",
|
||||
json=body,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def exchange_oauth(
|
||||
provider: str,
|
||||
code: str,
|
||||
redirect_uri: str,
|
||||
code_verifier: str,
|
||||
state: str = "",
|
||||
) -> dict:
|
||||
"""Exchange OAuth code for tokens via 9Router."""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.post(
|
||||
f"{NINE_ROUTER_API}/oauth/{provider}/exchange",
|
||||
json={
|
||||
"code": code,
|
||||
"redirectUri": redirect_uri,
|
||||
"codeVerifier": code_verifier,
|
||||
"state": state,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def get_models() -> list[dict]:
|
||||
"""Get all available models from 9Router."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(f"{NINE_ROUTER_V1}/models")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
models = data.get("data", [])
|
||||
return [
|
||||
{
|
||||
"value": m.get("id", ""),
|
||||
"label": m.get("id", "").split("/")[-1] if "/" in m.get("id", "") else m.get("id", ""),
|
||||
"context_window": 200_000,
|
||||
"provider": m.get("owned_by", "subscription"),
|
||||
}
|
||||
for m in models
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug(f"9Router models fetch failed: {e}")
|
||||
return []
|
||||
@@ -1,12 +1,4 @@
|
||||
"""Auto-start and manage 9Router subprocess.
|
||||
|
||||
9Router is a free AI subscription proxy that lets users connect their
|
||||
Claude/ChatGPT/Gemini subscriptions to OpenSwarm without API keys.
|
||||
|
||||
It runs silently in the background and exposes an OpenAI-compatible API
|
||||
at localhost:<port>/v1. The port is read from ports.config.json (dev
|
||||
vs prod) so the packaged app and the dev server never collide.
|
||||
"""
|
||||
"""Subprocess lifecycle management for the 9Router process."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -16,15 +8,17 @@ import subprocess
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.ports import NINE_ROUTER_PORT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backend.ports import NINE_ROUTER_PORT
|
||||
NINE_ROUTER_URL = f"http://localhost:{NINE_ROUTER_PORT}"
|
||||
NINE_ROUTER_API = f"{NINE_ROUTER_URL}/api"
|
||||
NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
|
||||
|
||||
_process: subprocess.Popen | None = None
|
||||
|
||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def is_running() -> bool:
|
||||
"""Check if 9Router is running."""
|
||||
@@ -41,16 +35,14 @@ def _find_9router_dir() -> str | None:
|
||||
|
||||
if _is_packaged:
|
||||
# Packaged Electron app — 9router is in extraResources
|
||||
import sys
|
||||
# In packaged mode, backend is at <resources>/backend/
|
||||
# So 9router is at <resources>/9router/
|
||||
_resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# _THIS_DIR is <resources>/backend/apps/nine_router/
|
||||
_resources = os.path.dirname(os.path.dirname(os.path.dirname(_THIS_DIR)))
|
||||
_candidate = os.path.join(_resources, "9router")
|
||||
if os.path.isdir(_candidate):
|
||||
return _candidate
|
||||
else:
|
||||
# Dev mode — 9router is at project root
|
||||
_backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_backend_dir = os.path.dirname(os.path.dirname(_THIS_DIR))
|
||||
_project_root = os.path.dirname(_backend_dir)
|
||||
_candidate = os.path.join(_project_root, "9router")
|
||||
if os.path.isdir(_candidate):
|
||||
@@ -61,7 +53,6 @@ def _find_9router_dir() -> str | None:
|
||||
|
||||
def _find_node() -> str | None:
|
||||
"""Find a Node.js binary (works in both dev and packaged mode)."""
|
||||
# Check system node first
|
||||
node = shutil.which("node")
|
||||
if node:
|
||||
return node
|
||||
@@ -196,130 +187,3 @@ def stop():
|
||||
pass
|
||||
_process = None
|
||||
logger.info("9Router stopped")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API proxy helpers — call 9Router's API from OpenSwarm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_usage_stats(period: str = "all") -> dict | None:
|
||||
"""Get usage statistics from 9Router."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(f"{NINE_ROUTER_API}/usage/stats", params={"period": period})
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.debug(f"9Router usage stats fetch failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_providers() -> list[dict]:
|
||||
"""Get all providers and their connection status from 9Router."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(f"{NINE_ROUTER_API}/providers")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.debug(f"9Router providers fetch failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def start_oauth(provider: str) -> dict:
|
||||
"""Start OAuth flow for a provider.
|
||||
|
||||
For device_code providers (github, qwen, kiro): returns {user_code, verification_uri, device_code}
|
||||
For authorization_code providers (claude, codex, gemini-cli): returns {authUrl, codeVerifier, state}
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
# Try device-code flow first
|
||||
try:
|
||||
r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
return {
|
||||
"flow": "device_code",
|
||||
"user_code": data.get("user_code", ""),
|
||||
"verification_uri": data.get("verification_uri", data.get("verification_uri_complete", "")),
|
||||
"device_code": data.get("device_code", ""),
|
||||
"code_verifier": data.get("codeVerifier", ""),
|
||||
"extra_data": {k: v for k, v in data.items() if k.startswith("_")},
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Authorization code flow — redirect to 9Router's own callback page
|
||||
# (Anthropic only accepts redirect URIs registered with 9Router's client ID)
|
||||
callback_url = f"http://localhost:{NINE_ROUTER_PORT}/callback"
|
||||
r = await client.get(
|
||||
f"{NINE_ROUTER_API}/oauth/{provider}/authorize",
|
||||
params={"redirect_uri": callback_url},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
return {
|
||||
"flow": "authorization_code",
|
||||
"auth_url": data.get("authUrl", ""),
|
||||
"code_verifier": data.get("codeVerifier", ""),
|
||||
"state": data.get("state", ""),
|
||||
"redirect_uri": callback_url,
|
||||
}
|
||||
|
||||
|
||||
async def poll_oauth(provider: str, device_code: str, code_verifier: str | None = None, extra_data: dict | None = None) -> dict:
|
||||
"""Poll for OAuth completion.
|
||||
|
||||
Returns: {success: true, connection: {...}} or {success: false, pending: true}
|
||||
"""
|
||||
body: dict = {"deviceCode": device_code}
|
||||
if code_verifier:
|
||||
body["codeVerifier"] = code_verifier
|
||||
if extra_data:
|
||||
body["extraData"] = extra_data
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.post(
|
||||
f"{NINE_ROUTER_API}/oauth/{provider}/poll",
|
||||
json=body,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def exchange_oauth(provider: str, code: str, redirect_uri: str, code_verifier: str, state: str = "") -> dict:
|
||||
"""Exchange OAuth code for tokens via 9Router."""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.post(
|
||||
f"{NINE_ROUTER_API}/oauth/{provider}/exchange",
|
||||
json={
|
||||
"code": code,
|
||||
"redirectUri": redirect_uri,
|
||||
"codeVerifier": code_verifier,
|
||||
"state": state,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def get_models() -> list[dict]:
|
||||
"""Get all available models from 9Router."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(f"{NINE_ROUTER_V1}/models")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
models = data.get("data", [])
|
||||
return [
|
||||
{
|
||||
"value": m.get("id", ""),
|
||||
"label": m.get("id", "").split("/")[-1] if "/" in m.get("id", "") else m.get("id", ""),
|
||||
"context_window": 200_000,
|
||||
"provider": m.get("owned_by", "subscription"),
|
||||
}
|
||||
for m in models
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug(f"9Router models fetch failed: {e}")
|
||||
return []
|
||||
+2
-1
@@ -19,6 +19,7 @@ from backend.apps.mcp_registry.mcp_registry import mcp_registry
|
||||
from backend.apps.skill_registry.skill_registry import skill_registry
|
||||
from backend.apps.outputs.outputs import outputs
|
||||
from backend.apps.dashboards.dashboards import dashboards
|
||||
from backend.apps.nine_router import nine_router
|
||||
from backend.apps.analytics.analytics import analytics
|
||||
from backend.apps.subscriptions.subscriptions import subscriptions
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -27,7 +28,7 @@ import json
|
||||
|
||||
main_app = MainApp([
|
||||
health, agents, templates, skills, tools_lib, modes, settings,
|
||||
mcp_registry, skill_registry, outputs, dashboards, analytics,
|
||||
mcp_registry, skill_registry, outputs, dashboards, nine_router, analytics,
|
||||
subscriptions,
|
||||
])
|
||||
app = main_app.app
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
],
|
||||
"exceptions": {
|
||||
"max-file-lines": [],
|
||||
"max-folder-items": []
|
||||
"max-folder-items": [
|
||||
"frontend"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,20 @@ fi
|
||||
BACKEND_PORT=$(python3 -c "import json; print(json.load(open('$PROJECT_ROOT/ports.config.json'))['backend']['dev'])")
|
||||
FRONTEND_PORT=$(python3 -c "import json; print(json.load(open('$PROJECT_ROOT/ports.config.json'))['frontend']['dev'])")
|
||||
|
||||
# --- Run structural linter (warnings only, non-blocking) ---
|
||||
LINT_OUTPUT=$(python3 "$PROJECT_ROOT/linter/structlint.py" --root "$PROJECT_ROOT" 2>&1)
|
||||
LINT_EXIT=$?
|
||||
if [ $LINT_EXIT -ne 0 ]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}${BOLD}[structlint] Violations found:${RESET}"
|
||||
echo "$LINT_OUTPUT" | grep -v "^structlint:" | while IFS= read -r line; do
|
||||
echo -e "${YELLOW} $line${RESET}"
|
||||
done
|
||||
LINT_COUNT=$(echo "$LINT_OUTPUT" | grep -oE '[0-9]+ error' | head -1 | grep -oE '[0-9]+')
|
||||
echo -e "${YELLOW}${BOLD} ${LINT_COUNT} violation(s) — fix or add exceptions in linter/structlint.json${RESET}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# --- Start backend ---
|
||||
echo -e "${BLUE}${BOLD}[backend]${RESET} Starting backend server..."
|
||||
bash "$PROJECT_ROOT/backend/run.sh" > >(
|
||||
|
||||
@@ -12,6 +12,14 @@ RUN_DIR_ROOT="$(dirname "$PUBLISH_ABSPATH")"
|
||||
PROJECT_ROOT="$(dirname "$RUN_DIR_ROOT")"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "Running structural linter..."
|
||||
if ! python3 "$PROJECT_ROOT/linter/structlint.py" --root "$PROJECT_ROOT"; then
|
||||
echo ""
|
||||
echo "Publish blocked: structlint found violations. Fix them or add exceptions in linter/structlint.json."
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "Building and deploying to Firebase Hosting..."
|
||||
bash run/utils/build-app.sh --publish
|
||||
|
||||
|
||||
Reference in New Issue
Block a user