fix(proxy): register classifier plugins before the model list resolves, clear the registry when the config drops the block

A config-file auto-router referencing a registry name resolved against an
empty registry because registration ran after the model list, failing startup
as a dotted import of the name. Registration now precedes the model list, and
the replace-on-reload semantics also cover an emptied or removed
classifier_plugins block, so stale names cannot stay selectable
This commit is contained in:
Tin Chi Lo 2026-08-18 16:53:43 -07:00
parent 0c61bee7ed
commit 9a3e784d23
2 changed files with 83 additions and 25 deletions

View file

@ -5311,6 +5311,32 @@ class ProxyConfig:
router_params["health_check_staleness_threshold"] = _hc_staleness
if _hc_ignore_transient:
router_params["health_check_ignore_transient_errors"] = True
## CLASSIFIER PLUGINS (complexity-router custom classifiers, picked by name in the Admin UI).
## Registered before the model list resolves, so config-file routers can reference names.
classifier_plugins_config: Final = config.get("classifier_plugins", None)
if classifier_plugins_config is not None and not isinstance(classifier_plugins_config, dict):
raise TypeError("classifier_plugins must map plugin names to dotted paths")
for plugin_name, plugin_path in (classifier_plugins_config or _EMPTY_MAPPING).items():
if not isinstance(plugin_path, str):
raise TypeError(f"classifier_plugins.{plugin_name} must be a dotted-path string")
resolved_classifier_entries: Final = tuple(
(
str(plugin_name),
resolve_classifier_plugin(
plugin_path=plugin_path,
config_file_path=config_file_path,
source_label=f"classifier_plugins.{plugin_name}",
),
)
for plugin_name, plugin_path in (classifier_plugins_config or _EMPTY_MAPPING).items()
)
# Replace, never merge: a reload that drops a name, empties the block, or removes it
# entirely must evict the stale entries, or a deleted plugin stays selectable until
# the next restart. Resolution runs before the clear, so a module broken at reload
# time keeps the old registry intact.
litellm.classifier_plugin_registry.clear()
litellm.classifier_plugin_registry.update(resolved_classifier_entries)
## MODEL LIST
model_list: Final = config.get("model_list", None)
if model_list:
@ -5492,31 +5518,6 @@ class ProxyConfig:
# Load vector stores from config
litellm.vector_store_registry.load_vector_stores_from_config(vector_store_registry_config)
## CLASSIFIER PLUGINS (complexity-router custom classifiers, picked by name in the Admin UI)
classifier_plugins_config: Final = config.get("classifier_plugins", None)
if classifier_plugins_config:
if not isinstance(classifier_plugins_config, dict):
raise TypeError("classifier_plugins must map plugin names to dotted paths")
for plugin_name, plugin_path in classifier_plugins_config.items():
if not isinstance(plugin_path, str):
raise TypeError(f"classifier_plugins.{plugin_name} must be a dotted-path string")
resolved_entries: Final = tuple(
(
str(plugin_name),
resolve_classifier_plugin(
plugin_path=plugin_path,
config_file_path=config_file_path,
source_label=f"classifier_plugins.{plugin_name}",
),
)
for plugin_name, plugin_path in classifier_plugins_config.items()
)
# Replace, never merge: a config reload that drops a name must evict it, or a
# deleted plugin stays selectable until the next restart. Resolution runs before
# the clear, so a module broken at reload time keeps the old registry intact.
litellm.classifier_plugin_registry.clear()
litellm.classifier_plugin_registry.update(resolved_entries)
## WORKER REGISTRY (Global Control Plane)
worker_registry_config: Final = config.get("worker_registry", None)
if worker_registry_config:

View file

@ -297,6 +297,63 @@ def test_classifier_plugins_config_key_replaces_the_registry_on_reload(monkeypat
assert set(litellm.classifier_plugin_registry) == {"fresh-name"}
def test_config_file_router_can_reference_a_registry_name(monkeypatch, tmp_path):
"""The registry registers before the model list resolves, so a config.yaml router may
set classifier_plugin to a registry name; regression for the ordering bug where the
registry filled after plugin resolution and names failed as dotted imports."""
import asyncio
import litellm
from litellm.proxy.proxy_server import ProxyConfig
(tmp_path / "reg_classifier.py").write_text(
"class _Classifier:\n"
" async def classify(self, context):\n"
" return 'SIMPLE'\n"
"\n"
"instance = _Classifier()\n"
)
config_path = tmp_path / "config.yaml"
config_path.write_text(
"classifier_plugins:\n"
" tier-by-team: reg_classifier.instance\n"
"model_list:\n"
" - model_name: smart-router\n"
" litellm_params:\n"
" model: auto_router/complexity_router\n"
" complexity_router_default_model: gpt-4o-mini\n"
" complexity_router_config:\n"
" classifier_type: custom\n"
" classifier_plugin: tier-by-team\n"
" tiers:\n"
" SIMPLE: gpt-4o-mini\n"
)
monkeypatch.setattr(litellm, "classifier_plugin_registry", {}, raising=True)
proxy_config = ProxyConfig()
router, _, _ = asyncio.run(proxy_config.load_config(router=None, config_file_path=str(config_path)))
assert "smart-router" in router.model_names
assert list(router.complexity_routers) == ["smart-router"]
def test_registry_clears_when_the_config_key_is_removed(monkeypatch, tmp_path):
"""Removing the classifier_plugins block on reload must evict every stale entry."""
import asyncio
import litellm
from litellm.proxy.proxy_server import ProxyConfig
class _Classifier:
async def classify(self, context):
return "SIMPLE"
monkeypatch.setattr(litellm, "classifier_plugin_registry", {"stale-name": _Classifier()}, raising=True)
config_path = tmp_path / "config.yaml"
config_path.write_text("model_list:\n - model_name: gpt-4o-mini\n litellm_params:\n model: gpt-4o-mini\n")
proxy_config = ProxyConfig()
asyncio.run(proxy_config.load_config(router=None, config_file_path=str(config_path)))
assert litellm.classifier_plugin_registry == {}
def test_resolve_complexity_router_plugins_leaves_live_classifier_instance_alone():
class _Classifier:
async def classify(self, context):