stage #4517: plugins panel active-provider badge via is_active_provider (#4496) + v0.51.531 CHANGELOG
This commit is contained in:
1 parent
163a805813
commit
a60ba78298
5 files changed
+216
-7
No files matched your search
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.531] — 2026-06-20 — Release SP (plugins panel: correct active-provider badge)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Settings → Plugins panel now shows the green "Active provider" badge only on the provider that's actually selected (#4496).** Exclusive provider plugins (e.g. memory backends) all report `enabled: false` by design, so the panel couldn't tell the *selected* provider from its inactive siblings — it badged any exclusive plugin as the active provider. The payload now carries an `is_active_provider` signal (computed from the category's `<category>.provider` config) and the panel badges only the selected one; unselected exclusive providers render as "Disabled". Flat-key exclusive plugins (where the category can't be inferred) keep the older activation-based badge, so nothing regresses. Thanks @franksong2702.
|
||||
|
||||
## [v0.51.530] — 2026-06-20 — Release SO (fix /api/profiles 500)
|
||||
|
||||
### Fixed
|
||||
|
||||
+48
-4
@@ -7071,6 +7071,31 @@ def _clean_plugin_visibility_text(value, *, limit=240) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _plugin_visibility_category_from_key(key: str) -> str:
|
||||
"""Return the config category prefix for nested plugin keys."""
|
||||
raw = str(key or "").strip().replace("\\", "/")
|
||||
if "/" not in raw:
|
||||
return ""
|
||||
category = raw.split("/", 1)[0].strip()
|
||||
return category if category and category not in {".", ".."} else ""
|
||||
|
||||
|
||||
def _plugin_visibility_selected_provider(category: str) -> str:
|
||||
"""Read ``<category>.provider`` without surfacing config errors."""
|
||||
if not category:
|
||||
return ""
|
||||
try:
|
||||
from api.config import get_config as _get_cfg
|
||||
|
||||
cfg = _get_cfg() or {}
|
||||
except Exception:
|
||||
return ""
|
||||
category_cfg = cfg.get(category, {}) if isinstance(cfg, dict) else {}
|
||||
if not isinstance(category_cfg, dict):
|
||||
return ""
|
||||
return str(category_cfg.get("provider") or "").strip().lower()
|
||||
|
||||
|
||||
def _plugin_visibility_payload(manager=None) -> dict:
|
||||
"""Build a sanitized plugin/hook visibility payload for Settings.
|
||||
|
||||
@@ -7084,8 +7109,12 @@ def _plugin_visibility_payload(manager=None) -> dict:
|
||||
category's ``<category>.provider`` config, not through ``plugins.enabled``.
|
||||
Their ``loaded.enabled`` stays False by design and they register hooks
|
||||
outside the four visibility hooks below. The payload surfaces ``kind``
|
||||
and ``activation`` so the panel can render them distinctly instead of
|
||||
mislabeling them as "Disabled" with no hooks (issue #2659).
|
||||
and ``activation`` plus ``is_active_provider`` when the plugin key carries a
|
||||
category prefix so the panel can render the selected provider distinctly
|
||||
instead of mislabeling it as "Disabled" with no hooks (issue #2659), while
|
||||
still showing unselected exclusive providers as disabled. Flat-key exclusive
|
||||
plugins omit the new field so older activation-based badge semantics remain
|
||||
intact when the category cannot be inferred.
|
||||
"""
|
||||
manager = manager or _get_plugin_manager_for_visibility()
|
||||
manager.discover_and_load(force=False)
|
||||
@@ -7107,19 +7136,31 @@ def _plugin_visibility_payload(manager=None) -> dict:
|
||||
description = _clean_plugin_visibility_text(getattr(manifest, "description", ""), limit=280)
|
||||
kind = _clean_plugin_visibility_text(getattr(manifest, "kind", "") or "standalone", limit=40)
|
||||
enabled_flag = bool(getattr(loaded, "enabled", False))
|
||||
category = _plugin_visibility_category_from_key(plugin_key)
|
||||
selected_provider = _plugin_visibility_selected_provider(category)
|
||||
plugin_slug = plugin_key.rsplit("/", 1)[-1].strip().lower()
|
||||
if kind == "exclusive":
|
||||
activation = "exclusive"
|
||||
elif kind == "model-provider" and enabled_flag:
|
||||
activation = "provider"
|
||||
else:
|
||||
activation = "enabled" if enabled_flag else "disabled"
|
||||
include_active_provider = True
|
||||
if kind == "exclusive":
|
||||
if category:
|
||||
is_active_provider = bool(selected_provider) and plugin_slug == selected_provider
|
||||
else:
|
||||
include_active_provider = False
|
||||
is_active_provider = False
|
||||
else:
|
||||
is_active_provider = kind == "model-provider" and enabled_flag
|
||||
registered = []
|
||||
for hook in list(getattr(manifest, "provides_hooks", []) or []) + list(getattr(loaded, "hooks_registered", []) or []):
|
||||
hook_name = str(hook or "").strip()
|
||||
if hook_name in _PLUGIN_VISIBILITY_HOOK_SET and hook_name not in registered:
|
||||
registered.append(hook_name)
|
||||
registered.sort(key=_PLUGIN_VISIBILITY_HOOKS.index)
|
||||
plugins.append({
|
||||
plugin_payload = {
|
||||
"name": name,
|
||||
"key": plugin_key or name,
|
||||
"version": version,
|
||||
@@ -7130,7 +7171,10 @@ def _plugin_visibility_payload(manager=None) -> dict:
|
||||
"kind": kind,
|
||||
"activation": activation,
|
||||
"hooks": registered,
|
||||
})
|
||||
}
|
||||
if include_active_provider:
|
||||
plugin_payload["is_active_provider"] = bool(is_active_provider)
|
||||
plugins.append(plugin_payload)
|
||||
|
||||
return {
|
||||
"plugins": plugins,
|
||||
|
||||
+4
-1
@@ -7565,9 +7565,12 @@ const enabled=plugin&&plugin.enabled!==false;
|
||||
</label>
|
||||
</div>`
|
||||
: '');
|
||||
const isProviderActive = plugin&&typeof plugin.is_active_provider==='boolean'
|
||||
? plugin.is_active_provider
|
||||
: isProvider;
|
||||
let badgeText;
|
||||
let badgeClass;
|
||||
if(isProvider){
|
||||
if(isProviderActive){
|
||||
badgeText=t('plugins_active_provider');
|
||||
badgeClass='plugin-card-badge-provider';
|
||||
}else if(activation==='enabled'){
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Regression coverage for #4496 plugin provider badge state."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_provider_badge_uses_active_provider_payload_state(tmp_path):
|
||||
script = tmp_path / "check_plugin_badge.js"
|
||||
script.write_text(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
const src = fs.readFileSync({json.dumps(str(REPO_ROOT / "static" / "panels.js"))}, 'utf8');
|
||||
|
||||
function extractFunction(name) {{
|
||||
const marker = 'function ' + name;
|
||||
const start = src.indexOf(marker);
|
||||
if (start < 0) throw new Error('missing function ' + name);
|
||||
const brace = src.indexOf('{{', start);
|
||||
let depth = 1;
|
||||
let i = brace + 1;
|
||||
while (depth && i < src.length) {{
|
||||
if (src[i] === '{{') depth += 1;
|
||||
else if (src[i] === '}}') depth -= 1;
|
||||
i += 1;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
|
||||
global.t = (key) => key;
|
||||
global.esc = (value) => String(value ?? '');
|
||||
global.document = {{
|
||||
createElement() {{
|
||||
return {{
|
||||
className: '',
|
||||
dataset: {{}},
|
||||
_html: '',
|
||||
set innerHTML(value) {{ this._html = String(value); }},
|
||||
get innerHTML() {{ return this._html; }},
|
||||
querySelector() {{ return null; }},
|
||||
}};
|
||||
}},
|
||||
}};
|
||||
|
||||
eval(extractFunction('_buildPluginCard'));
|
||||
|
||||
const activeExclusive = _buildPluginCard({{
|
||||
key: 'memory',
|
||||
name: 'Memory',
|
||||
activation: 'exclusive',
|
||||
is_active_provider: true,
|
||||
enabled: false,
|
||||
hooks: [],
|
||||
}}).innerHTML;
|
||||
assert(activeExclusive.includes('plugin-card-badge-provider'), activeExclusive);
|
||||
assert(!activeExclusive.includes('plugin-card-badge-disabled'), activeExclusive);
|
||||
assert(activeExclusive.includes('plugins_active_provider'), activeExclusive);
|
||||
|
||||
const legacyFlatKeyExclusive = _buildPluginCard({{
|
||||
key: 'noema',
|
||||
name: 'Noema',
|
||||
activation: 'exclusive',
|
||||
enabled: false,
|
||||
hooks: [],
|
||||
}}).innerHTML;
|
||||
assert(legacyFlatKeyExclusive.includes('plugin-card-badge-provider'), legacyFlatKeyExclusive);
|
||||
assert(!legacyFlatKeyExclusive.includes('plugin-card-badge-disabled'), legacyFlatKeyExclusive);
|
||||
assert(legacyFlatKeyExclusive.includes('plugins_active_provider'), legacyFlatKeyExclusive);
|
||||
|
||||
const inactiveExclusive = _buildPluginCard({{
|
||||
key: 'memory',
|
||||
name: 'Memory',
|
||||
activation: 'exclusive',
|
||||
is_active_provider: false,
|
||||
enabled: false,
|
||||
hooks: [],
|
||||
}}).innerHTML;
|
||||
assert(inactiveExclusive.includes('plugin-card-badge-disabled'), inactiveExclusive);
|
||||
assert(!inactiveExclusive.includes('plugin-card-badge-provider'), inactiveExclusive);
|
||||
assert(inactiveExclusive.includes('plugins_disabled'), inactiveExclusive);
|
||||
|
||||
const activeModelProvider = _buildPluginCard({{
|
||||
key: 'openrouter',
|
||||
name: 'OpenRouter',
|
||||
activation: 'provider',
|
||||
is_active_provider: true,
|
||||
enabled: true,
|
||||
hooks: [],
|
||||
}}).innerHTML;
|
||||
assert(activeModelProvider.includes('plugin-card-badge-provider'), activeModelProvider);
|
||||
assert(!activeModelProvider.includes('plugin-card-badge-disabled'), activeModelProvider);
|
||||
assert(activeModelProvider.includes('plugins_active_provider'), activeModelProvider);
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
subprocess.run(["node", str(script)], check=True, cwd=REPO_ROOT)
|
||||
@@ -90,6 +90,7 @@ class TestPluginsApi:
|
||||
"enabled": True,
|
||||
"kind": "standalone",
|
||||
"activation": "enabled",
|
||||
"is_active_provider": False,
|
||||
"hooks": ["pre_tool_call", "post_tool_call"],
|
||||
}]
|
||||
serialized = repr(payload)
|
||||
@@ -147,7 +148,7 @@ class TestPluginsApi:
|
||||
"noema": _FakeLoadedPlugin(
|
||||
_FakeManifest(
|
||||
name="noema",
|
||||
key="noema",
|
||||
key="memory/noema",
|
||||
version="0.1.0",
|
||||
description="Structured memory backed by a Noema Cortex",
|
||||
kind="exclusive",
|
||||
@@ -158,11 +159,13 @@ class TestPluginsApi:
|
||||
)
|
||||
})
|
||||
|
||||
payload = self._capture_plugins_response(manager)
|
||||
with patch("api.config.get_config", return_value={"memory": {"provider": "noema"}}):
|
||||
payload = self._capture_plugins_response(manager)
|
||||
|
||||
plugin = payload["plugins"][0]
|
||||
assert plugin["kind"] == "exclusive"
|
||||
assert plugin["activation"] == "exclusive"
|
||||
assert plugin["is_active_provider"] is True
|
||||
# `enabled` stays False for back-compat with older WebUI clients that
|
||||
# key off it directly; new clients must read `activation`.
|
||||
assert plugin["enabled"] is False
|
||||
@@ -171,6 +174,54 @@ class TestPluginsApi:
|
||||
# filesystem paths or other internals on other plugin kinds.
|
||||
assert "exclusive plugin" not in repr(payload)
|
||||
|
||||
def test_api_plugins_marks_unselected_exclusive_plugins_inactive(self):
|
||||
manager = _FakePluginManager({
|
||||
"honcho": _FakeLoadedPlugin(
|
||||
_FakeManifest(
|
||||
name="honcho",
|
||||
key="memory/honcho",
|
||||
version="1.0.0",
|
||||
description="Honcho memory provider",
|
||||
kind="exclusive",
|
||||
),
|
||||
enabled=False,
|
||||
hooks_registered=[],
|
||||
)
|
||||
})
|
||||
|
||||
with patch("api.config.get_config", return_value={"memory": {"provider": "noema"}}):
|
||||
payload = self._capture_plugins_response(manager)
|
||||
|
||||
plugin = payload["plugins"][0]
|
||||
assert plugin["kind"] == "exclusive"
|
||||
assert plugin["activation"] == "exclusive"
|
||||
assert plugin["enabled"] is False
|
||||
assert plugin["is_active_provider"] is False
|
||||
|
||||
def test_api_plugins_omits_active_provider_for_flat_key_exclusive_plugins(self):
|
||||
manager = _FakePluginManager({
|
||||
"noema": _FakeLoadedPlugin(
|
||||
_FakeManifest(
|
||||
name="noema",
|
||||
key="noema",
|
||||
version="0.1.0",
|
||||
description="Flat-key memory provider",
|
||||
kind="exclusive",
|
||||
),
|
||||
enabled=False,
|
||||
hooks_registered=[],
|
||||
)
|
||||
})
|
||||
|
||||
with patch("api.config.get_config", return_value={"memory": {"provider": "noema"}}):
|
||||
payload = self._capture_plugins_response(manager)
|
||||
|
||||
plugin = payload["plugins"][0]
|
||||
assert plugin["kind"] == "exclusive"
|
||||
assert plugin["activation"] == "exclusive"
|
||||
assert plugin["enabled"] is False
|
||||
assert "is_active_provider" not in plugin
|
||||
|
||||
def test_api_plugins_marks_active_model_provider(self):
|
||||
# model-provider plugins that loaded successfully (loaded.enabled True)
|
||||
# get the "provider" activation badge so the panel can distinguish
|
||||
@@ -194,6 +245,7 @@ class TestPluginsApi:
|
||||
plugin = payload["plugins"][0]
|
||||
assert plugin["kind"] == "model-provider"
|
||||
assert plugin["activation"] == "provider"
|
||||
assert plugin["is_active_provider"] is True
|
||||
assert plugin["enabled"] is True
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user