mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
415 lines
13 KiB
Python
415 lines
13 KiB
Python
"""
|
|
Config API handlers (R21/S13/F20).
|
|
Provides GET/PUT /moltbot/config and optional /moltbot/llm/test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
|
|
if __package__ and "." in __package__:
|
|
from ..services.import_fallback import import_attrs_dual, import_module_dual
|
|
else:
|
|
from services.import_fallback import ( # type: ignore
|
|
import_attrs_dual,
|
|
import_module_dual,
|
|
)
|
|
|
|
try:
|
|
(PACK_VERSION,) = import_attrs_dual(
|
|
__package__,
|
|
"..config",
|
|
"config",
|
|
("PACK_VERSION",),
|
|
)
|
|
except ImportError: # pragma: no cover
|
|
PACK_VERSION = "0.1.0"
|
|
|
|
try:
|
|
from aiohttp import web
|
|
except ImportError: # pragma: no cover (optional for unit tests)
|
|
# CRITICAL test/CI fallback:
|
|
# Some CI/unit environments intentionally run without aiohttp installed.
|
|
# Keep this module importable by providing a minimal `web` shim used by
|
|
# handler tests (json_response/status/body), while production keeps real aiohttp.
|
|
class _MockResponse:
|
|
def __init__(
|
|
self, payload: dict, status: int = 200, headers: dict | None = None
|
|
):
|
|
self.status = status
|
|
self.headers = headers or {}
|
|
self.body = json.dumps(payload).encode("utf-8")
|
|
|
|
class _MockWeb:
|
|
_IS_MOCKWEB = True
|
|
|
|
class Request: # pragma: no cover - typing shim only
|
|
pass
|
|
|
|
class Response: # pragma: no cover - typing shim only
|
|
pass
|
|
|
|
@staticmethod
|
|
def json_response(
|
|
payload: dict, status: int = 200, headers: dict | None = None
|
|
):
|
|
return _MockResponse(payload, status=status, headers=headers)
|
|
|
|
web = _MockWeb() # type: ignore
|
|
|
|
# Import discipline:
|
|
# - In real ComfyUI runtimes, this pack is loaded as a package and must use package-relative imports.
|
|
# - In unit tests, modules may be imported as top-level (e.g., `api.*`), so we allow top-level fallbacks.
|
|
(
|
|
is_loopback,
|
|
require_admin_token,
|
|
require_observability_access,
|
|
resolve_token_info,
|
|
) = import_attrs_dual(
|
|
__package__,
|
|
"..services.access_control",
|
|
"services.access_control",
|
|
(
|
|
"is_loopback",
|
|
"require_admin_token",
|
|
"require_observability_access",
|
|
"resolve_token_info",
|
|
),
|
|
)
|
|
(emit_audit_event,) = import_attrs_dual(
|
|
__package__,
|
|
"..services.audit",
|
|
"services.audit",
|
|
("emit_audit_event",),
|
|
)
|
|
|
|
try:
|
|
(require_same_origin_if_no_token,) = import_attrs_dual(
|
|
__package__,
|
|
"..services.csrf_protection",
|
|
"services.csrf_protection",
|
|
("require_same_origin_if_no_token",),
|
|
)
|
|
except Exception:
|
|
# CRITICAL test/CI fallback (DO NOT replace with a direct import):
|
|
# Some unit-test environments import `api.config` without aiohttp installed.
|
|
# `services.csrf_protection` imports aiohttp at module load, which can raise
|
|
# ModuleNotFoundError and break unrelated tests (`test_r53`, `test_r60`).
|
|
# Keep import-time behavior resilient by using a no-op guard in that case.
|
|
def require_same_origin_if_no_token(*_args, **_kwargs): # type: ignore
|
|
return None
|
|
|
|
|
|
(LLMClient,) = import_attrs_dual(
|
|
__package__,
|
|
"..services.llm_client",
|
|
"services.llm_client",
|
|
("LLMClient",),
|
|
)
|
|
(check_rate_limit, build_rate_limit_response) = import_attrs_dual(
|
|
__package__,
|
|
"..services.rate_limit",
|
|
"services.rate_limit",
|
|
("check_rate_limit", "build_rate_limit_response"),
|
|
)
|
|
(get_client_ip,) = import_attrs_dual(
|
|
__package__,
|
|
"..services.request_ip",
|
|
"services.request_ip",
|
|
("get_client_ip",),
|
|
)
|
|
(
|
|
ALLOWED_LLM_KEYS,
|
|
get_admin_token,
|
|
get_apply_semantics,
|
|
get_effective_config,
|
|
get_llm_egress_controls,
|
|
get_runtime_guardrails,
|
|
get_settings_schema,
|
|
is_loopback_client,
|
|
update_config,
|
|
) = import_attrs_dual(
|
|
__package__,
|
|
"..services.runtime_config",
|
|
"services.runtime_config",
|
|
(
|
|
"ALLOWED_LLM_KEYS",
|
|
"get_admin_token",
|
|
"get_apply_semantics",
|
|
"get_effective_config",
|
|
"get_llm_egress_controls",
|
|
"get_runtime_guardrails",
|
|
"get_settings_schema",
|
|
"is_loopback_client",
|
|
"update_config",
|
|
),
|
|
)
|
|
(
|
|
TenantBoundaryError,
|
|
request_tenant_scope,
|
|
) = import_attrs_dual(
|
|
__package__,
|
|
"..services.tenant_context",
|
|
"services.tenant_context",
|
|
("TenantBoundaryError", "request_tenant_scope"),
|
|
)
|
|
(
|
|
CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN,
|
|
payload_contains_runtime_guardrails,
|
|
) = import_attrs_dual(
|
|
__package__,
|
|
"..services.runtime_guardrails",
|
|
"services.runtime_guardrails",
|
|
("CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN", "payload_contains_runtime_guardrails"),
|
|
)
|
|
|
|
logger = logging.getLogger("ComfyUI-OpenClaw.api.config")
|
|
|
|
(
|
|
ConfigHandlerDependencies,
|
|
config_get_response,
|
|
config_put_response,
|
|
) = import_attrs_dual(
|
|
__package__,
|
|
"..api.config_projection_handlers",
|
|
"api.config_projection_handlers",
|
|
("ConfigHandlerDependencies", "config_get_response", "config_put_response"),
|
|
)
|
|
(llm_models_response,) = import_attrs_dual(
|
|
__package__,
|
|
"..api.config_model_handlers",
|
|
"api.config_model_handlers",
|
|
("llm_models_response",),
|
|
)
|
|
(llm_chat_response, llm_test_response) = import_attrs_dual(
|
|
__package__,
|
|
"..api.config_llm_handlers",
|
|
"api.config_llm_handlers",
|
|
("llm_chat_response", "llm_test_response"),
|
|
)
|
|
|
|
(
|
|
_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
|
|
(
|
|
AuthTier,
|
|
RiskTier,
|
|
RoutePlane,
|
|
endpoint_metadata,
|
|
) = import_attrs_dual(
|
|
__package__,
|
|
"..services.endpoint_manifest",
|
|
"services.endpoint_manifest",
|
|
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
|
|
)
|
|
|
|
|
|
# Provider catalog for UI dropdown (R16 dynamic)
|
|
PROVIDER_CATALOG = []
|
|
|
|
try:
|
|
raw_catalog_module = import_module_dual(
|
|
__package__,
|
|
"..services.providers.catalog",
|
|
"services.providers.catalog",
|
|
)
|
|
RAW_CATALOG = raw_catalog_module.PROVIDER_CATALOG
|
|
|
|
for pid, info in RAW_CATALOG.items():
|
|
PROVIDER_CATALOG.append(
|
|
{
|
|
"id": pid,
|
|
"label": info.name,
|
|
"requires_key": info.env_key_name is not None,
|
|
}
|
|
)
|
|
# Ensure custom is present if not in catalog (though it is)
|
|
if not any(p["id"] == "custom" for p in PROVIDER_CATALOG):
|
|
PROVIDER_CATALOG.append(
|
|
{"id": "custom", "label": "Custom OpenAI-compatible", "requires_key": True}
|
|
)
|
|
except ImportError:
|
|
# Fallback if catalog module missing
|
|
PROVIDER_CATALOG = [
|
|
{"id": "openai", "label": "OpenAI", "requires_key": True},
|
|
{"id": "anthropic", "label": "Anthropic", "requires_key": True},
|
|
{"id": "openrouter", "label": "OpenRouter", "requires_key": True},
|
|
{"id": "gemini", "label": "Google Gemini", "requires_key": True},
|
|
{"id": "groq", "label": "Groq", "requires_key": True},
|
|
{"id": "deepseek", "label": "DeepSeek", "requires_key": True},
|
|
{"id": "xai", "label": "xAI (Grok)", "requires_key": True},
|
|
{"id": "ollama", "label": "Ollama (Local)", "requires_key": False},
|
|
{"id": "lmstudio", "label": "LM Studio (Local)", "requires_key": False},
|
|
{"id": "custom", "label": "Custom OpenAI-compatible", "requires_key": True},
|
|
]
|
|
|
|
|
|
def _handler_dependencies():
|
|
"""Capture established facade patch seams for owned config handlers."""
|
|
|
|
return ConfigHandlerDependencies(
|
|
web=web,
|
|
logger=logger,
|
|
provider_catalog=PROVIDER_CATALOG,
|
|
pack_version=PACK_VERSION,
|
|
require_observability_access=require_observability_access,
|
|
require_admin_token=require_admin_token,
|
|
require_same_origin_if_no_token=require_same_origin_if_no_token,
|
|
resolve_token_info=resolve_token_info,
|
|
emit_audit_event=emit_audit_event,
|
|
check_rate_limit=check_rate_limit,
|
|
build_rate_limit_response=build_rate_limit_response,
|
|
get_client_ip=get_client_ip,
|
|
is_loopback=is_loopback,
|
|
get_admin_token=get_admin_token,
|
|
get_apply_semantics=get_apply_semantics,
|
|
get_effective_config=get_effective_config,
|
|
get_llm_egress_controls=get_llm_egress_controls,
|
|
get_runtime_guardrails=get_runtime_guardrails,
|
|
get_settings_schema=get_settings_schema,
|
|
is_loopback_client=is_loopback_client,
|
|
update_config=update_config,
|
|
tenant_boundary_error=TenantBoundaryError,
|
|
request_tenant_scope=request_tenant_scope,
|
|
runtime_only_code=CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN,
|
|
payload_contains_runtime_guardrails=payload_contains_runtime_guardrails,
|
|
model_cache_get=_cache_get,
|
|
format_llm_ssrf_error=_format_llm_ssrf_error,
|
|
llm_insecure_override_enabled=_llm_insecure_override_enabled,
|
|
fetch_remote_model_list=fetch_remote_model_list,
|
|
get_stale_cached_models=get_stale_cached_models,
|
|
resolve_model_list_target=resolve_model_list_target,
|
|
validate_model_list_target=validate_model_list_target,
|
|
llm_client=LLMClient,
|
|
)
|
|
|
|
|
|
@endpoint_metadata(
|
|
auth=AuthTier.OBSERVABILITY,
|
|
risk=RiskTier.LOW,
|
|
summary="Get configuration",
|
|
description="Returns effective config, sources, and provider catalog.",
|
|
audit="config.read",
|
|
plane=RoutePlane.ADMIN,
|
|
)
|
|
async def config_get_handler(request: web.Request) -> web.Response:
|
|
"""
|
|
GET /moltbot/config
|
|
Returns effective config, sources, and provider catalog.
|
|
Enforced by S14 Access Control.
|
|
"""
|
|
# CRITICAL: owned implementation performs require_observability_access before reads.
|
|
return await config_get_response(request, _handler_dependencies())
|
|
|
|
|
|
@endpoint_metadata(
|
|
auth=AuthTier.ADMIN,
|
|
risk=RiskTier.LOW, # Read-only external fetch, but admin-gated
|
|
summary="List remote models",
|
|
description="Fetch a remote model list (best-effort) for OpenAI-compatible providers.",
|
|
audit="llm.list_models",
|
|
plane=RoutePlane.ADMIN,
|
|
)
|
|
async def llm_models_handler(request: web.Request) -> web.Response:
|
|
"""
|
|
GET /openclaw/llm/models (legacy: /moltbot/llm/models)
|
|
Fetch a remote model list (best-effort) for OpenAI-compatible providers.
|
|
|
|
Security:
|
|
- admin boundary
|
|
- loopback-only unless OPENCLAW_ALLOW_REMOTE_ADMIN=1
|
|
- SSRF policy enforced via LLM egress controls, including scoped private-network allowance
|
|
"""
|
|
# CRITICAL: owned implementation performs require_admin_token( before network access.
|
|
# CRITICAL S65: fetch_remote_model_list remains the safe_request_json egress owner.
|
|
return await llm_models_response(request, _handler_dependencies())
|
|
|
|
|
|
@endpoint_metadata(
|
|
auth=AuthTier.ADMIN,
|
|
risk=RiskTier.HIGH,
|
|
summary="Update configuration",
|
|
description="Updates non-secret LLM config.",
|
|
audit="config.update",
|
|
plane=RoutePlane.ADMIN,
|
|
)
|
|
async def config_put_handler(request: web.Request) -> web.Response:
|
|
"""
|
|
PUT /moltbot/config
|
|
Updates non-secret LLM config. Protected by admin boundary (S13) + CSRF (S26+).
|
|
"""
|
|
# CRITICAL: owned implementation performs require_admin_token( before mutation.
|
|
return await config_put_response(request, _handler_dependencies())
|
|
|
|
|
|
@endpoint_metadata(
|
|
auth=AuthTier.ADMIN,
|
|
risk=RiskTier.MEDIUM,
|
|
summary="Test LLM connection",
|
|
description="Tests LLM connection using provided or stored credentials.",
|
|
audit="llm.test_connection",
|
|
plane=RoutePlane.ADMIN,
|
|
)
|
|
async def llm_test_handler(request: web.Request) -> web.Response:
|
|
"""
|
|
POST /moltbot/llm/test
|
|
Tests LLM connection. Protected by admin boundary (S13) + CSRF (S26+).
|
|
"""
|
|
# CRITICAL: owned implementation performs require_admin_token( before provider access.
|
|
return await llm_test_response(request, _handler_dependencies())
|
|
|
|
|
|
@endpoint_metadata(
|
|
auth=AuthTier.ADMIN,
|
|
risk=RiskTier.MEDIUM, # Consumed by connector, costs money/tokens
|
|
summary="Chat completion",
|
|
description="Run a simple chat completion using server-side LLM config.",
|
|
audit="llm.chat_completion",
|
|
plane=RoutePlane.ADMIN,
|
|
)
|
|
async def llm_chat_handler(request: web.Request) -> web.Response:
|
|
"""
|
|
POST /openclaw/llm/chat (legacy: /moltbot/llm/chat)
|
|
Run a simple chat completion using server-side LLM config + keys.
|
|
This endpoint is intended for the connector; no prompt content is logged.
|
|
"""
|
|
# CRITICAL: owned implementation performs require_admin_token( before provider access.
|
|
return await llm_chat_response(request, _handler_dependencies())
|