mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +00:00
refactor: decompose ui and model list hotspots
This commit is contained in:
+63
-228
@@ -7,7 +7,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.import_fallback import import_attrs_dual, import_module_dual
|
||||
@@ -167,49 +166,42 @@ except Exception:
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.config")
|
||||
|
||||
# R60: Bounded model list cache with TTL + LRU eviction.
|
||||
# Key: (provider, base_url) -> (timestamp, models[])
|
||||
# - TTL: entries older than _MODEL_LIST_TTL_SEC are treated as stale on read.
|
||||
# - Size cap: at most _MODEL_LIST_MAX_ENTRIES; oldest entry evicted on insert.
|
||||
from collections import OrderedDict
|
||||
|
||||
_MODEL_LIST_CACHE: OrderedDict = OrderedDict()
|
||||
_MODEL_LIST_TTL_SEC = 600 # 10 minutes
|
||||
_MODEL_LIST_MAX_ENTRIES = 16
|
||||
|
||||
|
||||
def _build_model_cache_key(provider: str, base_url: str, tenant_id: str) -> tuple:
|
||||
# S49: keep single-tenant key shape stable while isolating multi-tenant entries.
|
||||
if str(tenant_id).strip().lower() in ("", "default"):
|
||||
return (provider, base_url)
|
||||
return (tenant_id, provider, base_url)
|
||||
|
||||
|
||||
def _cache_put(key: tuple, models: list) -> None:
|
||||
"""Insert into bounded cache, evicting oldest if over cap."""
|
||||
if key in _MODEL_LIST_CACHE:
|
||||
_MODEL_LIST_CACHE.move_to_end(key)
|
||||
_MODEL_LIST_CACHE[key] = (time.time(), models)
|
||||
while len(_MODEL_LIST_CACHE) > _MODEL_LIST_MAX_ENTRIES:
|
||||
_MODEL_LIST_CACHE.popitem(last=False)
|
||||
|
||||
|
||||
def _cache_get(key: tuple):
|
||||
"""Return (timestamp, models) if fresh, else None.
|
||||
|
||||
Expired entries are NOT removed — they remain available for fallback
|
||||
on network failure (handler reads _MODEL_LIST_CACHE directly).
|
||||
Eviction is handled only by the size cap in _cache_put.
|
||||
"""
|
||||
entry = _MODEL_LIST_CACHE.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
ts, models = entry
|
||||
if (time.time() - ts) >= _MODEL_LIST_TTL_SEC:
|
||||
return None
|
||||
# Touch for LRU
|
||||
_MODEL_LIST_CACHE.move_to_end(key)
|
||||
return entry
|
||||
(
|
||||
_MODEL_LIST_CACHE,
|
||||
_MODEL_LIST_MAX_ENTRIES,
|
||||
_MODEL_LIST_TTL_SEC,
|
||||
_build_model_cache_key,
|
||||
_cache_get,
|
||||
_cache_put,
|
||||
_extract_models_from_payload,
|
||||
_format_llm_ssrf_error,
|
||||
_get_llm_allowed_hosts,
|
||||
_llm_insecure_override_enabled,
|
||||
fetch_remote_model_list,
|
||||
get_stale_cached_models,
|
||||
resolve_model_list_target,
|
||||
validate_model_list_target,
|
||||
) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.llm_model_list",
|
||||
"services.llm_model_list",
|
||||
(
|
||||
"_MODEL_LIST_CACHE",
|
||||
"_MODEL_LIST_MAX_ENTRIES",
|
||||
"_MODEL_LIST_TTL_SEC",
|
||||
"build_model_cache_key",
|
||||
"cache_get",
|
||||
"cache_put",
|
||||
"extract_models_from_payload",
|
||||
"format_llm_ssrf_error",
|
||||
"get_llm_allowed_hosts",
|
||||
"llm_insecure_override_enabled",
|
||||
"fetch_remote_model_list",
|
||||
"get_stale_cached_models",
|
||||
"resolve_model_list_target",
|
||||
"validate_model_list_target",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# S14/R98 / R64: Import Endpoint Metadata
|
||||
@@ -345,90 +337,6 @@ async def config_get_handler(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
def _env_flag(primary: str, legacy: str, default: bool = False) -> bool:
|
||||
import os
|
||||
|
||||
val = os.environ.get(primary)
|
||||
if val is None:
|
||||
val = os.environ.get(legacy)
|
||||
if val is None:
|
||||
return default
|
||||
return str(val).strip().lower() in ("1", "true", "yes", "y", "on")
|
||||
|
||||
|
||||
def _get_llm_allowed_hosts() -> set:
|
||||
import os
|
||||
|
||||
allowed_hosts_str = os.environ.get("OPENCLAW_LLM_ALLOWED_HOSTS") or os.environ.get(
|
||||
"MOLTBOT_LLM_ALLOWED_HOSTS", ""
|
||||
)
|
||||
env_hosts = {h.lower().strip() for h in allowed_hosts_str.split(",") if h.strip()}
|
||||
|
||||
# Default allowlist: built-in provider public hosts.
|
||||
# This makes core providers work out-of-the-box while keeping custom base URLs
|
||||
# constrained to explicit allowlists (or OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1).
|
||||
try:
|
||||
from ..services.providers.catalog import get_default_public_llm_hosts
|
||||
except ImportError: # pragma: no cover
|
||||
from services.providers.catalog import (
|
||||
get_default_public_llm_hosts, # type: ignore
|
||||
)
|
||||
|
||||
return set(get_default_public_llm_hosts()) | env_hosts
|
||||
|
||||
|
||||
def _format_llm_ssrf_error(exc: Exception) -> str:
|
||||
detail = str(exc)
|
||||
# IMPORTANT: keep this guidance aligned with services.runtime_config so Remote
|
||||
# Admin config writes and model refreshes explain the same fail-closed policy.
|
||||
return (
|
||||
f"SSRF policy blocked outbound URL: {detail}. "
|
||||
"OPENCLAW_LLM_ALLOWED_HOSTS only allows additional exact public hosts; "
|
||||
"private/reserved IP targets still require "
|
||||
"OPENCLAW_ALLOW_INSECURE_BASE_URL=1. Wildcard '*' entries are not "
|
||||
"supported."
|
||||
)
|
||||
|
||||
|
||||
def _llm_insecure_override_enabled() -> bool:
|
||||
return _env_flag(
|
||||
"OPENCLAW_ALLOW_INSECURE_BASE_URL",
|
||||
"MOLTBOT_ALLOW_INSECURE_BASE_URL",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
def _extract_models_from_payload(payload: dict) -> list:
|
||||
"""
|
||||
Extract model IDs from common provider responses.
|
||||
Expected OpenAI format: {"data":[{"id":"..."}]}
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
|
||||
data = payload.get("data")
|
||||
if isinstance(data, list):
|
||||
out = []
|
||||
for item in data:
|
||||
if isinstance(item, str):
|
||||
out.append(item)
|
||||
elif isinstance(item, dict) and isinstance(item.get("id"), str):
|
||||
out.append(item["id"])
|
||||
return sorted({m for m in out if m})
|
||||
|
||||
models = payload.get("models")
|
||||
if isinstance(models, list):
|
||||
out = []
|
||||
for item in models:
|
||||
if isinstance(item, str):
|
||||
out.append(item)
|
||||
elif isinstance(item, dict) and isinstance(item.get("id"), str):
|
||||
out.append(item["id"])
|
||||
return sorted({m for m in out if m})
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW, # Read-only external fetch, but admin-gated
|
||||
@@ -504,56 +412,26 @@ async def llm_models_handler(request: web.Request) -> web.Response:
|
||||
|
||||
provider_override = (request.query.get("provider") or "").strip().lower()
|
||||
effective, _sources = get_effective_config(tenant_id=tenant.tenant_id)
|
||||
provider = provider_override or (effective.get("provider") or "openai")
|
||||
|
||||
# Resolve Base URL (Runtime config > Catalog Default)
|
||||
# Allows users to override base_url for standard providers (e.g. self-hosted OpenAI compat)
|
||||
runtime_base_url = (effective.get("base_url") or "").strip()
|
||||
|
||||
try:
|
||||
from ..services.providers.catalog import ProviderType, get_provider_info
|
||||
from ..services.providers.keys import (
|
||||
get_api_key_for_provider,
|
||||
requires_api_key,
|
||||
target = resolve_model_list_target(
|
||||
provider_override,
|
||||
effective,
|
||||
tenant.tenant_id,
|
||||
)
|
||||
except ImportError:
|
||||
from services.providers.catalog import ProviderType, get_provider_info
|
||||
from services.providers.keys import (
|
||||
get_api_key_for_provider,
|
||||
requires_api_key,
|
||||
)
|
||||
|
||||
info = get_provider_info(provider)
|
||||
if not info:
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": f"Unknown provider: {provider}"}, status=400
|
||||
)
|
||||
|
||||
if info.api_type != ProviderType.OPENAI_COMPAT:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Model list is only supported for OpenAI-compatible providers.",
|
||||
},
|
||||
{"ok": False, "error": str(e)},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Priority: Runtime URL -> Info Default
|
||||
base_url = runtime_base_url if runtime_base_url else info.base_url
|
||||
if not base_url:
|
||||
except TypeError as e:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"No base URL configured for provider '{provider}'.",
|
||||
},
|
||||
{"ok": False, "error": str(e)},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# R60: Cache key includes provider + base_url to avoid cross-provider staleness.
|
||||
cache_key = _build_model_cache_key(provider, base_url, tenant.tenant_id)
|
||||
|
||||
# R60: Check bounded TTL+LRU cache
|
||||
cached_entry = _cache_get(cache_key)
|
||||
cached_entry = _cache_get(target.cache_key)
|
||||
if cached_entry:
|
||||
_ts, models = cached_entry
|
||||
if isinstance(models, list):
|
||||
@@ -561,47 +439,32 @@ async def llm_models_handler(request: web.Request) -> web.Response:
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": provider,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": True,
|
||||
}
|
||||
)
|
||||
|
||||
api_key = get_api_key_for_provider(provider, tenant_id=tenant.tenant_id)
|
||||
# CRITICAL:
|
||||
# Local providers (e.g. ollama/lmstudio) intentionally work without API keys.
|
||||
# Do not change this gate back to `if not api_key`, or local model-list loading
|
||||
# will regress with false 400 errors.
|
||||
if requires_api_key(provider) and not api_key:
|
||||
if target.requires_api_key and not target.api_key:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"No API key configured for provider '{provider}'.",
|
||||
"error": f"No API key configured for provider '{target.provider}'.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# SSRF policy
|
||||
try:
|
||||
try:
|
||||
from ..services.safe_io import (
|
||||
STANDARD_OUTBOUND_POLICY,
|
||||
validate_outbound_url,
|
||||
)
|
||||
except ImportError:
|
||||
from services.safe_io import ( # type: ignore
|
||||
STANDARD_OUTBOUND_POLICY,
|
||||
validate_outbound_url,
|
||||
)
|
||||
|
||||
controls = get_llm_egress_controls(provider, base_url)
|
||||
validate_outbound_url(
|
||||
base_url,
|
||||
allow_hosts=controls.get("allow_hosts"),
|
||||
allow_any_public_host=bool(controls.get("allow_any_public_host")),
|
||||
allow_loopback_hosts=controls.get("allow_loopback_hosts"),
|
||||
controls = get_llm_egress_controls(target.provider, target.base_url)
|
||||
validate_model_list_target(
|
||||
target,
|
||||
controls,
|
||||
allow_insecure_base_url=_llm_insecure_override_enabled(),
|
||||
policy=STANDARD_OUTBOUND_POLICY,
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response(
|
||||
@@ -612,50 +475,22 @@ async def llm_models_handler(request: web.Request) -> web.Response:
|
||||
# Fetch /models
|
||||
try:
|
||||
try:
|
||||
from ..services.safe_io import (
|
||||
STANDARD_OUTBOUND_POLICY,
|
||||
SSRFError,
|
||||
safe_request_json,
|
||||
)
|
||||
from ..services.safe_io import SSRFError
|
||||
except ImportError:
|
||||
from services.safe_io import ( # type: ignore
|
||||
STANDARD_OUTBOUND_POLICY,
|
||||
SSRFError,
|
||||
safe_request_json,
|
||||
)
|
||||
from services.safe_io import SSRFError # type: ignore
|
||||
|
||||
url = f"{base_url.rstrip('/')}/models"
|
||||
request_headers = {
|
||||
"User-Agent": f"ComfyUI-OpenClaw/{PACK_VERSION}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if api_key:
|
||||
request_headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
# S65: Enforce outbound policy via safe_io
|
||||
payload = safe_request_json(
|
||||
method="GET",
|
||||
url=url,
|
||||
json_body=None,
|
||||
headers=request_headers,
|
||||
timeout_sec=10,
|
||||
policy=STANDARD_OUTBOUND_POLICY,
|
||||
allow_hosts=controls.get("allow_hosts"),
|
||||
allow_any_public_host=bool(controls.get("allow_any_public_host")),
|
||||
allow_loopback_hosts=controls.get("allow_loopback_hosts"),
|
||||
models = fetch_remote_model_list(
|
||||
target,
|
||||
controls,
|
||||
pack_version=PACK_VERSION,
|
||||
allow_insecure_base_url=_llm_insecure_override_enabled(),
|
||||
)
|
||||
|
||||
models = _extract_models_from_payload(payload)
|
||||
|
||||
# R60: Insert/update bounded cache
|
||||
_cache_put(cache_key, models)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": provider,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": False,
|
||||
}
|
||||
@@ -671,7 +506,7 @@ async def llm_models_handler(request: web.Request) -> web.Response:
|
||||
str_e = str(e)
|
||||
if "HTTP" in str_e:
|
||||
# Fallback: serve stale cache entry (if any) on fetch failure
|
||||
stale = _MODEL_LIST_CACHE.get(cache_key)
|
||||
stale = get_stale_cached_models(target.cache_key)
|
||||
if stale:
|
||||
_ts, models = stale
|
||||
warning = f"Using cached list (refresh failed: {str_e})"
|
||||
@@ -679,7 +514,7 @@ async def llm_models_handler(request: web.Request) -> web.Response:
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": provider,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": True,
|
||||
"warning": warning,
|
||||
@@ -691,7 +526,7 @@ async def llm_models_handler(request: web.Request) -> web.Response:
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
stale = _MODEL_LIST_CACHE.get(cache_key)
|
||||
stale = get_stale_cached_models(target.cache_key)
|
||||
if stale:
|
||||
# IMPORTANT:
|
||||
# Test path intentionally injects network failures to verify cache fallback.
|
||||
@@ -705,7 +540,7 @@ async def llm_models_handler(request: web.Request) -> web.Response:
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": provider,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": True,
|
||||
"warning": warning,
|
||||
|
||||
@@ -70,7 +70,9 @@ def emit_legacy_header_warning(
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> None:
|
||||
_increment_legacy_api_hits()
|
||||
active_logger = logger or logging.getLogger("ComfyUI-OpenClaw.services.legacy_compat")
|
||||
active_logger = logger or logging.getLogger(
|
||||
"ComfyUI-OpenClaw.services.legacy_compat"
|
||||
)
|
||||
active_logger.warning(
|
||||
"DEPRECATION WARNING: Legacy header %s used. Please migrate to %s.",
|
||||
alias.legacy,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
R150 model-list helper seam.
|
||||
|
||||
Centralizes cache, provider resolution, and outbound fetch helpers so
|
||||
`api.config` can stay focused on HTTP/auth flow while preserving its legacy
|
||||
test compatibility surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
|
||||
if __package__ and "." in __package__:
|
||||
from .import_fallback import import_module_dual
|
||||
else:
|
||||
from services.import_fallback import import_module_dual # type: ignore
|
||||
|
||||
_MODEL_LIST_CACHE: OrderedDict = OrderedDict()
|
||||
_MODEL_LIST_TTL_SEC = 600
|
||||
_MODEL_LIST_MAX_ENTRIES = 16
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelListTarget:
|
||||
provider: str
|
||||
base_url: str
|
||||
tenant_id: str
|
||||
cache_key: tuple
|
||||
api_key: str | None
|
||||
requires_api_key: bool
|
||||
|
||||
|
||||
def _providers_catalog_module():
|
||||
return import_module_dual(
|
||||
__package__,
|
||||
".providers.catalog",
|
||||
"services.providers.catalog",
|
||||
)
|
||||
|
||||
|
||||
def _provider_keys_module():
|
||||
return import_module_dual(
|
||||
__package__,
|
||||
".providers.keys",
|
||||
"services.providers.keys",
|
||||
)
|
||||
|
||||
|
||||
def _safe_io_module():
|
||||
return import_module_dual(
|
||||
__package__,
|
||||
".safe_io",
|
||||
"services.safe_io",
|
||||
)
|
||||
|
||||
|
||||
def build_model_cache_key(provider: str, base_url: str, tenant_id: str) -> tuple:
|
||||
if str(tenant_id).strip().lower() in ("", "default"):
|
||||
return (provider, base_url)
|
||||
return (tenant_id, provider, base_url)
|
||||
|
||||
|
||||
def cache_put(key: tuple, models: list) -> None:
|
||||
if key in _MODEL_LIST_CACHE:
|
||||
_MODEL_LIST_CACHE.move_to_end(key)
|
||||
_MODEL_LIST_CACHE[key] = (time.time(), models)
|
||||
while len(_MODEL_LIST_CACHE) > _MODEL_LIST_MAX_ENTRIES:
|
||||
_MODEL_LIST_CACHE.popitem(last=False)
|
||||
|
||||
|
||||
def cache_get(key: tuple):
|
||||
entry = _MODEL_LIST_CACHE.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
ts, models = entry
|
||||
if (time.time() - ts) >= _MODEL_LIST_TTL_SEC:
|
||||
return None
|
||||
_MODEL_LIST_CACHE.move_to_end(key)
|
||||
return entry
|
||||
|
||||
|
||||
def get_stale_cached_models(key: tuple):
|
||||
return _MODEL_LIST_CACHE.get(key)
|
||||
|
||||
|
||||
def get_llm_allowed_hosts() -> set[str]:
|
||||
allowed_hosts_str = os.environ.get("OPENCLAW_LLM_ALLOWED_HOSTS") or os.environ.get(
|
||||
"MOLTBOT_LLM_ALLOWED_HOSTS", ""
|
||||
)
|
||||
env_hosts = {h.lower().strip() for h in allowed_hosts_str.split(",") if h.strip()}
|
||||
catalog = _providers_catalog_module()
|
||||
return set(catalog.get_default_public_llm_hosts()) | env_hosts
|
||||
|
||||
|
||||
def format_llm_ssrf_error(exc: Exception) -> str:
|
||||
detail = str(exc)
|
||||
return (
|
||||
f"SSRF policy blocked outbound URL: {detail}. "
|
||||
"OPENCLAW_LLM_ALLOWED_HOSTS only allows additional exact public hosts; "
|
||||
"private/reserved IP targets still require "
|
||||
"OPENCLAW_ALLOW_INSECURE_BASE_URL=1. Wildcard '*' entries are not "
|
||||
"supported."
|
||||
)
|
||||
|
||||
|
||||
def llm_insecure_override_enabled() -> bool:
|
||||
val = os.environ.get("OPENCLAW_ALLOW_INSECURE_BASE_URL")
|
||||
if val is None:
|
||||
val = os.environ.get("MOLTBOT_ALLOW_INSECURE_BASE_URL")
|
||||
if val is None:
|
||||
return False
|
||||
return str(val).strip().lower() in ("1", "true", "yes", "y", "on")
|
||||
|
||||
|
||||
def extract_models_from_payload(payload: dict) -> list[str]:
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
|
||||
data = payload.get("data")
|
||||
if isinstance(data, list):
|
||||
out = []
|
||||
for item in data:
|
||||
if isinstance(item, str):
|
||||
out.append(item)
|
||||
elif isinstance(item, dict) and isinstance(item.get("id"), str):
|
||||
out.append(item["id"])
|
||||
return sorted({model for model in out if model})
|
||||
|
||||
models = payload.get("models")
|
||||
if isinstance(models, list):
|
||||
out = []
|
||||
for item in models:
|
||||
if isinstance(item, str):
|
||||
out.append(item)
|
||||
elif isinstance(item, dict) and isinstance(item.get("id"), str):
|
||||
out.append(item["id"])
|
||||
return sorted({model for model in out if model})
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def resolve_model_list_target(
|
||||
provider_override: str,
|
||||
effective: dict,
|
||||
tenant_id: str,
|
||||
) -> ModelListTarget:
|
||||
catalog = _providers_catalog_module()
|
||||
keys = _provider_keys_module()
|
||||
|
||||
provider = provider_override or str(effective.get("provider") or "openai").lower()
|
||||
runtime_base_url = str(effective.get("base_url") or "").strip()
|
||||
info = catalog.get_provider_info(provider)
|
||||
if not info:
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
|
||||
if info.api_type != catalog.ProviderType.OPENAI_COMPAT:
|
||||
raise TypeError("Model list is only supported for OpenAI-compatible providers.")
|
||||
|
||||
base_url = runtime_base_url if runtime_base_url else info.base_url
|
||||
if not base_url:
|
||||
raise ValueError(f"No base URL configured for provider '{provider}'.")
|
||||
|
||||
return ModelListTarget(
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
tenant_id=tenant_id,
|
||||
cache_key=build_model_cache_key(provider, base_url, tenant_id),
|
||||
api_key=keys.get_api_key_for_provider(provider, tenant_id=tenant_id),
|
||||
requires_api_key=bool(keys.requires_api_key(provider)),
|
||||
)
|
||||
|
||||
|
||||
def validate_model_list_target(
|
||||
target: ModelListTarget,
|
||||
controls: dict,
|
||||
*,
|
||||
allow_insecure_base_url: bool,
|
||||
) -> None:
|
||||
safe_io = _safe_io_module()
|
||||
safe_io.validate_outbound_url(
|
||||
target.base_url,
|
||||
allow_hosts=controls.get("allow_hosts"),
|
||||
allow_any_public_host=bool(controls.get("allow_any_public_host")),
|
||||
allow_loopback_hosts=controls.get("allow_loopback_hosts"),
|
||||
allow_insecure_base_url=allow_insecure_base_url,
|
||||
policy=safe_io.STANDARD_OUTBOUND_POLICY,
|
||||
)
|
||||
|
||||
|
||||
def fetch_remote_model_list(
|
||||
target: ModelListTarget,
|
||||
controls: dict,
|
||||
*,
|
||||
pack_version: str,
|
||||
allow_insecure_base_url: bool,
|
||||
) -> list[str]:
|
||||
safe_io = _safe_io_module()
|
||||
request_headers = {
|
||||
"User-Agent": f"ComfyUI-OpenClaw/{pack_version}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if target.api_key:
|
||||
request_headers["Authorization"] = f"Bearer {target.api_key}"
|
||||
|
||||
payload = safe_io.safe_request_json(
|
||||
method="GET",
|
||||
url=f"{target.base_url.rstrip('/')}/models",
|
||||
json_body=None,
|
||||
headers=request_headers,
|
||||
timeout_sec=10,
|
||||
policy=safe_io.STANDARD_OUTBOUND_POLICY,
|
||||
allow_hosts=controls.get("allow_hosts"),
|
||||
allow_any_public_host=bool(controls.get("allow_any_public_host")),
|
||||
allow_loopback_hosts=controls.get("allow_loopback_hosts"),
|
||||
allow_insecure_base_url=allow_insecure_base_url,
|
||||
)
|
||||
models = extract_models_from_payload(payload)
|
||||
cache_put(target.cache_key, models)
|
||||
return models
|
||||
@@ -3,8 +3,8 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from services.legacy_compat import (
|
||||
ADMIN_TOKEN_HEADERS,
|
||||
OPENCLAW_API_PREFIX,
|
||||
LEGACY_API_PREFIX,
|
||||
OPENCLAW_API_PREFIX,
|
||||
get_api_path_candidates,
|
||||
get_header_alias_value,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.llm_model_list import (
|
||||
ModelListTarget,
|
||||
fetch_remote_model_list,
|
||||
resolve_model_list_target,
|
||||
)
|
||||
|
||||
|
||||
class LlmModelListServiceTests(unittest.TestCase):
|
||||
@patch("services.providers.keys.requires_api_key", return_value=False)
|
||||
@patch("services.providers.keys.get_api_key_for_provider", return_value=None)
|
||||
def test_resolve_target_uses_runtime_base_url(self, _mock_key, _mock_requires_key):
|
||||
target = resolve_model_list_target(
|
||||
provider_override="custom",
|
||||
effective={"provider": "custom", "base_url": "https://custom.example/v1"},
|
||||
tenant_id="tenant-a",
|
||||
)
|
||||
|
||||
self.assertEqual(target.provider, "custom")
|
||||
self.assertEqual(target.base_url, "https://custom.example/v1")
|
||||
self.assertEqual(
|
||||
target.cache_key,
|
||||
("tenant-a", "custom", "https://custom.example/v1"),
|
||||
)
|
||||
|
||||
@patch("services.safe_io.safe_request_json")
|
||||
def test_fetch_remote_model_list_builds_auth_header(self, mock_safe_request):
|
||||
mock_safe_request.return_value = {"data": [{"id": "gpt-4o-mini"}]}
|
||||
|
||||
target = ModelListTarget(
|
||||
provider="openai",
|
||||
base_url="https://api.openai.com/v1",
|
||||
tenant_id="default",
|
||||
cache_key=("openai", "https://api.openai.com/v1"),
|
||||
api_key="sk-test",
|
||||
requires_api_key=True,
|
||||
)
|
||||
|
||||
models = fetch_remote_model_list(
|
||||
target,
|
||||
{"allow_hosts": {"api.openai.com"}},
|
||||
pack_version="0.1.0",
|
||||
allow_insecure_base_url=False,
|
||||
)
|
||||
|
||||
self.assertEqual(models, ["gpt-4o-mini"])
|
||||
self.assertEqual(
|
||||
mock_safe_request.call_args.kwargs["headers"]["Authorization"],
|
||||
"Bearer sk-test",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,187 @@
|
||||
import { tabManager } from "./openclaw_tabs.js";
|
||||
import { openclawApi } from "./openclaw_api.js";
|
||||
import { buildDoctorAdvisoryBanner } from "./openclaw_security_advisory.js";
|
||||
|
||||
/**
|
||||
* F51: Unified Action Router.
|
||||
* Centralizes navigation and command logic for key operator tasks.
|
||||
*/
|
||||
export class OpenClawActions {
|
||||
constructor(ui, deps = {}) {
|
||||
this.ui = ui;
|
||||
this.api = deps.api || openclawApi;
|
||||
this.tabs = deps.tabs || tabManager;
|
||||
this.bannerBuilder = deps.bannerBuilder || buildDoctorAdvisoryBanner;
|
||||
this.documentRef = deps.documentRef || document;
|
||||
this.windowRef = deps.windowRef || window;
|
||||
this.setTimeoutRef = deps.setTimeoutRef || window.setTimeout.bind(window);
|
||||
if (Object.prototype.hasOwnProperty.call(deps, "capabilities")) {
|
||||
this.capabilities = deps.capabilities;
|
||||
this._initPromise = Promise.resolve(this.capabilities);
|
||||
} else {
|
||||
this.capabilities = null;
|
||||
this._initPromise = this._fetchCapabilities();
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchCapabilities() {
|
||||
try {
|
||||
const res = await this.api.getCapabilities();
|
||||
if (res.ok) {
|
||||
this.capabilities = res.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("OpenClawActions: Failed to fetch capabilities", e);
|
||||
}
|
||||
return this.capabilities;
|
||||
}
|
||||
|
||||
dispatch(actionId, context = null) {
|
||||
switch (actionId) {
|
||||
case "doctor":
|
||||
return this.openDoctor();
|
||||
case "queue":
|
||||
return this.openQueue();
|
||||
case "settings":
|
||||
return this.openSettings();
|
||||
case "inspect":
|
||||
return this.openExplorer();
|
||||
default:
|
||||
console.warn("Unknown action:", actionId, context);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
_checkAction(actionName) {
|
||||
if (!this.capabilities || !this.capabilities.actions) {
|
||||
return { enabled: true, mutating: false };
|
||||
}
|
||||
const cap = this.capabilities.actions[actionName] || {
|
||||
enabled: false,
|
||||
mutating: false,
|
||||
};
|
||||
|
||||
if (!cap.enabled && cap.blocked_reason) {
|
||||
this._showBlockedToast(actionName, cap.blocked_reason);
|
||||
}
|
||||
return cap;
|
||||
}
|
||||
|
||||
_showBlockedToast(actionName, reason) {
|
||||
const toast = this.documentRef.createElement("div");
|
||||
toast.className = "openclaw-blocked-toast";
|
||||
toast.style.cssText = `
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 99999;
|
||||
background: #1e1e2e; border: 1px solid #f59e0b;
|
||||
border-radius: 8px; padding: 12px 16px; max-width: 380px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
font-family: 'Inter', sans-serif; font-size: 13px;
|
||||
color: #e0e0e0; animation: slideIn 0.3s ease;
|
||||
`;
|
||||
toast.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||
<span style="font-size:16px;">!</span>
|
||||
<strong style="color:#f59e0b;">Action Blocked - Split Mode</strong>
|
||||
</div>
|
||||
<div style="margin-bottom:4px;"><code>${actionName}</code> is not available in the current deployment mode.</div>
|
||||
<div style="font-size:11px;color:#999;">${reason || "Use the external control plane for this operation."}</div>
|
||||
`;
|
||||
this.documentRef.body.appendChild(toast);
|
||||
this.setTimeoutRef(() => toast.remove(), 6000);
|
||||
}
|
||||
|
||||
async _runGuarded(actionName, fn) {
|
||||
await this._initPromise;
|
||||
const cap = this._checkAction(actionName);
|
||||
|
||||
if (!cap.enabled) {
|
||||
this.ui.showBanner("warning", `Action '${actionName}' is disabled by policy.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cap.mutating) {
|
||||
this.ui.showConfirm({
|
||||
title: "Confirm Action",
|
||||
message: `This action (${actionName}) will modify system state. Proceed?`,
|
||||
onConfirm: fn,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return fn();
|
||||
}
|
||||
|
||||
openSettings(section = "general") {
|
||||
this.tabs.activateTab("settings");
|
||||
return section;
|
||||
}
|
||||
|
||||
openQueue(filter = "all") {
|
||||
if (this.tabs.tabs["job-monitor"]) {
|
||||
this.tabs.activateTab("job-monitor");
|
||||
return filter;
|
||||
}
|
||||
this.tabs.activateTab("explorer");
|
||||
return filter;
|
||||
}
|
||||
|
||||
openDoctor() {
|
||||
return this._runGuarded("doctor", async () => {
|
||||
await this._openDoctorImpl();
|
||||
});
|
||||
}
|
||||
|
||||
async _openDoctorImpl() {
|
||||
this.tabs.activateTab("settings");
|
||||
try {
|
||||
const res = await this.api.fetch(this.api._path("/security/doctor"));
|
||||
if (res.ok && res.data) {
|
||||
const report = res.data.report || res.data;
|
||||
const advisoryBanner = this.bannerBuilder(report);
|
||||
if (advisoryBanner) {
|
||||
this.ui.showBanner(advisoryBanner);
|
||||
return;
|
||||
}
|
||||
|
||||
const issueCount = Array.isArray(report?.checks)
|
||||
? report.checks.filter(
|
||||
(check) => check?.severity === "warn" || check?.severity === "fail"
|
||||
).length
|
||||
: 0;
|
||||
this.ui.showBanner(
|
||||
issueCount > 0 ? "warning" : "success",
|
||||
issueCount > 0
|
||||
? `Doctor found ${issueCount} issues. See Settings for details.`
|
||||
: "Doctor check passed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Capability fallback below.
|
||||
}
|
||||
this.ui.showBanner(
|
||||
"info",
|
||||
"Doctor diagnostics endpoint unavailable. Open Settings for manual checks."
|
||||
);
|
||||
}
|
||||
|
||||
openExplorer(nodeType = null) {
|
||||
this.tabs.activateTab("explorer");
|
||||
return nodeType;
|
||||
}
|
||||
|
||||
openCompare(node = null) {
|
||||
this.tabs.activateTab("parameter-lab");
|
||||
if (node) {
|
||||
console.log("OpenClaw: Compare requested for", node.title || node.type);
|
||||
this.setTimeoutRef(() => {
|
||||
this.windowRef.dispatchEvent(
|
||||
new CustomEvent("openclaw:lab:compare", { detail: { node } })
|
||||
);
|
||||
this.windowRef.dispatchEvent(
|
||||
new CustomEvent("moltbot:lab:compare", { detail: { node } })
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { openclawApi } from "./openclaw_api.js";
|
||||
|
||||
/**
|
||||
* F48/F49: Queue Lifecycle Monitor.
|
||||
* Consumes R71 events (SSE) with polling fallback to show deduplicated status banners.
|
||||
* Handles disconnected state and recovery based on B-Strict/B-Loose contracts.
|
||||
*/
|
||||
export class QueueMonitor {
|
||||
constructor(ui, deps = {}) {
|
||||
this.ui = ui;
|
||||
this.api = deps.api || openclawApi;
|
||||
this.setIntervalRef = deps.setIntervalRef || window.setInterval.bind(window);
|
||||
this.now = deps.now || (() => Date.now());
|
||||
this.lastBannerTime = 0;
|
||||
this.lastStatusId = null;
|
||||
this.bannerTTL = 5000;
|
||||
this.es = null;
|
||||
this.isConnected = true;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.connectSSE();
|
||||
this.setIntervalRef(() => this.checkHealth(), 10000);
|
||||
}
|
||||
|
||||
connectSSE() {
|
||||
if (this.es) {
|
||||
this.es.close();
|
||||
}
|
||||
|
||||
this.es = this.api.subscribeEvents(
|
||||
(data) => this.handleEvent(data),
|
||||
(err) => this.handleConnectionError(err)
|
||||
);
|
||||
}
|
||||
|
||||
handleEvent(data) {
|
||||
if (!this.isConnected) {
|
||||
this.isConnected = true;
|
||||
this.showBanner("success", "\u2705 OpenClaw Backend Connected", "connection_restored", 3000);
|
||||
}
|
||||
|
||||
const type = data.event_type;
|
||||
const pid = data.prompt_id ? data.prompt_id.slice(0, 8) : "???";
|
||||
|
||||
switch (type) {
|
||||
case "queued":
|
||||
this.showBanner("info", `\u23F3 Job ${pid} queued`, `job_${type}`, 2000);
|
||||
break;
|
||||
case "running":
|
||||
this.showBanner("info", `\u25B6 Job ${pid} running...`, `job_${type}`, 5000);
|
||||
break;
|
||||
case "failed":
|
||||
this.showBanner("error", `\u274C Job ${pid} failed`, `job_${type}`, 10000);
|
||||
break;
|
||||
case "completed":
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionError(err) {
|
||||
if (this.isConnected) {
|
||||
this.isConnected = false;
|
||||
this.showBanner("error", "\u26A0\uFE0F Backend Disconnected. Retrying...", "connection_lost");
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
async checkHealth() {
|
||||
try {
|
||||
const res = await this.api.getHealth();
|
||||
if (res.ok && res.data) {
|
||||
if (!this.isConnected) {
|
||||
this.isConnected = true;
|
||||
this.showBanner("success", "\u2705 Connection Restored", "connection_restored", 3000);
|
||||
if (!this.es || this.es.readyState === 2) {
|
||||
this.connectSSE();
|
||||
}
|
||||
}
|
||||
|
||||
const stats = res.data.stats || {};
|
||||
const obs = stats.observability || {};
|
||||
if (obs.total_dropped > 0) {
|
||||
this.showBanner(
|
||||
"warning",
|
||||
`\u26A0\uFE0F High load: ${obs.total_dropped} events dropped.`,
|
||||
"backpressure"
|
||||
);
|
||||
}
|
||||
} else if (this.isConnected) {
|
||||
this.isConnected = false;
|
||||
this.showBanner("error", "\u26A0\uFE0F Backend Unreachable", "health_check_failed");
|
||||
}
|
||||
} catch (_err) {
|
||||
if (this.isConnected) {
|
||||
this.isConnected = false;
|
||||
this.showBanner("error", "\u26A0\uFE0F Connection Error", "health_check_exception");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showBanner(type, message, statusId, ttl = this.bannerTTL) {
|
||||
const now = this.now();
|
||||
if (this.lastStatusId === statusId && (now - this.lastBannerTime < ttl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastStatusId = statusId;
|
||||
this.lastBannerTime = now;
|
||||
|
||||
this.ui.showBanner({
|
||||
id: statusId || "monitor_" + now,
|
||||
severity: type,
|
||||
message,
|
||||
source: "QueueMonitor",
|
||||
ttl_ms: ttl,
|
||||
dismissible: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
-331
@@ -5,8 +5,9 @@
|
||||
import { tabManager } from "./openclaw_tabs.js";
|
||||
import { ErrorBoundary } from "./ErrorBoundary.js";
|
||||
import { openclawApi } from "./openclaw_api.js";
|
||||
import { buildDoctorAdvisoryBanner } from "./openclaw_security_advisory.js";
|
||||
import { normalizeLegacyClassNames } from "./openclaw_utils.js";
|
||||
import { OpenClawActions } from "./openclaw_actions.js";
|
||||
import { QueueMonitor } from "./openclaw_queue_monitor.js";
|
||||
|
||||
export class OpenClawUI {
|
||||
constructor() {
|
||||
@@ -388,338 +389,10 @@ export class OpenClawUI {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F48/F49: Queue Lifecycle Monitor.
|
||||
* Consumes R71 events (SSE) with polling fallback to show deduplicated status banners.
|
||||
* Handles disconnected state and recovery based on B-Strict/B-Loose contracts.
|
||||
*/
|
||||
class QueueMonitor {
|
||||
constructor(ui) {
|
||||
this.ui = ui;
|
||||
this.lastBannerTime = 0;
|
||||
this.lastStatusId = null;
|
||||
this.bannerTTL = 5000; // 5s debounce for transient statuses
|
||||
this.es = null;
|
||||
this.isConnected = true; // Assume connected initially
|
||||
}
|
||||
|
||||
start() {
|
||||
// 1. Start SSE subscription
|
||||
this.connectSSE();
|
||||
|
||||
// 2. Poll health periodically (backup & backpressure check)
|
||||
setInterval(() => this.checkHealth(), 10000);
|
||||
}
|
||||
|
||||
connectSSE() {
|
||||
if (this.es) {
|
||||
this.es.close();
|
||||
}
|
||||
|
||||
this.es = openclawApi.subscribeEvents(
|
||||
(data) => this.handleEvent(data),
|
||||
(err) => this.handleConnectionError(err)
|
||||
);
|
||||
}
|
||||
|
||||
handleEvent(data) {
|
||||
// Recovered connection if we get an event
|
||||
if (!this.isConnected) {
|
||||
this.isConnected = true;
|
||||
this.showBanner("success", "\u2705 OpenClaw Backend Connected", "connection_restored", 3000);
|
||||
}
|
||||
|
||||
const type = data.event_type;
|
||||
const pid = data.prompt_id ? data.prompt_id.slice(0, 8) : "???";
|
||||
|
||||
switch (type) {
|
||||
case "queued":
|
||||
this.showBanner("info", `\u23F3 Job ${pid} queued`, `job_${type}`, 2000);
|
||||
break;
|
||||
case "running":
|
||||
this.showBanner("info", `\u25B6 Job ${pid} running...`, `job_${type}`, 5000);
|
||||
break;
|
||||
case "failed":
|
||||
this.showBanner("error", `\u274C Job ${pid} failed`, `job_${type}`, 10000);
|
||||
break;
|
||||
case "completed":
|
||||
// Optional: distinct success banner or silent
|
||||
// this.showBanner("success", `\u2705 Job ${pid} completed`, `job_${type}`, 3000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionError(err) {
|
||||
// EventSource will retry automatically, but we flag UI state
|
||||
// Only show disconnected if it persists (debounce?)
|
||||
// For now, strict feedback:
|
||||
if (this.isConnected) {
|
||||
this.isConnected = false;
|
||||
this.showBanner("error", "\u26A0\uFE0F Backend Disconnected. Retrying...", "connection_lost");
|
||||
}
|
||||
}
|
||||
|
||||
async checkHealth() {
|
||||
try {
|
||||
const res = await openclawApi.getHealth();
|
||||
if (res.ok && res.data) {
|
||||
if (!this.isConnected) {
|
||||
this.isConnected = true;
|
||||
this.showBanner("success", "\u2705 Connection Restored", "connection_restored", 3000);
|
||||
// Reconnect SSE if it was closed or dead
|
||||
if (!this.es || this.es.readyState === 2) {
|
||||
this.connectSSE();
|
||||
}
|
||||
}
|
||||
|
||||
const stats = res.data.stats || {};
|
||||
const obs = stats.observability || {};
|
||||
|
||||
// R87: Backpressure
|
||||
if (obs.total_dropped > 0) {
|
||||
this.showBanner(
|
||||
"warning",
|
||||
`\u26A0\uFE0F High load: ${obs.total_dropped} events dropped.`,
|
||||
"backpressure"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (this.isConnected) {
|
||||
this.isConnected = false;
|
||||
this.showBanner("error", "\u26A0\uFE0F Backend Unreachable", "health_check_failed");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (this.isConnected) {
|
||||
this.isConnected = false;
|
||||
this.showBanner("error", "\u26A0\uFE0F Connection Error", "health_check_exception");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showBanner(type, message, statusId, ttl = this.bannerTTL) {
|
||||
const now = Date.now();
|
||||
// Dedupe at Monitor level (still useful to avoid spamming UI)
|
||||
if (this.lastStatusId === statusId && (now - this.lastBannerTime < ttl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update state
|
||||
this.lastStatusId = statusId;
|
||||
this.lastBannerTime = now;
|
||||
|
||||
// F49: Delegate to UI with full schema
|
||||
this.ui.showBanner({
|
||||
id: statusId || "monitor_" + now,
|
||||
severity: type,
|
||||
message: message,
|
||||
source: "QueueMonitor",
|
||||
ttl_ms: ttl,
|
||||
dismissible: true
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* F51: Unified Action Router.
|
||||
* Centralizes navigation and command logic for key operator tasks.
|
||||
*/
|
||||
export class OpenClawActions {
|
||||
constructor(ui) {
|
||||
this.ui = ui;
|
||||
this.capabilities = null;
|
||||
this._initPromise = this._fetchCapabilities();
|
||||
}
|
||||
|
||||
async _fetchCapabilities() {
|
||||
try {
|
||||
const res = await openclawApi.getCapabilities();
|
||||
if (res.ok) {
|
||||
this.capabilities = res.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("OpenClawActions: Failed to fetch capabilities", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F51: Universal dispatcher for string-based action IDs.
|
||||
*/
|
||||
dispatch(actionId, context = null) {
|
||||
switch (actionId) {
|
||||
case "doctor": this.openDoctor(); break;
|
||||
case "queue": this.openQueue(); break;
|
||||
case "settings": this.openSettings(); break;
|
||||
case "inspect": this.openExplorer(); break;
|
||||
default: console.warn("Unknown action:", actionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action is allowed/mutating.
|
||||
* F55: Enhanced with split-mode blocking UX.
|
||||
*/
|
||||
_checkAction(actionName) {
|
||||
if (!this.capabilities || !this.capabilities.actions) return { enabled: true, mutating: false };
|
||||
const cap = this.capabilities.actions[actionName] || { enabled: false, mutating: false };
|
||||
|
||||
// F55: If blocked in split mode, show remediation toast
|
||||
if (!cap.enabled && cap.blocked_reason) {
|
||||
this._showBlockedToast(actionName, cap.blocked_reason);
|
||||
}
|
||||
return cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* F55: Show a toast notification when an action is blocked by surface guard.
|
||||
*/
|
||||
_showBlockedToast(actionName, reason) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "openclaw-blocked-toast";
|
||||
toast.style.cssText = `
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 99999;
|
||||
background: #1e1e2e; border: 1px solid #f59e0b;
|
||||
border-radius: 8px; padding: 12px 16px; max-width: 380px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
font-family: 'Inter', sans-serif; font-size: 13px;
|
||||
color: #e0e0e0; animation: slideIn 0.3s ease;
|
||||
`;
|
||||
toast.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||
<span style="font-size:16px;">!</span>
|
||||
<strong style="color:#f59e0b;">Action Blocked - Split Mode</strong>
|
||||
</div>
|
||||
<div style="margin-bottom:4px;"><code>${actionName}</code> is not available in the current deployment mode.</div>
|
||||
<div style="font-size:11px;color:#999;">${reason || "Use the external control plane for this operation."}</div>
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 6000);
|
||||
}
|
||||
|
||||
async _runGuarded(actionName, fn) {
|
||||
await this._initPromise;
|
||||
const cap = this._checkAction(actionName);
|
||||
|
||||
if (!cap.enabled) {
|
||||
this.ui.showBanner("warning", `Action '${actionName}' is disabled by policy.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cap.mutating) {
|
||||
this.ui.showConfirm({
|
||||
title: "Confirm Action",
|
||||
message: `This action (${actionName}) will modify system state. Proceed?`,
|
||||
onConfirm: fn
|
||||
});
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Settings tab, optionally scrolling to a specific section.
|
||||
*/
|
||||
openSettings(section = "general") {
|
||||
tabManager.activateTab("settings");
|
||||
// Future: signal settings tab to scroll to section
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Queue/Jobs view.
|
||||
* Currently mapped to "Queue" or "Jobs" tab if it exists, or just sidebar.
|
||||
* For MVP, we don't have a dedicated Jobs tab yet (it's part of Explorer or separate).
|
||||
* We'll map to Explorer for now as it has "Jobs" sub-view concept in plan.
|
||||
*/
|
||||
openQueue(filter = "all") {
|
||||
if (tabManager.tabs["job-monitor"]) {
|
||||
tabManager.activateTab("job-monitor");
|
||||
return;
|
||||
}
|
||||
tabManager.activateTab("explorer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Doctor diagnostics.
|
||||
* Opens Doctor view (in Explorer or Settings).
|
||||
*/
|
||||
async openDoctor() {
|
||||
this._runGuarded("doctor", async () => {
|
||||
await this._openDoctorImpl();
|
||||
});
|
||||
}
|
||||
|
||||
async _openDoctorImpl() {
|
||||
tabManager.activateTab("settings");
|
||||
try {
|
||||
const res = await openclawApi.fetch(openclawApi._path("/security/doctor"));
|
||||
if (res.ok && res.data) {
|
||||
const report = res.data.report || res.data;
|
||||
const advisoryBanner = buildDoctorAdvisoryBanner(report);
|
||||
if (advisoryBanner) {
|
||||
this.ui.showBanner(advisoryBanner);
|
||||
return;
|
||||
}
|
||||
|
||||
const issueCount = Array.isArray(report?.checks)
|
||||
? report.checks.filter(
|
||||
(check) => check?.severity === "warn" || check?.severity === "fail"
|
||||
).length
|
||||
: 0;
|
||||
this.ui.showBanner(
|
||||
issueCount > 0 ? "warning" : "success",
|
||||
issueCount > 0
|
||||
? `Doctor found ${issueCount} issues. See Settings for details.`
|
||||
: "Doctor check passed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Capability fallback below.
|
||||
}
|
||||
this.ui.showBanner(
|
||||
"info",
|
||||
"Doctor diagnostics endpoint unavailable. Open Settings for manual checks."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Explorer, optionally filtering by node type.
|
||||
*/
|
||||
openExplorer(nodeType = null) {
|
||||
tabManager.activateTab("explorer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Parameter Lab for comparison.
|
||||
* Sets the lab to Compare mode for the given node.
|
||||
*/
|
||||
openCompare(node = null) {
|
||||
tabManager.activateTab("parameter-lab");
|
||||
// F50: Signal Lab to init comparison for this node
|
||||
// We'll rely on global accessible tab instance or event bus
|
||||
// For now, let's assume tabManager can give us the instance if we need to call methods directly
|
||||
// or we just open the tab and let the user set it up (MVP)
|
||||
if (node) {
|
||||
console.log("OpenClaw: Compare requested for", node.title || node.type);
|
||||
// Dispatch after tab activation tick so listeners are ready.
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("openclaw:lab:compare", { detail: { node } })
|
||||
);
|
||||
// Legacy event name for compatibility with older listeners.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("moltbot:lab:compare", { detail: { node } })
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const openclawUI = new OpenClawUI();
|
||||
export const openclawActions = new OpenClawActions(openclawUI);
|
||||
export { OpenClawActions } from "./openclaw_actions.js";
|
||||
export { QueueMonitor } from "./openclaw_queue_monitor.js";
|
||||
|
||||
const monitor = new QueueMonitor(openclawUI);
|
||||
monitor.start();
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../openclaw_api.js", () => ({
|
||||
openclawApi: {
|
||||
getCapabilities: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
_path: vi.fn((path) => path),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../openclaw_tabs.js", () => ({
|
||||
tabManager: {
|
||||
tabs: {},
|
||||
activateTab: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { OpenClawActions } = await import("../../openclaw_actions.js");
|
||||
|
||||
describe("OpenClawActions", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows a blocked toast and warning banner when an action is disabled", async () => {
|
||||
const ui = {
|
||||
showBanner: vi.fn(),
|
||||
showConfirm: vi.fn(),
|
||||
};
|
||||
const tabs = {
|
||||
tabs: { "job-monitor": true },
|
||||
activateTab: vi.fn(),
|
||||
};
|
||||
const actions = new OpenClawActions(ui, {
|
||||
tabs,
|
||||
capabilities: {
|
||||
actions: {
|
||||
doctor: {
|
||||
enabled: false,
|
||||
mutating: false,
|
||||
blocked_reason: "Use control plane.",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actions.openDoctor();
|
||||
|
||||
expect(ui.showBanner).toHaveBeenCalledWith(
|
||||
"warning",
|
||||
"Action 'doctor' is disabled by policy."
|
||||
);
|
||||
expect(document.body.querySelector(".openclaw-blocked-toast")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("dispatches compare events for both modern and legacy listeners", () => {
|
||||
const ui = {
|
||||
showBanner: vi.fn(),
|
||||
showConfirm: vi.fn(),
|
||||
};
|
||||
const tabs = {
|
||||
tabs: {},
|
||||
activateTab: vi.fn(),
|
||||
};
|
||||
const modernListener = vi.fn();
|
||||
const legacyListener = vi.fn();
|
||||
window.addEventListener("openclaw:lab:compare", modernListener);
|
||||
window.addEventListener("moltbot:lab:compare", legacyListener);
|
||||
|
||||
const actions = new OpenClawActions(ui, {
|
||||
tabs,
|
||||
capabilities: { actions: {} },
|
||||
});
|
||||
|
||||
actions.openCompare({ id: 7, title: "Sampler" });
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(tabs.activateTab).toHaveBeenCalledWith("parameter-lab");
|
||||
expect(modernListener).toHaveBeenCalledTimes(1);
|
||||
expect(legacyListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.removeEventListener("openclaw:lab:compare", modernListener);
|
||||
window.removeEventListener("moltbot:lab:compare", legacyListener);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../openclaw_api.js", () => ({
|
||||
openclawApi: {
|
||||
getHealth: vi.fn(),
|
||||
subscribeEvents: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { QueueMonitor } = await import("../../openclaw_queue_monitor.js");
|
||||
|
||||
describe("QueueMonitor", () => {
|
||||
it("deduplicates repeated status banners within the ttl window", () => {
|
||||
const ui = { showBanner: vi.fn() };
|
||||
let nowValue = 1000;
|
||||
const monitor = new QueueMonitor(ui, {
|
||||
api: {},
|
||||
now: () => nowValue,
|
||||
setIntervalRef: vi.fn(),
|
||||
});
|
||||
|
||||
monitor.showBanner("info", "Queued", "job_queued", 5000);
|
||||
nowValue = 2000;
|
||||
monitor.showBanner("info", "Queued", "job_queued", 5000);
|
||||
nowValue = 7000;
|
||||
monitor.showBanner("info", "Queued", "job_queued", 5000);
|
||||
|
||||
expect(ui.showBanner).toHaveBeenCalledTimes(2);
|
||||
expect(ui.showBanner.mock.calls[0][0].severity).toBe("info");
|
||||
});
|
||||
|
||||
it("reconnects the event stream when health checks recover from a disconnect", async () => {
|
||||
const ui = { showBanner: vi.fn() };
|
||||
const closedStream = { readyState: 2, close: vi.fn() };
|
||||
const subscribeEvents = vi.fn(() => ({ readyState: 1, close: vi.fn() }));
|
||||
const monitor = new QueueMonitor(ui, {
|
||||
api: {
|
||||
getHealth: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
data: { stats: { observability: { total_dropped: 0 } } },
|
||||
}),
|
||||
subscribeEvents,
|
||||
},
|
||||
setIntervalRef: vi.fn(),
|
||||
});
|
||||
|
||||
monitor.isConnected = false;
|
||||
monitor.es = closedStream;
|
||||
|
||||
await monitor.checkHealth();
|
||||
|
||||
expect(subscribeEvents).toHaveBeenCalledTimes(1);
|
||||
expect(ui.showBanner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "connection_restored",
|
||||
severity: "success",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user