[eric] custom OpenAI-compatible provider support, youtube bug fix (windows)

This commit is contained in:
ciregenz
2026-05-07 21:22:40 -07:00
parent 947b8f9155
commit b3428a736b
11 changed files with 720 additions and 28 deletions
+62 -2
View File
@@ -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"
+35
View File
@@ -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/<slug>/<model_id>` 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}
+59 -8
View File
@@ -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/<slug>/<model_id>` 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:<vendor>/<model>`) 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:<vendor>/<model>`) and custom-provider
entries (prefixed `custom/<slug>/<model_id>`) 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-<slug>/<model>` 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/<slug>/<bare_model>`;
# 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
+141
View File
@@ -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-<rand>` and a user-defined
# `prefix`. At request time, model_id `<prefix>/<bare_model>` 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-<slug>`,
# letting us address each provider as `cp-<slug>/<model_id>` 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`.
+6 -3
View File
@@ -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",
+19 -1
View File
@@ -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,
))
+58 -8
View File
@@ -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 <pkg>` 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:
+82
View File
@@ -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/<slug>/<bare>` picker values must synthesise a route='api',
api='custom' entry whose model_id is the 9Router routing string
`cp-<slug>/<bare>`. 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/<slug>/<bare>` 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
# ===========================================================================
+2 -2
View File
@@ -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",
+248 -1
View File
@@ -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 = () => {
</Box>
</Box>
{/* Custom Providers — OpenAI-compatible endpoints (Ollama Cloud, Together AI, local Ollama, etc.) */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 0.25 }}>
<Typography sx={labelSx}>Custom Providers</Typography>
{(() => {
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 (
<Typography sx={{
fontSize: '0.6rem',
fontWeight: 600,
color: allReady ? c.status.success : c.status.warning,
bgcolor: allReady ? `${c.status.success}15` : `${c.status.warning}1F`,
px: 0.75, py: 0.15, borderRadius: '3px',
}}>
{readyCount} OF {list.length} READY
</Typography>
);
})()}
</Box>
<Typography sx={{ ...descSx, mb: 1.25 }}>
Add OpenAI-compatible endpoints Ollama Cloud, Together, Groq, local Ollama, anything that speaks /v1/chat/completions.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{(form.custom_providers || []).map((cp, idx) => {
const list = form.custom_providers || [];
const updateProvider = (patch: Partial<CustomProvider>) => {
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 (
<Box
key={idx}
sx={{
p: 1.5,
borderRadius: `${c.radius.md}px`,
border: `1px solid ${isReady ? c.status.success + '30' : c.status.warning + '40'}`,
bgcolor: isReady ? `${c.status.success}04` : `${c.status.warning}06`,
transition: 'all 0.2s ease',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: -0.25 }}>
<Typography sx={{
fontSize: '0.62rem',
fontWeight: 700,
letterSpacing: '0.05em',
textTransform: 'uppercase' as const,
color: isReady ? c.status.success : c.status.warning,
bgcolor: isReady ? `${c.status.success}15` : `${c.status.warning}1F`,
px: 0.75,
py: 0.2,
borderRadius: '3px',
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
}}>
<Box component="span" sx={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
bgcolor: isReady ? c.status.success : c.status.warning,
}} />
{isReady ? 'Ready' : `Incomplete · add ${missingLabels.join(', ')}`}
</Typography>
<IconButton
onClick={removeProvider}
size="small"
title="Remove provider"
sx={{
color: c.text.tertiary,
'&:hover': { color: c.status.error, bgcolor: `${c.status.error}10` },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<TextField
value={cp.name || ''}
onChange={(e) => 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}
/>
<TextField
value={cp.base_url || ''}
onChange={(e) => 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 } }}
/>
<TextField
type={showApiKey ? 'text' : 'password'}
value={cp.api_key || ''}
onChange={(e) => 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: (
<InputAdornment position="end">
<IconButton onClick={() => setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 0.25 }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase' as const, letterSpacing: '0.05em' }}>
Models
</Typography>
{((cp.models || []).length === 0) ? (
<Typography sx={{ fontSize: '0.7rem', color: c.text.muted, fontStyle: 'italic', px: 0.5 }}>
No models yet add the model IDs this endpoint serves.
</Typography>
) : (
(cp.models || []).map((m, mIdx) => (
<Box key={mIdx} sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
<TextField
value={m.value || ''}
onChange={(e) => 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' } }}
/>
<IconButton
onClick={() => removeModel(mIdx)}
size="small"
title="Remove model"
sx={{
color: c.text.tertiary,
'&:hover': { color: c.status.error, bgcolor: `${c.status.error}10` },
}}
>
<CloseIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
))
)}
<Button
onClick={addModel}
size="small"
sx={{
alignSelf: 'flex-start',
mt: 0.25,
textTransform: 'none',
color: c.accent.primary,
fontSize: '0.72rem',
minWidth: 'auto',
px: 0.75,
py: 0.25,
'&:hover': { bgcolor: `${c.accent.primary}10` },
}}
>
+ Add model
</Button>
</Box>
</Box>
);
})}
<Button
onClick={() => {
const next: CustomProvider[] = [
...(form.custom_providers || []),
{ name: '', base_url: '', api_key: '', models: [{ value: '', label: '' }] },
];
setForm({ ...form, custom_providers: next });
}}
variant="outlined"
size="small"
sx={{
alignSelf: 'flex-start',
textTransform: 'none',
color: c.text.primary,
borderColor: c.border.medium,
borderStyle: 'dashed',
fontSize: '0.78rem',
px: 1.5,
py: 0.6,
'&:hover': { borderColor: c.accent.primary, bgcolor: `${c.accent.primary}08` },
transition: 'all 0.2s ease',
}}
>
+ Add Custom Provider
</Button>
</Box>
</Box>
</Box>
) : activeTab === 'usage' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
+8 -3
View File
@@ -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 {