From b3428a736bcd98c4cb64abf52f450e96e85d8019 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 7 May 2026 21:22:40 -0700 Subject: [PATCH] [eric] custom OpenAI-compatible provider support, youtube bug fix (windows) --- backend/apps/agents/agent_manager.py | 64 ++++- backend/apps/agents/agents.py | 35 +++ backend/apps/agents/providers/registry.py | 67 ++++- backend/apps/nine_router.py | 141 +++++++++++ backend/apps/settings/credentials.py | 9 +- backend/apps/settings/settings.py | 20 +- backend/apps/tools_lib/tools_lib.py | 66 ++++- backend/tests/test_v2_invariants.py | 82 ++++++ electron/package-lock.json | 4 +- frontend/src/app/pages/Settings/Settings.tsx | 249 ++++++++++++++++++- frontend/src/app/pages/Tools/Tools.tsx | 11 +- 11 files changed, 720 insertions(+), 28 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index fda0e3fd..457d668b 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1672,9 +1672,21 @@ class AgentManager: and not _router_model_id.startswith(("cc/", "cx/", "gc/", "ag/", "gemini/")) and _api_type_for_session == "anthropic" ) + # Custom-provider sessions (Ollama Cloud, Together, Groq, etc.) + # set ANTHROPIC_BASE_URL to 9Router but 9Router has no Claude + # connection unless the user separately set up one. The CLI's + # built-in WebSearch delegates to Anthropic Haiku, which falls + # through 9Router to whichever connection serves anthropic/... + # ids — usually OpenRouter — and 401s. Force the openswarm-web + # MCP to register so WebSearch always cascades through our own + # /api/web/search (Gemini → OpenAI → DuckDuckGo). + _is_custom_session = _api_type_for_session == "custom" _has_anthropic_path = ( - bool(getattr(global_settings, "anthropic_api_key", None)) # direct env bypass - or (_9r_has_anthropic and _primary_is_claude) + not _is_custom_session + and ( + bool(getattr(global_settings, "anthropic_api_key", None)) + or (_9r_has_anthropic and _primary_is_claude) + ) ) _need_web_mcp = not _has_anthropic_path @@ -1855,6 +1867,45 @@ class AgentManager: "ANTHROPIC_BASE_URL": "http://localhost:20128", } logger.info(f"[MCP-DEBUG] Using direct OpenAI API key (route=api) for {session.model}") + elif _is_pinned_api_route and _api_route_provider == "custom": + # User-configured OpenAI-compatible endpoint (Ollama Cloud, + # Together, local Ollama, etc.). Routes through 9Router's + # openai-compatible provider node we synced from settings. + from backend.apps.nine_router import ensure_running as _9r_ensure_c + if not _9r_running(): + logger.info(f"[MCP-DEBUG] custom provider selected but 9Router not running; waiting for startup") + await _9r_ensure_c() + if not _9r_running(): + raise ValueError( + "9Router could not start. Custom OpenAI-compatible " + "providers need 9Router to translate the Anthropic " + "protocol — install Node.js and restart the app." + ) + from backend.apps.agents.providers.registry import _find_custom_provider_for_value + cp = _find_custom_provider_for_value(global_settings, session.model) + env = { + "ANTHROPIC_API_KEY": "9router", + "ANTHROPIC_BASE_URL": "http://localhost:20128", + "ENABLE_TOOL_SEARCH": "auto", + } + if cp: + env["OPENAI_API_KEY"] = (cp.api_key or "") + env["OPENAI_BASE_URL"] = (cp.base_url or "") + # Pin subagent ids — without these, CLI's default Haiku 4.5 + # gets sent to the custom provider and 404s. + if global_settings.anthropic_api_key: + env["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-sonnet-4-6" + env["ANTHROPIC_SMALL_FAST_MODEL"] = "claude-haiku-4-5-20251001" + env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "claude-haiku-4-5-20251001" + else: + # Pin to the same custom-provider model so subagents stay + # within the user's configured endpoint instead of hitting + # an unconfigured Anthropic lane. + env["CLAUDE_CODE_SUBAGENT_MODEL"] = resolved_model + env["ANTHROPIC_SMALL_FAST_MODEL"] = resolved_model + env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = resolved_model + options_kwargs["env"] = env + logger.info(f"[MCP-DEBUG] Using custom provider for {session.model} → {resolved_model}") elif _is_pinned_api_route and _api_route_provider == "gemini" and getattr(global_settings, "google_api_key", None): # Routed through the local anthropic-proxy so it can scrub the # JSON-Schema fields Gemini's API rejects ($schema, additionalProperties, @@ -2970,6 +3021,15 @@ class AgentManager: _free_route = True elif resolved_model.startswith("openrouter/") and ":free" in resolved_model: _free_route = True + elif resolved_model.startswith("cp-"): + # User-configured custom OpenAI-compatible + # provider (Ollama Cloud, Together, Groq, + # local LMs, etc.). Pricing is unknowable + # without per-provider rate tables that + # would rot fast — zero out instead of + # showing the SDK's Anthropic-rate + # estimate, which is meaningless here. + _free_route = True if ( api_type == "anthropic" and getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro" diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 73854996..01bd7c97 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -601,6 +601,41 @@ async def list_models(): entries = sorted(by_vendor[vendor], key=lambda x: x["label"].lower()) result[f"OpenRouter · {pretty}"] = entries + # User-configured custom OpenAI-compatible providers (Ollama Cloud, Together, etc). + # Each provider becomes its own group in the picker; each model is addressed via + # the `custom//` value, which `_find_builtin_model` synthesises + # into a route='api' / api='custom' entry at request time. + from backend.apps.agents.providers.registry import _custom_provider_slug_for_lookup + for cp in (getattr(settings, "custom_providers", None) or []): + cp_name = (getattr(cp, "name", "") or "").strip() + cp_base_url = (getattr(cp, "base_url", "") or "").strip() + cp_models = getattr(cp, "models", None) or [] + if not cp_name or not cp_base_url or not cp_models: + continue + slug = _custom_provider_slug_for_lookup(cp_name) + entries: list[dict] = [] + for m in cp_models: + bare = (m.get("value") or m.get("id") or "").strip() + if not bare: + continue + label = (m.get("label") or bare).strip() or bare + ctx = m.get("context_window") + if not isinstance(ctx, int) or ctx <= 0: + ctx = 128_000 + entries.append({ + "value": f"custom/{slug}/{bare}", + "label": label, + "context_window": ctx, + "reasoning": bool(m.get("reasoning", False)), + "input_cost_per_1m": 0.0, + "output_cost_per_1m": 0.0, + "is_free": False, + "billing_kind": "api_key", + "tiers": [3, 3, 1], + }) + if entries: + result[cp_name] = entries + return {"models": result, "notes": notes} diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index bd662a64..10f398bd 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -319,13 +319,39 @@ def _is_9router_available() -> bool: # Model resolution (used by the live claude_agent_sdk path) # --------------------------------------------------------------------------- +_CUSTOM_VALUE_PREFIX = "custom/" + + +def _custom_provider_slug_for_lookup(name: str) -> str: + """Mirror nine_router._custom_provider_slug — duplicated here to avoid + importing from nine_router (circular: nine_router imports from settings).""" + import re + s = re.sub(r"[^a-zA-Z0-9-]+", "-", (name or "").strip().lower()).strip("-") + return s or "custom" + + +def _find_custom_provider_for_value(settings, value: str): + """Look up the CustomProvider whose slug matches the slug encoded in a + `custom//` picker value. Returns None if no match.""" + if not isinstance(value, str) or not value.startswith(_CUSTOM_VALUE_PREFIX): + return None + rest = value[len(_CUSTOM_VALUE_PREFIX):] + slug, _sep, _bare = rest.partition("/") + if not slug: + return None + for cp in getattr(settings, "custom_providers", None) or []: + if _custom_provider_slug_for_lookup(getattr(cp, "name", "")) == slug: + return cp + return None + + def _find_builtin_model(short_name: str) -> dict | None: """Look up a model entry by its short `value`. - OpenRouter entries (prefixed `or:/`) aren't in - BUILTIN_MODELS — they're synthesised at request time from the live - OR entries are synthesised on demand so the rest of the routing - code can treat them like BUILTIN_MODELS entries.""" + OpenRouter entries (prefixed `or:/`) and custom-provider + entries (prefixed `custom//`) aren't in BUILTIN_MODELS — + they're synthesised on demand so the rest of the routing code can treat + them like BUILTIN_MODELS entries.""" for models in BUILTIN_MODELS.values(): for m in models: if m.get("value") == short_name: @@ -343,6 +369,23 @@ def _find_builtin_model(short_name: str) -> dict | None: "route": "openrouter", "reasoning": False, } + if isinstance(short_name, str) and short_name.startswith(_CUSTOM_VALUE_PREFIX): + rest = short_name[len(_CUSTOM_VALUE_PREFIX):] + slug, _sep, bare_model = rest.partition("/") + if slug and bare_model: + # Routing string `cp-/` matches the prefix we use + # when sync_custom_providers registers the provider node. + routed = f"cp-{slug}/{bare_model}" + return { + "value": short_name, + "label": bare_model, + "context_window": 128_000, + "model_id": routed, + "router_model_id": routed, + "api": "custom", + "route": "api", + "reasoning": False, + } return None @@ -484,12 +527,20 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None = if m["value"] == model: return m.get("context_window", 128_000) - # Check custom providers + # Check custom providers — picker values are `custom//`; + # cp.models[].value stores the bare model id the user typed. Match the + # bare-model tail against any custom provider's models list. if settings: + bare_model = model + if isinstance(model, str) and model.startswith(_CUSTOM_VALUE_PREFIX): + rest = model[len(_CUSTOM_VALUE_PREFIX):] + _slug, _sep, bare_model = rest.partition("/") for cp in getattr(settings, "custom_providers", []): - for m in cp.models: - if m.get("value") == model or m.get("id") == model: - return m.get("context_window", 128_000) + for m in (getattr(cp, "models", None) or []): + if m.get("value") == bare_model or m.get("id") == bare_model: + cw = m.get("context_window") + if isinstance(cw, int) and cw > 0: + return cw return 128_000 # safe default diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py index d57c6bb8..21f6b6fb 100644 --- a/backend/apps/nine_router.py +++ b/backend/apps/nine_router.py @@ -501,6 +501,147 @@ async def sync_openrouter_api_key(api_key: str | None) -> None: ) +# --------------------------------------------------------------------------- +# Custom OpenAI-compatible providers (Ollama Cloud, Together AI, local Ollama, etc.) +# --------------------------------------------------------------------------- +# +# 9Router supports arbitrary OpenAI-compatible endpoints via "provider nodes" +# (POST /api/provider-nodes with type="openai-compatible"). Each node gets a +# unique provider id like `openai-compatible-chat-` and a user-defined +# `prefix`. At request time, model_id `/` routes to that +# node's baseUrl, with auth from a connection of the node's provider type. +# +# We mirror settings.custom_providers[] into 9Router with prefix `cp-`, +# letting us address each provider as `cp-/` without colliding +# with the user's primary OpenAI key (different provider type). + +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" + + +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 []: + # Tolerate both Pydantic instances and plain dicts. + 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 + 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": base_url.strip(), + "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 + + # Ensure a connection exists for this provider node carrying the apikey. + 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 that no longer correspond to any settings entry. + # DELETE on a node cascades to its 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`. diff --git a/backend/apps/settings/credentials.py b/backend/apps/settings/credentials.py index 9883d134..0695fa3e 100644 --- a/backend/apps/settings/credentials.py +++ b/backend/apps/settings/credentials.py @@ -157,16 +157,19 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic: def get_anthropic_client_for_model(settings: AppSettings, api_model: str) -> anthropic.AsyncAnthropic: """Return a client configured for the given resolved model id. - When api_model carries a 9Router prefix (cc/, cx/, gc/), the client + When api_model carries a 9Router prefix (cc/, cx/, gc/, cp-), the client targets 9Router directly — even if connection_mode is openswarm-pro. This is what lets pinned-route models like "sonnet-cc" actually reach the user's own subscription instead of getting sent through the managed proxy - with an unrecognizable model id. + with an unrecognizable model id. cp- is the prefix we use when registering + user-configured custom OpenAI-compatible providers in 9Router. Otherwise delegates to get_anthropic_client() for the default mode-driven routing. """ import anthropic - if isinstance(api_model, str) and api_model.startswith(("cc/", "cx/", "gc/")): + if isinstance(api_model, str) and ( + api_model.startswith(("cc/", "cx/", "gc/")) or api_model.startswith("cp-") + ): return anthropic.AsyncAnthropic( api_key="9router", base_url="http://localhost:20128", diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index 8943cd9a..81d0d357 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -31,6 +31,7 @@ async def settings_lifespan(): sync_openai_api_key, sync_openrouter_api_key, sync_openswarm_pro_as_claude, + sync_custom_providers, ) s = load_settings() import asyncio as _asyncio @@ -45,6 +46,7 @@ async def settings_lifespan(): getattr(s, "openai_api_key", None), getattr(s, "openrouter_api_key", None), getattr(s, "connection_mode", None) == "openswarm-pro", + bool(getattr(s, "custom_providers", None) or []), ]) if needs_router: try: @@ -62,6 +64,7 @@ async def settings_lifespan(): proxy = getattr(s, "openswarm_proxy_url", None) or "https://api.openswarm.com" if bearer: await sync_openswarm_pro_as_claude(bearer, proxy) + await sync_custom_providers(getattr(s, "custom_providers", None) or []) _asyncio.create_task(_boot_router_then_sync()) except Exception as e: @@ -203,10 +206,18 @@ async def update_settings(body: AppSettings): openrouter_changed = ( getattr(body, "openrouter_api_key", None) != getattr(old, "openrouter_api_key", None) ) + custom_providers_changed = ( + [cp.model_dump() for cp in (getattr(body, "custom_providers", None) or [])] + != [cp.model_dump() for cp in (getattr(old, "custom_providers", None) or [])] + ) any_keyed_added = ( (getattr(body, "google_api_key", None) and not getattr(old, "google_api_key", None)) or (getattr(body, "openai_api_key", None) and not getattr(old, "openai_api_key", None)) or (getattr(body, "openrouter_api_key", None) and not getattr(old, "openrouter_api_key", None)) + or ( + bool(getattr(body, "custom_providers", None) or []) + and not bool(getattr(old, "custom_providers", None) or []) + ) ) if openrouter_changed: @@ -218,14 +229,16 @@ async def update_settings(body: AppSettings): # Boot+sync runs off the request path — ensure_running() can take 5min # on first install (npm pull) and would freeze the event loop. - if google_changed or openai_changed or openrouter_changed: + if google_changed or openai_changed or openrouter_changed or custom_providers_changed: async def _boot_and_sync_keys( google_key: str | None, openai_key: str | None, openrouter_key: str | None, + custom_providers: list, do_google: bool, do_openai: bool, do_openrouter: bool, + do_custom: bool, need_boot: bool, ): try: @@ -235,6 +248,7 @@ async def update_settings(body: AppSettings): sync_gemini_api_key, sync_openai_api_key, sync_openrouter_api_key, + sync_custom_providers, ) if need_boot and not _9r_running(): await _9r_ensure() @@ -244,6 +258,8 @@ async def update_settings(body: AppSettings): await sync_openai_api_key(openai_key or None) if do_openrouter: await sync_openrouter_api_key(openrouter_key or None) + if do_custom: + await sync_custom_providers(custom_providers or []) except Exception as e: logger.warning(f"Background apikey sync failed: {e}") @@ -251,9 +267,11 @@ async def update_settings(body: AppSettings): getattr(body, "google_api_key", None), getattr(body, "openai_api_key", None), getattr(body, "openrouter_api_key", None), + getattr(body, "custom_providers", None) or [], google_changed, openai_changed, openrouter_changed, + custom_providers_changed, any_keyed_added, )) diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index 8b9386fb..3edeb0d0 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -647,24 +647,51 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None, limit=10 * 1024 * 1024, # 10 MB buffer for large tool lists ) + # Drain stderr in the background. Two reasons: (1) the OS pipe buffer is + # ~64 KB; if npx prints more than that during a cold-cache install + # (which happens when AV scanning slows npm), the child blocks on + # write and we'd see what looks like a hang. (2) the rolling tail lets + # us include npx's own diagnostic in any error we surface, instead of + # the opaque "discovery failed" we used to show. + stderr_tail: list[str] = [] + + async def _drain_stderr() -> None: + try: + while True: + chunk = await proc.stderr.readline() + if not chunk: + return + stderr_tail.append(chunk.decode(errors="replace")) + if len(stderr_tail) > 50: + del stderr_tail[: len(stderr_tail) - 50] + except asyncio.CancelledError: + return + except Exception: + return + + stderr_task = asyncio.create_task(_drain_stderr()) + async def _send(msg: dict) -> None: line = json.dumps(msg) + "\n" proc.stdin.write(line.encode()) await proc.stdin.drain() - async def _recv() -> dict: + async def _recv(timeout_s: float = 30.0) -> dict: """Read JSON-RPC responses, skipping notification lines (no 'id' field).""" while True: - line = await asyncio.wait_for(proc.stdout.readline(), timeout=30.0) + line = await asyncio.wait_for(proc.stdout.readline(), timeout=timeout_s) if not line: - stderr_out = "" + # stdout EOF = child exited. Wait briefly for the stderr + # drain to catch up so we capture the real failure reason + # (which often arrives a few ms after stdout closes). try: - stderr_out = (await asyncio.wait_for(proc.stderr.read(4096), timeout=2.0)).decode(errors="replace") - except (asyncio.TimeoutError, Exception): + await asyncio.wait_for(asyncio.shield(stderr_task), timeout=1.0) + except (asyncio.TimeoutError, asyncio.CancelledError, Exception): pass + tail = "".join(stderr_tail[-10:]).strip() raise HTTPException( status_code=502, - detail=f"MCP stdio process exited unexpectedly{': ' + stderr_out if stderr_out else ''}", + detail=f"MCP stdio process exited unexpectedly{': ' + tail if tail else ''}", ) stripped = line.decode(errors="replace").strip() if not stripped: @@ -685,7 +712,12 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None, "clientInfo": {"name": "self-swarm", "version": "0.1.0"}, }, }) - await _recv() + # First response is the slow one. On Windows with a cold npx cache, + # `npx -y ` has to download the package + transitive deps and + # AV-scan every file npm writes; total install time often exceeds + # 60 s and occasionally pushes past 90 s. Subsequent reads run + # against an already-running server and stay at the default 30 s. + await _recv(timeout_s=120.0) await _send({"jsonrpc": "2.0", "method": "notifications/initialized"}) @@ -696,12 +728,30 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None, return [{"name": t.get("name", ""), "description": t.get("description", ""), "inputSchema": t.get("inputSchema")} for t in tools_list] except HTTPException as e: + # Heal-on-corrupt-npx-cache still triggers from the EOF branch, + # which now includes the full stderr tail in `e.detail` — so the + # ERR_MODULE_NOT_FOUND signature is still discoverable here. if _attempt == 0 and _try_heal_npx_cache(str(e.detail) if e.detail is not None else ""): return await _discover_mcp_tools_stdio(command, args, env, _attempt=1) raise except asyncio.TimeoutError: - raise HTTPException(status_code=504, detail="MCP stdio server timed out during discovery") + # Most common cause: cold npx cache on Windows. The npm install + # persists across attempts, so a retry usually finishes against a + # warm cache. Surface npx's own progress line if we have one — it + # makes the cause obvious ("downloading X...") instead of opaque. + tail_text = "".join(stderr_tail[-5:]).strip() + detail = "MCP discovery timed out — the server may still be downloading on first run" + if tail_text: + preview = tail_text[-200:].replace("\n", " ").strip() + detail += f" (last output: {preview})" + detail += ". Try again in a moment." + raise HTTPException(status_code=504, detail=detail) finally: + stderr_task.cancel() + try: + await stderr_task + except (asyncio.CancelledError, Exception): + pass try: proc.stdin.close() except Exception: diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 828301a2..9e030899 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -996,6 +996,88 @@ def test_get_context_window_unknown_returns_default(): assert cw == 128_000 +# --------------------------------------------------------------------------- +# Custom OpenAI-compatible providers (Ollama Cloud, Together, etc.) +# --------------------------------------------------------------------------- + + +def test_custom_provider_value_synthesises_route_api_entry(): + """`custom//` picker values must synthesise a route='api', + api='custom' entry whose model_id is the 9Router routing string + `cp-/`. agent_manager keys on api='custom' and resolved_model + must be the cp- prefixed string for 9Router to forward correctly.""" + from backend.apps.agents.providers.registry import _find_builtin_model + entry = _find_builtin_model("custom/ollama-cloud/gpt-oss:120b") + assert entry is not None + assert entry.get("api") == "custom" + assert entry.get("route") == "api" + assert entry.get("model_id") == "cp-ollama-cloud/gpt-oss:120b" + assert entry.get("router_model_id") == "cp-ollama-cloud/gpt-oss:120b" + + +def test_custom_provider_value_resolve_model_id_returns_cp_prefix(): + from backend.apps.agents.providers.registry import resolve_model_id_for_sdk + from backend.apps.settings.models import AppSettings + rid = resolve_model_id_for_sdk("custom/ollama-cloud/gpt-oss:120b", AppSettings()) + assert rid == "cp-ollama-cloud/gpt-oss:120b" + + +def test_custom_provider_value_with_multi_segment_model_id(): + """Model ids may contain '/' (e.g. meta-llama/llama-3-70b-instruct on + Together AI). Synthesis must use partition on the FIRST '/' so the + rest of the model id stays intact.""" + from backend.apps.agents.providers.registry import _find_builtin_model + entry = _find_builtin_model("custom/together-ai/meta-llama/llama-3-70b-instruct") + assert entry is not None + assert entry.get("model_id") == "cp-together-ai/meta-llama/llama-3-70b-instruct" + + +def test_custom_provider_lookup_finds_entry_by_slug(): + """_find_custom_provider_for_value must slugify the same way as the + UI/sync layer so name 'Ollama Cloud' resolves to the value + 'custom/ollama-cloud/...'.""" + from backend.apps.agents.providers.registry import _find_custom_provider_for_value + from backend.apps.settings.models import AppSettings, CustomProvider + s = AppSettings(custom_providers=[ + CustomProvider(name="Ollama Cloud", base_url="https://ollama.com/v1", api_key="x"), + CustomProvider(name="Together AI", base_url="https://api.together.xyz/v1", api_key="y"), + ]) + cp = _find_custom_provider_for_value(s, "custom/ollama-cloud/gpt-oss:120b") + assert cp is not None and cp.name == "Ollama Cloud" + cp2 = _find_custom_provider_for_value(s, "custom/together-ai/meta-llama/llama-3-70b") + assert cp2 is not None and cp2.name == "Together AI" + # Unknown slug → None. + assert _find_custom_provider_for_value(s, "custom/nonexistent/whatever") is None + + +def test_get_context_window_custom_provider_value_format(): + """Picker values use `custom//` but the user-stored model + list keys context_window by the bare model id. Lookup must strip the + prefix before matching.""" + from backend.apps.agents.providers.registry import get_context_window + from backend.apps.settings.models import AppSettings, CustomProvider + s = AppSettings(custom_providers=[ + CustomProvider( + name="Together AI", + base_url="https://api.together.xyz/v1", + api_key="x", + models=[{"value": "deepseek-r1", "label": "DeepSeek R1", "context_window": 64_000}], + ), + ]) + assert get_context_window("Together AI", "custom/together-ai/deepseek-r1", s) == 64_000 + + +def test_custom_provider_slug_is_url_safe(): + """The slug must be alnum-and-dash only — it's used both as the 9Router + prefix and as a URL path segment. Spaces, slashes, and special chars + must all be folded to dashes.""" + from backend.apps.agents.providers.registry import _custom_provider_slug_for_lookup + assert _custom_provider_slug_for_lookup("Ollama Cloud") == "ollama-cloud" + assert _custom_provider_slug_for_lookup("My/Local LM!!!") == "my-local-lm" + assert _custom_provider_slug_for_lookup("") == "custom" + assert _custom_provider_slug_for_lookup(" ") == "custom" + + # =========================================================================== # Group S — calculate_cost regression tests # =========================================================================== diff --git a/electron/package-lock.json b/electron/package-lock.json index f9a11ba9..cbdfa636 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.0.28", + "version": "1.0.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.0.28", + "version": "1.0.29", "hasInstallScript": true, "dependencies": { "electron-updater": "^6.3.0", diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 822fa2c7..fa4176b1 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -41,7 +41,7 @@ import LinearProgress from '@mui/material/LinearProgress'; import Collapse from '@mui/material/Collapse'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { updateSettings, closeSettingsModal, resetSystemPrompt, disconnectSubscription, signOut, AppSettings, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice'; +import { updateSettings, closeSettingsModal, resetSystemPrompt, disconnectSubscription, signOut, AppSettings, CustomProvider, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice'; import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { fetchModels } from '@/shared/state/modelsSlice'; import { setChecking, setUpdateError, setInstalling } from '@/shared/state/updateSlice'; @@ -2327,6 +2327,253 @@ const Settings: React.FC = () => { + {/* Custom Providers — OpenAI-compatible endpoints (Ollama Cloud, Together AI, local Ollama, etc.) */} + + + Custom Providers + {(() => { + const list = form.custom_providers || []; + if (list.length === 0) return null; + const readyCount = list.filter(cp => { + const filled = (cp.models || []).filter(m => (m.value || '').trim()).length; + return !!cp.name?.trim() && !!cp.base_url?.trim() && !!cp.api_key?.trim() && filled > 0; + }).length; + const allReady = readyCount === list.length; + return ( + + {readyCount} OF {list.length} READY + + ); + })()} + + + Add OpenAI-compatible endpoints — Ollama Cloud, Together, Groq, local Ollama, anything that speaks /v1/chat/completions. + + + + {(form.custom_providers || []).map((cp, idx) => { + const list = form.custom_providers || []; + const updateProvider = (patch: Partial) => { + const next = list.map((x, i) => (i === idx ? { ...x, ...patch } : x)); + setForm({ ...form, custom_providers: next }); + }; + const removeProvider = () => { + const next = list.filter((_, i) => i !== idx); + setForm({ ...form, custom_providers: next }); + }; + const addModel = () => { + const nextModels = [...(cp.models || []), { value: '', label: '' }]; + updateProvider({ models: nextModels }); + }; + const updateModel = (mIdx: number, value: string) => { + const nextModels = (cp.models || []).map((m, i) => + i === mIdx ? { ...m, value, label: value } : m + ); + updateProvider({ models: nextModels }); + }; + const removeModel = (mIdx: number) => { + const nextModels = (cp.models || []).filter((_, i) => i !== mIdx); + updateProvider({ models: nextModels }); + }; + const filledModelCount = (cp.models || []).filter(m => (m.value || '').trim()).length; + const nameMissing = !cp.name?.trim(); + const urlMissing = !cp.base_url?.trim(); + const keyMissing = !cp.api_key?.trim(); + const modelsMissing = filledModelCount === 0; + const isReady = !nameMissing && !urlMissing && !keyMissing && !modelsMissing; + const dupeNameWithEarlier = list.findIndex((other, i) => + i < idx && (other.name || '').trim().toLowerCase() === (cp.name || '').trim().toLowerCase() && (cp.name || '').trim() !== '' + ) !== -1; + const missingLabels: string[] = []; + if (nameMissing) missingLabels.push('name'); + if (urlMissing) missingLabels.push('base URL'); + if (keyMissing) missingLabels.push('API key'); + if (modelsMissing) missingLabels.push('a model'); + + return ( + + + + + {isReady ? 'Ready' : `Incomplete · add ${missingLabels.join(', ')}`} + + + + + + + updateProvider({ name: e.target.value })} + size="small" + fullWidth + placeholder="e.g. Ollama Cloud" + label="Name" + required + error={dupeNameWithEarlier} + helperText={dupeNameWithEarlier ? 'Name must be unique' : undefined} + InputLabelProps={{ shrink: true, sx: { fontSize: '0.72rem', color: c.text.tertiary } }} + sx={fieldSx} + /> + updateProvider({ base_url: e.target.value })} + size="small" + fullWidth + placeholder="https://ollama.com/v1" + label="Base URL" + required + InputLabelProps={{ shrink: true, sx: { fontSize: '0.72rem', color: c.text.tertiary } }} + sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }} + /> + updateProvider({ api_key: e.target.value })} + size="small" + fullWidth + placeholder="API key" + label="API Key" + required + InputLabelProps={{ shrink: true, sx: { fontSize: '0.72rem', color: c.text.tertiary } }} + sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }} + InputProps={{ + endAdornment: ( + + setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}> + {showApiKey ? : } + + + ), + }} + /> + + + + + Models + + {((cp.models || []).length === 0) ? ( + + No models yet — add the model IDs this endpoint serves. + + ) : ( + (cp.models || []).map((m, mIdx) => ( + + updateModel(mIdx, e.target.value)} + size="small" + fullWidth + placeholder="e.g. gpt-oss:120b" + sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono, fontSize: '0.78rem' } }} + /> + removeModel(mIdx)} + size="small" + title="Remove model" + sx={{ + color: c.text.tertiary, + '&:hover': { color: c.status.error, bgcolor: `${c.status.error}10` }, + }} + > + + + + )) + )} + + + + ); + })} + + + + + ) : activeTab === 'usage' ? ( diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index 531ab3e4..3643a439 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -579,7 +579,8 @@ const Tools: React.FC = () => { if (discoverTools.fulfilled.match(discoverResult)) { setSnackbar({ open: true, message: `${integration.name} ready — actions discovered` }); } else { - setSnackbar({ open: true, message: `${integration.name} enabled but discovery failed`, severity: 'error' }); + const detail = (discoverResult as any).error?.message || 'discovery failed'; + setSnackbar({ open: true, message: `${integration.name}: ${detail}`, severity: 'error' }); } } } else { @@ -602,7 +603,9 @@ const Tools: React.FC = () => { if (discoverTools.fulfilled.match(discoverResult)) { setSnackbar({ open: true, message: `${integration.name} ready — actions discovered` }); } else { - setSnackbar({ open: true, message: `${integration.name} enabled but discovery failed — is ${integration.mcp_config.command || 'the server'} installed?`, severity: 'error' }); + const detail = (discoverResult as any).error?.message + || `discovery failed — is ${integration.mcp_config.command || 'the server'} installed?`; + setSnackbar({ open: true, message: `${integration.name}: ${detail}`, severity: 'error' }); } } } @@ -880,7 +883,9 @@ const Tools: React.FC = () => { if (discoverTools.fulfilled.match(discoverResult)) { setSnackbar({ open: true, message: `${f.name} ready — actions discovered` }); } else { - setSnackbar({ open: true, message: `${f.name} installed but discovery failed — the MCP server may need setup first`, severity: 'error' }); + const detail = (discoverResult as any).error?.message + || 'discovery failed — the MCP server may need setup first'; + setSnackbar({ open: true, message: `${f.name}: ${detail}`, severity: 'error' }); } } } else {