diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 26163f9d12d..303cf7284cc 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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: diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py index 85c6603c699..84929c0ca2a 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -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):