"""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 backend.apps.nine_router.process import NINE_ROUTER_API, cli_auth_headers from backend.apps.nine_router.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 p_os port = p_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, headers=cli_auth_headers()) 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, headers=cli_auth_headers()) 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, headers=cli_auth_headers()) 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, headers=cli_auth_headers()) 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 p_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, headers=cli_auth_headers()) 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 = p_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, headers=cli_auth_headers()) 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, headers=cli_auth_headers()) 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, headers=cli_auth_headers()) 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, headers=cli_auth_headers()) 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}") async def sync_pro_routing(settings_obj) -> None: """Mirror the settings' cloud-proxy state (openswarm-pro OR free-trial) into the 9Router Claude lane. Call after any flow that changes connection_mode or the bearer (activate, sign-in, sign-out, disconnect, free-trial arm/clear). Never raises.""" try: from backend.apps.settings.credentials import proxy_auth bearer, base = proxy_auth(settings_obj) active = bool(bearer) await sync_openswarm_pro_as_claude( bearer if active else None, base if active else None, ) except Exception as e: logger.warning(f"OpenSwarm-Pro → Claude sync failed: {e}")