[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:
haikdc
2026-03-30 09:12:43 -07:00
parent 6deebbd052
commit 6ea5235a01
9 changed files with 254 additions and 158 deletions
+32
View File
@@ -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
-12
View File
@@ -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")
+41
View File
@@ -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)
+146
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -21,6 +21,8 @@
],
"exceptions": {
"max-file-lines": [],
"max-folder-items": []
"max-folder-items": [
"frontend"
]
}
}
+14
View File
@@ -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" > >(
+8
View File
@@ -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