From 971f1e2c822016d7a7ca602ca890ad79b7e23eee Mon Sep 17 00:00:00 2001 From: rookiestar28 Date: Fri, 20 Mar 2026 01:06:12 +0800 Subject: [PATCH] refactor: split route family registrars --- api/route_registrars.py | 256 ++++++++++++++++++++++ api/routes.py | 324 +++++++++------------------- tests/test_r151_route_registrars.py | 124 +++++++++++ 3 files changed, 477 insertions(+), 227 deletions(-) create mode 100644 api/route_registrars.py create mode 100644 tests/test_r151_route_registrars.py diff --git a/api/route_registrars.py b/api/route_registrars.py new file mode 100644 index 0000000..b0ff84d --- /dev/null +++ b/api/route_registrars.py @@ -0,0 +1,256 @@ +""" +R151 route-family registrar helpers. + +Keep route composition data-driven without importing api.routes back into this +module, which would reintroduce circular bootstrap fragility. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable + + +@dataclass(frozen=True) +class RouteSpec: + method: str + path: str + handler: Any + + +def register_route_family(server, register_route_fn, specs: Iterable[RouteSpec]) -> None: + for spec in specs: + register_route_fn(server, spec.method, spec.path, spec.handler) + + +def build_core_route_specs(prefix: str, handlers: dict[str, Any]) -> tuple[RouteSpec, ...]: + return ( + RouteSpec("GET", f"{prefix}/admin", handlers["remote_admin_page_handler"]), + RouteSpec("GET", f"{prefix}/health", handlers["health_handler"]), + RouteSpec("GET", f"{prefix}/logs/tail", handlers["logs_tail_handler"]), + RouteSpec("GET", f"{prefix}/jobs", handlers["jobs_handler"]), + RouteSpec("GET", f"{prefix}/trace/{{prompt_id}}", handlers["trace_handler"]), + RouteSpec("POST", f"{prefix}/webhook", handlers["webhook_handler"]), + RouteSpec( + "POST", + f"{prefix}/webhook/submit", + handlers["webhook_submit_handler"], + ), + RouteSpec( + "POST", + f"{prefix}/webhook/validate", + handlers["webhook_validate_handler"], + ), + RouteSpec("GET", f"{prefix}/capabilities", handlers["capabilities_handler"]), + RouteSpec("GET", f"{prefix}/config", handlers["config_get_handler"]), + RouteSpec("PUT", f"{prefix}/config", handlers["config_put_handler"]), + RouteSpec("POST", f"{prefix}/llm/test", handlers["llm_test_handler"]), + RouteSpec("POST", f"{prefix}/llm/chat", handlers["llm_chat_handler"]), + RouteSpec("GET", f"{prefix}/llm/models", handlers["llm_models_handler"]), + RouteSpec("GET", f"{prefix}/templates", handlers["templates_list_handler"]), + RouteSpec("POST", f"{prefix}/preflight", handlers["preflight_handler"]), + RouteSpec( + "GET", + f"{prefix}/preflight/inventory", + handlers["inventory_handler"], + ), + RouteSpec("GET", f"{prefix}/checkpoints", handlers["list_checkpoints_handler"]), + RouteSpec( + "POST", + f"{prefix}/checkpoints", + handlers["create_checkpoint_handler"], + ), + RouteSpec( + "GET", + f"{prefix}/checkpoints/{{id}}", + handlers["get_checkpoint_handler"], + ), + RouteSpec( + "DELETE", + f"{prefix}/checkpoints/{{id}}", + handlers["delete_checkpoint_handler"], + ), + RouteSpec( + "GET", + f"{prefix}/rewrite/recipes", + handlers["rewrite_recipes_list_handler"], + ), + RouteSpec( + "POST", + f"{prefix}/rewrite/recipes", + handlers["rewrite_recipe_create_handler"], + ), + RouteSpec( + "GET", + f"{prefix}/rewrite/recipes/{{recipe_id}}", + handlers["rewrite_recipe_get_handler"], + ), + RouteSpec( + "PUT", + f"{prefix}/rewrite/recipes/{{recipe_id}}", + handlers["rewrite_recipe_update_handler"], + ), + RouteSpec( + "DELETE", + f"{prefix}/rewrite/recipes/{{recipe_id}}", + handlers["rewrite_recipe_delete_handler"], + ), + RouteSpec( + "POST", + f"{prefix}/rewrite/recipes/{{recipe_id}}/dry-run", + handlers["rewrite_recipe_dry_run_handler"], + ), + RouteSpec( + "POST", + f"{prefix}/rewrite/recipes/{{recipe_id}}/apply", + handlers["rewrite_recipe_apply_handler"], + ), + RouteSpec("GET", f"{prefix}/models/search", handlers["model_search_handler"]), + RouteSpec( + "POST", + f"{prefix}/models/downloads", + handlers["model_download_create_handler"], + ), + RouteSpec( + "GET", + f"{prefix}/models/downloads", + handlers["model_download_list_handler"], + ), + RouteSpec( + "GET", + f"{prefix}/models/downloads/{{task_id}}", + handlers["model_download_get_handler"], + ), + RouteSpec( + "POST", + f"{prefix}/models/downloads/{{task_id}}/cancel", + handlers["model_download_cancel_handler"], + ), + RouteSpec("POST", f"{prefix}/models/import", handlers["model_import_handler"]), + RouteSpec( + "GET", + f"{prefix}/models/installations", + handlers["model_installations_list_handler"], + ), + RouteSpec( + "GET", + f"{prefix}/secrets/status", + handlers["secrets_status_handler"], + ), + RouteSpec("PUT", f"{prefix}/secrets", handlers["secrets_put_handler"]), + RouteSpec( + "GET", + f"{prefix}/events/stream", + handlers["events_stream_handler"], + ), + RouteSpec("GET", f"{prefix}/events", handlers["events_poll_handler"]), + RouteSpec( + "DELETE", + f"{prefix}/secrets/{{provider}}", + handlers["secrets_delete_handler"], + ), + RouteSpec( + "GET", + f"{prefix}/security/doctor", + handlers["security_doctor_handler"], + ), + RouteSpec("GET", f"{prefix}/tools", handlers["tools_list_handler"]), + RouteSpec( + "POST", + f"{prefix}/tools/{{name}}/run", + handlers["tools_run_handler"], + ), + RouteSpec("POST", f"{prefix}/lab/sweep", handlers["create_sweep_handler"]), + RouteSpec("POST", f"{prefix}/lab/compare", handlers["create_compare_handler"]), + RouteSpec( + "GET", + f"{prefix}/lab/experiments", + handlers["list_experiments_handler"], + ), + RouteSpec( + "GET", + f"{prefix}/lab/experiments/{{exp_id}}", + handlers["get_experiment_handler"], + ), + RouteSpec( + "POST", + f"{prefix}/lab/experiments/{{exp_id}}/runs/{{run_id}}", + handlers["update_experiment_handler"], + ), + RouteSpec( + "POST", + f"{prefix}/lab/experiments/{{exp_id}}/winner", + handlers["select_apply_winner_handler"], + ), + ) + + +def build_assist_route_specs(prefix: str, assist) -> tuple[RouteSpec, ...]: + return ( + RouteSpec( + "GET", + f"{prefix}/assist/planner/profiles", + assist.planner_profiles_handler, + ), + RouteSpec("POST", f"{prefix}/assist/planner", assist.planner_handler), + RouteSpec( + "POST", + f"{prefix}/assist/planner/stream", + assist.planner_stream_handler, + ), + RouteSpec("POST", f"{prefix}/assist/refiner", assist.refiner_handler), + RouteSpec( + "POST", + f"{prefix}/assist/refiner/stream", + assist.refiner_stream_handler, + ), + RouteSpec( + "POST", + f"{prefix}/assist/automation/compose", + assist.compose_handler, + ), + ) + + +def build_connector_installation_route_specs( + prefix: str, handlers: dict[str, Any] +) -> tuple[RouteSpec, ...]: + return ( + RouteSpec( + "GET", + f"{prefix}/connector/installations", + handlers["connector_installations_list_handler"], + ), + RouteSpec( + "GET", + f"{prefix}/connector/installations/resolve", + handlers["connector_installation_resolve_handler"], + ), + RouteSpec( + "GET", + f"{prefix}/connector/installations/audit", + handlers["connector_installation_audit_handler"], + ), + RouteSpec( + "GET", + f"{prefix}/connector/installations/{{installation_id}}", + handlers["connector_installation_get_handler"], + ), + ) + + +def build_pack_route_specs(prefix: str, packs) -> tuple[RouteSpec, ...]: + return ( + RouteSpec("GET", f"{prefix}/packs", packs.list_packs_handler), + RouteSpec("POST", f"{prefix}/packs/import", packs.import_pack_handler), + RouteSpec( + "GET", + f"{prefix}/packs/export/{{name}}/{{version}}", + packs.export_pack_handler, + ), + RouteSpec( + "DELETE", + f"{prefix}/packs/{{name}}/{{version}}", + packs.delete_pack_handler, + ), + ) diff --git a/api/routes.py b/api/routes.py index 57009ab..dfae0a6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -17,6 +17,28 @@ if __package__ and "." in __package__: else: from services.import_fallback import import_attrs_dual # type: ignore +# IMPORTANT: route-family helpers must stay one-way imported from here. +# Do not make api.route_registrars import api.routes back, or bootstrap can +# regress into circular import failures before PromptServer registration. +( + build_assist_route_specs, + build_connector_installation_route_specs, + build_core_route_specs, + build_pack_route_specs, + register_route_family, +) = import_attrs_dual( + __package__, + "..api.route_registrars", + "api.route_registrars", + ( + "build_assist_route_specs", + "build_connector_installation_route_specs", + "build_core_route_specs", + "build_pack_route_specs", + "register_route_family", + ), +) + # R98 / R64: Endpoint Metadata import via shared helper ( AuthTier, @@ -815,231 +837,91 @@ def register_routes(server) -> None: print("[OpenClaw] Registering routes (Shim Alignment R26)...") prefixes = ["/openclaw", "/moltbot"] # new, legacy + core_handlers = { + "remote_admin_page_handler": remote_admin_page_handler, + "health_handler": health_handler, + "logs_tail_handler": logs_tail_handler, + "jobs_handler": jobs_handler, + "trace_handler": trace_handler, + "webhook_handler": webhook_handler, + "webhook_submit_handler": webhook_submit_handler, + "webhook_validate_handler": webhook_validate_handler, + "capabilities_handler": capabilities_handler, + "config_get_handler": config_get_handler, + "config_put_handler": config_put_handler, + "llm_test_handler": llm_test_handler, + "llm_chat_handler": llm_chat_handler, + "llm_models_handler": llm_models_handler, + "templates_list_handler": templates_list_handler, + "preflight_handler": preflight_handler, + "inventory_handler": inventory_handler, + "list_checkpoints_handler": list_checkpoints_handler, + "create_checkpoint_handler": create_checkpoint_handler, + "get_checkpoint_handler": get_checkpoint_handler, + "delete_checkpoint_handler": delete_checkpoint_handler, + "rewrite_recipes_list_handler": rewrite_recipes_list_handler, + "rewrite_recipe_create_handler": rewrite_recipe_create_handler, + "rewrite_recipe_get_handler": rewrite_recipe_get_handler, + "rewrite_recipe_update_handler": rewrite_recipe_update_handler, + "rewrite_recipe_delete_handler": rewrite_recipe_delete_handler, + "rewrite_recipe_dry_run_handler": rewrite_recipe_dry_run_handler, + "rewrite_recipe_apply_handler": rewrite_recipe_apply_handler, + "model_search_handler": model_search_handler, + "model_download_create_handler": model_download_create_handler, + "model_download_list_handler": model_download_list_handler, + "model_download_get_handler": model_download_get_handler, + "model_download_cancel_handler": model_download_cancel_handler, + "model_import_handler": model_import_handler, + "model_installations_list_handler": model_installations_list_handler, + "secrets_status_handler": secrets_status_handler, + "secrets_put_handler": secrets_put_handler, + "events_stream_handler": events_stream_handler, + "events_poll_handler": events_poll_handler, + "secrets_delete_handler": secrets_delete_handler, + "security_doctor_handler": security_doctor_handler, + "tools_list_handler": tools_list_handler, + "tools_run_handler": tools_run_handler, + "create_sweep_handler": create_sweep_handler, + "create_compare_handler": create_compare_handler, + "list_experiments_handler": list_experiments_handler, + "get_experiment_handler": get_experiment_handler, + "update_experiment_handler": update_experiment_handler, + "select_apply_winner_handler": select_apply_winner_handler, + } # Core Observability & Config for prefix in prefixes: - core_routes = [ - ("GET", f"{prefix}/admin", remote_admin_page_handler), # F61 - ("GET", f"{prefix}/health", health_handler), - ("GET", f"{prefix}/logs/tail", logs_tail_handler), - ("GET", f"{prefix}/jobs", jobs_handler), - ("GET", f"{prefix}/trace/{{prompt_id}}", trace_handler), - ("POST", f"{prefix}/webhook", webhook_handler), - ("POST", f"{prefix}/webhook/submit", webhook_submit_handler), - ( - "POST", - f"{prefix}/webhook/validate", - webhook_validate_handler, - ), # R32: Validation endpoint - ("GET", f"{prefix}/capabilities", capabilities_handler), - ("GET", f"{prefix}/config", config_get_handler), - ("PUT", f"{prefix}/config", config_put_handler), - ("POST", f"{prefix}/llm/test", llm_test_handler), - # NOTE: Connector uses this endpoint to avoid missing UI-stored keys. - ("POST", f"{prefix}/llm/chat", llm_chat_handler), - ( - "GET", - f"{prefix}/llm/models", - llm_models_handler, - ), # F20+: Remote model list (best-effort) - ( - "GET", - f"{prefix}/templates", - templates_list_handler, - ), # F29: Template quick list for chat connectors - ( - "POST", - f"{prefix}/preflight", - preflight_handler, - ), # R42: Preflight diagnostics - ( - "GET", - f"{prefix}/preflight/inventory", - inventory_handler, - ), # F28: Explorer Inventory - ( - "GET", - f"{prefix}/checkpoints", - list_checkpoints_handler, - ), # R47: Checkpoints - ( - "POST", - f"{prefix}/checkpoints", - create_checkpoint_handler, - ), - ( - "GET", - f"{prefix}/checkpoints/{{id}}", - get_checkpoint_handler, - ), - ( - "DELETE", - f"{prefix}/checkpoints/{{id}}", - delete_checkpoint_handler, - ), - ("GET", f"{prefix}/rewrite/recipes", rewrite_recipes_list_handler), - ("POST", f"{prefix}/rewrite/recipes", rewrite_recipe_create_handler), - ( - "GET", - f"{prefix}/rewrite/recipes/{{recipe_id}}", - rewrite_recipe_get_handler, - ), - ( - "PUT", - f"{prefix}/rewrite/recipes/{{recipe_id}}", - rewrite_recipe_update_handler, - ), - ( - "DELETE", - f"{prefix}/rewrite/recipes/{{recipe_id}}", - rewrite_recipe_delete_handler, - ), - ( - "POST", - f"{prefix}/rewrite/recipes/{{recipe_id}}/dry-run", - rewrite_recipe_dry_run_handler, - ), - ( - "POST", - f"{prefix}/rewrite/recipes/{{recipe_id}}/apply", - rewrite_recipe_apply_handler, - ), - ("GET", f"{prefix}/models/search", model_search_handler), - ("POST", f"{prefix}/models/downloads", model_download_create_handler), - ("GET", f"{prefix}/models/downloads", model_download_list_handler), - ( - "GET", - f"{prefix}/models/downloads/{{task_id}}", - model_download_get_handler, - ), - ( - "POST", - f"{prefix}/models/downloads/{{task_id}}/cancel", - model_download_cancel_handler, - ), - ("POST", f"{prefix}/models/import", model_import_handler), - ( - "GET", - f"{prefix}/models/installations", - model_installations_list_handler, - ), - ( - "GET", - f"{prefix}/secrets/status", - secrets_status_handler, - ), # S25: Secret status (no values) - ("PUT", f"{prefix}/secrets", secrets_put_handler), # S25: Save secret - ( - "GET", - f"{prefix}/events/stream", - events_stream_handler, - ), # R71: SSE event stream - ( - "GET", - f"{prefix}/events", - events_poll_handler, - ), # R71: JSON polling fallback - ( - "DELETE", - f"{prefix}/secrets/{{provider}}", - secrets_delete_handler, - ), # S25: Clear secret - ( - "GET", - f"{prefix}/security/doctor", - security_doctor_handler, - ), # S30: Security Doctor diagnostics - ( - "GET", - f"{prefix}/tools", - tools_list_handler, - ), # S12: List allowed tools - ( - "POST", - f"{prefix}/tools/{{name}}/run", - tools_run_handler, - ), # S12: Execute tool (admin only) - # F52: Parameter Lab - ("POST", f"{prefix}/lab/sweep", create_sweep_handler), - ("POST", f"{prefix}/lab/compare", create_compare_handler), - ("GET", f"{prefix}/lab/experiments", list_experiments_handler), - ("GET", f"{prefix}/lab/experiments/{{exp_id}}", get_experiment_handler), - ( - "POST", - f"{prefix}/lab/experiments/{{exp_id}}/runs/{{run_id}}", - update_experiment_handler, - ), - ( - "POST", - f"{prefix}/lab/experiments/{{exp_id}}/winner", - select_apply_winner_handler, - ), - ] - - for method, path, handler in core_routes: - register_dual_route(server, method, path, handler) + register_route_family( + server, + register_dual_route, + build_core_route_specs(prefix, core_handlers), + ) # F8/F21 Assist Routes # R84 Boot Boundary: CORE (Planner/Refiner part of core/assist) if assist: for prefix in prefixes: - register_dual_route( + register_route_family( server, - "GET", - f"{prefix}/assist/planner/profiles", - assist.planner_profiles_handler, - ) - register_dual_route( - server, "POST", f"{prefix}/assist/planner", assist.planner_handler - ) - register_dual_route( - server, - "POST", - f"{prefix}/assist/planner/stream", - assist.planner_stream_handler, - ) - register_dual_route( - server, "POST", f"{prefix}/assist/refiner", assist.refiner_handler - ) - register_dual_route( - server, - "POST", - f"{prefix}/assist/refiner/stream", - assist.refiner_stream_handler, - ) - register_dual_route( - server, - "POST", - f"{prefix}/assist/automation/compose", - assist.compose_handler, + register_dual_route, + build_assist_route_specs(prefix, assist), ) # R126: Connector installation diagnostics/read APIs if connector_installations_list_handler: + connector_installation_handlers = { + "connector_installations_list_handler": connector_installations_list_handler, + "connector_installation_resolve_handler": connector_installation_resolve_handler, + "connector_installation_audit_handler": connector_installation_audit_handler, + "connector_installation_get_handler": connector_installation_get_handler, + } for prefix in prefixes: - register_dual_route( + register_route_family( server, - "GET", - f"{prefix}/connector/installations", - connector_installations_list_handler, - ) - register_dual_route( - server, - "GET", - f"{prefix}/connector/installations/resolve", - connector_installation_resolve_handler, - ) - register_dual_route( - server, - "GET", - f"{prefix}/connector/installations/audit", - connector_installation_audit_handler, - ) - register_dual_route( - server, - "GET", - f"{prefix}/connector/installations/{{installation_id}}", - connector_installation_get_handler, + register_dual_route, + build_connector_installation_route_specs( + prefix, connector_installation_handlers + ), ) # F10 Bridge Routes (Sidecar) @@ -1090,23 +972,11 @@ def register_routes(server) -> None: packs = PacksHandlers(DATA_DIR) for prefix in prefixes: - pack_routes = [ - ("GET", f"{prefix}/packs", packs.list_packs_handler), - ("POST", f"{prefix}/packs/import", packs.import_pack_handler), - ( - "GET", - f"{prefix}/packs/export/{{name}}/{{version}}", - packs.export_pack_handler, - ), - ( - "DELETE", - f"{prefix}/packs/{{name}}/{{version}}", - packs.delete_pack_handler, - ), - ] - - for method, path, handler in pack_routes: - register_dual_route(server, method, path, handler) + register_route_family( + server, + register_dual_route, + build_pack_route_specs(prefix, packs), + ) except ImportError: pass diff --git a/tests/test_r151_route_registrars.py b/tests/test_r151_route_registrars.py new file mode 100644 index 0000000..2815fbd --- /dev/null +++ b/tests/test_r151_route_registrars.py @@ -0,0 +1,124 @@ +import unittest +from unittest.mock import sentinel + +from api.route_registrars import ( + build_assist_route_specs, + build_connector_installation_route_specs, + build_core_route_specs, + build_pack_route_specs, +) + + +class _AssistStub: + planner_profiles_handler = sentinel.planner_profiles_handler + planner_handler = sentinel.planner_handler + planner_stream_handler = sentinel.planner_stream_handler + refiner_handler = sentinel.refiner_handler + refiner_stream_handler = sentinel.refiner_stream_handler + compose_handler = sentinel.compose_handler + + +class _PacksStub: + list_packs_handler = sentinel.list_packs_handler + import_pack_handler = sentinel.import_pack_handler + export_pack_handler = sentinel.export_pack_handler + delete_pack_handler = sentinel.delete_pack_handler + + +class TestR151RouteRegistrars(unittest.TestCase): + def test_build_core_route_specs_preserves_expected_paths(self): + handlers = { + "remote_admin_page_handler": sentinel.remote_admin_page_handler, + "health_handler": sentinel.health_handler, + "logs_tail_handler": sentinel.logs_tail_handler, + "jobs_handler": sentinel.jobs_handler, + "trace_handler": sentinel.trace_handler, + "webhook_handler": sentinel.webhook_handler, + "webhook_submit_handler": sentinel.webhook_submit_handler, + "webhook_validate_handler": sentinel.webhook_validate_handler, + "capabilities_handler": sentinel.capabilities_handler, + "config_get_handler": sentinel.config_get_handler, + "config_put_handler": sentinel.config_put_handler, + "llm_test_handler": sentinel.llm_test_handler, + "llm_chat_handler": sentinel.llm_chat_handler, + "llm_models_handler": sentinel.llm_models_handler, + "templates_list_handler": sentinel.templates_list_handler, + "preflight_handler": sentinel.preflight_handler, + "inventory_handler": sentinel.inventory_handler, + "list_checkpoints_handler": sentinel.list_checkpoints_handler, + "create_checkpoint_handler": sentinel.create_checkpoint_handler, + "get_checkpoint_handler": sentinel.get_checkpoint_handler, + "delete_checkpoint_handler": sentinel.delete_checkpoint_handler, + "rewrite_recipes_list_handler": sentinel.rewrite_recipes_list_handler, + "rewrite_recipe_create_handler": sentinel.rewrite_recipe_create_handler, + "rewrite_recipe_get_handler": sentinel.rewrite_recipe_get_handler, + "rewrite_recipe_update_handler": sentinel.rewrite_recipe_update_handler, + "rewrite_recipe_delete_handler": sentinel.rewrite_recipe_delete_handler, + "rewrite_recipe_dry_run_handler": sentinel.rewrite_recipe_dry_run_handler, + "rewrite_recipe_apply_handler": sentinel.rewrite_recipe_apply_handler, + "model_search_handler": sentinel.model_search_handler, + "model_download_create_handler": sentinel.model_download_create_handler, + "model_download_list_handler": sentinel.model_download_list_handler, + "model_download_get_handler": sentinel.model_download_get_handler, + "model_download_cancel_handler": sentinel.model_download_cancel_handler, + "model_import_handler": sentinel.model_import_handler, + "model_installations_list_handler": sentinel.model_installations_list_handler, + "secrets_status_handler": sentinel.secrets_status_handler, + "secrets_put_handler": sentinel.secrets_put_handler, + "events_stream_handler": sentinel.events_stream_handler, + "events_poll_handler": sentinel.events_poll_handler, + "secrets_delete_handler": sentinel.secrets_delete_handler, + "security_doctor_handler": sentinel.security_doctor_handler, + "tools_list_handler": sentinel.tools_list_handler, + "tools_run_handler": sentinel.tools_run_handler, + "create_sweep_handler": sentinel.create_sweep_handler, + "create_compare_handler": sentinel.create_compare_handler, + "list_experiments_handler": sentinel.list_experiments_handler, + "get_experiment_handler": sentinel.get_experiment_handler, + "update_experiment_handler": sentinel.update_experiment_handler, + "select_apply_winner_handler": sentinel.select_apply_winner_handler, + } + + specs = build_core_route_specs("/openclaw", handlers) + keys = {(spec.method, spec.path) for spec in specs} + + self.assertIn(("GET", "/openclaw/health"), keys) + self.assertIn(("POST", "/openclaw/webhook"), keys) + self.assertIn(("GET", "/openclaw/llm/models"), keys) + self.assertIn(("POST", "/openclaw/lab/experiments/{exp_id}/winner"), keys) + self.assertEqual(49, len(specs)) + + def test_build_assist_route_specs_preserves_expected_paths(self): + specs = build_assist_route_specs("/moltbot", _AssistStub()) + keys = {(spec.method, spec.path) for spec in specs} + self.assertEqual(6, len(specs)) + self.assertIn(("GET", "/moltbot/assist/planner/profiles"), keys) + self.assertIn(("POST", "/moltbot/assist/automation/compose"), keys) + + def test_build_connector_installation_specs_preserves_expected_paths(self): + specs = build_connector_installation_route_specs( + "/openclaw", + { + "connector_installations_list_handler": sentinel.list_handler, + "connector_installation_resolve_handler": sentinel.resolve_handler, + "connector_installation_audit_handler": sentinel.audit_handler, + "connector_installation_get_handler": sentinel.get_handler, + }, + ) + keys = {(spec.method, spec.path) for spec in specs} + self.assertEqual(4, len(specs)) + self.assertIn(("GET", "/openclaw/connector/installations/audit"), keys) + self.assertIn( + ("GET", "/openclaw/connector/installations/{installation_id}"), keys + ) + + def test_build_pack_route_specs_preserves_expected_paths(self): + specs = build_pack_route_specs("/openclaw", _PacksStub()) + keys = {(spec.method, spec.path) for spec in specs} + self.assertEqual(4, len(specs)) + self.assertIn(("GET", "/openclaw/packs"), keys) + self.assertIn(("DELETE", "/openclaw/packs/{name}/{version}"), keys) + + +if __name__ == "__main__": + unittest.main()