diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..b55cfa3c --- /dev/null +++ b/.github/workflows/lint.yml @@ -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 diff --git a/backend/apps/analytics/analytics.py b/backend/apps/analytics/analytics.py index 07e2604c..75e75c53 100644 --- a/backend/apps/analytics/analytics.py +++ b/backend/apps/analytics/analytics.py @@ -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") diff --git a/backend/apps/nine_router/__init__.py b/backend/apps/nine_router/__init__.py new file mode 100644 index 00000000..793e973a --- /dev/null +++ b/backend/apps/nine_router/__init__.py @@ -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) diff --git a/backend/apps/nine_router/client.py b/backend/apps/nine_router/client.py new file mode 100644 index 00000000..aa6db1f4 --- /dev/null +++ b/backend/apps/nine_router/client.py @@ -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 [] diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router/process.py similarity index 51% rename from backend/apps/nine_router.py rename to backend/apps/nine_router/process.py index 8bba0ce4..788859da 100644 --- a/backend/apps/nine_router.py +++ b/backend/apps/nine_router/process.py @@ -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:/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 /backend/ - # So 9router is at /9router/ - _resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + # _THIS_DIR is /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 [] diff --git a/backend/main.py b/backend/main.py index cfca4815..9433f92f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/linter/structlint.json b/linter/structlint.json index 12f8bfb1..ec656496 100644 --- a/linter/structlint.json +++ b/linter/structlint.json @@ -21,6 +21,8 @@ ], "exceptions": { "max-file-lines": [], - "max-folder-items": [] + "max-folder-items": [ + "frontend" + ] } } diff --git a/run/local.sh b/run/local.sh index 2fa20632..51f04f97 100755 --- a/run/local.sh +++ b/run/local.sh @@ -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" > >( diff --git a/run/publish.sh b/run/publish.sh index f9e0584a..0fb562ef 100755 --- a/run/publish.sh +++ b/run/publish.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