Make API handler caching optional; plugin fixes

Introduce CACHE_ENABLED (default False) and update CACHE_AREA in api.py so cached handlers are only read/added when caching is enabled; also cast request.remote_addr to str in requires_loopback. In plugins.py add cache import and an invalidate_plugin_cache() helper that clears plugin caches, tidy imports/formatting, simplify override detection using any(), and apply minor refactors/whitespace fixes (including toggle_plugin and get_plugin_config). Note: meta.always_enabled early-return was removed.

frdel committed Feb 25, 2026 at 12:26 UTC 8b62f7151ab6a9e7d162d6cc817fe723d2a329d3
2 files changed +57 -34
python/helpers/api.py
+9 -6
@@ -16,7 +16,8 @@ from python.helpers import files, cache
16
17 ThreadLockType = Union[threading.Lock, threading.RLock]
18
19 -CACHE_AREA = "api_handlers"
19 +CACHE_AREA = "api_handlers(api)(plugins)"
20 +CACHE_ENABLED = False
21
22
23 Input = dict
@@ -167,7 +168,7 @@ def requires_api_key(f):
168 def requires_loopback(f):
169 @wraps(f)
170 async def decorated(*args, **kwargs):
170 - if not is_loopback_address(request.remote_addr):
171 + if not is_loopback_address(str(request.remote_addr)):
172 return Response("Access denied.", 403, {})
173 return await f(*args, **kwargs)
174
@@ -209,9 +210,10 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
210
211 async def _dispatch(path: str) -> BaseResponse:
212 # Return cached wrapped handler if available
212 - cached = cache.get(CACHE_AREA, path)
213 - if cached is not None:
214 - return await cached()
213 + if CACHE_ENABLED:
214 + cached = cache.get(CACHE_AREA, path)
215 + if cached is not None:
216 + return await cached()
217
218 # Resolve file path for the handler
219 # Try built-in api folder first, then plugin api folders
@@ -259,7 +261,8 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
261 if handler_cls.requires_loopback():
262 handler_fn = requires_loopback(handler_fn)
263
262 - cache.add(CACHE_AREA, path, handler_fn)
264 + if CACHE_ENABLED:
265 + cache.add(CACHE_AREA, path, handler_fn)
266 return await handler_fn()
267
268 app.add_url_rule(
python/helpers/plugins.py
+48 -28
@@ -2,9 +2,18 @@ from __future__ import annotations
2
3 import re, json, glob
4 from pathlib import Path
5 -from typing import Any, Dict, Iterator, List, Literal, Optional, TYPE_CHECKING, TypedDict
5 +from typing import (
6 + Any,
7 + Dict,
8 + Iterator,
9 + List,
10 + Literal,
11 + Optional,
12 + TYPE_CHECKING,
13 + TypedDict,
14 +)
15
7 -from python.helpers import files, print_style, yaml as yaml_helper
16 +from python.helpers import files, print_style, yaml as yaml_helper, cache
17 from pydantic import BaseModel, Field
18
19 if TYPE_CHECKING:
@@ -17,11 +26,14 @@ _META_TARGET_RE = re.compile(
26 )
27
28 type ToggleState = Literal["enabled", "disabled", "advanced"]
29 +
30 +
31 class PluginAssetFile(TypedDict):
32 path: str
33 project_name: str
34 agent_profile: str
35
36 +
37 META_FILE_NAME = "plugin.yaml"
38 CONFIG_FILE_NAME = "config.json"
39 CONFIG_DEFAULT_FILE_NAME = "default_config.yaml"
@@ -59,7 +71,11 @@ class PluginListItem(BaseModel):
71 toggle_state: ToggleState = "disabled"
72
73
62 -def get_plugin_roots(plugin_name:str="") -> List[str]:
74 +def invalidate_plugin_cache():
75 + cache.clear("*(plugins)*")
76 +
77 +
78 +def get_plugin_roots(plugin_name: str = "") -> List[str]:
79 """Plugin root directories, ordered by priority (user first)."""
80 return [
81 files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name),
@@ -97,9 +113,7 @@ def get_enhanced_plugins_list(
113 meta_file = str(d / META_FILE_NAME)
114 if not files.exists(meta_file):
115 continue
100 - meta = PluginMetadata.model_validate(
101 - files.read_file_yaml(meta_file)
102 - )
116 + meta = PluginMetadata.model_validate(files.read_file_yaml(meta_file))
117 has_main_screen = files.exists(str(d / "webui" / "main.html"))
118 has_config_screen = files.exists(str(d / "webui" / "config.html"))
119 has_readme = files.exists(str(d / "README.md"))
@@ -233,7 +247,8 @@ def get_enabled_plugins(agent: Agent | None):
247
248 return active
249
236 -def determined_toggle_from_paths(default:bool, paths:Iterator[str]):
250 +
251 +def determined_toggle_from_paths(default: bool, paths: Iterator[str]):
252 enabled = default
253 for plugin_path in paths:
254 if enabled:
@@ -241,11 +256,10 @@ def determined_toggle_from_paths(default:bool, paths:Iterator[str]):
256 files.get_abs_path(plugin_path, DISABLED_FILE_NAME)
257 )
258 else:
244 - enabled = files.exists(
245 - files.get_abs_path(plugin_path, ENABLED_FILE_NAME)
246 - )
259 + enabled = files.exists(files.get_abs_path(plugin_path, ENABLED_FILE_NAME))
260 return enabled
261
262 +
263 def get_toggle_state(plugin_name: str) -> ToggleState:
264 meta = get_plugin_meta(plugin_name)
265 if not meta:
@@ -255,12 +269,22 @@ def get_toggle_state(plugin_name: str) -> ToggleState:
269
270 # root plugin paths
271 plugin_paths = get_plugin_roots(plugin_name)
258 - state = "enabled" if determined_toggle_from_paths(True, reversed(plugin_paths)) else "disabled"
272 + state = (
273 + "enabled"
274 + if determined_toggle_from_paths(True, reversed(plugin_paths))
275 + else "disabled"
276 + )
277
278 # global toggles
279 usr_toggles = [
262 - files.find_existing_paths_by_pattern(files.get_abs_path(files.PLUGINS_DIR, plugin_name, TOGGLE_FILE_PATTERN)),
263 - files.find_existing_paths_by_pattern(files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name, TOGGLE_FILE_PATTERN))
280 + files.find_existing_paths_by_pattern(
281 + files.get_abs_path(files.PLUGINS_DIR, plugin_name, TOGGLE_FILE_PATTERN)
282 + ),
283 + files.find_existing_paths_by_pattern(
284 + files.get_abs_path(
285 + files.USER_DIR, files.PLUGINS_DIR, plugin_name, TOGGLE_FILE_PATTERN
286 + )
287 + ),
288 ]
289
290 # additional toggles in project/agent directories, return advanced
@@ -272,27 +296,23 @@ def get_toggle_state(plugin_name: str) -> ToggleState:
296 agent_profile="*" if meta.per_agent_config else "",
297 only_first=False,
298 )
275 -
299 +
300 # Advanced if there are specific overrides (project or agent specific)
277 - specific_overrides = [
278 - c for c in configs
279 - if c.get("project_name") or c.get("agent_profile")
280 - ]
281 -
282 - if len(specific_overrides) > 0:
301 + if any(c.get("project_name") or c.get("agent_profile") for c in configs):
302 state = "advanced"
303
285 - if state != "advanced" and meta.always_enabled:
286 - return "enabled"
287 -
304 return state
305
306
307 def toggle_plugin(
308 plugin_name: str, enabled: bool, project_name: str = "", agent_profile: str = ""
309 ):
294 - enabled_file = determine_plugin_asset_path(plugin_name, project_name, agent_profile, ENABLED_FILE_NAME)
295 - disabled_file = determine_plugin_asset_path(plugin_name, project_name, agent_profile, DISABLED_FILE_NAME)
310 + enabled_file = determine_plugin_asset_path(
311 + plugin_name, project_name, agent_profile, ENABLED_FILE_NAME
312 + )
313 + disabled_file = determine_plugin_asset_path(
314 + plugin_name, project_name, agent_profile, DISABLED_FILE_NAME
315 + )
316
317 # ensure clean state by deleting both potential files first
318 files.delete_file(enabled_file)
@@ -346,9 +366,9 @@ def get_plugin_config(
366 find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
367 )
368 if file_path and files.exists(file_path):
349 - return (json.loads if file_path.lower().endswith(".json") else yaml_helper.loads)(
350 - files.read_file(file_path)
351 - )
369 + return (
370 + json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
371 + )(files.read_file(file_path))
372 return None
373
374