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