From 4f016a0f9e78c8542f2cd604455952967e7a0796 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 23 May 2026 02:51:28 -0700 Subject: [PATCH] [eric] split: nine_router into process/sync/oauth package --- backend/apps/nine_router.py | 1190 ----------------------- backend/apps/nine_router/__init__.py | 85 ++ backend/apps/nine_router/oauth.py | 356 +++++++ backend/apps/nine_router/process.py | 423 ++++++++ backend/apps/nine_router/sync.py | 139 +++ backend/apps/nine_router/sync_custom.py | 325 +++++++ 6 files changed, 1328 insertions(+), 1190 deletions(-) delete mode 100644 backend/apps/nine_router.py create mode 100644 backend/apps/nine_router/__init__.py create mode 100644 backend/apps/nine_router/oauth.py create mode 100644 backend/apps/nine_router/process.py create mode 100644 backend/apps/nine_router/sync.py create mode 100644 backend/apps/nine_router/sync_custom.py diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py deleted file mode 100644 index f1a111ab..00000000 --- a/backend/apps/nine_router.py +++ /dev/null @@ -1,1190 +0,0 @@ -"""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 on port 20128 and exposes an -OpenAI-compatible API at localhost:20128/v1. -""" - -import asyncio -import logging -import os -import shutil -import subprocess -import time -from typing import Any - -import httpx - -logger = logging.getLogger(__name__) - -NINE_ROUTER_PORT = 20128 -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" - -# Pinned 9router npm package version. Using 0.3.60 to match exactly what -# openswarm-ai v1.0.25 (last known-good production release) vendored via -# `9router/package.json`. Versions between 0.3.60 and 0.3.96 regressed -# cross-provider WebSearch: the CLI's WebSearch call from Codex/Gemini -# primaries used to route cleanly through 9Router's translator and hit -# Anthropic's server-side web_search (returning real results), but later -# translator changes broke that path; non-Claude primaries now see -# "claude-haiku-4-5-20251001 unavailable" or hallucinated output. -# Pinning to 0.3.60 restores v1.0.25 behavior. -# -# Note: 0.3.60-0.4.20 ALL emit `max_tokens` (not max_completion_tokens) -# when translating Anthropic→OpenAI, which OpenAI's GPT-5 family rejects. -# The fix lives in our /api/openai-passthrough proxy; see openai_passthrough.py -# and sync_openai_api_key for how the translation lane is rerouted via an -# `openai-compatible` provider-node that honors `baseUrl`. -NINE_ROUTER_NPM_VERSION = "0.3.60" - -_process: subprocess.Popen | None = None - -# Short TTL cache for positive is_running() results. The probe is a sync -# httpx.get that blocks the event loop, and under load (9Router busy -# streaming inference) it can exceed its 2s timeout and return False even -# though 9Router is fine. Caching a recent True result avoids those false -# negatives without masking a real crash for more than _IS_RUNNING_TTL seconds. -# Negative results are NOT cached so startup detection in ensure_running() -# remains correct. -_IS_RUNNING_TTL = 10.0 -_is_running_last_ok: float = 0.0 - - -def is_running() -> bool: - """Check if 9Router is running.""" - global _is_running_last_ok - now = time.monotonic() - if now - _is_running_last_ok < _IS_RUNNING_TTL: - return True - try: - r = httpx.get(f"{NINE_ROUTER_V1}/models", timeout=2.0) - if r.status_code == 200: - _is_running_last_ok = now - return True - return False - except Exception: - return False - - -def _find_9router_dir() -> str | None: - """Locate the bundled 9Router directory (works in both dev and packaged mode).""" - _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" - - if _is_packaged: - import sys - _resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - _candidate = os.path.join(_resources, "router") - if os.path.isdir(_candidate): - return _candidate - else: - _backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - _project_root = os.path.dirname(_backend_dir) - _candidate = os.path.join(_project_root, "router") - if os.path.isdir(_candidate): - return _candidate - - return None - - -def _gpt5_patch_path() -> str | None: - """Absolute path to backend/apps/agents/9router_gpt5_patch.js, used as - `node --require ` when spawning 9router. - - The patch intercepts outbound HTTPS to api.openai.com and renames - `max_tokens` → `max_completion_tokens` for GPT-5 models. Without it, - every gpt-5* own-key session 400's because OpenAI rejects the legacy - field name and 9router (every version including 0.4.20) emits it. - - Returns None if the file is missing; `subprocess.Popen` would fail - on `node --require `, so the caller drops the flag and - spawns 9router unpatched (failure mode = identical to pre-patch - baseline; GPT-5 still 400's but everything else works). - - Path resolution: walks up from this module to backend/apps/agents/. - Works identically in dev (`bash run.sh`) and packaged builds (Mac dmg - + Windows exe both ship this file under Resources/backend/...). - """ - here = os.path.dirname(os.path.abspath(__file__)) - candidate = os.path.join(here, "agents", "9router_gpt5_patch.js") - return candidate if os.path.exists(candidate) else None - - -def _find_node() -> str | None: - """Find a Node.js binary (works in both dev and packaged mode). - - Priority order: - 1. OPENSWARM_NODE_PATH; set by electron/main.js when a real Node - binary is bundled in extraResources. Always preferred on user - machines because it (a) avoids the bouncing "exec" Dock icon - that ELECTRON_RUN_AS_NODE produces on fresh Macs and (b) starts - in ~50ms vs Electron-as-Node's 5, 15s cold-start, shrinking the - splash window the user stares at. - 2. System `node` on PATH; dev convenience. - 3. ELECTRON_RUN_AS_NODE fallback; last resort. Only hits this on - packaged builds that for some reason shipped without the bundled - node payload. - """ - bundled = os.environ.get("OPENSWARM_NODE_PATH") - if bundled and os.path.exists(bundled): - return bundled - - node = shutil.which("node") - if node: - return node - - electron_path = os.environ.get("OPENSWARM_ELECTRON_PATH") - if electron_path and os.path.exists(electron_path): - return electron_path - - return None - - -def _dev_router_cache_dir() -> str: - """Cache dir for the npm 9router package used in dev mode. - - Pinned per version so bumping NINE_ROUTER_NPM_VERSION triggers a fresh - install instead of reusing a stale cache. - """ - base = os.environ.get("XDG_CACHE_HOME") or os.path.join( - os.path.expanduser("~"), ".cache" - ) - return os.path.join(base, "openswarm-router", NINE_ROUTER_NPM_VERSION) - - -def _ensure_router_cached() -> str | None: - """Ensure the npm 9router package is installed in the dev cache. - - Returns the absolute path to `app/server.js` on success, or None if - npm isn't available or the install fails. Idempotent; returns - immediately when the server file already exists. - - Running `node app/server.js` directly (instead of `npx 9router`) - skips the CLI wrapper, which means no systray menu-bar icon, - no update-check spinner, and no accidental-quit foot-gun when a - non-developer right-clicks the "9" tray icon and picks Quit. - """ - cache_dir = _dev_router_cache_dir() - server_js = os.path.join(cache_dir, "node_modules", "9router", "app", "server.js") - if os.path.exists(server_js): - return server_js - - npm = shutil.which("npm") - if not npm: - logger.warning("npm not found; install Node.js to auto-start 9Router in dev.") - return None - - try: - os.makedirs(cache_dir, exist_ok=True) - pkg_json = os.path.join(cache_dir, "package.json") - if not os.path.exists(pkg_json): - with open(pkg_json, "w") as f: - f.write('{"name":"_openswarm_router_cache","version":"0.0.0","private":true}\n') - - logger.info( - "Installing 9router@%s into %s (one-time, ~30s)...", - NINE_ROUTER_NPM_VERSION, cache_dir, - ) - # Note: we do NOT pass --ignore-scripts. The package's postinstall - # rebuilds better-sqlite3 for the host platform; skipping it leaves - # the server unable to load its native addon. - subprocess.run( - [npm, "install", f"9router@{NINE_ROUTER_NPM_VERSION}", - "--no-save", "--no-audit", "--no-fund", "--silent"], - cwd=cache_dir, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=300, - check=False, - ) - except Exception as e: - logger.warning("Failed to install 9router into %s: %s", cache_dir, e) - return None - - return server_js if os.path.exists(server_js) else None - - -async def ensure_running(): - """Start 9Router if not already running.""" - global _process - _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" - - if is_running(): - # In dev mode, kill stale standalone servers (from previous builds) - # so we can start `next dev` which always uses latest source code - if not _is_packaged: - import subprocess as _sp - try: - result = _sp.run( - ["pgrep", "-f", "next-server"], - capture_output=True, text=True, timeout=3, - ) - if result.stdout.strip(): - logger.info("Dev mode: killing stale standalone 9Router to use next dev instead") - _sp.run(["pkill", "-f", "next-server"], timeout=5) - await asyncio.sleep(2) - else: - logger.info("9Router already running on port %d", NINE_ROUTER_PORT) - return - except Exception: - logger.info("9Router already running on port %d", NINE_ROUTER_PORT) - return - else: - logger.info("9Router already running on port %d", NINE_ROUTER_PORT) - return - _9router_dir = _find_9router_dir() - - if _is_packaged and _9router_dir: - # Packaged mode; run the pre-built standalone server staged at - # /router/server.js by scripts/fetch-router.sh at build time. - standalone_server = os.path.join(_9router_dir, "server.js") - if not os.path.exists(standalone_server): - standalone_server = os.path.join(_9router_dir, ".next", "standalone", "server.js") - if not os.path.exists(standalone_server): - logger.warning("9Router standalone build not found in %s", _9router_dir) - return - - node = _find_node() - if not node: - logger.warning("Node.js not found; cannot start 9Router in packaged mode.") - return - - logger.info("Starting 9Router (production) on port %d...", NINE_ROUTER_PORT) - cmd = [node] - _patch = _gpt5_patch_path() - if _patch: - cmd += ["--require", _patch] - cmd.append(standalone_server) - cwd = os.path.dirname(standalone_server) - env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"} - if node == os.environ.get("OPENSWARM_ELECTRON_PATH"): - env["ELECTRON_RUN_AS_NODE"] = "1" - - else: - # Dev mode; install the pinned 9router npm package into a local - # cache the first time run.sh boots, then spawn `node app/server.js` - # directly on subsequent launches. Bypassing the package's cli.js - # avoids its menu-bar tray icon (which users confusingly quit, - # silently killing their subscription routing), its update-check - # spinner, and the interactive TUI. - cached_server = _ensure_router_cached() - if not cached_server: - return - - node = _find_node() - if not node: - logger.warning("Node.js not found; cannot start 9Router in dev mode.") - return - - logger.info( - "Starting 9Router (dev cache, 9router@%s) on port %d...", - NINE_ROUTER_NPM_VERSION, NINE_ROUTER_PORT, - ) - cmd = [node] - _patch = _gpt5_patch_path() - if _patch: - cmd += ["--require", _patch] - cmd.append(cached_server) - cwd = os.path.dirname(cached_server) - env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"} - - # By default, 9Router's stdout/stderr go to /dev/null (Next.js dev mode - # is extremely chatty and floods the openswarm console otherwise). When - # debugging is needed, set OPENSWARM_DEBUG_9ROUTER=1 in the environment - # before launching the backend; output will then be appended to - # backend/data/9router.log line-buffered, which can be `tail -f`'d. - if os.environ.get("OPENSWARM_DEBUG_9ROUTER"): - _log_path = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "data", - "9router.log", - ) - os.makedirs(os.path.dirname(_log_path), exist_ok=True) - _stdout = open(_log_path, "a", buffering=1) # line-buffered - _stderr = subprocess.STDOUT - logger.info(f"9Router debug logging enabled → {_log_path}") - else: - _stdout = subprocess.DEVNULL - _stderr = subprocess.DEVNULL - - try: - _process = subprocess.Popen( - cmd, - cwd=cwd, - stdout=_stdout, - stderr=_stderr, - env=env, - ) - - timeout = 20 if _is_packaged else 30 - for _ in range(timeout * 2): - await asyncio.sleep(0.5) - if is_running(): - logger.info("9Router started successfully") - return - - logger.warning("9Router did not start within %ds", timeout) - except Exception as e: - logger.warning(f"Failed to start 9Router: {e}") - - -def stop(): - """Stop the 9Router subprocess.""" - global _process - if _process: - try: - _process.terminate() - _process.wait(timeout=5) - except Exception: - try: - _process.kill() - except Exception: - pass - _process = None - logger.info("9Router stopped") - - -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_latest_reasoning_tokens(model_hint: str | None = None) -> int | None: - """Fetch reasoning_tokens from 9Router for the most recently completed - request, optionally filtered by model. Returns None if 9Router isn't - running, the request didn't expose reasoning tokens, or the lookup - fails for any reason. - - 9Router's request-details endpoint returns the most recent N requests - in reverse chronological order with full token breakdowns including - `reasoning_tokens` (OpenAI's `completion_tokens_details.reasoning_tokens`) - and `thoughtsTokenCount` (Gemini's). For Anthropic via 9Router this - field will be absent/zero; Anthropic doesn't break out reasoning - tokens in its API response; so callers get None and should fall - back to the heuristic. - """ - if not is_running(): - return None - try: - async with httpx.AsyncClient(timeout=2.0) as client: - params: dict[str, Any] = {"page": 1, "pageSize": 5} - if model_hint: - params["model"] = model_hint - r = await client.get(f"{NINE_ROUTER_API}/usage/request-details", params=params) - if r.status_code != 200: - return None - data = r.json() - requests = data.get("requests") or data.get("data") or [] - for req in requests: - tokens = req.get("tokens") or req.get("usage") or {} - rt = ( - tokens.get("reasoning_tokens") - or tokens.get("thoughtsTokenCount") - or tokens.get("thoughts_token_count") - or 0 - ) - if rt and int(rt) > 0: - return int(rt) - except Exception as e: - logger.debug(f"9Router reasoning-token lookup failed: {e}") - return None - - -async def get_providers() -> list[dict]: - """Get all providers and their connection status from 9Router. - - 9Router's GET /api/providers returns `{"connections": [...]}`; we - unwrap so callers always see a plain list of connection dicts. - """ - try: - async with httpx.AsyncClient(timeout=5.0) as client: - r = await client.get(f"{NINE_ROUTER_API}/providers") - if r.status_code == 200: - data = r.json() - if isinstance(data, dict): - return data.get("connections") or [] - if isinstance(data, list): - return data - except Exception as e: - logger.debug(f"9Router providers fetch failed: {e}") - return [] - - -# API-key auth (provider="gemini", authType="apikey") and OAuth hit different -# Google quotas: OAuth uses the Code Assist free tier (aggressively rate-limited; -# 429s on Gemini 3 Pro/Flash even for paid users), while an AI Studio API key -# uses generativelanguage.googleapis.com (independent and far higher). We mirror -# google_api_key into 9Router so the API-key path is preferred when a key is set. - -NINE_ROUTER_KEYED_NAME = "AI Studio (OpenSwarm-managed)" -NINE_ROUTER_OPENAI_KEYED_NAME = "OpenAI (OpenSwarm-managed)" -NINE_ROUTER_OPENROUTER_KEYED_NAME = "OpenRouter (OpenSwarm-managed)" -NINE_ROUTER_CLAUDE_PRO_NAME = "OpenSwarm Pro (OpenSwarm-managed)" - - -async def _find_keyed_connection(provider: str, name: str) -> dict | None: - """Return the 9Router connection we manage for this provider, if any.""" - conns = await get_providers() - if not isinstance(conns, list): - return None - for c in conns: - if ( - isinstance(c, dict) - and c.get("provider") == provider - and c.get("authType") == "apikey" - and c.get("name") == name - ): - return c - return None - - -async def _sync_apikey_provider( - provider: str, - api_key: str | None, - name: str, - *, - label: str, -) -> None: - """Create/update/delete an OpenSwarm-managed apikey connection. Silent if 9Router is down.""" - if not is_running(): - return - - existing = await _find_keyed_connection(provider, name) - try: - async with httpx.AsyncClient(timeout=5.0) as client: - if api_key: - payload = { - "provider": provider, - "authType": "apikey", - "name": name, - "apiKey": api_key, - # Priority 0 = highest. OAuth connections default to 1, - # so keyed connections are preferred when both exist. - "priority": 0, - } - if existing: - await client.patch( - f"{NINE_ROUTER_API}/providers/{existing['id']}", - json=payload, - ) - logger.info(f"9Router: updated {label} API-key connection") - else: - r = await client.post(f"{NINE_ROUTER_API}/providers", json=payload) - if r.status_code < 300: - logger.info(f"9Router: created {label} API-key connection") - else: - logger.warning( - f"9Router: failed to create {label} API-key connection: " - f"{r.status_code} {r.text[:200]}" - ) - else: - if existing: - await client.delete(f"{NINE_ROUTER_API}/providers/{existing['id']}") - logger.info(f"9Router: removed {label} API-key connection") - except Exception as e: - logger.warning(f"9Router {label} API-key sync failed: {e}") - - -async def sync_gemini_api_key(api_key: str | None) -> None: - """Mirror google_api_key into 9Router; bypasses Code Assist's tight quota.""" - await _sync_apikey_provider( - "gemini", api_key, NINE_ROUTER_KEYED_NAME, label="Gemini" - ) - - -async def sync_openai_api_key(api_key: str | None) -> None: - """Mirror openai_api_key into 9Router as an `openai-compatible` provider - node pointed at our local /api/openai-passthrough proxy. - - Why not the built-in `openai` provider type: 9Router 0.3.60 hardcodes - `https://api.openai.com/v1` for the `openai` provider and ignores any - `baseUrl` field on the connection. Only the `openai-compatible-*` - provider-node type honors `baseUrl` (verified statically against - 9Router's compiled bundle). So we register our OpenAI lane AS an - openai-compatible node; same upstream protocol, different routing. - - Why we route through openai-passthrough at all: OpenAI's GPT-5 family - rejects the legacy `max_tokens` parameter with HTTP 400, but every - 9Router version (including 0.4.20) emits `max_tokens` in its - Anthropic→OpenAI translator. The passthrough renames it to - `max_completion_tokens` for `gpt-5*` models before forwarding to - api.openai.com. Pre-fix: every gpt-5.* own-key session 400'd silently. - - Companion change: the registry entries `gpt-5.*-api` are routed via - the `cp-openai/` prefix (set by NINE_ROUTER_OPENAI_KEYED_PREFIX - below) so 9Router's translator dispatches to this provider-node - instead of the built-in `openai` provider. - """ - await _sync_openai_compat_node(api_key) - - -# Reserved prefix that registry.py's gpt-5.*-api router_model_ids depend on. -# Changing this breaks model resolution for OpenAI own-key users. -NINE_ROUTER_OPENAI_KEYED_PREFIX = "cp-openai" - - -async def _sync_openai_compat_node(api_key: str | None) -> None: - """Create / update / delete the openai-compatible node + connection - pair we use to ferry OpenAI requests through openai-passthrough.""" - if not is_running(): - return - import os as _os - port = _os.environ.get("OPENSWARM_PORT", "8324") - base_url = f"http://127.0.0.1:{port}/api/openai-passthrough/v1" - managed_name = f"OpenAI{NINE_ROUTER_CUSTOM_NAME_SUFFIX}" - - try: - async with httpx.AsyncClient(timeout=5.0) as client: - r = await client.get(f"{NINE_ROUTER_API}/provider-nodes") - existing_nodes = (r.json().get("nodes") if r.status_code == 200 else []) or [] - except Exception as e: - logger.warning(f"9Router OpenAI-compat node list failed: {e}") - return - existing_node = next( - (n for n in existing_nodes if isinstance(n, dict) and n.get("prefix") == NINE_ROUTER_OPENAI_KEYED_PREFIX), - None, - ) - - if not api_key: - if existing_node: - try: - async with httpx.AsyncClient(timeout=5.0) as client: - await client.delete(f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}") - logger.info("9Router: removed OpenAI compat node (key cleared)") - except Exception as e: - logger.warning(f"9Router OpenAI compat delete failed: {e}") - return - - node_payload = { - "name": managed_name, - "prefix": NINE_ROUTER_OPENAI_KEYED_PREFIX, - "apiType": "chat", - "baseUrl": base_url, - "type": "openai-compatible", - } - node_id: str | None = existing_node.get("id") if existing_node else None - try: - async with httpx.AsyncClient(timeout=5.0) as client: - if existing_node: - await client.put( - f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}", - json=node_payload, - ) - logger.info(f"9Router: updated OpenAI compat node {NINE_ROUTER_OPENAI_KEYED_PREFIX}") - else: - r = await client.post( - f"{NINE_ROUTER_API}/provider-nodes", json=node_payload, - ) - if r.status_code >= 300: - logger.warning( - f"9Router: failed to create OpenAI compat node: " - f"{r.status_code} {r.text[:200]}" - ) - return - node_id = (r.json() or {}).get("node", {}).get("id") - if not node_id: - return - logger.info(f"9Router: created OpenAI compat node {NINE_ROUTER_OPENAI_KEYED_PREFIX} ({node_id})") - except Exception as e: - logger.warning(f"9Router OpenAI compat node sync failed: {e}") - return - - try: - existing_conn = await _find_keyed_connection(node_id, managed_name) - conn_payload = { - "provider": node_id, - "authType": "apikey", - "name": managed_name, - "apiKey": api_key, - "priority": 0, - } - async with httpx.AsyncClient(timeout=5.0) as client: - if existing_conn: - await client.patch( - f"{NINE_ROUTER_API}/providers/{existing_conn['id']}", - json=conn_payload, - ) - else: - r = await client.post(f"{NINE_ROUTER_API}/providers", json=conn_payload) - if r.status_code >= 300: - logger.warning( - f"9Router: failed to create OpenAI compat connection: " - f"{r.status_code} {r.text[:200]}" - ) - except Exception as e: - logger.warning(f"9Router OpenAI compat connection sync failed: {e}") - - -async def sync_openrouter_api_key(api_key: str | None) -> None: - """Mirror openrouter_api_key into 9Router; supplies bearer for openrouter/ routes.""" - await _sync_apikey_provider( - "openrouter", api_key, NINE_ROUTER_OPENROUTER_KEYED_NAME, label="OpenRouter" - ) - - -# 9Router exposes arbitrary OpenAI-compatible endpoints via "provider nodes" -# (POST /api/provider-nodes, type="openai-compatible"). model_id / -# routes to that node's baseUrl. We mirror settings.custom_providers[] with -# prefix `cp-` so they don't collide with the user's primary OpenAI key. - -NINE_ROUTER_CUSTOM_NAME_SUFFIX = " (OpenSwarm-managed)" - - -def _custom_provider_slug(name: str) -> str: - """Slugify a user-supplied custom-provider name for use as a 9Router prefix. - Always returns a non-empty alnum-and-dash string.""" - import re - s = re.sub(r"[^a-zA-Z0-9-]+", "-", (name or "").strip().lower()).strip("-") - return s or "custom" - - -def normalize_openai_compat_base_url(url: str) -> str: - """Append `/v1` when the user supplied a host without an API path. - - 9Router forwards openai-compatible nodes to `/chat/completions` - verbatim. Ollama, LM Studio, llama.cpp, vLLM, and every other OpenAI- - compatible server exposes the API under `/v1`, so a user pasting - `http://host:11434` (which is what Ollama prints on launch) ends up - routed to `/chat/completions` and 404s. Path-bearing URLs are left - alone, so `https://api.together.xyz/v1`, `https://openrouter.ai/api/v1`, - or anything custom is untouched. - """ - from urllib.parse import urlparse - s = (url or "").strip().rstrip("/") - if not s: - return s - try: - path = urlparse(s).path - except Exception: - return s - if not path: - return s + "/v1" - return s - - -async def sync_custom_providers(providers: list) -> None: - """Mirror settings.custom_providers into 9Router as openai-compatible nodes. - - Idempotent: existing managed nodes (identified by name suffix) are PUT-updated - in place, missing ones are POST-created, and any managed node whose prefix is - no longer in `providers` is deleted (which cascades to its connection). - Silent no-op when 9Router isn't running. - """ - if not is_running(): - return - - try: - async with httpx.AsyncClient(timeout=5.0) as client: - r = await client.get(f"{NINE_ROUTER_API}/provider-nodes") - existing_nodes = (r.json().get("nodes") if r.status_code == 200 else []) or [] - except Exception as e: - logger.warning(f"9Router custom-provider node list failed: {e}") - return - - managed = [ - n for n in existing_nodes - if isinstance(n, dict) - and isinstance(n.get("name"), str) - and n["name"].endswith(NINE_ROUTER_CUSTOM_NAME_SUFFIX) - ] - managed_by_prefix = {n.get("prefix"): n for n in managed if n.get("prefix")} - - seen_prefixes: set[str] = set() - for cp in providers or []: - name = getattr(cp, "name", None) or (cp.get("name") if isinstance(cp, dict) else None) or "" - base_url = getattr(cp, "base_url", None) or (cp.get("base_url") if isinstance(cp, dict) else None) or "" - api_key = getattr(cp, "api_key", None) or (cp.get("api_key") if isinstance(cp, dict) else None) or "" - if not name.strip() or not base_url.strip(): - continue - # Local OpenAI-compat servers (LM Studio, Ollama, etc.) reject a blank - # Bearer header even with auth disabled. Substitute a placeholder; real - # auth deployments always have api_key set. - api_key = api_key.strip() or "no-auth-required" - slug = _custom_provider_slug(name) - prefix = f"cp-{slug}" - seen_prefixes.add(prefix) - managed_name = f"{name.strip()}{NINE_ROUTER_CUSTOM_NAME_SUFFIX}" - - node = managed_by_prefix.get(prefix) - node_payload = { - "name": managed_name, - "prefix": prefix, - "apiType": "chat", - "baseUrl": normalize_openai_compat_base_url(base_url), - "type": "openai-compatible", - } - try: - async with httpx.AsyncClient(timeout=5.0) as client: - if node: - await client.put( - f"{NINE_ROUTER_API}/provider-nodes/{node['id']}", - json=node_payload, - ) - node_id = node["id"] - logger.info(f"9Router: updated custom node {prefix}") - else: - r = await client.post( - f"{NINE_ROUTER_API}/provider-nodes", json=node_payload, - ) - if r.status_code >= 300: - logger.warning( - f"9Router: failed to create custom node {prefix}: " - f"{r.status_code} {r.text[:200]}" - ) - continue - node_id = (r.json() or {}).get("node", {}).get("id") - if not node_id: - continue - logger.info(f"9Router: created custom node {prefix} ({node_id})") - except Exception as e: - logger.warning(f"9Router custom node {prefix} sync failed: {e}") - continue - - try: - existing_conn = await _find_keyed_connection(node_id, managed_name) - conn_payload = { - "provider": node_id, - "authType": "apikey", - "name": managed_name, - "apiKey": api_key, - "priority": 0, - } - async with httpx.AsyncClient(timeout=5.0) as client: - if existing_conn: - await client.patch( - f"{NINE_ROUTER_API}/providers/{existing_conn['id']}", - json=conn_payload, - ) - else: - r = await client.post( - f"{NINE_ROUTER_API}/providers", json=conn_payload, - ) - if r.status_code >= 300: - logger.warning( - f"9Router: failed to create custom connection {prefix}: " - f"{r.status_code} {r.text[:200]}" - ) - except Exception as e: - logger.warning(f"9Router custom connection {prefix} sync failed: {e}") - - # Drop managed nodes no longer in settings; DELETE cascades to connections. - for prefix, node in managed_by_prefix.items(): - if prefix in seen_prefixes: - continue - try: - async with httpx.AsyncClient(timeout=5.0) as client: - await client.delete(f"{NINE_ROUTER_API}/provider-nodes/{node['id']}") - logger.info(f"9Router: removed orphaned custom node {prefix}") - except Exception as e: - logger.warning(f"9Router custom node {prefix} delete failed: {e}") - - -async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str | None) -> None: - """Register OpenSwarm Pro as a `claude` apikey connection in 9Router, - pointing at our cloud proxy via `providerSpecificData.baseUrl`. - - This is what makes the CLI's built-in WebSearch work on non-Claude - primaries for Pro users: the CLI delegates the search execution to - Anthropic via ANTHROPIC_SMALL_FAST_MODEL (claude-haiku). That small- - model call hits `ANTHROPIC_BASE_URL` which we've already set to - localhost:20128 (9Router). Without this sync, 9Router has no Claude - path for openswarm-pro users, so the search fails with - "no credentials for provider: claude". With this sync, 9Router sees - the OpenSwarm-Pro-backed Claude connection and routes the search - call through our cloud; same quota the user's Pro subscription - already covers, no extra cost.""" - if not is_running(): - return - - # 9Router's POST /api/providers only accepts direct-API provider ids - # for apikey auth; `claude` is the subscription/IDE id, `anthropic` - # is the direct-API id. Use `anthropic`. - existing = await _find_keyed_connection("anthropic", NINE_ROUTER_CLAUDE_PRO_NAME) - try: - async with httpx.AsyncClient(timeout=5.0) as client: - if bearer_token and proxy_url: - payload = { - "provider": "anthropic", - "authType": "apikey", - "name": NINE_ROUTER_CLAUDE_PRO_NAME, - "apiKey": bearer_token, - # Priority 1 so a real user-owned Claude subscription - # (priority 0) still takes precedence if they have one. - # Pro is the fallback, not the default. - "priority": 1, - "providerSpecificData": { - "baseUrl": proxy_url.rstrip("/") + "/v1", - }, - } - if existing: - await client.patch( - f"{NINE_ROUTER_API}/providers/{existing['id']}", - json=payload, - ) - logger.info("9Router: updated OpenSwarm Pro → Claude connection") - else: - r = await client.post(f"{NINE_ROUTER_API}/providers", json=payload) - if r.status_code < 300: - logger.info("9Router: created OpenSwarm Pro → Claude connection") - else: - logger.warning( - f"9Router: failed to create OpenSwarm Pro → Claude connection: {r.status_code} {r.text[:200]}" - ) - else: - if existing: - await client.delete(f"{NINE_ROUTER_API}/providers/{existing['id']}") - logger.info("9Router: removed OpenSwarm Pro → Claude connection") - except Exception as e: - logger.warning(f"9Router OpenSwarm-Pro Claude sync failed: {e}") - - -# OpenAI's Codex OAuth client is registered with a fixed redirect URI -# `http://localhost:1455/auth/callback` and rejects any other with `unknown_error`. -# Anthropic and Google's clients accept arbitrary localhost callbacks (we use -# 9Router's 20128 callback page). For Codex we spawn a one-shot listener on -# 1455 that serves the same postMessage/BroadcastChannel/localStorage relay so -# the frontend's existing popup + msgHandler flow works unchanged. - -_CODEX_CALLBACK_PORT = 1455 -_CODEX_CALLBACK_PATH = "/auth/callback" -_CODEX_CALLBACK_HTML = b""" -Authorization Complete - -

Authorization Successful

-

This window will close automatically...

- -""" - - -async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base_events.Server | None: - """Spawn a one-shot HTTP listener on 127.0.0.1:1455 for the Codex OAuth callback. - - Serves GET /auth/callback with _CODEX_CALLBACK_HTML. After serving the - callback (or after `timeout` seconds with no callback) the listener - closes itself in a background task. Safe to call even if 1455 is busy , - logs the collision and returns None so start_oauth can still proceed and - surface whatever error OpenAI returns. - - Also performs the OAuth exchange server-side before serving the HTML. - Relying on the frontend's postMessage path alone breaks on Windows where - COOP / popup-opener quirks silently drop the message, leaving the user - stuck on "Connecting…" until the 30s timeout fires. Exchanging here - (the same pattern backend/main.py uses for the Gemini callback) makes - the connection land in 9Router's DB regardless of whether the UI's - postMessage listener ever gets notified; the Settings / OnboardingModal - status pollers then pick it up within a couple seconds. - """ - - callback_served = asyncio.Event() - - async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): - try: - # Read the request line ("GET /auth/callback?... HTTP/1.1\r\n") - raw_request_line = await asyncio.wait_for(reader.readline(), timeout=5.0) - request_line = raw_request_line.decode("latin-1", errors="replace").strip() - while True: - line = await asyncio.wait_for(reader.readline(), timeout=5.0) - if not line or line in (b"\r\n", b"\n"): - break - - # Only respond to the OAuth callback path. Chrome preflights and - # favicon fetches get a 404 so they don't trigger the served-event. - parts = request_line.split(" ") - path = parts[1] if len(parts) >= 2 else "" - method = parts[0] if parts else "" - - if method == "GET" and path.startswith(_CODEX_CALLBACK_PATH): - # Parse code/state out of the query string and exchange - # server-side before serving the HTML. Duplicate exchanges - # are harmless (single-use auth codes fail the second call, - # which we swallow) so racing with the frontend's - # msgHandler-driven exchange is fine. - try: - from urllib.parse import urlparse, parse_qs - parsed = urlparse(path) - q = parse_qs(parsed.query) - code = (q.get("code") or [""])[0] - state = (q.get("state") or [""])[0] - if code and state: - try: - from backend.main import _pending_oauth, _mark_oauth_completed - except Exception: - _pending_oauth = None - _mark_oauth_completed = None - - if _pending_oauth is not None: - pending = _pending_oauth.pop(state, None) - if pending: - try: - await exchange_oauth( - pending["provider"], - code, - pending["redirect_uri"], - pending["code_verifier"], - state, - ) - if _mark_oauth_completed is not None: - _mark_oauth_completed(state) - logger.info( - f"Codex callback: server-side exchange succeeded for state {state[:8]}..." - ) - except Exception as e: - # Put the pending entry back so the - # frontend's msgHandler retry via - # /agents/subscriptions/exchange still - # has a shot. Safe because we only popped - # it a moment ago. - _pending_oauth[state] = pending - logger.debug( - f"Codex callback: server-side exchange failed ({e}); leaving for frontend retry" - ) - except Exception as e: - logger.debug(f"Codex callback listener pre-exchange error: {e}") - - body = _CODEX_CALLBACK_HTML - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/html; charset=utf-8\r\n" - b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n" - b"Cache-Control: no-store\r\n" - b"Connection: close\r\n\r\n" - + body - ) - writer.write(response) - await writer.drain() - callback_served.set() - else: - writer.write( - b"HTTP/1.1 404 Not Found\r\n" - b"Content-Length: 0\r\n" - b"Connection: close\r\n\r\n" - ) - await writer.drain() - except Exception as e: - logger.debug(f"Codex callback listener handler error: {e}") - finally: - try: - writer.close() - await writer.wait_closed() - except Exception: - pass - - try: - server = await asyncio.start_server(_handle, "127.0.0.1", _CODEX_CALLBACK_PORT) - except OSError as e: - # Port already in use; probably another Codex connect attempt still - # running, or an actual Codex CLI process holding 1455. Log and bail. - logger.warning( - f"Could not start Codex callback listener on port {_CODEX_CALLBACK_PORT}: {e}. " - "If another connection attempt is in progress, wait for it to finish or time out." - ) - return None - - async def _lifecycle(): - try: - await asyncio.wait_for(callback_served.wait(), timeout=timeout) - # Give the served HTML a moment to run its JS (postMessage + - # window.close) before we close the socket. Chromium closes - # the tab on window.close() but the JS needs to run first. - await asyncio.sleep(2.0) - except asyncio.TimeoutError: - logger.info(f"Codex callback listener timed out after {timeout}s") - except Exception as e: - logger.debug(f"Codex callback listener lifecycle error: {e}") - finally: - try: - server.close() - await server.wait_closed() - except Exception: - pass - - asyncio.create_task(_lifecycle()) - logger.info(f"Started Codex callback listener on http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}") - return server - - -# Providers whose OAuth flow MUST run in the user's real browser via -# shell.openExternal, not the in-Electron window.open popup: -# - gemini-cli, antigravity: Google's Embedded WebView Restrictions policy uses -# JS-fingerprint detection that no UA spoof defeats. RFC 8252 and Google's -# own Desktop-app OAuth guidance both prescribe the system browser. -# - codex: auth.openai.com renders blank in our popup on some machines (newer -# embed detection + regional checks); system browser surfaces the real error. -# The callback for gemini-cli/antigravity lands on /api/subscriptions/callback -# and runs the exchange server-side; codex uses its fixed 1455 listener. -_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli", "antigravity", "codex"} - - -def _should_use_external_browser(provider: str) -> bool: - return provider in _EXTERNAL_BROWSER_PROVIDERS - - -def _backend_port() -> int: - """Best-effort lookup of the OpenSwarm backend HTTP port. - - Falls back to 8324 (the default in backend/main.py) if OPENSWARM_PORT - hasn't been set yet. backend/main.py:239 sets this env var at startup - before any request handler runs, so `start_oauth` will always see the - correct value. - """ - try: - return int(os.environ.get("OPENSWARM_PORT", "8324")) - except (TypeError, ValueError): - return 8324 - - -def _callback_uri_for_provider(provider: str) -> str: - """Return the redirect URI to pass to 9Router's authorize endpoint. - - Most providers accept 9Router's built-in callback page at port 20128. - Two special cases: - - Codex/OpenAI's OAuth client is bound to a fixed - http://localhost:1455/auth/callback URI; handled by - _start_codex_callback_listener above. - - Gemini/Google's OAuth consent page rejects embedded browsers, so we - route the callback through OpenSwarm's backend endpoint at - /api/subscriptions/callback (backend/main.py:138) which runs the - exchange itself. This is the only provider where the callback lands - on OpenSwarm's port rather than 9Router's. - """ - if provider == "codex": - return f"http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}" - if provider in _EXTERNAL_BROWSER_PROVIDERS: - return f"http://localhost:{_backend_port()}/api/subscriptions/callback" - return f"http://localhost:{NINE_ROUTER_PORT}/callback" - - -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 - - callback_url = _callback_uri_for_provider(provider) - if provider == "codex": - await _start_codex_callback_listener() - - 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, - "use_external_browser": _should_use_external_browser(provider), - } - - -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/__init__.py b/backend/apps/nine_router/__init__.py new file mode 100644 index 00000000..a9801614 --- /dev/null +++ b/backend/apps/nine_router/__init__.py @@ -0,0 +1,85 @@ +"""Auto-start and manage the 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 on port 20128 and exposes an OpenAI-compatible API at +localhost:20128/v1. + +This package was split out of a single ~1190-line module. The public API is +unchanged: every `from backend.apps.nine_router import X` keeps resolving via +the re-exports below. + +- process.py: subprocess lifecycle (the single owner of the process handle), + constants, ports/URLs, the pinned NPM version, path resolution, stats. +- sync.py: Gemini/OpenAI/OpenRouter API-key sync. +- sync_custom.py: custom OpenAI-compatible provider + OpenSwarm Pro sync. +- oauth.py: OAuth start/poll/exchange + the Codex 1455 callback listener. +""" + +import httpx # noqa: F401 patch point: tests stub backend.apps.nine_router.httpx.AsyncClient + +from .process import ( + NINE_ROUTER_API, + NINE_ROUTER_NPM_VERSION, + NINE_ROUTER_PORT, + NINE_ROUTER_URL, + NINE_ROUTER_V1, + ensure_running, + get_latest_reasoning_tokens, + get_providers, + get_usage_stats, + is_running, + stop, +) +from .sync import ( + NINE_ROUTER_CLAUDE_PRO_NAME, + NINE_ROUTER_KEYED_NAME, + NINE_ROUTER_OPENAI_KEYED_NAME, + NINE_ROUTER_OPENAI_KEYED_PREFIX, + NINE_ROUTER_OPENROUTER_KEYED_NAME, + sync_gemini_api_key, + sync_openai_api_key, + sync_openrouter_api_key, +) +from .sync_custom import ( + NINE_ROUTER_CUSTOM_NAME_SUFFIX, + normalize_openai_compat_base_url, + sync_custom_providers, + sync_openswarm_pro_as_claude, +) +from .oauth import ( + exchange_oauth, + get_models, + poll_oauth, + start_oauth, +) + +__all__ = [ + "NINE_ROUTER_API", + "NINE_ROUTER_NPM_VERSION", + "NINE_ROUTER_PORT", + "NINE_ROUTER_URL", + "NINE_ROUTER_V1", + "NINE_ROUTER_CLAUDE_PRO_NAME", + "NINE_ROUTER_KEYED_NAME", + "NINE_ROUTER_OPENAI_KEYED_NAME", + "NINE_ROUTER_OPENAI_KEYED_PREFIX", + "NINE_ROUTER_OPENROUTER_KEYED_NAME", + "NINE_ROUTER_CUSTOM_NAME_SUFFIX", + "ensure_running", + "stop", + "is_running", + "get_usage_stats", + "get_latest_reasoning_tokens", + "get_providers", + "get_models", + "start_oauth", + "poll_oauth", + "exchange_oauth", + "sync_gemini_api_key", + "sync_openai_api_key", + "sync_openrouter_api_key", + "sync_custom_providers", + "sync_openswarm_pro_as_claude", + "normalize_openai_compat_base_url", +] diff --git a/backend/apps/nine_router/oauth.py b/backend/apps/nine_router/oauth.py new file mode 100644 index 00000000..93bcce8b --- /dev/null +++ b/backend/apps/nine_router/oauth.py @@ -0,0 +1,356 @@ +"""9Router OAuth flow: start/poll/exchange + the Codex 1455 callback listener. + +Talks to the already-running 9Router over HTTP; never spawns the subprocess +(that's process.py's job). +""" + +import asyncio +import logging +import os + +import httpx + +from .process import NINE_ROUTER_API, NINE_ROUTER_PORT, NINE_ROUTER_V1 + +logger = logging.getLogger(__name__) + +# OpenAI's Codex OAuth client is registered with a fixed redirect URI +# `http://localhost:1455/auth/callback` and rejects any other with `unknown_error`. +# Anthropic and Google's clients accept arbitrary localhost callbacks (we use +# 9Router's 20128 callback page). For Codex we spawn a one-shot listener on +# 1455 that serves the same postMessage/BroadcastChannel/localStorage relay so +# the frontend's existing popup + msgHandler flow works unchanged. + +_CODEX_CALLBACK_PORT = 1455 +_CODEX_CALLBACK_PATH = "/auth/callback" +_CODEX_CALLBACK_HTML = b""" +Authorization Complete + +

Authorization Successful

+

This window will close automatically...

+ +""" + + +async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base_events.Server | None: + """Spawn a one-shot HTTP listener on 127.0.0.1:1455 for the Codex OAuth callback. + + Serves GET /auth/callback with _CODEX_CALLBACK_HTML. After serving the + callback (or after `timeout` seconds with no callback) the listener + closes itself in a background task. Safe to call even if 1455 is busy , + logs the collision and returns None so start_oauth can still proceed and + surface whatever error OpenAI returns. + + Also performs the OAuth exchange server-side before serving the HTML. + Relying on the frontend's postMessage path alone breaks on Windows where + COOP / popup-opener quirks silently drop the message, leaving the user + stuck on "Connecting…" until the 30s timeout fires. Exchanging here + (the same pattern backend/main.py uses for the Gemini callback) makes + the connection land in 9Router's DB regardless of whether the UI's + postMessage listener ever gets notified; the Settings / OnboardingModal + status pollers then pick it up within a couple seconds. + """ + + callback_served = asyncio.Event() + + async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + try: + # Read the request line ("GET /auth/callback?... HTTP/1.1\r\n") + raw_request_line = await asyncio.wait_for(reader.readline(), timeout=5.0) + request_line = raw_request_line.decode("latin-1", errors="replace").strip() + while True: + line = await asyncio.wait_for(reader.readline(), timeout=5.0) + if not line or line in (b"\r\n", b"\n"): + break + + # Only respond to the OAuth callback path. Chrome preflights and + # favicon fetches get a 404 so they don't trigger the served-event. + parts = request_line.split(" ") + path = parts[1] if len(parts) >= 2 else "" + method = parts[0] if parts else "" + + if method == "GET" and path.startswith(_CODEX_CALLBACK_PATH): + # Parse code/state out of the query string and exchange + # server-side before serving the HTML. Duplicate exchanges + # are harmless (single-use auth codes fail the second call, + # which we swallow) so racing with the frontend's + # msgHandler-driven exchange is fine. + try: + from urllib.parse import urlparse, parse_qs + parsed = urlparse(path) + q = parse_qs(parsed.query) + code = (q.get("code") or [""])[0] + state = (q.get("state") or [""])[0] + if code and state: + try: + from backend.main import _pending_oauth, _mark_oauth_completed + except Exception: + _pending_oauth = None + _mark_oauth_completed = None + + if _pending_oauth is not None: + pending = _pending_oauth.pop(state, None) + if pending: + try: + await exchange_oauth( + pending["provider"], + code, + pending["redirect_uri"], + pending["code_verifier"], + state, + ) + if _mark_oauth_completed is not None: + _mark_oauth_completed(state) + logger.info( + f"Codex callback: server-side exchange succeeded for state {state[:8]}..." + ) + except Exception as e: + # Put the pending entry back so the + # frontend's msgHandler retry via + # /agents/subscriptions/exchange still + # has a shot. Safe because we only popped + # it a moment ago. + _pending_oauth[state] = pending + logger.debug( + f"Codex callback: server-side exchange failed ({e}); leaving for frontend retry" + ) + except Exception as e: + logger.debug(f"Codex callback listener pre-exchange error: {e}") + + body = _CODEX_CALLBACK_HTML + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/html; charset=utf-8\r\n" + b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n" + b"Cache-Control: no-store\r\n" + b"Connection: close\r\n\r\n" + + body + ) + writer.write(response) + await writer.drain() + callback_served.set() + else: + writer.write( + b"HTTP/1.1 404 Not Found\r\n" + b"Content-Length: 0\r\n" + b"Connection: close\r\n\r\n" + ) + await writer.drain() + except Exception as e: + logger.debug(f"Codex callback listener handler error: {e}") + finally: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + + try: + server = await asyncio.start_server(_handle, "127.0.0.1", _CODEX_CALLBACK_PORT) + except OSError as e: + # Port already in use; probably another Codex connect attempt still + # running, or an actual Codex CLI process holding 1455. Log and bail. + logger.warning( + f"Could not start Codex callback listener on port {_CODEX_CALLBACK_PORT}: {e}. " + "If another connection attempt is in progress, wait for it to finish or time out." + ) + return None + + async def _lifecycle(): + try: + await asyncio.wait_for(callback_served.wait(), timeout=timeout) + # Give the served HTML a moment to run its JS (postMessage + + # window.close) before we close the socket. Chromium closes + # the tab on window.close() but the JS needs to run first. + await asyncio.sleep(2.0) + except asyncio.TimeoutError: + logger.info(f"Codex callback listener timed out after {timeout}s") + except Exception as e: + logger.debug(f"Codex callback listener lifecycle error: {e}") + finally: + try: + server.close() + await server.wait_closed() + except Exception: + pass + + asyncio.create_task(_lifecycle()) + logger.info(f"Started Codex callback listener on http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}") + return server + + +# Providers whose OAuth flow MUST run in the user's real browser via +# shell.openExternal, not the in-Electron window.open popup: +# - gemini-cli, antigravity: Google's Embedded WebView Restrictions policy uses +# JS-fingerprint detection that no UA spoof defeats. RFC 8252 and Google's +# own Desktop-app OAuth guidance both prescribe the system browser. +# - codex: auth.openai.com renders blank in our popup on some machines (newer +# embed detection + regional checks); system browser surfaces the real error. +# The callback for gemini-cli/antigravity lands on /api/subscriptions/callback +# and runs the exchange server-side; codex uses its fixed 1455 listener. +_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli", "antigravity", "codex"} + + +def _should_use_external_browser(provider: str) -> bool: + return provider in _EXTERNAL_BROWSER_PROVIDERS + + +def _backend_port() -> int: + """Best-effort lookup of the OpenSwarm backend HTTP port. + + Falls back to 8324 (the default in backend/main.py) if OPENSWARM_PORT + hasn't been set yet. backend/main.py:239 sets this env var at startup + before any request handler runs, so `start_oauth` will always see the + correct value. + """ + try: + return int(os.environ.get("OPENSWARM_PORT", "8324")) + except (TypeError, ValueError): + return 8324 + + +def _callback_uri_for_provider(provider: str) -> str: + """Return the redirect URI to pass to 9Router's authorize endpoint. + + Most providers accept 9Router's built-in callback page at port 20128. + Two special cases: + - Codex/OpenAI's OAuth client is bound to a fixed + http://localhost:1455/auth/callback URI; handled by + _start_codex_callback_listener above. + - Gemini/Google's OAuth consent page rejects embedded browsers, so we + route the callback through OpenSwarm's backend endpoint at + /api/subscriptions/callback (backend/main.py:138) which runs the + exchange itself. This is the only provider where the callback lands + on OpenSwarm's port rather than 9Router's. + """ + if provider == "codex": + return f"http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}" + if provider in _EXTERNAL_BROWSER_PROVIDERS: + return f"http://localhost:{_backend_port()}/api/subscriptions/callback" + return f"http://localhost:{NINE_ROUTER_PORT}/callback" + + +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 + + callback_url = _callback_uri_for_provider(provider) + if provider == "codex": + await _start_codex_callback_listener() + + 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, + "use_external_browser": _should_use_external_browser(provider), + } + + +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/process.py b/backend/apps/nine_router/process.py new file mode 100644 index 00000000..379f902e --- /dev/null +++ b/backend/apps/nine_router/process.py @@ -0,0 +1,423 @@ +"""9Router subprocess lifecycle: constants, path resolution, start/stop, stats. + +This is the single owner of the 9Router process handle and its is_running +cache. Nothing else in the package spawns or kills the subprocess; the sync +and oauth modules only talk to the already-running server over HTTP. + +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 on port 20128 and exposes an OpenAI-compatible +API at localhost:20128/v1. +""" + +import asyncio +import logging +import os +import shutil +import subprocess +import time +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +NINE_ROUTER_PORT = 20128 +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" + +# Pinned 9router npm package version. Using 0.3.60 to match exactly what +# openswarm-ai v1.0.25 (last known-good production release) vendored via +# `9router/package.json`. Versions between 0.3.60 and 0.3.96 regressed +# cross-provider WebSearch: the CLI's WebSearch call from Codex/Gemini +# primaries used to route cleanly through 9Router's translator and hit +# Anthropic's server-side web_search (returning real results), but later +# translator changes broke that path; non-Claude primaries now see +# "claude-haiku-4-5-20251001 unavailable" or hallucinated output. +# Pinning to 0.3.60 restores v1.0.25 behavior. +# +# Note: 0.3.60-0.4.20 ALL emit `max_tokens` (not max_completion_tokens) +# when translating Anthropic→OpenAI, which OpenAI's GPT-5 family rejects. +# The fix lives in our /api/openai-passthrough proxy; see openai_passthrough.py +# and sync_openai_api_key for how the translation lane is rerouted via an +# `openai-compatible` provider-node that honors `baseUrl`. +NINE_ROUTER_NPM_VERSION = "0.3.60" + +_process: subprocess.Popen | None = None + +# Short TTL cache for positive is_running() results. The probe is a sync +# httpx.get that blocks the event loop, and under load (9Router busy +# streaming inference) it can exceed its 2s timeout and return False even +# though 9Router is fine. Caching a recent True result avoids those false +# negatives without masking a real crash for more than _IS_RUNNING_TTL seconds. +# Negative results are NOT cached so startup detection in ensure_running() +# remains correct. +_IS_RUNNING_TTL = 10.0 +_is_running_last_ok: float = 0.0 + + +def is_running() -> bool: + """Check if 9Router is running.""" + global _is_running_last_ok + now = time.monotonic() + if now - _is_running_last_ok < _IS_RUNNING_TTL: + return True + try: + r = httpx.get(f"{NINE_ROUTER_V1}/models", timeout=2.0) + if r.status_code == 200: + _is_running_last_ok = now + return True + return False + except Exception: + return False + + +def _find_9router_dir() -> str | None: + """Locate the bundled 9Router directory (works in both dev and packaged mode).""" + _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" + + if _is_packaged: + import sys + _resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + _candidate = os.path.join(_resources, "router") + if os.path.isdir(_candidate): + return _candidate + else: + _backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + _project_root = os.path.dirname(_backend_dir) + _candidate = os.path.join(_project_root, "router") + if os.path.isdir(_candidate): + return _candidate + + return None + + +def _gpt5_patch_path() -> str | None: + """Absolute path to backend/apps/agents/9router_gpt5_patch.js, used as + `node --require ` when spawning 9router. + + The patch intercepts outbound HTTPS to api.openai.com and renames + `max_tokens` → `max_completion_tokens` for GPT-5 models. Without it, + every gpt-5* own-key session 400's because OpenAI rejects the legacy + field name and 9router (every version including 0.4.20) emits it. + + Returns None if the file is missing; `subprocess.Popen` would fail + on `node --require `, so the caller drops the flag and + spawns 9router unpatched (failure mode = identical to pre-patch + baseline; GPT-5 still 400's but everything else works). + + Path resolution: walks up from this module to backend/apps/agents/. + Works identically in dev (`bash run.sh`) and packaged builds (Mac dmg + + Windows exe both ship this file under Resources/backend/...). + """ + apps_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + candidate = os.path.join(apps_dir, "agents", "9router_gpt5_patch.js") + return candidate if os.path.exists(candidate) else None + + +def _find_node() -> str | None: + """Find a Node.js binary (works in both dev and packaged mode). + + Priority order: + 1. OPENSWARM_NODE_PATH; set by electron/main.js when a real Node + binary is bundled in extraResources. Always preferred on user + machines because it (a) avoids the bouncing "exec" Dock icon + that ELECTRON_RUN_AS_NODE produces on fresh Macs and (b) starts + in ~50ms vs Electron-as-Node's 5, 15s cold-start, shrinking the + splash window the user stares at. + 2. System `node` on PATH; dev convenience. + 3. ELECTRON_RUN_AS_NODE fallback; last resort. Only hits this on + packaged builds that for some reason shipped without the bundled + node payload. + """ + bundled = os.environ.get("OPENSWARM_NODE_PATH") + if bundled and os.path.exists(bundled): + return bundled + + node = shutil.which("node") + if node: + return node + + electron_path = os.environ.get("OPENSWARM_ELECTRON_PATH") + if electron_path and os.path.exists(electron_path): + return electron_path + + return None + + +def _dev_router_cache_dir() -> str: + """Cache dir for the npm 9router package used in dev mode. + + Pinned per version so bumping NINE_ROUTER_NPM_VERSION triggers a fresh + install instead of reusing a stale cache. + """ + base = os.environ.get("XDG_CACHE_HOME") or os.path.join( + os.path.expanduser("~"), ".cache" + ) + return os.path.join(base, "openswarm-router", NINE_ROUTER_NPM_VERSION) + + +def _ensure_router_cached() -> str | None: + """Ensure the npm 9router package is installed in the dev cache. + + Returns the absolute path to `app/server.js` on success, or None if + npm isn't available or the install fails. Idempotent; returns + immediately when the server file already exists. + + Running `node app/server.js` directly (instead of `npx 9router`) + skips the CLI wrapper, which means no systray menu-bar icon, + no update-check spinner, and no accidental-quit foot-gun when a + non-developer right-clicks the "9" tray icon and picks Quit. + """ + cache_dir = _dev_router_cache_dir() + server_js = os.path.join(cache_dir, "node_modules", "9router", "app", "server.js") + if os.path.exists(server_js): + return server_js + + npm = shutil.which("npm") + if not npm: + logger.warning("npm not found; install Node.js to auto-start 9Router in dev.") + return None + + try: + os.makedirs(cache_dir, exist_ok=True) + pkg_json = os.path.join(cache_dir, "package.json") + if not os.path.exists(pkg_json): + with open(pkg_json, "w") as f: + f.write('{"name":"_openswarm_router_cache","version":"0.0.0","private":true}\n') + + logger.info( + "Installing 9router@%s into %s (one-time, ~30s)...", + NINE_ROUTER_NPM_VERSION, cache_dir, + ) + # Note: we do NOT pass --ignore-scripts. The package's postinstall + # rebuilds better-sqlite3 for the host platform; skipping it leaves + # the server unable to load its native addon. + subprocess.run( + [npm, "install", f"9router@{NINE_ROUTER_NPM_VERSION}", + "--no-save", "--no-audit", "--no-fund", "--silent"], + cwd=cache_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=300, + check=False, + ) + except Exception as e: + logger.warning("Failed to install 9router into %s: %s", cache_dir, e) + return None + + return server_js if os.path.exists(server_js) else None + + +async def ensure_running(): + """Start 9Router if not already running.""" + global _process + _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" + + if is_running(): + # In dev mode, kill stale standalone servers (from previous builds) + # so we can start `next dev` which always uses latest source code + if not _is_packaged: + import subprocess as _sp + try: + result = _sp.run( + ["pgrep", "-f", "next-server"], + capture_output=True, text=True, timeout=3, + ) + if result.stdout.strip(): + logger.info("Dev mode: killing stale standalone 9Router to use next dev instead") + _sp.run(["pkill", "-f", "next-server"], timeout=5) + await asyncio.sleep(2) + else: + logger.info("9Router already running on port %d", NINE_ROUTER_PORT) + return + except Exception: + logger.info("9Router already running on port %d", NINE_ROUTER_PORT) + return + else: + logger.info("9Router already running on port %d", NINE_ROUTER_PORT) + return + _9router_dir = _find_9router_dir() + + if _is_packaged and _9router_dir: + # Packaged mode; run the pre-built standalone server staged at + # /router/server.js by scripts/fetch-router.sh at build time. + standalone_server = os.path.join(_9router_dir, "server.js") + if not os.path.exists(standalone_server): + standalone_server = os.path.join(_9router_dir, ".next", "standalone", "server.js") + if not os.path.exists(standalone_server): + logger.warning("9Router standalone build not found in %s", _9router_dir) + return + + node = _find_node() + if not node: + logger.warning("Node.js not found; cannot start 9Router in packaged mode.") + return + + logger.info("Starting 9Router (production) on port %d...", NINE_ROUTER_PORT) + cmd = [node] + _patch = _gpt5_patch_path() + if _patch: + cmd += ["--require", _patch] + cmd.append(standalone_server) + cwd = os.path.dirname(standalone_server) + env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"} + if node == os.environ.get("OPENSWARM_ELECTRON_PATH"): + env["ELECTRON_RUN_AS_NODE"] = "1" + + else: + # Dev mode; install the pinned 9router npm package into a local + # cache the first time run.sh boots, then spawn `node app/server.js` + # directly on subsequent launches. Bypassing the package's cli.js + # avoids its menu-bar tray icon (which users confusingly quit, + # silently killing their subscription routing), its update-check + # spinner, and the interactive TUI. + cached_server = _ensure_router_cached() + if not cached_server: + return + + node = _find_node() + if not node: + logger.warning("Node.js not found; cannot start 9Router in dev mode.") + return + + logger.info( + "Starting 9Router (dev cache, 9router@%s) on port %d...", + NINE_ROUTER_NPM_VERSION, NINE_ROUTER_PORT, + ) + cmd = [node] + _patch = _gpt5_patch_path() + if _patch: + cmd += ["--require", _patch] + cmd.append(cached_server) + cwd = os.path.dirname(cached_server) + env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"} + + # By default, 9Router's stdout/stderr go to /dev/null (Next.js dev mode + # is extremely chatty and floods the openswarm console otherwise). When + # debugging is needed, set OPENSWARM_DEBUG_9ROUTER=1 in the environment + # before launching the backend; output will then be appended to + # backend/data/9router.log line-buffered, which can be `tail -f`'d. + if os.environ.get("OPENSWARM_DEBUG_9ROUTER"): + _log_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "data", + "9router.log", + ) + os.makedirs(os.path.dirname(_log_path), exist_ok=True) + _stdout = open(_log_path, "a", buffering=1) # line-buffered + _stderr = subprocess.STDOUT + logger.info(f"9Router debug logging enabled → {_log_path}") + else: + _stdout = subprocess.DEVNULL + _stderr = subprocess.DEVNULL + + try: + _process = subprocess.Popen( + cmd, + cwd=cwd, + stdout=_stdout, + stderr=_stderr, + env=env, + ) + + timeout = 20 if _is_packaged else 30 + for _ in range(timeout * 2): + await asyncio.sleep(0.5) + if is_running(): + logger.info("9Router started successfully") + return + + logger.warning("9Router did not start within %ds", timeout) + except Exception as e: + logger.warning(f"Failed to start 9Router: {e}") + + +def stop(): + """Stop the 9Router subprocess.""" + global _process + if _process: + try: + _process.terminate() + _process.wait(timeout=5) + except Exception: + try: + _process.kill() + except Exception: + pass + _process = None + logger.info("9Router stopped") + + +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_latest_reasoning_tokens(model_hint: str | None = None) -> int | None: + """Fetch reasoning_tokens from 9Router for the most recently completed + request, optionally filtered by model. Returns None if 9Router isn't + running, the request didn't expose reasoning tokens, or the lookup + fails for any reason. + + 9Router's request-details endpoint returns the most recent N requests + in reverse chronological order with full token breakdowns including + `reasoning_tokens` (OpenAI's `completion_tokens_details.reasoning_tokens`) + and `thoughtsTokenCount` (Gemini's). For Anthropic via 9Router this + field will be absent/zero; Anthropic doesn't break out reasoning + tokens in its API response; so callers get None and should fall + back to the heuristic. + """ + if not is_running(): + return None + try: + async with httpx.AsyncClient(timeout=2.0) as client: + params: dict[str, Any] = {"page": 1, "pageSize": 5} + if model_hint: + params["model"] = model_hint + r = await client.get(f"{NINE_ROUTER_API}/usage/request-details", params=params) + if r.status_code != 200: + return None + data = r.json() + requests = data.get("requests") or data.get("data") or [] + for req in requests: + tokens = req.get("tokens") or req.get("usage") or {} + rt = ( + tokens.get("reasoning_tokens") + or tokens.get("thoughtsTokenCount") + or tokens.get("thoughts_token_count") + or 0 + ) + if rt and int(rt) > 0: + return int(rt) + except Exception as e: + logger.debug(f"9Router reasoning-token lookup failed: {e}") + return None + + +async def get_providers() -> list[dict]: + """Get all providers and their connection status from 9Router. + + 9Router's GET /api/providers returns `{"connections": [...]}`; we + unwrap so callers always see a plain list of connection dicts. + """ + try: + async with httpx.AsyncClient(timeout=5.0) as client: + r = await client.get(f"{NINE_ROUTER_API}/providers") + if r.status_code == 200: + data = r.json() + if isinstance(data, dict): + return data.get("connections") or [] + if isinstance(data, list): + return data + except Exception as e: + logger.debug(f"9Router providers fetch failed: {e}") + return [] diff --git a/backend/apps/nine_router/sync.py b/backend/apps/nine_router/sync.py new file mode 100644 index 00000000..fe32ddab --- /dev/null +++ b/backend/apps/nine_router/sync.py @@ -0,0 +1,139 @@ +"""Provider API-key sync into 9Router. + +Mirrors the user's stored Gemini / OpenAI / OpenRouter keys into 9Router +as OpenSwarm-managed apikey connections. Talks to the already-running +9Router over HTTP; never spawns the subprocess (that's process.py's job). +""" + +import logging + +from .process import NINE_ROUTER_API + +logger = logging.getLogger(__name__) + + +def _nr(): + """The package module. is_running / get_providers / httpx are read off it + at call time so tests that patch `backend.apps.nine_router.` still + take effect after the split (they used to all live on one module).""" + from backend.apps import nine_router + return nine_router + +# API-key auth (provider="gemini", authType="apikey") and OAuth hit different +# Google quotas: OAuth uses the Code Assist free tier (aggressively rate-limited; +# 429s on Gemini 3 Pro/Flash even for paid users), while an AI Studio API key +# uses generativelanguage.googleapis.com (independent and far higher). We mirror +# google_api_key into 9Router so the API-key path is preferred when a key is set. + +NINE_ROUTER_KEYED_NAME = "AI Studio (OpenSwarm-managed)" +NINE_ROUTER_OPENAI_KEYED_NAME = "OpenAI (OpenSwarm-managed)" +NINE_ROUTER_OPENROUTER_KEYED_NAME = "OpenRouter (OpenSwarm-managed)" +NINE_ROUTER_CLAUDE_PRO_NAME = "OpenSwarm Pro (OpenSwarm-managed)" + +# Reserved prefix that registry.py's gpt-5.*-api router_model_ids depend on. +# Changing this breaks model resolution for OpenAI own-key users. +NINE_ROUTER_OPENAI_KEYED_PREFIX = "cp-openai" + + +async def _find_keyed_connection(provider: str, name: str) -> dict | None: + """Return the 9Router connection we manage for this provider, if any.""" + conns = await _nr().get_providers() + if not isinstance(conns, list): + return None + for c in conns: + if ( + isinstance(c, dict) + and c.get("provider") == provider + and c.get("authType") == "apikey" + and c.get("name") == name + ): + return c + return None + + +async def _sync_apikey_provider( + provider: str, + api_key: str | None, + name: str, + *, + label: str, +) -> None: + """Create/update/delete an OpenSwarm-managed apikey connection. Silent if 9Router is down.""" + if not _nr().is_running(): + return + + existing = await _find_keyed_connection(provider, name) + try: + async with _nr().httpx.AsyncClient(timeout=5.0) as client: + if api_key: + payload = { + "provider": provider, + "authType": "apikey", + "name": name, + "apiKey": api_key, + # Priority 0 = highest. OAuth connections default to 1, + # so keyed connections are preferred when both exist. + "priority": 0, + } + if existing: + await client.patch( + f"{NINE_ROUTER_API}/providers/{existing['id']}", + json=payload, + ) + logger.info(f"9Router: updated {label} API-key connection") + else: + r = await client.post(f"{NINE_ROUTER_API}/providers", json=payload) + if r.status_code < 300: + logger.info(f"9Router: created {label} API-key connection") + else: + logger.warning( + f"9Router: failed to create {label} API-key connection: " + f"{r.status_code} {r.text[:200]}" + ) + else: + if existing: + await client.delete(f"{NINE_ROUTER_API}/providers/{existing['id']}") + logger.info(f"9Router: removed {label} API-key connection") + except Exception as e: + logger.warning(f"9Router {label} API-key sync failed: {e}") + + +async def sync_gemini_api_key(api_key: str | None) -> None: + """Mirror google_api_key into 9Router; bypasses Code Assist's tight quota.""" + await _sync_apikey_provider( + "gemini", api_key, NINE_ROUTER_KEYED_NAME, label="Gemini" + ) + + +async def sync_openai_api_key(api_key: str | None) -> None: + """Mirror openai_api_key into 9Router as an `openai-compatible` provider + node pointed at our local /api/openai-passthrough proxy. + + Why not the built-in `openai` provider type: 9Router 0.3.60 hardcodes + `https://api.openai.com/v1` for the `openai` provider and ignores any + `baseUrl` field on the connection. Only the `openai-compatible-*` + provider-node type honors `baseUrl` (verified statically against + 9Router's compiled bundle). So we register our OpenAI lane AS an + openai-compatible node; same upstream protocol, different routing. + + Why we route through openai-passthrough at all: OpenAI's GPT-5 family + rejects the legacy `max_tokens` parameter with HTTP 400, but every + 9Router version (including 0.4.20) emits `max_tokens` in its + Anthropic→OpenAI translator. The passthrough renames it to + `max_completion_tokens` for `gpt-5*` models before forwarding to + api.openai.com. Pre-fix: every gpt-5.* own-key session 400'd silently. + + Companion change: the registry entries `gpt-5.*-api` are routed via + the `cp-openai/` prefix (set by NINE_ROUTER_OPENAI_KEYED_PREFIX + above) so 9Router's translator dispatches to this provider-node + instead of the built-in `openai` provider. + """ + from .sync_custom import _sync_openai_compat_node + await _sync_openai_compat_node(api_key) + + +async def sync_openrouter_api_key(api_key: str | None) -> None: + """Mirror openrouter_api_key into 9Router; supplies bearer for openrouter/ routes.""" + await _sync_apikey_provider( + "openrouter", api_key, NINE_ROUTER_OPENROUTER_KEYED_NAME, label="OpenRouter" + ) diff --git a/backend/apps/nine_router/sync_custom.py b/backend/apps/nine_router/sync_custom.py new file mode 100644 index 00000000..8a1003bf --- /dev/null +++ b/backend/apps/nine_router/sync_custom.py @@ -0,0 +1,325 @@ +"""Custom OpenAI-compatible provider sync into 9Router. + +9Router exposes arbitrary OpenAI-compatible endpoints via "provider nodes" +(POST /api/provider-nodes, type="openai-compatible"). A model_id of +/ routes to that node's baseUrl. This module mirrors the +user's custom providers, the OpenAI passthrough lane, and OpenSwarm Pro +into those nodes. Talks to the already-running 9Router over HTTP; never +spawns the subprocess (that's process.py's job). +""" + +import logging + +from .process import NINE_ROUTER_API +from .sync import ( + NINE_ROUTER_CLAUDE_PRO_NAME, + NINE_ROUTER_OPENAI_KEYED_PREFIX, + _find_keyed_connection, + _nr, +) + +logger = logging.getLogger(__name__) + +# We mirror settings.custom_providers[] with prefix `cp-` so they don't +# collide with the user's primary OpenAI key. +NINE_ROUTER_CUSTOM_NAME_SUFFIX = " (OpenSwarm-managed)" + + +async def _sync_openai_compat_node(api_key: str | None) -> None: + """Create / update / delete the openai-compatible node + connection + pair we use to ferry OpenAI requests through openai-passthrough.""" + if not _nr().is_running(): + return + import os as _os + port = _os.environ.get("OPENSWARM_PORT", "8324") + base_url = f"http://127.0.0.1:{port}/api/openai-passthrough/v1" + managed_name = f"OpenAI{NINE_ROUTER_CUSTOM_NAME_SUFFIX}" + + try: + async with _nr().httpx.AsyncClient(timeout=5.0) as client: + r = await client.get(f"{NINE_ROUTER_API}/provider-nodes") + existing_nodes = (r.json().get("nodes") if r.status_code == 200 else []) or [] + except Exception as e: + logger.warning(f"9Router OpenAI-compat node list failed: {e}") + return + existing_node = next( + (n for n in existing_nodes if isinstance(n, dict) and n.get("prefix") == NINE_ROUTER_OPENAI_KEYED_PREFIX), + None, + ) + + if not api_key: + if existing_node: + try: + async with _nr().httpx.AsyncClient(timeout=5.0) as client: + await client.delete(f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}") + logger.info("9Router: removed OpenAI compat node (key cleared)") + except Exception as e: + logger.warning(f"9Router OpenAI compat delete failed: {e}") + return + + node_payload = { + "name": managed_name, + "prefix": NINE_ROUTER_OPENAI_KEYED_PREFIX, + "apiType": "chat", + "baseUrl": base_url, + "type": "openai-compatible", + } + node_id: str | None = existing_node.get("id") if existing_node else None + try: + async with _nr().httpx.AsyncClient(timeout=5.0) as client: + if existing_node: + await client.put( + f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}", + json=node_payload, + ) + logger.info(f"9Router: updated OpenAI compat node {NINE_ROUTER_OPENAI_KEYED_PREFIX}") + else: + r = await client.post( + f"{NINE_ROUTER_API}/provider-nodes", json=node_payload, + ) + if r.status_code >= 300: + logger.warning( + f"9Router: failed to create OpenAI compat node: " + f"{r.status_code} {r.text[:200]}" + ) + return + node_id = (r.json() or {}).get("node", {}).get("id") + if not node_id: + return + logger.info(f"9Router: created OpenAI compat node {NINE_ROUTER_OPENAI_KEYED_PREFIX} ({node_id})") + except Exception as e: + logger.warning(f"9Router OpenAI compat node sync failed: {e}") + return + + try: + existing_conn = await _find_keyed_connection(node_id, managed_name) + conn_payload = { + "provider": node_id, + "authType": "apikey", + "name": managed_name, + "apiKey": api_key, + "priority": 0, + } + async with _nr().httpx.AsyncClient(timeout=5.0) as client: + if existing_conn: + await client.patch( + f"{NINE_ROUTER_API}/providers/{existing_conn['id']}", + json=conn_payload, + ) + else: + r = await client.post(f"{NINE_ROUTER_API}/providers", json=conn_payload) + if r.status_code >= 300: + logger.warning( + f"9Router: failed to create OpenAI compat connection: " + f"{r.status_code} {r.text[:200]}" + ) + except Exception as e: + logger.warning(f"9Router OpenAI compat connection sync failed: {e}") + + +def _custom_provider_slug(name: str) -> str: + """Slugify a user-supplied custom-provider name for use as a 9Router prefix. + Always returns a non-empty alnum-and-dash string.""" + import re + s = re.sub(r"[^a-zA-Z0-9-]+", "-", (name or "").strip().lower()).strip("-") + return s or "custom" + + +def normalize_openai_compat_base_url(url: str) -> str: + """Append `/v1` when the user supplied a host without an API path. + + 9Router forwards openai-compatible nodes to `/chat/completions` + verbatim. Ollama, LM Studio, llama.cpp, vLLM, and every other OpenAI- + compatible server exposes the API under `/v1`, so a user pasting + `http://host:11434` (which is what Ollama prints on launch) ends up + routed to `/chat/completions` and 404s. Path-bearing URLs are left + alone, so `https://api.together.xyz/v1`, `https://openrouter.ai/api/v1`, + or anything custom is untouched. + """ + from urllib.parse import urlparse + s = (url or "").strip().rstrip("/") + if not s: + return s + try: + path = urlparse(s).path + except Exception: + return s + if not path: + return s + "/v1" + return s + + +async def sync_custom_providers(providers: list) -> None: + """Mirror settings.custom_providers into 9Router as openai-compatible nodes. + + Idempotent: existing managed nodes (identified by name suffix) are PUT-updated + in place, missing ones are POST-created, and any managed node whose prefix is + no longer in `providers` is deleted (which cascades to its connection). + Silent no-op when 9Router isn't running. + """ + if not _nr().is_running(): + return + + try: + async with _nr().httpx.AsyncClient(timeout=5.0) as client: + r = await client.get(f"{NINE_ROUTER_API}/provider-nodes") + existing_nodes = (r.json().get("nodes") if r.status_code == 200 else []) or [] + except Exception as e: + logger.warning(f"9Router custom-provider node list failed: {e}") + return + + managed = [ + n for n in existing_nodes + if isinstance(n, dict) + and isinstance(n.get("name"), str) + and n["name"].endswith(NINE_ROUTER_CUSTOM_NAME_SUFFIX) + ] + managed_by_prefix = {n.get("prefix"): n for n in managed if n.get("prefix")} + + seen_prefixes: set[str] = set() + for cp in providers or []: + name = getattr(cp, "name", None) or (cp.get("name") if isinstance(cp, dict) else None) or "" + base_url = getattr(cp, "base_url", None) or (cp.get("base_url") if isinstance(cp, dict) else None) or "" + api_key = getattr(cp, "api_key", None) or (cp.get("api_key") if isinstance(cp, dict) else None) or "" + if not name.strip() or not base_url.strip(): + continue + # Local OpenAI-compat servers (LM Studio, Ollama, etc.) reject a blank + # Bearer header even with auth disabled. Substitute a placeholder; real + # auth deployments always have api_key set. + api_key = api_key.strip() or "no-auth-required" + slug = _custom_provider_slug(name) + prefix = f"cp-{slug}" + seen_prefixes.add(prefix) + managed_name = f"{name.strip()}{NINE_ROUTER_CUSTOM_NAME_SUFFIX}" + + node = managed_by_prefix.get(prefix) + node_payload = { + "name": managed_name, + "prefix": prefix, + "apiType": "chat", + "baseUrl": normalize_openai_compat_base_url(base_url), + "type": "openai-compatible", + } + try: + async with _nr().httpx.AsyncClient(timeout=5.0) as client: + if node: + await client.put( + f"{NINE_ROUTER_API}/provider-nodes/{node['id']}", + json=node_payload, + ) + node_id = node["id"] + logger.info(f"9Router: updated custom node {prefix}") + else: + r = await client.post( + f"{NINE_ROUTER_API}/provider-nodes", json=node_payload, + ) + if r.status_code >= 300: + logger.warning( + f"9Router: failed to create custom node {prefix}: " + f"{r.status_code} {r.text[:200]}" + ) + continue + node_id = (r.json() or {}).get("node", {}).get("id") + if not node_id: + continue + logger.info(f"9Router: created custom node {prefix} ({node_id})") + except Exception as e: + logger.warning(f"9Router custom node {prefix} sync failed: {e}") + continue + + try: + existing_conn = await _find_keyed_connection(node_id, managed_name) + conn_payload = { + "provider": node_id, + "authType": "apikey", + "name": managed_name, + "apiKey": api_key, + "priority": 0, + } + async with _nr().httpx.AsyncClient(timeout=5.0) as client: + if existing_conn: + await client.patch( + f"{NINE_ROUTER_API}/providers/{existing_conn['id']}", + json=conn_payload, + ) + else: + r = await client.post( + f"{NINE_ROUTER_API}/providers", json=conn_payload, + ) + if r.status_code >= 300: + logger.warning( + f"9Router: failed to create custom connection {prefix}: " + f"{r.status_code} {r.text[:200]}" + ) + except Exception as e: + logger.warning(f"9Router custom connection {prefix} sync failed: {e}") + + # Drop managed nodes no longer in settings; DELETE cascades to connections. + for prefix, node in managed_by_prefix.items(): + if prefix in seen_prefixes: + continue + try: + async with _nr().httpx.AsyncClient(timeout=5.0) as client: + await client.delete(f"{NINE_ROUTER_API}/provider-nodes/{node['id']}") + logger.info(f"9Router: removed orphaned custom node {prefix}") + except Exception as e: + logger.warning(f"9Router custom node {prefix} delete failed: {e}") + + +async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str | None) -> None: + """Register OpenSwarm Pro as a `claude` apikey connection in 9Router, + pointing at our cloud proxy via `providerSpecificData.baseUrl`. + + This is what makes the CLI's built-in WebSearch work on non-Claude + primaries for Pro users: the CLI delegates the search execution to + Anthropic via ANTHROPIC_SMALL_FAST_MODEL (claude-haiku). That small- + model call hits `ANTHROPIC_BASE_URL` which we've already set to + localhost:20128 (9Router). Without this sync, 9Router has no Claude + path for openswarm-pro users, so the search fails with + "no credentials for provider: claude". With this sync, 9Router sees + the OpenSwarm-Pro-backed Claude connection and routes the search + call through our cloud; same quota the user's Pro subscription + already covers, no extra cost.""" + if not _nr().is_running(): + return + + # 9Router's POST /api/providers only accepts direct-API provider ids + # for apikey auth; `claude` is the subscription/IDE id, `anthropic` + # is the direct-API id. Use `anthropic`. + existing = await _find_keyed_connection("anthropic", NINE_ROUTER_CLAUDE_PRO_NAME) + try: + async with _nr().httpx.AsyncClient(timeout=5.0) as client: + if bearer_token and proxy_url: + payload = { + "provider": "anthropic", + "authType": "apikey", + "name": NINE_ROUTER_CLAUDE_PRO_NAME, + "apiKey": bearer_token, + # Priority 1 so a real user-owned Claude subscription + # (priority 0) still takes precedence if they have one. + # Pro is the fallback, not the default. + "priority": 1, + "providerSpecificData": { + "baseUrl": proxy_url.rstrip("/") + "/v1", + }, + } + if existing: + await client.patch( + f"{NINE_ROUTER_API}/providers/{existing['id']}", + json=payload, + ) + logger.info("9Router: updated OpenSwarm Pro → Claude connection") + else: + r = await client.post(f"{NINE_ROUTER_API}/providers", json=payload) + if r.status_code < 300: + logger.info("9Router: created OpenSwarm Pro → Claude connection") + else: + logger.warning( + f"9Router: failed to create OpenSwarm Pro → Claude connection: {r.status_code} {r.text[:200]}" + ) + else: + if existing: + await client.delete(f"{NINE_ROUTER_API}/providers/{existing['id']}") + logger.info("9Router: removed OpenSwarm Pro → Claude connection") + except Exception as e: + logger.warning(f"9Router OpenSwarm-Pro Claude sync failed: {e}")