mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 14:28:59 +02:00
[eric] custom OpenAI-compatible provider support, youtube bug fix (windows)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# ===========================================================================
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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 } } }}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user