This commit is contained in:
mrinal-berri 2026-10-11 19:20:20 -07:00 • committed by GitHub
commit 9606fea0da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2080 additions and 209 deletions

View file

@ -41,6 +41,8 @@ litellm/proxy/_experimental/mcp_server/
sampling_handler.py # MCP sampling to LiteLLM completion flow
elicitation_handler.py # MCP elicitation relay flow
semantic_tool_filter.py # semantic filtering of available MCP tools
semantic_tool_index_store.py # Redis-shared embeddings + build lock for the semantic tool index
semantic_tool_encoder.py # CachingToolEncoder bridging the store and LiteLLMRouterEncoder
tool_search.py # opt-in virtual tools (mcp_tool_search + mcp_tool_call) for large catalogs
guardrail_translation/
handler.py # MCP guardrail result translation

View file

@ -0,0 +1,83 @@
"""
Caching encoder for the shared MCP semantic tool index.
Lives apart from semantic_tool_index_store because LiteLLMRouterEncoder pulls
in semantic_router, an optional extra that must stay out of the store's import
chain.
"""
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Final
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import (
ToolVectorStore,
tool_vector_key,
)
from litellm.router_strategy.auto_router.litellm_encoder import LiteLLMRouterEncoder
if TYPE_CHECKING:
from litellm.router import Router
class CachingToolEncoder(LiteLLMRouterEncoder):
"""LiteLLMRouterEncoder that serves document embeddings from a ToolVectorStore.
Queries are never cached; only add/aadd's document utterances go through
the store. Duplicate texts share one key and one embed. Cached vectors
whose length disagrees with a live probe are treated as misses, so a
stale entry can never feed the index a wrong-shape vector.
"""
_tool_vector_store: ToolVectorStore
_embedding_identity: str
_dimensions: int | None
def __init__(
self,
*,
store: ToolVectorStore,
embedding_identity: str,
litellm_router_instance: "Router",
model_name: str,
score_threshold: float | None = None,
) -> None:
super().__init__(
litellm_router_instance=litellm_router_instance,
model_name=model_name,
score_threshold=score_threshold,
)
self._tool_vector_store = store
self._embedding_identity = embedding_identity
self._dimensions = None
async def adimensions(self) -> int:
if self._dimensions is None:
probe: Final = await self.aencode_queries(["test"]) # pyright: ignore[reportUnknownMemberType] # semantic_router ships no type stubs
self._dimensions = len(probe[0])
return self._dimensions
def encode_documents( # pyright: ignore[reportIncompatibleMethodOverride] # semantic_router calls encode_documents without kwargs
self, docs: Sequence[str]
) -> list[list[float]]: # mutable-ok: mirrors the parent DenseEncoder contract
return super().encode_documents(list(docs))
async def aencode_documents( # pyright: ignore[reportIncompatibleMethodOverride] # semantic_router calls aencode_documents without kwargs
self, docs: Sequence[str]
) -> list[list[float]]: # mutable-ok: mirrors the parent DenseEncoder contract
unique_docs: Final = tuple(dict.fromkeys(docs))
keys: Final = tuple(tool_vector_key(self._embedding_identity, text) for text in unique_docs)
hits: Final[Mapping[str, tuple[float, ...]]] = await self._tool_vector_store.get_many(keys)
valid_hits: Final = (
{key: vector for key, vector in hits.items() if len(vector) == await self.adimensions()} if hits else {}
)
miss_pairs: Final = tuple((text, key) for text, key in zip(unique_docs, keys) if key not in valid_hits)
embedded: Final = dict(valid_hits)
if miss_pairs:
vectors: Final = await super().aencode_documents([text for text, _ in miss_pairs])
new_vectors: Final = {key: tuple(vector) for (_, key), vector in zip(miss_pairs, vectors)}
await self._tool_vector_store.put_many(new_vectors)
embedded.update(new_vectors)
vectors_by_text: Final = dict(zip(unique_docs, (embedded[key] for key in keys)))
return [list(vectors_by_text[text]) for text in docs]

View file

@ -8,17 +8,25 @@ import asyncio
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final
from mcp.types import Tool as MCPTool
from pydantic import TypeAdapter
from litellm._logging import verbose_logger
from litellm.exceptions import ContextWindowExceededError
from litellm.litellm_core_utils.exception_mapping_utils import ExceptionCheckers
from litellm.proxy._experimental.mcp_server.catalog import catalog_operation, global_manager
from litellm.proxy._experimental.mcp_server.faults import iter_exception_tree
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import (
ToolVectorStore,
tool_vector_key,
)
from litellm.proxy._experimental.mcp_server.utils import MCP_TOOL_PREFIX_SEPARATOR
if TYPE_CHECKING:
from semantic_router.routers import SemanticRouter
from litellm.router import Router
from litellm.router_strategy.auto_router.litellm_encoder import LiteLLMRouterEncoder
class SemanticToolFilterContextWindowError(Exception):
@ -48,6 +56,13 @@ def _is_context_window_error(error: BaseException | None) -> bool:
)
def _embedding_params_suffix(litellm_params: Mapping[str, object]) -> str:
"""Deployment traits that make cached vectors non-interchangeable across endpoints."""
return (f"@dimensions={litellm_params['dimensions']}" if litellm_params.get("dimensions") is not None else "") + (
f"@api_base={litellm_params['api_base']}" if litellm_params.get("api_base") is not None else ""
)
class SemanticMCPToolFilter:
"""Filters MCP tools using semantic similarity to reduce context window size."""
@ -58,6 +73,9 @@ class SemanticMCPToolFilter:
top_k: int = 10,
similarity_threshold: float = 0.3,
enabled: bool = True,
vector_store: ToolVectorStore | None = None,
lock_poll_interval_s: float = 5.0,
lock_wait_timeout_s: float = 300.0,
):
"""
Initialize the semantic tool filter.
@ -68,6 +86,13 @@ class SemanticMCPToolFilter:
top_k: Maximum number of tools to return
similarity_threshold: Minimum similarity score for filtering
enabled: Whether filtering is enabled
vector_store: Shared embedding store; when set the index builds
cross-pod in the background and requests pass through unfiltered
until startup_index_ready flips
lock_poll_interval_s: How often to re-check the store while another
pod holds the build lock
lock_wait_timeout_s: Give up waiting for another pod's vectors after
this long and embed locally (matches the build-lock TTL)
"""
self.enabled = enabled
self.top_k = top_k
@ -78,10 +103,63 @@ class SemanticMCPToolFilter:
self.context_window_error: str | None = None
self._tool_map: dict[str, object] = {} # MCPTool objects or OpenAI function dicts
self._index_sync_lock = asyncio.Lock()
self._vector_store = vector_store
self._lock_poll_interval_s = lock_poll_interval_s
self._lock_wait_timeout_s = lock_wait_timeout_s
self.embedding_identity = self._compute_embedding_identity()
self.startup_index_ready = vector_store is None
def _compute_embedding_identity(self) -> str:
"""embedding_model plus every deployment model it routes to, so repointing an alias invalidates the shared vectors."""
try:
deployments: Final = self.router_instance.get_model_list(model_name=self.embedding_model) or ()
deployment_maps: Final = TypeAdapter(tuple[Mapping[str, object], ...]).validate_python(deployments)
litellm_params: Final = tuple(
TypeAdapter(Mapping[str, object]).validate_python(deployment.get("litellm_params") or {})
for deployment in deployment_maps
)
deployment_models: Final = sorted(
{
str(model) + _embedding_params_suffix(params)
for model, params in ((p.get("model"), p) for p in litellm_params)
if model
}
)
except (AttributeError, TypeError, KeyError, ValueError):
return self.embedding_model
if not deployment_models:
return self.embedding_model
return self.embedding_model + "|" + "|".join(deployment_models)
def _new_encoder(self) -> "LiteLLMRouterEncoder":
"""Encoder used for every index build; cached in Redis when a store is set."""
from litellm.proxy._experimental.mcp_server.semantic_tool_encoder import CachingToolEncoder
from litellm.router_strategy.auto_router.litellm_encoder import (
LiteLLMRouterEncoder,
)
if self._vector_store is not None:
return CachingToolEncoder(
litellm_router_instance=self.router_instance,
model_name=self.embedding_model,
score_threshold=self.similarity_threshold,
store=self._vector_store,
embedding_identity=self.embedding_identity,
)
return LiteLLMRouterEncoder(
litellm_router_instance=self.router_instance,
model_name=self.embedding_model,
score_threshold=self.similarity_threshold,
)
@catalog_operation(global_manager)
async def build_router_from_mcp_registry(self) -> None:
"""Build semantic router from all MCP tools in the registry (no auth checks)."""
"""Build semantic router from all MCP tools in the registry (no auth checks).
With a vector store the shared Redis path runs: acquire the build lock
or wait for the holding pod's vectors, then embed only the misses.
Without one, the sync build blocks startup exactly as before.
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
@ -95,7 +173,7 @@ class SemanticMCPToolFilter:
return
# Fetch tools from all servers in parallel
all_tools: Final = []
all_tools: Final[Sequence[object]] = []
for server_id, server in registry.items():
try:
tools = await global_mcp_server_manager.get_tools_for_server(server_id)
@ -110,39 +188,66 @@ class SemanticMCPToolFilter:
return
verbose_logger.info("Fetched %s tools from %s MCP servers", len(all_tools), len(registry))
self._build_router(all_tools)
if self._vector_store is not None:
await self._abuild_router(all_tools)
else:
self._build_router(all_tools)
except Exception as e:
verbose_logger.error("Failed to build router from MCP registry: %s", e)
self.tool_router = None
raise
finally:
self.startup_index_ready = True
def extract_tool_info(self, tool) -> tuple[str, str]:
def extract_tool_info(self, tool: object) -> tuple[str, str]:
"""Extract name and description from MCP tool or OpenAI function dict."""
name: str
description: str
if isinstance(tool, dict):
# OpenAI function format
name = tool.get("name", "")
description = tool.get("description", name)
tool_dict: Final = TypeAdapter(Mapping[str, object]).validate_python(tool)
name = str(tool_dict.get("name", ""))
description = str(tool_dict.get("description", name))
else:
# MCPTool object
name = str(tool.name)
description = str(tool.description) if tool.description else str(tool.name)
mcp_tool: Final = MCPTool.model_validate(tool, from_attributes=True)
name = str(mcp_tool.name)
description = str(mcp_tool.description) if mcp_tool.description else str(mcp_tool.name)
return name, description
_extract_tool_info = extract_tool_info
def _build_router(self, tools: list) -> None:
"""Build semantic router with tools (MCPTool objects or OpenAI function dicts)."""
from semantic_router.routers import SemanticRouter
def _tools_to_routes_and_map(self, tools: Sequence[object]) -> tuple[Sequence[object], Mapping[str, object]]:
from semantic_router.routers.base import Route
from litellm.router_strategy.auto_router.litellm_encoder import (
LiteLLMRouterEncoder,
)
extracted: Final = tuple((self.extract_tool_info(tool), tool) for tool in tools)
tool_map: Final = {name: tool for (name, _), tool in extracted}
routes: Final[Sequence[object]] = [
Route(
name=name,
description=description,
utterances=[description],
score_threshold=self.similarity_threshold,
)
for (name, description), _ in extracted
]
return routes, tool_map
def _handle_build_error(self, error: Exception) -> bool:
"""True when the failure was recorded as a context-window overflow; False when the caller should re-raise."""
verbose_logger.error("Failed to build semantic router: %s", error)
self.tool_router = None
if _is_context_window_error(error):
self.context_window_error = str(error)
return True
return False
def _build_router(self, tools: Sequence[object]) -> None:
"""Build semantic router with tools (MCPTool objects or OpenAI function dicts)."""
from semantic_router.routers import SemanticRouter
if not tools:
self.tool_router = None
@ -150,112 +255,161 @@ class SemanticMCPToolFilter:
try:
self.context_window_error = None
# Convert tools to routes
routes: Final = []
self._tool_map = {}
for tool in tools:
name, description = self.extract_tool_info(tool)
self._tool_map[name] = tool
routes.append(
Route(
name=name,
description=description,
utterances=[description],
score_threshold=self.similarity_threshold,
)
)
routes, tool_map = self._tools_to_routes_and_map(tools)
self.tool_router = SemanticRouter(
routes=routes,
encoder=LiteLLMRouterEncoder(
litellm_router_instance=self.router_instance,
model_name=self.embedding_model,
score_threshold=self.similarity_threshold,
),
routes=list(routes),
encoder=self._new_encoder(),
auto_sync="local",
)
self._tool_map = dict(tool_map)
verbose_logger.info("Built semantic router with %s tools", len(routes))
except Exception as e:
verbose_logger.error("Failed to build semantic router: %s", e)
self.tool_router = None
if _is_context_window_error(e):
self.context_window_error = str(e)
if self._handle_build_error(e):
return
raise
def _has_tools_missing_from_index(self, tools: Sequence[object]) -> bool:
"""Allocation-free check for any named tool not yet in the semantic index."""
return any(name and name not in self._tool_map for name in (self.extract_tool_info(t)[0] for t in tools))
def _tools_missing_from_index(self, tools: Sequence[object]) -> Mapping[str, object]:
"""Map name -> tool for every named tool not yet in the semantic index."""
return {
name: tool
for name, tool in ((self.extract_tool_info(t)[0], t) for t in tools)
if name and name not in self._tool_map
}
async def _ensure_tools_indexed(self, available_tools: Sequence[object]) -> None:
async def _abuild_router(self, tools: Sequence[object]) -> None:
"""
Index request-time tools the startup build never saw.
Async index build for the background path.
The startup index lists every registered MCP server WITHOUT per-user
credentials, so servers requiring per-user auth (interactive OAuth
tokens, user-scoped env vars) contribute zero routes. Tools reaching
the filter came through an authenticated expansion; without indexing
them here they can never be selected, so requests either bypass
filtering entirely (N->N) or lose every tool to unrelated matches.
Runs async-only (no synchronous embedding on the request path) and
never writes shared error state: an embedding failure here raises and
is scoped to the requesting call, so one request's oversized tool
description cannot poison the filter for other users on the worker.
When a store is set, this first waits on the cross-pod build lock so
only one pod embeds; the caching encoder then fills any vectors the
holder did not write.
"""
from semantic_router.routers import SemanticRouter
from semantic_router.routers.base import Route
from litellm.router_strategy.auto_router.litellm_encoder import (
LiteLLMRouterEncoder,
from litellm.proxy._experimental.mcp_server.semantic_tool_encoder import CachingToolEncoder
if not tools:
self.tool_router = None
return
lock_held: Final = await self._await_store_or_lock(tools) if self._vector_store is not None else False
try:
self.context_window_error = None
routes, tool_map = self._tools_to_routes_and_map(tools)
encoder: Final = self._new_encoder()
router: Final = SemanticRouter( # pyright: ignore[reportUnknownVariableType] # semantic_router ships no type stubs
routes=[],
encoder=encoder,
init_async_index=True,
top_k=self.top_k,
)
# aadd's lazy init probes index dimensions through the sync encoder,
# so dimensions are seeded with an async probe to keep the build async.
dimensions: Final = (
await encoder.adimensions()
if isinstance(encoder, CachingToolEncoder)
else len((await encoder.aencode_queries(["test"]))[0]) # pyright: ignore[reportUnknownMemberType] # semantic_router ships no type stubs
)
router.index.dimensions = dimensions # pyright: ignore[reportUnknownMemberType] # semantic_router ships no type stubs
await router.aadd(list(routes)) # pyright: ignore[reportUnknownMemberType] # semantic_router ships no type stubs
self._tool_map = dict(tool_map)
self.tool_router = router
verbose_logger.info("Built semantic router with %s tools via async index build", len(routes))
except Exception as e:
if self._handle_build_error(e):
return
raise
finally:
if lock_held and self._vector_store is not None:
await self._vector_store.release_build_lock()
async def _await_store_or_lock(self, tools: Sequence[object]) -> bool:
"""True when this pod holds the build lock after the wait; False when every vector arrived without it."""
store: Final = self._vector_store
if store is None:
return False
descriptions: Final = tuple(self.extract_tool_info(tool)[1] for tool in tools)
keys_set: Final = frozenset(tool_vector_key(self.embedding_identity, text) for text in set(descriptions))
loop: Final = asyncio.get_running_loop()
deadline: Final = loop.time() + self._lock_wait_timeout_s
while keys_set - (await store.get_many(tuple(keys_set))).keys():
if await store.try_acquire_build_lock():
return True
if loop.time() >= deadline:
verbose_logger.warning(
"MCP semantic tool index wait exceeded %.1fs; embedding locally",
self._lock_wait_timeout_s,
)
return False
await asyncio.sleep(self._lock_poll_interval_s)
return False
def _has_tools_missing_from_index(self, tools: Sequence[object], *, reindex_changed_descriptions: bool) -> bool:
"""Allocation-free check for any named tool not yet in the semantic index."""
return any(
name
and (
name not in self._tool_map
or (reindex_changed_descriptions and self.extract_tool_info(self._tool_map[name])[1] != description)
)
for name, description in (self.extract_tool_info(t) for t in tools)
)
if not self._has_tools_missing_from_index(available_tools):
def _tools_missing_from_index(
self, tools: Sequence[object], *, reindex_changed_descriptions: bool
) -> Mapping[str, object]:
"""Map name -> tool for every named tool not yet in the semantic index."""
infos: Final = ((self.extract_tool_info(t), t) for t in tools)
return {
name: tool
for (name, description), tool in infos
if name
and (
name not in self._tool_map
or (reindex_changed_descriptions and self.extract_tool_info(self._tool_map[name])[1] != description)
)
}
async def _ensure_tools_indexed(
self, available_tools: Sequence[object], *, reindex_changed_descriptions: bool
) -> None:
"""
Index request-time tools missing from the startup index.
With ``reindex_changed_descriptions`` a tool whose description changed
also counts as missing: its old route is dropped before re-embedding so
a failed embed leaves it "missing" and the next request retries.
"""
from semantic_router.routers import SemanticRouter
if not self._has_tools_missing_from_index(
available_tools, reindex_changed_descriptions=reindex_changed_descriptions
):
return
async with self._index_sync_lock:
missing: Final = self._tools_missing_from_index(available_tools)
missing: Final = self._tools_missing_from_index(
available_tools, reindex_changed_descriptions=reindex_changed_descriptions
)
if not missing:
return
descriptions: Final = {name: self.extract_tool_info(tool)[1] for name, tool in missing.items()}
routes: Final = [
Route(
name=name,
description=description,
utterances=[description],
score_threshold=self.similarity_threshold,
)
for name, description in descriptions.items()
]
routes, _ = self._tools_to_routes_and_map(tuple(missing.values()))
if self.tool_router is None:
router: Final = SemanticRouter(
routes=[],
encoder=LiteLLMRouterEncoder(
litellm_router_instance=self.router_instance,
model_name=self.embedding_model,
score_threshold=self.similarity_threshold,
),
encoder=self._new_encoder(),
auto_sync="local",
top_k=self.top_k,
)
await router.aadd(routes)
await router.aadd(list(routes))
self.tool_router = router
else:
await self.tool_router.aadd(routes)
for name in (name for name in missing if name in self._tool_map):
await self.tool_router.adelete(name) # pyright: ignore[reportUnknownMemberType] # semantic_router ships no type stubs
self._tool_map.pop(name)
await self.tool_router.aadd(list(routes))
self._tool_map.update(missing)
verbose_logger.info(
@ -267,6 +421,8 @@ class SemanticMCPToolFilter:
query: str,
available_tools: list[Any],
top_k: int | None = None,
*,
reindex_changed_descriptions: bool = False,
) -> list[Any]:
"""
Filter tools semantically based on query.
@ -298,7 +454,7 @@ class SemanticMCPToolFilter:
# Run semantic filtering
try:
await self._ensure_tools_indexed(available_tools)
await self._ensure_tools_indexed(available_tools, reindex_changed_descriptions=reindex_changed_descriptions)
if self.tool_router is None:
verbose_logger.warning("Semantic router could not be built from the request's tools")

View file

@ -0,0 +1,134 @@
"""
Redis-shared vector store for the MCP semantic tool index.
Lets every pod reuse embeddings another pod already computed instead of
re-embedding every tool description at startup.
"""
import base64
import sys
from array import array
from collections.abc import Mapping, Sequence
from hashlib import sha256
from typing import TYPE_CHECKING, Final, Protocol
from pydantic import TypeAdapter
from litellm._internal_context import with_service_target
from litellm._logging import verbose_logger
from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager
if TYPE_CHECKING:
from litellm.caching.redis_cache import RedisCache
_KEY_PREFIX: Final = "litellm:mcp_semantic_tool_index:v1:"
_BUILD_LOCK_ID: Final = "mcp_semantic_tool_index_build"
_BUILD_LOCK_TTL_S: Final = 300
_VECTOR_TTL_S: Final = 30 * 24 * 60 * 60
_PUT_CHUNK_SIZE: Final = 50
_CACHE_TARGET: Final = "mcp_semantic_tool_index"
def tool_vector_key(embedding_identity: str, text: str) -> str:
"""Cache key for one embedded description; identity changes invalidate every entry."""
return _KEY_PREFIX + sha256((embedding_identity + "\0" + text).encode("utf-8")).hexdigest()
class ToolVectorStore(Protocol):
"""Shared embedding cache plus a cross-pod build lock."""
async def get_many(self, keys: Sequence[str]) -> Mapping[str, tuple[float, ...]]:
"""Return only the keys that hit."""
...
async def put_many(self, vectors: Mapping[str, Sequence[float]]) -> None: ...
async def try_acquire_build_lock(self) -> bool: ...
async def release_build_lock(self) -> None: ...
class RedisToolVectorStore:
"""ToolVectorStore over RedisCache; every Redis failure degrades to a local embed."""
def __init__(self, redis_cache: "RedisCache", lock_manager: PodLockManager) -> None:
self._redis_cache = redis_cache
self._lock_manager = lock_manager
self._degraded = False
@with_service_target(_CACHE_TARGET)
async def get_many(self, keys: Sequence[str]) -> Mapping[str, tuple[float, ...]]:
try:
raw: Final = TypeAdapter(Mapping[str, object]).validate_python(
await self._redis_cache.async_batch_get_cache(list(keys)) # pyright: ignore[reportUnknownMemberType] # RedisCache declares a partially untyped signature
)
except Exception as e: # noqa: BLE001 # any redis failure must degrade to a local embed, never raise
self._degraded = True
verbose_logger.warning("MCP semantic tool index store get_many failed; embedding locally: %s", e)
return {}
return {key: vector for key in keys if (vector := _decode_vector(raw.get(key))) is not None}
@with_service_target(_CACHE_TARGET)
async def put_many(self, vectors: Mapping[str, Sequence[float]]) -> None:
try:
entries: Final = tuple((key, _encode_vector(vector)) for key, vector in vectors.items())
for start in range(0, len(entries), _PUT_CHUNK_SIZE):
await self._redis_cache.async_set_cache_pipeline( # pyright: ignore[reportUnknownMemberType] # RedisCache declares a partially untyped signature
entries[start : start + _PUT_CHUNK_SIZE], ttl=_VECTOR_TTL_S
)
except Exception as e: # noqa: BLE001 # any redis failure must degrade to a local embed, never raise
self._degraded = True
verbose_logger.warning("MCP semantic tool index store put_many failed; vectors stay local: %s", e)
@with_service_target(_CACHE_TARGET)
async def try_acquire_build_lock(self) -> bool:
# PodLockManager returns False on Redis errors too; once degraded, embed locally instead of waiting.
try:
acquired: Final = await self._lock_manager.acquire_lock(
_BUILD_LOCK_ID, ttl=_BUILD_LOCK_TTL_S, allow_reentrant=True
)
held_by: Final = (
None
if acquired or self._degraded
else await self._redis_cache.async_get_cache(
PodLockManager.get_redis_lock_key(_BUILD_LOCK_ID) # pyright: ignore[reportUnknownArgumentType] # RedisCache declares a partially untyped signature
)
)
except Exception as e: # noqa: BLE001 # any redis failure must degrade to a local embed, never raise
self._degraded = True
verbose_logger.warning("MCP semantic tool index store lock acquire failed; embedding locally: %s", e)
return True
if acquired or self._degraded:
return True
if held_by is not None:
return False
self._degraded = True
verbose_logger.warning("MCP semantic tool index store lock write failed with no holder; embedding locally")
return True
async def release_build_lock(self) -> None:
try:
await self._lock_manager.release_lock(_BUILD_LOCK_ID)
except Exception as e: # noqa: BLE001 # any redis failure must degrade to a local embed, never raise
verbose_logger.warning("MCP semantic tool index store lock release failed: %s", e)
def _encode_vector(vector: Sequence[float]) -> str:
packed: Final = array("f", vector)
if sys.byteorder == "big":
packed.byteswap()
return base64.b64encode(packed.tobytes()).decode("ascii")
def _decode_vector(value: object) -> tuple[float, ...] | None:
try:
if not isinstance(value, (str, bytes, bytearray)):
return None
raw: Final = base64.b64decode(value)
vector: Final = array("f")
vector.frombytes(raw)
if sys.byteorder == "big":
vector.byteswap()
return tuple(vector)
except Exception: # noqa: BLE001 # corrupt payloads are treated as a plain cache miss
return None

View file

@ -5,6 +5,8 @@ Pre-call hook that filters MCP tools semantically before LLM inference.
Reduces context window size and improves tool selection accuracy.
"""
import asyncio
import importlib.util
from collections.abc import Awaitable, Callable, Collection, Iterable, Mapping, Sequence
from typing import TYPE_CHECKING, Final, Optional
@ -24,13 +26,39 @@ from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
if TYPE_CHECKING:
from litellm.caching.caching import DualCache
from litellm.caching.redis_cache import RedisCache
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
SemanticMCPToolFilter,
)
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import ToolVectorStore
from litellm.proxy._types import UserAPIKeyAuth
from litellm.router import Router
def _make_vector_store(redis_cache: Optional["RedisCache"]) -> Optional["ToolVectorStore"]:
"""Redis-backed shared vector store, or None when no Redis is configured."""
if redis_cache is None:
return None
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import (
RedisToolVectorStore,
)
from litellm.proxy.db.db_transaction_queue.pod_lock_manager import (
PodLockManager,
)
return RedisToolVectorStore(redis_cache, PodLockManager(redis_cache))
async def _run_deferred_index_build(semantic_filter: "SemanticMCPToolFilter") -> None:
"""Background task for a shared (Redis-backed) index build; swallows failures so requests keep passing through."""
try:
await semantic_filter.build_router_from_mcp_registry()
except Exception: # noqa: BLE001 # background task must not die; requests keep passing through
verbose_proxy_logger.exception(
"MCP semantic tool index deferred build failed; request-time indexing remains active"
)
class SemanticToolFilterConfig(TypedDict, total=False):
enabled: ReadOnly[bool]
embedding_model: ReadOnly[str]
@ -69,6 +97,7 @@ class SemanticToolFilterHook(CustomLogger):
"""
super().__init__()
self.filter = semantic_filter
self.index_build_task: asyncio.Task[None] | None = None
verbose_proxy_logger.debug(
"Initialized SemanticToolFilterHook with filter: enabled=%s, top_k=%s",
@ -165,7 +194,9 @@ class SemanticToolFilterHook(CustomLogger):
verbose_proxy_logger.debug("No user query found, skipping semantic filter on expanded MCP tools")
return expanded_tools
return await self.filter.filter_tools(query=user_query, available_tools=expanded_tools)
return await self.filter.filter_tools(
query=user_query, available_tools=expanded_tools, reindex_changed_descriptions=True
)
def _selected_tool_names(self, filtered_tools: Sequence[object]) -> list[str]:
"""Names of the semantically selected tools, as produced by the MCP expansion."""
@ -314,8 +345,11 @@ class SemanticToolFilterHook(CustomLogger):
if self._should_expand_mcp_tools(tools):
verbose_proxy_logger.debug("Detected litellm_proxy MCP references, expanding before semantic filtering")
if not self.filter.enabled:
verbose_proxy_logger.debug("Semantic filter disabled, leaving MCP references untouched")
if not self.filter.enabled or not self.filter.startup_index_ready:
verbose_proxy_logger.debug(
"Semantic filter %s, leaving MCP references untouched",
"disabled" if not self.filter.enabled else "index still building",
)
return None
try:
@ -360,8 +394,11 @@ class SemanticToolFilterHook(CustomLogger):
verbose_proxy_logger.debug("No messages in request, skipping semantic filter")
return None
if not self.filter.enabled:
verbose_proxy_logger.debug("Semantic filter disabled, skipping")
if not self.filter.enabled or not self.filter.startup_index_ready:
verbose_proxy_logger.debug(
"Semantic filter %s, skipping",
"disabled" if not self.filter.enabled else "index still building",
)
return None
try:
@ -476,6 +513,7 @@ class SemanticToolFilterHook(CustomLogger):
async def initialize_from_config(
config: SemanticToolFilterConfig | None,
llm_router: Optional["Router"],
redis_cache: Optional["RedisCache"] = None,
) -> Optional["SemanticToolFilterHook"]:
"""
Initialize semantic tool filter from proxy config.
@ -483,6 +521,9 @@ class SemanticToolFilterHook(CustomLogger):
Args:
config: Proxy configuration dict (litellm_settings.mcp_semantic_tool_filter)
llm_router: LiteLLM router instance for embeddings
redis_cache: Usage-tracking Redis cache; when configured the tool
index is built in the background with embeddings shared
cross-pod through it
Returns:
SemanticToolFilterHook instance if enabled, None otherwise
@ -500,6 +541,9 @@ class SemanticToolFilterHook(CustomLogger):
return None
try:
if redis_cache is not None and importlib.util.find_spec("semantic_router") is None:
raise ImportError("No module named 'semantic_router'")
embedding_model: Final = config.get("embedding_model", DEFAULT_MCP_SEMANTIC_FILTER_EMBEDDING_MODEL)
top_k: Final = config.get("top_k", DEFAULT_MCP_SEMANTIC_FILTER_TOP_K)
similarity_threshold = config.get("similarity_threshold", DEFAULT_MCP_SEMANTIC_FILTER_SIMILARITY_THRESHOLD)
@ -510,18 +554,24 @@ class SemanticToolFilterHook(CustomLogger):
top_k=top_k,
similarity_threshold=similarity_threshold,
enabled=True,
vector_store=_make_vector_store(redis_cache),
)
# Build router from MCP registry on startup
await semantic_filter.build_router_from_mcp_registry()
hook: Final = SemanticToolFilterHook(semantic_filter)
if redis_cache is not None:
# Index builds in the background; requests pass through unfiltered until it finishes.
hook.index_build_task = asyncio.create_task(_run_deferred_index_build(semantic_filter))
else:
# Build router from MCP registry on startup
await semantic_filter.build_router_from_mcp_registry()
verbose_proxy_logger.info(
"✅ MCP Semantic Tool Filter enabled: embedding_model=%s, top_k=%s, similarity_threshold=%s",
"✅ MCP Semantic Tool Filter enabled: embedding_model=%s, top_k=%s, similarity_threshold=%s, shared_index=%s",
embedding_model,
top_k,
similarity_threshold,
redis_cache is not None,
)
return hook

View file

@ -8533,6 +8533,41 @@ class ProxyConfig:
db_values: Final = self._prepared_db_settings_values("litellm_settings", raw_settings)
self._apply_litellm_settings_db_values(db_values)
def _semantic_filter_settings_unchanged(self, mcp_semantic_filter_config: object, hook_type: type) -> bool:
"""True when the stored settings match the in-memory ones and a live hook already covers them."""
import litellm
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
if not hasattr(self, "_last_semantic_filter_config"):
return False
if self._last_semantic_filter_config != mcp_semantic_filter_config:
return False
for active_hook in litellm.logging_callback_manager.get_custom_loggers_for_type(hook_type):
if not isinstance(active_hook, SemanticToolFilterHook):
continue
if active_hook.index_build_task is not None and not active_hook.index_build_task.done():
verbose_proxy_logger.debug(
"Semantic filter settings unchanged, index build already started; skipping reinitialization"
)
return True
if active_hook.filter is not None and active_hook.filter.tool_router is not None:
verbose_proxy_logger.debug("Semantic filter settings unchanged, skipping reinitialization")
return True
verbose_proxy_logger.info(
"Semantic filter settings unchanged, but hook is missing or uninitialized. Reinitializing."
)
return False
@staticmethod
def _cancel_orphaned_semantic_filter_index_builds() -> None:
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
for active_hook in litellm.logging_callback_manager.get_custom_loggers_for_type(SemanticToolFilterHook):
if not isinstance(active_hook, SemanticToolFilterHook):
continue
if (build_task := active_hook.index_build_task) is not None and not build_task.done():
build_task.cancel()
async def init_semantic_filter_settings_in_db(self, prisma_client: PrismaClient) -> None:
"""
Initialize MCP semantic filter settings from database.
@ -8559,21 +8594,10 @@ class ProxyConfig:
return
# Check if settings have changed (compare with in-memory state)
if hasattr(self, "_last_semantic_filter_config"):
if self._last_semantic_filter_config == mcp_semantic_filter_config:
# If hook is missing or router isn't built yet, reinitialize anyway
active_hooks = litellm.logging_callback_manager.get_custom_loggers_for_type(SemanticToolFilterHook)
if active_hooks:
for active_hook in active_hooks:
if isinstance(active_hook, SemanticToolFilterHook):
if active_hook.filter is not None and active_hook.filter.tool_router is not None:
verbose_proxy_logger.debug(
"Semantic filter settings unchanged, skipping reinitialization"
)
return
verbose_proxy_logger.info(
"Semantic filter settings unchanged, but hook is missing or uninitialized. Reinitializing."
)
if self._semantic_filter_settings_unchanged(mcp_semantic_filter_config, SemanticToolFilterHook):
return
self._cancel_orphaned_semantic_filter_index_builds()
# Remove old hooks using logging callback manager
litellm.logging_callback_manager.remove_callbacks_by_type(litellm.callbacks, SemanticToolFilterHook)
@ -8584,6 +8608,7 @@ class ProxyConfig:
hook: Final = await SemanticToolFilterHook.initialize_from_config(
config=mcp_semantic_filter_config,
llm_router=llm_router,
redis_cache=redis_usage_cache,
)
if hook:
litellm.logging_callback_manager.add_litellm_callback(hook)
@ -10602,6 +10627,7 @@ class ProxyStartupEvent:
hook: Final = await SemanticToolFilterHook.initialize_from_config(
config=mcp_semantic_filter_config,
llm_router=llm_router,
redis_cache=redis_usage_cache,
)
if hook:

View file

@ -0,0 +1,513 @@
import json
import os
import sys
import threading
import time
import uuid
from collections import Counter
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Final, Literal
import httpx
import pytest
import yaml
from integration._support.client import GATEWAY_LIMITS, Gateway, eventually
from integration._support.mcp import ScriptedTool, scripted_peer, text_result
from integration._support.process import (
DB_PUSH,
OwnedProxy,
_launch,
_proxy_environment,
_proxy_root,
_stop_launch,
owned_proxy_process,
)
from integration._support.redis_process import OwnedRedis, owned_redis
from integration._support.wire import Reply, Request, wire_server
PROMPT: Final = "please refund the invoice for ACME"
MCP_REF: Final = {
"type": "mcp",
"server_url": "litellm_proxy",
"server_label": "litellm",
"require_approval": "never",
}
SEMANTIC_SETTINGS: Final = {
"enabled": True,
"embedding_model": "semantic-embed",
"top_k": 3,
"similarity_threshold": 0.5,
}
NO_REDIS_ENV: Final = (
"REDIS_HOST",
"REDIS_PORT",
"REDIS_URL",
"REDIS_USERNAME",
"REDIS_PASSWORD",
"REDIS_CLUSTER_NODES",
"REDIS_SENTINEL_NODES",
"REDIS_SSL",
"DATABASE_URL_READ_REPLICA",
)
Surface = Literal["chat", "responses"]
TOOL_COUNT: Final = 300
class EmbeddingDouble:
def __init__(self, mode: Literal["healthy", "held"], delay: float = 0.0) -> None:
self.release = threading.Event()
self.mode = mode
self.delay = delay
self.arrived: list[tuple[tuple[str, ...], float]] = []
self.answered: list[tuple[tuple[str, ...], float]] = []
def document_inputs(self, descriptions: frozenset[str]) -> list[str]:
"""Embedding inputs that are tool descriptions; query embeddings ('test', prompts) excluded."""
return [text for inputs, _ in self.arrived for text in inputs if text in descriptions]
def respond(self, request: Request) -> Reply:
if request.method == "GET" and request.target.endswith("/models"):
return Reply(body=b'{"object": "list", "data": []}')
assert request.method == "POST" and request.target.endswith("/embeddings"), request.target
body: Final = json.loads(request.body)
raw: Final = body.get("input", [])
inputs: Final = tuple(str(text) for text in raw) if isinstance(raw, list) else (str(raw),)
self.arrived.append((inputs, time.monotonic()))
if self.mode == "held":
self.release.wait(timeout=120)
if self.delay:
time.sleep(self.delay)
data: Final = [
{
"object": "embedding",
"index": index,
"embedding": [1.0, 0.0] if "invoice" in text.lower() else [0.0, 1.0],
}
for index, text in enumerate(inputs)
]
self.answered.append((inputs, time.monotonic()))
return Reply(
body=json.dumps(
{
"object": "list",
"data": data,
"model": "text-embedding-3-small",
"usage": {"prompt_tokens": 1, "total_tokens": 1},
}
).encode()
)
def _chat_double() -> tuple[Callable[[Request], Reply], list[dict[str, object]]]:
bodies: list[dict[str, object]] = []
def respond(request: Request) -> Reply:
if request.method == "GET" and request.target.endswith("/models"):
return Reply(body=b'{"object": "list", "data": []}')
body: Final = json.loads(request.body)
bodies.append(body)
identity: Final = uuid.uuid4().hex[:12]
usage: Final = {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
if request.target.endswith("/chat/completions"):
return Reply(
body=json.dumps(
{
"id": f"chatcmpl-{identity}",
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": "done"},
}
],
"usage": usage,
}
).encode()
)
assert request.target.endswith("/responses"), request.target
return Reply(
body=json.dumps(
{
"id": f"resp_{identity}",
"object": "response",
"created_at": 1,
"status": "completed",
"model": "gpt-4o-mini",
"output": [
{
"type": "message",
"id": f"msg_{identity}",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "done", "annotations": []}],
}
],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
}
).encode()
)
return respond, bodies
def _tool_names(body: dict[str, object]) -> tuple[str, ...]:
tools: Final = body.get("tools")
if not isinstance(tools, list):
return ()
return tuple(
str(tool["name"] if "name" in tool else tool["function"]["name"]) for tool in tools if isinstance(tool, dict)
)
def _peer_names(bodies: list[dict[str, object]], alias: str) -> frozenset[str]:
prefix: Final = f"{alias}-"
return frozenset(name for body in bodies for name in _tool_names(body) if name.startswith(prefix))
def _tools(invoice_description: str = "Refund an invoice for a customer") -> tuple[ScriptedTool, ...]:
refund: Final = ScriptedTool(
"refund_invoice",
lambda params: text_result("refunded"),
description=invoice_description,
)
weather: Final = tuple(
ScriptedTool(
f"weather_{n}",
lambda params: text_result("sunny"),
description=f"Look up the weather forecast for city {n}",
)
for n in range(TOOL_COUNT - 1)
)
return (refund, *weather)
def _all_names(alias: str) -> frozenset[str]:
return frozenset((f"{alias}-refund_invoice", *(f"{alias}-weather_{n}" for n in range(TOOL_COUNT - 1))))
def _descriptions(invoice_description: str = "Refund an invoice for a customer") -> frozenset[str]:
return frozenset(tool.description for tool in _tools(invoice_description))
def _config(
directory: Path,
*,
alias: str,
peer_url: str,
embeddings_url: str,
chat_url: str,
redis: OwnedRedis | None,
) -> Path:
document: Final = {
"model_list": [
{
"model_name": "semantic-embed",
"litellm_params": {
"model": "openai/text-embedding-3-small",
"api_base": f"{embeddings_url}/v1",
"api_key": "synthetic-embed-key",
},
},
{
"model_name": "chat-model",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_base": f"{chat_url}/v1",
"api_key": "synthetic-chat-key",
},
},
{
"model_name": "responses-model",
"litellm_params": {
"model": "openai/responses/gpt-4o-mini",
"api_base": f"{chat_url}/v1",
"api_key": "synthetic-chat-key",
},
},
],
"general_settings": {
"master_key": "os.environ/LITELLM_MASTER_KEY",
"database_url": "os.environ/DATABASE_URL",
"store_model_in_db": True,
**({"coordination_redis": {"host": redis.host, "port": redis.port}} if redis is not None else {}),
},
"mcp_servers": {alias: {"transport": "http", "url": peer_url}},
"litellm_settings": {"mcp_semantic_tool_filter": SEMANTIC_SETTINGS},
}
path: Final = directory / f"semantic-filter-{uuid.uuid4().hex}.yaml"
path.write_text(yaml.dump(document))
return path
def _send(proxy: Gateway, surface: Surface) -> httpx.Response:
if surface == "chat":
return proxy.request(
"POST",
"/v1/chat/completions",
{"model": "chat-model", "messages": [{"role": "user", "content": PROMPT}], "tools": [MCP_REF]},
)
return proxy.request(
"POST",
"/v1/responses",
{"model": "responses-model", "input": PROMPT, "tools": [MCP_REF]},
)
def _request_is_filtered(proxy: Gateway, surface: Surface, bodies: list[dict[str, object]], alias: str) -> bool:
try:
response: Final = _send(proxy, surface)
except httpx.TransportError:
return False
return response.status_code == 200 and _peer_names(bodies[-1:], alias) == frozenset((f"{alias}-refund_invoice",))
@contextmanager
def _proxy_without_readiness_gate(
gateway: Gateway, directory: Path, overrides: dict[str, str], *, config: Path
) -> Iterator[OwnedProxy]:
"""owned_proxy_process without the readiness wait, for builds that must stay unready."""
root: Final = _proxy_root()
environment: Final = _proxy_environment(gateway, overrides, NO_REDIS_ENV)
output: Final = Path(os.environ.get("INTEGRATION_RESULTS_DIR", str(directory)))
output.mkdir(parents=True, exist_ok=True)
command: Final = (
sys.executable,
"-m",
"integration._support.proxy",
"--config",
str(config),
"--host",
"127.0.0.1",
"--num_workers",
"1",
"--timeout_worker_healthcheck",
"5",
*DB_PUSH,
)
launch: Final = _launch(command, root, environment, output)
try:
with httpx.Client(
base_url=f"http://127.0.0.1:{launch.port}", timeout=15, trust_env=False, limits=GATEWAY_LIMITS
) as client:
yield OwnedProxy(Gateway(client, gateway.key, gateway.upstream_url), launch.process, launch.log)
finally:
_stop_launch(launch)
def test_redis_backed_proxy_is_live_while_300_tool_index_builds(gateway: Gateway, tmp_path: Path) -> None:
alias: Final = "sf" + uuid.uuid4().hex[:8]
embeddings: Final = EmbeddingDouble("held")
respond, bodies = _chat_double()
with (
owned_redis(tmp_path) as redis,
scripted_peer(*_tools()) as peer,
wire_server(respond) as chat_wire,
wire_server(embeddings.respond) as embedding_wire,
):
try:
config: Final = _config(
tmp_path,
alias=alias,
peer_url=peer.url,
embeddings_url=embedding_wire.url,
chat_url=chat_wire.url,
redis=redis,
)
with owned_proxy_process(gateway, tmp_path, {}, config=config) as proxy:
liveliness: Final = proxy.gateway.client.get("/health/liveliness")
assert liveliness.status_code == 200, liveliness.text
response: Final = _send(proxy.gateway, "chat")
assert response.status_code == 200, response.text
assert _peer_names(bodies[-1:], alias) == _all_names(alias), _peer_names(bodies[-1:], alias)
embeddings.release.set()
eventually(
lambda: _request_is_filtered(proxy.gateway, "chat", bodies, alias),
lambda filtered: filtered,
seconds=60,
)
finally:
embeddings.release.set()
def test_second_proxy_on_warm_redis_embeds_no_descriptions(gateway: Gateway, tmp_path: Path) -> None:
alias: Final = "sf" + uuid.uuid4().hex[:8]
descriptions: Final = _descriptions()
with owned_redis(tmp_path) as redis, scripted_peer(*_tools()) as peer:
embeddings: Final = EmbeddingDouble("healthy")
respond, bodies = _chat_double()
second_respond, second_bodies = _chat_double()
with (
wire_server(respond) as chat_wire,
wire_server(second_respond) as second_chat_wire,
wire_server(embeddings.respond) as embedding_wire,
):
config: Final = _config(
tmp_path,
alias=alias,
peer_url=peer.url,
embeddings_url=embedding_wire.url,
chat_url=chat_wire.url,
redis=redis,
)
with owned_proxy_process(gateway, tmp_path, {}, config=config) as first_proxy:
eventually(
lambda: _request_is_filtered(first_proxy.gateway, "chat", bodies, alias),
lambda filtered: filtered,
seconds=60,
)
embedded_so_far: Final = embeddings.document_inputs(descriptions)
config = _config(
tmp_path,
alias=alias,
peer_url=peer.url,
embeddings_url=embedding_wire.url,
chat_url=second_chat_wire.url,
redis=redis,
)
with owned_proxy_process(gateway, tmp_path, {}, config=config) as second_proxy:
assert _request_is_filtered(second_proxy.gateway, "chat", second_bodies, alias)
assert embeddings.document_inputs(descriptions) == embedded_so_far
def test_two_proxies_cold_starting_together_embed_each_description_once(gateway: Gateway, tmp_path: Path) -> None:
alias: Final = "sf" + uuid.uuid4().hex[:8]
descriptions: Final = _descriptions()
embeddings: Final = EmbeddingDouble("healthy", delay=10.0)
respond_a, bodies_a = _chat_double()
respond_b, bodies_b = _chat_double()
with (
owned_redis(tmp_path) as redis,
scripted_peer(*_tools()) as peer,
wire_server(respond_a) as chat_wire_a,
wire_server(respond_b) as chat_wire_b,
wire_server(embeddings.respond) as embedding_wire,
):
config_a: Final = _config(
tmp_path,
alias=alias,
peer_url=peer.url,
embeddings_url=embedding_wire.url,
chat_url=chat_wire_a.url,
redis=redis,
)
config_b: Final = _config(
tmp_path,
alias=alias,
peer_url=peer.url,
embeddings_url=embedding_wire.url,
chat_url=chat_wire_b.url,
redis=redis,
)
with (
owned_proxy_process(gateway, tmp_path, {}, config=config_a) as proxy_a,
owned_proxy_process(gateway, tmp_path, {}, config=config_b) as proxy_b,
):
eventually(
lambda: _request_is_filtered(proxy_a.gateway, "chat", bodies_a, alias),
lambda filtered: filtered,
seconds=120,
)
eventually(
lambda: _request_is_filtered(proxy_b.gateway, "chat", bodies_b, alias),
lambda filtered: filtered,
seconds=60,
)
document_inputs: Final = Counter(embeddings.document_inputs(descriptions))
assert document_inputs == Counter({description: 1 for description in descriptions})
def test_edited_description_reembeds_only_that_tool(gateway: Gateway, tmp_path: Path) -> None:
alias: Final = "sf" + uuid.uuid4().hex[:8]
descriptions_v1: Final = _descriptions()
edited: Final = "Cancel and refund an invoice instantly"
with owned_redis(tmp_path) as redis:
embeddings: Final = EmbeddingDouble("healthy")
respond, bodies = _chat_double()
second_respond, second_bodies = _chat_double()
with wire_server(embeddings.respond) as embedding_wire:
with (
scripted_peer(*_tools()) as peer_v1,
wire_server(respond) as chat_wire,
):
config: Final = _config(
tmp_path,
alias=alias,
peer_url=peer_v1.url,
embeddings_url=embedding_wire.url,
chat_url=chat_wire.url,
redis=redis,
)
with owned_proxy_process(gateway, tmp_path, {}, config=config) as first_proxy:
eventually(
lambda: _request_is_filtered(first_proxy.gateway, "chat", bodies, alias),
lambda filtered: filtered,
seconds=60,
)
with (
scripted_peer(*_tools(edited)) as peer_v2,
wire_server(second_respond) as second_chat_wire,
):
config = _config(
tmp_path,
alias=alias,
peer_url=peer_v2.url,
embeddings_url=embedding_wire.url,
chat_url=second_chat_wire.url,
redis=redis,
)
with owned_proxy_process(gateway, tmp_path, {}, config=config) as second_proxy:
eventually(
lambda: _request_is_filtered(second_proxy.gateway, "chat", second_bodies, alias),
lambda filtered: filtered,
seconds=60,
)
document_inputs: Final = Counter(
text for inputs, _ in embeddings.arrived for text in inputs if text not in ("test", PROMPT)
)
assert document_inputs == Counter({**{d: 1 for d in descriptions_v1}, edited: 1})
def test_without_redis_startup_waits_for_the_index(gateway: Gateway, tmp_path: Path) -> None:
alias: Final = "sf" + uuid.uuid4().hex[:8]
embeddings: Final = EmbeddingDouble("held")
respond, bodies = _chat_double()
with (
scripted_peer(*_tools()) as peer,
wire_server(respond) as chat_wire,
wire_server(embeddings.respond) as embedding_wire,
):
try:
config: Final = _config(
tmp_path,
alias=alias,
peer_url=peer.url,
embeddings_url=embedding_wire.url,
chat_url=chat_wire.url,
redis=None,
)
with _proxy_without_readiness_gate(gateway, tmp_path, {}, config=config) as proxy:
def liveliness() -> int | None:
try:
return proxy.gateway.client.get("/health/liveliness", timeout=2).status_code
except httpx.TransportError:
return None
deadline: Final = time.monotonic() + 5
while time.monotonic() < deadline:
assert liveliness() != 200, "blocking build should keep liveliness from 200 while held"
time.sleep(0.5)
embeddings.release.set()
eventually(liveliness, lambda status: status == 200, seconds=60)
eventually(
lambda: _request_is_filtered(proxy.gateway, "chat", bodies, alias),
lambda filtered: filtered,
seconds=60,
)
finally:
embeddings.release.set()

View file

@ -0,0 +1,208 @@
"""
Unit tests for CachingToolEncoder, the LiteLLMRouterEncoder that serves
document embeddings from the shared ToolVectorStore.
"""
import asyncio
import sys
from unittest.mock import Mock
import pytest
from mcp.types import Tool as MCPTool
requires_semantic_router = pytest.mark.skipif(
sys.version_info >= (3, 14), reason="The semantic-router extra excludes Python 3.14"
)
def _fake_embedding_router(recorded_inputs):
"""Router stand-in returning deterministic 2-dim keyword one-hots, like the filter tests."""
from litellm.types.utils import Embedding, EmbeddingResponse
def _vector(text):
return [1.0, 0.0] if "ticket" in text.lower() else [0.0, 1.0]
def mock_embedding_sync(*args, **kwargs):
texts = kwargs["input"]
recorded_inputs.append(list(texts))
return EmbeddingResponse(
data=[Embedding(embedding=_vector(t), index=i, object="embedding") for i, t in enumerate(texts)],
model="text-embedding-3-small",
object="list",
usage={"prompt_tokens": 10, "total_tokens": 10},
)
async def mock_embedding_async(*args, **kwargs):
return mock_embedding_sync(*args, **kwargs)
router = Mock()
router.embedding = mock_embedding_sync
router.aembedding = mock_embedding_async
return router
class _InMemoryToolVectorStore:
def __init__(self, vectors=None):
self.vectors = dict(vectors or {})
self.get_many_calls = 0
self.acquire_calls = 0
self.release_calls = 0
self.lock_result = True
self._fill_on_call = None
def fill_on_get_many_call(self, call_number):
self._fill_on_call = call_number
async def get_many(self, keys):
self.get_many_calls += 1
if self._fill_on_call is not None and self.get_many_calls >= self._fill_on_call:
self.vectors.update(self._pending_fill)
return {key: self.vectors[key] for key in keys if key in self.vectors}
def seed_from(self, keys_vectors):
self._pending_fill = dict(keys_vectors)
async def put_many(self, vectors):
self.vectors.update({k: tuple(v) for k, v in vectors.items()})
async def try_acquire_build_lock(self):
self.acquire_calls += 1
return self.lock_result
async def release_build_lock(self):
self.release_calls += 1
def _tool(name, description):
return MCPTool(name=name, description=description, inputSchema={"type": "object"})
def _make_filter(recorded_inputs, store=None, embedding_model="text-embedding-3-small"):
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
SemanticMCPToolFilter,
)
return SemanticMCPToolFilter(
embedding_model=embedding_model,
litellm_router_instance=_fake_embedding_router(recorded_inputs),
top_k=3,
similarity_threshold=0.3,
enabled=True,
vector_store=store,
lock_poll_interval_s=0.0,
)
def _make_encoder(recorded_inputs, store, embedding_identity="text-embedding-3-small"):
from litellm.proxy._experimental.mcp_server.semantic_tool_encoder import CachingToolEncoder
return CachingToolEncoder(
litellm_router_instance=_fake_embedding_router(recorded_inputs),
model_name="text-embedding-3-small",
score_threshold=0.3,
store=store,
embedding_identity=embedding_identity,
)
@requires_semantic_router
@pytest.mark.asyncio
async def test_build_with_warm_store_never_embeds_descriptions():
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import (
tool_vector_key,
)
tools = [_tool("srv-a_get_ticket", "get a ticket"), _tool("srv-b_get_weather", "weather forecast")]
identity = "text-embedding-3-small"
warm = _InMemoryToolVectorStore(
{
tool_vector_key(identity, description): [0.0, 1.0]
for _, description in ((t.name, t.description) for t in tools)
}
)
recorded = []
filter_instance = _make_filter(recorded, store=warm)
await filter_instance._abuild_router(tools)
assert filter_instance.tool_router is not None
# Only the dims probe embeds; the 300-tool warm path makes zero embedding calls.
assert recorded == [["test"]]
assert warm.acquire_calls == 0
@requires_semantic_router
@pytest.mark.asyncio
async def test_cold_store_embeds_once_then_second_filter_embeds_nothing():
tools = [_tool("srv-a_get_ticket", "get a ticket"), _tool("srv-b_get_weather", "weather forecast")]
store = _InMemoryToolVectorStore()
first_recorded = []
first = _make_filter(first_recorded, store=store)
await first._abuild_router(tools)
assert first.tool_router is not None
assert [batch for batch in first_recorded if batch != ["test"]] == [["get a ticket", "weather forecast"]]
second_recorded = []
second = _make_filter(second_recorded, store=store)
await second._abuild_router(tools)
assert second.tool_router is not None
assert second_recorded == [["test"]]
@requires_semantic_router
@pytest.mark.asyncio
async def test_edited_description_reembeds_only_that_tool():
tools = [_tool("srv-a_get_ticket", "get a ticket"), _tool("srv-b_get_weather", "weather forecast")]
store = _InMemoryToolVectorStore()
recorded = []
filter_instance = _make_filter(recorded, store=store)
await filter_instance._abuild_router(tools)
recorded.clear()
edited = [_tool("srv-a_get_ticket", "close a support ticket fast"), _tool("srv-b_get_weather", "weather forecast")]
await filter_instance._abuild_router(edited)
assert [batch for batch in recorded if batch != ["test"]] == [["close a support ticket fast"]]
@requires_semantic_router
@pytest.mark.asyncio
async def test_different_embedding_identity_reembeds_everything():
tools = [_tool("srv-a_get_ticket", "get a ticket"), _tool("srv-b_get_weather", "weather forecast")]
store = _InMemoryToolVectorStore()
first_recorded = []
await _make_filter(first_recorded, store=store, embedding_model="text-embedding-3-small")._abuild_router(tools)
second_recorded = []
second = _make_filter(second_recorded, store=store, embedding_model="text-embedding-3-large")
await second._abuild_router(tools)
assert second.tool_router is not None
assert [batch for batch in second_recorded if batch != ["test"]] == [["get a ticket", "weather forecast"]]
@requires_semantic_router
@pytest.mark.asyncio
async def test_wrong_length_cached_vector_is_reembedded_and_overwritten():
"""
A cached vector whose length disagrees with a live dimension probe is
treated as a miss: it is re-embedded, served at the right length, and the
store entry is overwritten so the stale shape cannot leak into the index.
"""
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import (
tool_vector_key,
)
key = tool_vector_key("text-embedding-3-small", "get a ticket")
store = _InMemoryToolVectorStore({key: (9.9, 9.9, 9.9, 9.9)})
recorded = []
encoder = _make_encoder(recorded, store)
vectors = await encoder.aencode_documents(["get a ticket"])
assert vectors == [[1.0, 0.0]]
assert [batch for batch in recorded if batch != ["test"]] == [["get a ticket"]]
assert store.vectors[key] == (1.0, 0.0)

View file

@ -129,25 +129,17 @@ async def test_semantic_filter_basic_filtering():
)
# Assertions - validate filtering mechanics work
assert (
len(filtered) <= 3
), f"Should return at most 3 tools (top_k), got {len(filtered)}"
assert len(filtered) <= 3, f"Should return at most 3 tools (top_k), got {len(filtered)}"
assert len(filtered) > 0, "Should return at least some tools"
assert len(filtered) < len(
tools
), f"Should filter down from {len(tools)} tools, got {len(filtered)}"
assert len(filtered) < len(tools), f"Should filter down from {len(tools)} tools, got {len(filtered)}"
# Validate tools are actual MCPTool objects
for tool in filtered:
assert hasattr(tool, "name"), "Filtered result should be MCPTool with name"
assert hasattr(
tool, "description"
), "Filtered result should be MCPTool with description"
assert hasattr(tool, "description"), "Filtered result should be MCPTool with description"
filtered_names = [t.name for t in filtered]
print(
f"✅ Successfully filtered {len(tools)} tools down to top {len(filtered)}: {filtered_names}"
)
print(f"✅ Successfully filtered {len(tools)} tools down to top {len(filtered)}: {filtered_names}")
print(f" Filter respects top_k parameter correctly")
@ -226,12 +218,7 @@ async def test_semantic_filter_disabled():
SemanticMCPToolFilter,
)
tools = [
MCPTool(
name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}
)
for i in range(10)
]
tools = [MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) for i in range(10)]
mock_router = Mock()
@ -251,9 +238,7 @@ async def test_semantic_filter_disabled():
)
# Should return all tools when disabled
assert len(filtered) == len(
tools
), f"Expected all {len(tools)} tools, got {len(filtered)}"
assert len(filtered) == len(tools), f"Expected all {len(tools)} tools, got {len(filtered)}"
@pytest.mark.asyncio
@ -373,12 +358,7 @@ async def test_semantic_filter_hook_triggers_on_completion():
)
# Prepare data - completion request with tools
tools = [
MCPTool(
name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}
)
for i in range(10)
]
tools = [MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) for i in range(10)]
# Build router with the tools before filtering
filter_instance._build_router(tools)
@ -408,9 +388,7 @@ async def test_semantic_filter_hook_triggers_on_completion():
# Assertions
assert result is not None, "Hook should return modified data"
assert "tools" in result, "Result should contain tools"
assert len(result["tools"]) < len(
tools
), f"Hook should filter tools, got {len(result['tools'])}/{len(tools)}"
assert len(result["tools"]) < len(tools), f"Hook should filter tools, got {len(result['tools'])}/{len(tools)}"
print(f"✅ Hook triggered correctly: {len(tools)} -> {len(result['tools'])} tools")
@ -557,18 +535,12 @@ async def test_semantic_filter_hook_preserves_native_tools():
filtered = result["tools"]
# Native tools must survive
native_in_result = [
t for t in filtered if isinstance(t, dict) and t.get("type") == "function"
]
assert (
len(native_in_result) == 2
), f"Both native tools must survive, got {len(native_in_result)}"
native_in_result = [t for t in filtered if isinstance(t, dict) and t.get("type") == "function"]
assert len(native_in_result) == 2, f"Both native tools must survive, got {len(native_in_result)}"
# MCP tools should be filtered (top_k=2)
mcp_in_result = [t for t in filtered if not isinstance(t, dict)]
assert (
len(mcp_in_result) <= 2
), f"MCP tools should be filtered to top_k=2, got {len(mcp_in_result)}"
assert len(mcp_in_result) <= 2, f"MCP tools should be filtered to top_k=2, got {len(mcp_in_result)}"
# Total should be native + filtered MCP
assert len(filtered) <= 4, f"Expected at most 4 tools, got {len(filtered)}"
@ -579,12 +551,8 @@ async def test_semantic_filter_hook_preserves_native_tools():
# Stats should report MCP-only counts, not inflated with native tools
stats = result["metadata"]["litellm_semantic_filter_stats"]
mcp_before, mcp_after = stats.split("->")
assert (
int(mcp_before) == 5
), f"Stats 'from' should be MCP count (5), got {mcp_before}"
assert int(mcp_after) == len(
mcp_in_result
), f"Stats 'to' should match filtered MCP count, got {mcp_after}"
assert int(mcp_before) == 5, f"Stats 'from' should be MCP count (5), got {mcp_before}"
assert int(mcp_after) == len(mcp_in_result), f"Stats 'to' should match filtered MCP count, got {mcp_after}"
print(
f"✅ Hook preserves native tools: {len(all_tools)} -> {len(filtered)} "
@ -679,20 +647,15 @@ async def test_semantic_filter_hook_all_native_tools():
filtered = result["tools"]
# All native tools must pass through
assert (
len(filtered) == 3
), f"All 3 native tools must pass through, got {len(filtered)}"
assert len(filtered) == 3, f"All 3 native tools must pass through, got {len(filtered)}"
# No spurious semantic filter stats (P2 fix)
assert (
"litellm_semantic_filter_stats" not in result["metadata"]
), "Should NOT emit semantic filter stats for all-native-tool requests"
print(
f"✅ Hook passes through all {len(filtered)} native tools, "
f"no spurious filter headers emitted"
assert "litellm_semantic_filter_stats" not in result["metadata"], (
"Should NOT emit semantic filter stats for all-native-tool requests"
)
print(f"✅ Hook passes through all {len(filtered)} native tools, no spurious filter headers emitted")
@requires_semantic_router
@pytest.mark.asyncio
@ -758,8 +721,7 @@ async def test_semantic_filter_hook_responses_api_name_collision():
# Verify classification: should be native, not MCP
assert not hook._is_mcp_tool(responses_api_tool), (
"Responses API tool with type=function + top-level name "
"should be classified as native, not MCP"
"Responses API tool with type=function + top-level name should be classified as native, not MCP"
)
# Full hook test: all-native request should preserve tools
@ -890,9 +852,9 @@ async def test_semantic_filter_hook_filters_expanded_litellm_proxy_tools():
for name in allowed_tools:
assert name in expanded_names, "Selected tool names must come from the expanded tools"
assert (
"litellm_semantic_filter_stats" in result["metadata"]
), "Filter stats must be emitted for the litellm_proxy expansion path"
assert "litellm_semantic_filter_stats" in result["metadata"], (
"Filter stats must be emitted for the litellm_proxy expansion path"
)
stats = result["metadata"]["litellm_semantic_filter_stats"]
total, selected = stats.split("->")
assert int(total) == 5, f"Stats 'from' should be pre-filter expanded count (5), got {total}"
@ -1274,9 +1236,9 @@ async def test_semantic_filter_hook_expansion_skips_filter_when_disabled():
"require_approval": "never",
}
], "The MCP reference must be left intact for the MCP gateway to expand"
assert (
"litellm_semantic_filter_stats" not in data["metadata"]
), "No filter stats may be emitted when the filter is disabled"
assert "litellm_semantic_filter_stats" not in data["metadata"], (
"No filter stats may be emitted when the filter is disabled"
)
print("✅ Disabled filter: MCP reference untouched, no spurious stats")
@ -1380,9 +1342,7 @@ async def test_semantic_filter_hook_preserves_tool_order():
assert filtered[1] is native_tool, "Second tool should be native_tool"
assert filtered[2] is mcp_tool_b, "Third tool should be mcp_tool_b"
print(
"✅ Tool ordering preserved: [mcp_A, native, mcp_B] maintained after filtering"
)
print("✅ Tool ordering preserved: [mcp_A, native, mcp_B] maintained after filtering")
class TestGetToolsByNames:
@ -1492,9 +1452,7 @@ class TestGetToolsByNames:
filter_instance = self._make_filter()
available_tools = [{"name": "litellm_api-fs-read_file", "description": "read"}]
matched = filter_instance._get_tools_by_names(
["fs-read_file", "api-fs-read_file"], available_tools
)
matched = filter_instance._get_tools_by_names(["fs-read_file", "api-fs-read_file"], available_tools)
assert len(matched) == 1
@ -1525,9 +1483,7 @@ class TestGetToolsByNames:
{"name": "litellm_fs-delete", "description": "delete"},
]
matched = filter_instance._get_tools_by_names(
["fs-write", "fs-delete", "fs-read"], available_tools
)
matched = filter_instance._get_tools_by_names(["fs-write", "fs-delete", "fs-read"], available_tools)
names = [t["name"] for t in matched]
assert names == [
@ -1682,10 +1638,7 @@ async def test_semantic_filter_fails_closed_on_query_time_context_window_error()
state = {"raise_context_error": False}
filter_instance = _make_context_window_filter(state)
tools = [
MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"})
for i in range(5)
]
tools = [MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) for i in range(5)]
filter_instance._build_router(tools)
assert filter_instance.tool_router is not None
@ -1715,10 +1668,7 @@ async def test_semantic_filter_records_build_time_context_window_error():
state = {"raise_context_error": True}
filter_instance = _make_context_window_filter(state)
tools = [
MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"})
for i in range(5)
]
tools = [MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) for i in range(5)]
filter_instance._build_router(tools)
assert filter_instance.tool_router is None
@ -1749,10 +1699,7 @@ async def test_semantic_filter_hook_fails_closed_on_context_window_error():
state = {"raise_context_error": False}
filter_instance = _make_context_window_filter(state)
tools = [
MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"})
for i in range(5)
]
tools = [MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) for i in range(5)]
filter_instance._build_router(tools)
hook = SemanticToolFilterHook(filter_instance)
@ -1861,10 +1808,7 @@ async def test_semantic_filter_hook_ignores_build_error_for_native_only_tools():
state = {"raise_context_error": True}
filter_instance = _make_context_window_filter(state)
mcp_tools = [
MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"})
for i in range(3)
]
mcp_tools = [MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) for i in range(3)]
filter_instance._build_router(mcp_tools)
assert filter_instance.context_window_error is not None
@ -1921,9 +1865,7 @@ def test_is_context_window_error_detection_variants():
def wrap_without_explicit_chaining():
try:
raise litellm.ContextWindowExceededError(
message="overflow", model="m", llm_provider="openai"
)
raise litellm.ContextWindowExceededError(message="overflow", model="m", llm_provider="openai")
except litellm.ContextWindowExceededError:
raise ValueError("wrapper without explicit chaining")
@ -2239,3 +2181,288 @@ async def test_semantic_filter_hook_narrows_only_references_the_gateway_serves()
)
assert narrowed == [{**gateway_reference, "allowed_tools": ["srv-tool_1"]}, external_tool]
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_reindexes_tool_when_description_changes():
"""
Regression test: a tool that keeps its name but gets a new description
must be re-embedded, otherwise the index keeps serving the stale vector.
"""
recorded_inputs = []
filter_instance = _make_keyword_filter(recorded_inputs)
original = MCPTool(
name="srv-a-get_thing",
description="get the weather for a city",
inputSchema={"type": "object"},
)
edited = MCPTool(
name="srv-a-get_thing",
description="get a Linear ticket by id",
inputSchema={"type": "object"},
)
filter_instance._build_router([original])
recorded_inputs.clear()
filtered = await filter_instance.filter_tools(
query="linear ticket",
available_tools=[edited],
reindex_changed_descriptions=True,
)
assert [edited] == filtered
assert any("get a Linear ticket by id" in text for batch in recorded_inputs for text in batch)
indexed_tool = filter_instance._tool_map["srv-a-get_thing"]
assert filter_instance.extract_tool_info(indexed_tool)[1] == "get a Linear ticket by id"
@requires_semantic_router
@pytest.mark.asyncio
async def test_hook_request_body_tool_cannot_trigger_description_reindex():
"""
A caller-supplied tool with a registered name but a made-up description
must not re-embed or replace the worker-wide index entry. Only the
registry-expanded path is trusted to reindex changed descriptions.
"""
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
recorded_inputs = []
filter_instance = _make_keyword_filter(recorded_inputs)
original = MCPTool(
name="srv-a-get_thing",
description="get the weather for a city",
inputSchema={"type": "object"},
)
filter_instance._build_router([original])
recorded_inputs.clear()
spoofed = {
"name": "srv-a-get_thing",
"description": "totally made up description",
"inputSchema": {"type": "object"},
}
hook = SemanticToolFilterHook(filter_instance)
data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "weather"}],
"tools": [spoofed],
"metadata": {},
}
await hook.async_pre_call_hook(user_api_key_dict=Mock(), cache=Mock(), data=data, call_type="completion")
assert recorded_inputs == [["weather"]], "request-body tools must not trigger document embeddings"
indexed_tool = filter_instance._tool_map["srv-a-get_thing"]
assert filter_instance.extract_tool_info(indexed_tool)[1] == "get the weather for a city"
@requires_semantic_router
@pytest.mark.asyncio
async def test_hook_expanded_path_reindexes_changed_description():
"""
Tools the proxy expanded from the MCP registry are trusted: a changed
description re-embeds exactly that one tool through _filter_expanded_tools.
"""
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
recorded_inputs = []
filter_instance = _make_keyword_filter(recorded_inputs)
original = MCPTool(
name="srv-a-get_thing",
description="get the weather for a city",
inputSchema={"type": "object"},
)
filter_instance._build_router([original])
recorded_inputs.clear()
edited = {
"name": "srv-a-get_thing",
"description": "get a Linear ticket by id",
"inputSchema": {"type": "object"},
}
hook = SemanticToolFilterHook(filter_instance)
filtered = await hook._filter_expanded_tools(
data={"messages": [{"role": "user", "content": "linear ticket"}]},
expanded_tools=[edited],
)
assert [edited] == filtered
assert recorded_inputs == [["get a Linear ticket by id"], ["linear ticket"]]
indexed_tool = filter_instance._tool_map["srv-a-get_thing"]
assert filter_instance.extract_tool_info(indexed_tool)[1] == "get a Linear ticket by id"
@requires_semantic_router
@pytest.mark.asyncio
async def test_failed_reindex_retries_on_next_request():
"""
When reindexing a changed description raises mid-embed, the tool must stay
'missing' so the next request retries it instead of silently dropping it
from matching (its route was already deleted from the router).
"""
recorded_inputs = []
filter_instance = _make_keyword_filter(recorded_inputs)
original = MCPTool(
name="srv-a-get_thing",
description="get the weather for a city",
inputSchema={"type": "object"},
)
filter_instance._build_router([original])
recorded_inputs.clear()
original_aembedding = filter_instance.router_instance.aembedding
calls = {"n": 0}
async def fail_once(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("embedding backend down")
return await original_aembedding(*args, **kwargs)
filter_instance.router_instance.aembedding = fail_once
edited = MCPTool(
name="srv-a-get_thing",
description="get a Linear ticket by id",
inputSchema={"type": "object"},
)
await filter_instance.filter_tools(
query="linear ticket",
available_tools=[edited],
reindex_changed_descriptions=True,
)
assert "srv-a-get_thing" not in filter_instance._tool_map, (
"failed re-embed must leave the tool missing, not half-indexed"
)
filtered = await filter_instance.filter_tools(
query="linear ticket",
available_tools=[edited],
reindex_changed_descriptions=True,
)
assert [edited] == filtered
assert any("get a Linear ticket by id" in text for batch in recorded_inputs for text in batch)
assert "srv-a-get_thing" in filter_instance._tool_map
@requires_semantic_router
@pytest.mark.asyncio
async def test_hook_returns_before_shared_index_build_finishes():
"""
With Redis configured the hook registers immediately and the shared
index build runs as a background task that finishes once the store
responds; without Redis the build stays inline.
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
gate = asyncio.Event()
class _GatedRedis:
def __init__(self):
self.backing = {}
async def async_batch_get_cache(self, key_list, parent_otel_span=None):
await gate.wait()
return {key: self.backing[key] for key in key_list if key in self.backing}
async def async_set_cache(self, key, value, **kwargs):
self.backing[key] = value
return True
async def async_get_cache(self, key):
return self.backing.get(key)
async def async_set_cache_pipeline(self, cache_list, ttl=None, **kwargs):
self.backing.update(dict(cache_list))
recorded_inputs = []
tool = MCPTool(
name="srv-a-get_thing",
description="get a Linear ticket by id",
inputSchema={"type": "object"},
)
with (
patch.object(global_mcp_server_manager, "get_registry", return_value={"srv-a": object()}),
patch.object(global_mcp_server_manager, "get_tools_for_server", new=AsyncMock(return_value=[tool])),
):
hook = await SemanticToolFilterHook.initialize_from_config(
config={"enabled": True},
llm_router=_make_keyword_embedding_router(recorded_inputs),
redis_cache=_GatedRedis(),
)
assert hook is not None
assert hook.index_build_task is not None
await asyncio.sleep(0)
assert not hook.index_build_task.done()
assert not hook.filter.startup_index_ready
gate.set()
await asyncio.wait_for(hook.index_build_task, timeout=30)
assert hook.filter.startup_index_ready
assert hook.filter.tool_router is not None
inline_hook = await SemanticToolFilterHook.initialize_from_config(
config={"enabled": True},
llm_router=_make_keyword_embedding_router([]),
redis_cache=None,
)
assert inline_hook is not None
assert inline_hook.index_build_task is None
assert inline_hook.filter.startup_index_ready
assert inline_hook.filter.tool_router is not None
def test_embedding_identity_marks_deployment_dimensions():
"""
The shared-vector key identity must change when a deployment's embedding
dimensions change, and stay byte-identical to the pre-dimensions format
when no deployment sets them (warm caches must keep hitting).
"""
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
SemanticMCPToolFilter,
)
router_without = Mock()
router_without.get_model_list = lambda model_name: [{"litellm_params": {"model": "amazon.titan-embed-text-v2:0"}}]
router_with = Mock()
router_with.get_model_list = lambda model_name: [
{"litellm_params": {"model": "amazon.titan-embed-text-v2:0", "dimensions": 512}}
]
router_with_api_base = Mock()
router_with_api_base.get_model_list = lambda model_name: [
{
"litellm_params": {
"model": "amazon.titan-embed-text-v2:0",
"dimensions": 512,
"api_base": "https://bedrock-endpoint.example.com",
}
}
]
base = SemanticMCPToolFilter(
embedding_model="titan-alias",
litellm_router_instance=router_without,
enabled=True,
)
sized = SemanticMCPToolFilter(
embedding_model="titan-alias",
litellm_router_instance=router_with,
enabled=True,
)
based = SemanticMCPToolFilter(
embedding_model="titan-alias",
litellm_router_instance=router_with_api_base,
enabled=True,
)
assert base.embedding_identity == "titan-alias|amazon.titan-embed-text-v2:0"
assert sized.embedding_identity == "titan-alias|amazon.titan-embed-text-v2:0@dimensions=512"
assert (
based.embedding_identity
== "titan-alias|amazon.titan-embed-text-v2:0@dimensions=512@api_base=https://bedrock-endpoint.example.com"
)

View file

@ -0,0 +1,366 @@
"""
Unit tests for the Redis-shared MCP semantic tool index store.
Covers the float32 base64 round trip, degradation on Redis failure, the
cross-pod build lock, and the CachingToolEncoder serving document
embeddings from the store instead of re-embedding.
"""
import asyncio
import os
import subprocess
import sys
from unittest.mock import AsyncMock, Mock, patch
import pytest
if sys.version_info < (3, 11): # BaseExceptionGroup is a builtin only from 3.11
from exceptiongroup import BaseExceptionGroup
from mcp.types import Tool as MCPTool
requires_semantic_router = pytest.mark.skipif(
sys.version_info >= (3, 14), reason="The semantic-router extra excludes Python 3.14"
)
def _fake_embedding_router(recorded_inputs):
"""Router stand-in returning deterministic 2-dim keyword one-hots, like the filter tests."""
from litellm.types.utils import Embedding, EmbeddingResponse
def _vector(text):
return [1.0, 0.0] if "ticket" in text.lower() else [0.0, 1.0]
def mock_embedding_sync(*args, **kwargs):
texts = kwargs["input"]
recorded_inputs.append(list(texts))
return EmbeddingResponse(
data=[Embedding(embedding=_vector(t), index=i, object="embedding") for i, t in enumerate(texts)],
model="text-embedding-3-small",
object="list",
usage={"prompt_tokens": 10, "total_tokens": 10},
)
async def mock_embedding_async(*args, **kwargs):
return mock_embedding_sync(*args, **kwargs)
router = Mock()
router.embedding = mock_embedding_sync
router.aembedding = mock_embedding_async
return router
class _InMemoryToolVectorStore:
def __init__(self, vectors=None):
self.vectors = dict(vectors or {})
self.get_many_calls = 0
self.acquire_calls = 0
self.release_calls = 0
self.lock_result = True
self._fill_on_call = None
self.block_put_many = False
self.put_started = asyncio.Event()
self.put_block = asyncio.Event()
def fill_on_get_many_call(self, call_number):
self._fill_on_call = call_number
async def get_many(self, keys):
self.get_many_calls += 1
if self._fill_on_call is not None and self.get_many_calls >= self._fill_on_call:
self.vectors.update(self._pending_fill)
return {key: self.vectors[key] for key in keys if key in self.vectors}
def seed_from(self, keys_vectors):
self._pending_fill = dict(keys_vectors)
async def put_many(self, vectors):
if self.block_put_many:
self.put_started.set()
await self.put_block.wait()
self.vectors.update({k: tuple(v) for k, v in vectors.items()})
async def try_acquire_build_lock(self):
self.acquire_calls += 1
return self.lock_result
async def release_build_lock(self):
self.release_calls += 1
class _RaisingRedisCache:
async def async_batch_get_cache(self, key_list, parent_otel_span=None):
raise ConnectionError("redis is down")
async def async_set_cache_pipeline(self, cache_list, ttl=None, **kwargs):
raise ConnectionError("redis is down")
async def async_get_cache(self, key):
raise ConnectionError("redis is down")
async def async_set_cache(self, key, value, **kwargs):
raise ConnectionError("redis is down")
class _ReadOnlyRedisCache:
"""Reads work; every write reports failure like a read-only replica."""
def __init__(self):
self.backing = {}
async def async_batch_get_cache(self, key_list, parent_otel_span=None):
return {key: self.backing[key] for key in key_list if key in self.backing}
async def async_set_cache_pipeline(self, cache_list, ttl=None, **kwargs):
raise ConnectionError("NOPERM read only replica")
async def async_get_cache(self, key, parent_otel_span=None, **kwargs):
return self.backing.get(key)
async def async_set_cache(self, key, value, **kwargs):
return False
class _DictRedisCache:
def __init__(self):
self.backing = {}
self.pipeline_calls = 0
async def async_batch_get_cache(self, key_list, parent_otel_span=None):
return {key: self.backing[key] for key in key_list if key in self.backing}
async def async_set_cache_pipeline(self, cache_list, ttl=None, **kwargs):
self.pipeline_calls += 1
self.backing.update(dict(cache_list))
def _tool(name, description):
return MCPTool(name=name, description=description, inputSchema={"type": "object"})
def _make_filter(recorded_inputs, store=None, embedding_model="text-embedding-3-small", lock_wait_timeout_s=300.0):
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
SemanticMCPToolFilter,
)
return SemanticMCPToolFilter(
embedding_model=embedding_model,
litellm_router_instance=_fake_embedding_router(recorded_inputs),
top_k=3,
similarity_threshold=0.3,
enabled=True,
vector_store=store,
lock_poll_interval_s=0.0,
lock_wait_timeout_s=lock_wait_timeout_s,
)
@requires_semantic_router
@pytest.mark.asyncio
async def test_store_put_get_round_trip_within_float32_tolerance():
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import (
RedisToolVectorStore,
tool_vector_key,
)
redis = _DictRedisCache()
store = RedisToolVectorStore(redis, Mock())
key = tool_vector_key("embed-a", "get a ticket")
await store.put_many({key: [0.6000000238, -0.8123456789, 3.14]})
hits = await store.get_many([key])
assert key in hits
assert list(hits[key]) == pytest.approx([0.6000000238, -0.8123456789, 3.14], abs=1e-6)
@requires_semantic_router
@pytest.mark.asyncio
async def test_store_treats_corrupt_value_as_miss():
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import (
RedisToolVectorStore,
tool_vector_key,
)
redis = _DictRedisCache()
key = tool_vector_key("embed-a", "get a ticket")
redis.backing[key] = "!!! not base64 / not float32 bytes !!!"
store = RedisToolVectorStore(redis, Mock())
assert await store.get_many([key]) == {}
@requires_semantic_router
@pytest.mark.asyncio
async def test_raising_redis_still_builds_the_index():
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import (
RedisToolVectorStore,
)
from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager
redis = _RaisingRedisCache()
recorded = []
tools = [_tool("srv-a_get_ticket", "get a ticket"), _tool("srv-b_get_weather", "weather forecast")]
store = RedisToolVectorStore(redis, PodLockManager(redis))
filter_instance = _make_filter(recorded, store=store)
await filter_instance._abuild_router(tools)
assert filter_instance.tool_router is not None
assert [batch for batch in recorded if batch != ["test"]] == [["get a ticket", "weather forecast"]]
@requires_semantic_router
@pytest.mark.asyncio
async def test_lock_held_by_other_pod_waits_for_shared_vectors():
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import (
tool_vector_key,
)
tools = [_tool("srv-a_get_ticket", "get a ticket"), _tool("srv-b_get_weather", "weather forecast")]
store = _InMemoryToolVectorStore()
store.lock_result = False
identity = "text-embedding-3-small"
# The "holder" pod's vectors appear on the third get_many poll.
store.seed_from({tool_vector_key(identity, t.description): [0.0, 1.0] for t in tools})
store.fill_on_get_many_call(3)
recorded = []
filter_instance = _make_filter(recorded, store=store)
await filter_instance._abuild_router(tools)
assert filter_instance.tool_router is not None
assert store.acquire_calls >= 1
assert store.get_many_calls >= 3
assert recorded == [["test"]]
@requires_semantic_router
@pytest.mark.asyncio
async def test_full_build_reads_the_store_a_constant_number_of_times():
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
tools = [_tool(f"srv-a_get_tool_{i}", f"do thing {i}") for i in range(20)]
store = _InMemoryToolVectorStore()
filter_instance = _make_filter([], store=store)
with (
patch.object(global_mcp_server_manager, "get_registry", return_value={"srv-a": object()}),
patch.object(global_mcp_server_manager, "get_tools_for_server", new=AsyncMock(return_value=tools)),
):
await filter_instance.build_router_from_mcp_registry()
assert filter_instance.tool_router is not None
assert store.get_many_calls <= 3, (
f"{store.get_many_calls} get_many calls for {len(tools)} tools; reads must not scale with the tool count"
)
@requires_semantic_router
@pytest.mark.asyncio
async def test_cancelling_a_build_while_holding_the_lock_releases_it():
tools = [_tool("srv-a_get_ticket", "get a ticket"), _tool("srv-b_get_weather", "weather forecast")]
store = _InMemoryToolVectorStore()
store.block_put_many = True
filter_instance = _make_filter([], store=store)
build_task = asyncio.create_task(filter_instance._abuild_router(tools))
await asyncio.wait_for(store.put_started.wait(), timeout=5)
assert store.acquire_calls == 1
build_task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(build_task, timeout=5)
assert store.release_calls == 1
@requires_semantic_router
@pytest.mark.asyncio
async def test_lock_wait_times_out_and_the_build_falls_back_to_local_embed():
"""
A store that never fills and never grants the lock must not wait forever:
after lock_wait_timeout_s the build embeds locally, the startup index
flips ready, and the filter still selects tools.
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
tools = [_tool("srv-a_get_ticket", "get a ticket"), _tool("srv-b_get_weather", "weather forecast")]
store = _InMemoryToolVectorStore()
store.lock_result = False
recorded = []
filter_instance = _make_filter(recorded, store=store, lock_wait_timeout_s=0.05)
filter_instance._lock_poll_interval_s = 0.01
with (
patch.object(global_mcp_server_manager, "get_registry", return_value={"srv-a": object()}),
patch.object(global_mcp_server_manager, "get_tools_for_server", new=AsyncMock(return_value=tools)),
):
await filter_instance.build_router_from_mcp_registry()
assert filter_instance.startup_index_ready is True
assert filter_instance.tool_router is not None
assert store.release_calls == 0
assert [batch for batch in recorded if batch != ["test"]] == [["get a ticket", "weather forecast"]]
filtered = await filter_instance.filter_tools(
query="refund a ticket", available_tools=tools, reindex_changed_descriptions=True
)
assert filtered == [tools[0]]
@requires_semantic_router
@pytest.mark.asyncio
async def test_lock_acquire_embeds_locally_when_writes_fail_and_no_pod_holds_the_lock():
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import (
RedisToolVectorStore,
)
from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager
redis = _ReadOnlyRedisCache()
store = RedisToolVectorStore(redis, PodLockManager(redis))
assert await store.try_acquire_build_lock() is True
@requires_semantic_router
@pytest.mark.asyncio
async def test_lock_acquire_returns_false_when_another_pod_holds_the_lock():
from litellm.proxy._experimental.mcp_server.semantic_tool_index_store import (
RedisToolVectorStore,
)
from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager
redis = _ReadOnlyRedisCache()
lock_manager = PodLockManager(redis)
redis.backing[PodLockManager.get_redis_lock_key("mcp_semantic_tool_index_build")] = "other-pod"
store = RedisToolVectorStore(redis, lock_manager)
assert await store.try_acquire_build_lock() is False
assert store._degraded is False
def test_store_and_hook_import_without_semantic_router():
"""semantic_router is an optional extra; the store and the hook must import when it is absent."""
root = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))))
)
result = subprocess.run(
[
sys.executable,
"-I",
"-c",
"import sys; sys.path.insert(0, " + repr(root) + "); "
"sys.modules['semantic_router'] = None; "
"import litellm.proxy._experimental.mcp_server.semantic_tool_index_store; "
"import litellm.proxy.hooks.mcp_semantic_filter",
],
capture_output=True,
text=True,
timeout=120,
)
assert result.returncode == 0, result.stderr

View file

@ -96,7 +96,9 @@ def test_proxy_config_upserts_existing_deployment_without_duplicating_it(monkeyp
@pytest.mark.asyncio
async def test_proxy_config_preserves_models_when_config_read_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
async def test_proxy_config_preserves_models_when_config_read_fails(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from litellm.proxy import proxy_server
router = litellm.Router(model_list=[_deployment("config-deployment").to_json(exclude_none=True)])
@ -129,6 +131,8 @@ async def test_proxy_config_keeps_database_models_and_deletes_stale_models(
assert desired == frozenset({"kept-deployment"})
assert router.get_model_ids() == ["kept-deployment"]
from tests._master_key import MASTER_KEY
@ -144,7 +148,6 @@ async def test_proxy_config_loads_lens_store_from_yaml(tmp_path, monkeypatch) ->
assert is_lens_tracing_enabled(settings["tracing"], {}) is True
@pytest.mark.asyncio
@pytest.mark.parametrize("shutdown_error", [False, True])
async def test_tracing_config_automatically_exports_spend_without_a_storage_dependency(
@ -170,7 +173,9 @@ async def test_tracing_config_automatically_exports_spend_without_a_storage_depe
outcome: Final = pytest.raises(RuntimeError, match="shutdown failure") if shutdown_error else nullcontext()
with outcome:
async with manage_tracing(enabled=True, client_factory=client):
logger: Final = next(callback for callback in litellm._async_success_callback if isinstance(callback, LensExporter))
logger: Final = next(
callback for callback in litellm._async_success_callback if isinstance(callback, LensExporter)
)
await logger.async_log_success_event(
{"standard_logging_object": {"id": "response-1", "response_cost": 0.25}}, None, None, None
)
@ -186,7 +191,6 @@ async def test_tracing_config_automatically_exports_spend_without_a_storage_depe
assert logger.task is not None and logger.task.done() and not logger.task.cancelled()
# ---------------------------------------------------------------------------
# _is_remote_module_url
# ---------------------------------------------------------------------------
@ -4835,7 +4839,9 @@ async def test_ProxyConfig__update_config_from_db_keeps_keys_the_config_file_omi
@pytest.mark.asyncio
@pytest.mark.parametrize("ui_settings_already_synced", [False, True])
async def test_ProxyConfig_add_deployment_continues_after_null_pass_through_endpoints(monkeypatch, ui_settings_already_synced):
async def test_ProxyConfig_add_deployment_continues_after_null_pass_through_endpoints(
monkeypatch, ui_settings_already_synced
):
from litellm.proxy import proxy_server
pc = ProxyConfig()
@ -5607,3 +5613,103 @@ async def test_proxy_config_validates_advertised_mcp_versions_at_load(tmp_path,
return
with pytest.raises(ValidationError):
await ProxyConfig().load_config(router=None, config_file_path=str(config))
@pytest.mark.asyncio
async def test_proxy_config_semantic_filter_db_sync_skips_reinit_while_index_build_in_flight(monkeypatch):
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
settings: Final = {
"enabled": True,
"embedding_model": "embed",
"top_k": 3,
"similarity_threshold": 0.5,
}
gate: Final = asyncio.Event()
build_task: Final = asyncio.create_task(gate.wait())
hook: Final = MagicMock(spec=SemanticToolFilterHook)
hook.index_build_task = build_task
hook.filter = SimpleNamespace(tool_router=None)
removed: Final = MagicMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.get_config_param",
AsyncMock(return_value=SimpleNamespace(param_value={"mcp_semantic_tool_filter": settings})),
)
monkeypatch.setattr(litellm.logging_callback_manager, "get_custom_loggers_for_type", lambda _: [hook])
monkeypatch.setattr(litellm.logging_callback_manager, "remove_callbacks_by_type", removed)
pc: Final = ProxyConfig()
pc._last_semantic_filter_config = settings
try:
await pc._init_semantic_filter_settings_in_db(prisma_client=MagicMock())
removed.assert_not_called()
finally:
gate.set()
await build_task
@pytest.mark.asyncio
async def test_proxy_config_semantic_filter_db_sync_reinits_after_finished_failed_build(monkeypatch):
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
settings: Final = {
"enabled": True,
"embedding_model": "embed",
"top_k": 3,
"similarity_threshold": 0.5,
}
build_task: Final = asyncio.create_task(asyncio.sleep(0))
await build_task
hook: Final = MagicMock(spec=SemanticToolFilterHook)
hook.index_build_task = build_task
hook.filter = SimpleNamespace(tool_router=None)
removed: Final = MagicMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.get_config_param",
AsyncMock(return_value=SimpleNamespace(param_value={"mcp_semantic_tool_filter": settings})),
)
monkeypatch.setattr(litellm.logging_callback_manager, "get_custom_loggers_for_type", lambda _: [hook])
monkeypatch.setattr(litellm.logging_callback_manager, "remove_callbacks_by_type", removed)
pc: Final = ProxyConfig()
pc._last_semantic_filter_config = settings
await pc._init_semantic_filter_settings_in_db(prisma_client=MagicMock())
removed.assert_called_once()
@pytest.mark.asyncio
async def test_proxy_config_semantic_filter_db_sync_cancels_orphaned_index_build(monkeypatch):
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
settings: Final = {
"enabled": True,
"embedding_model": "embed",
"top_k": 3,
"similarity_threshold": 0.5,
}
changed_settings: Final = {**settings, "top_k": 5}
gate: Final = asyncio.Event()
build_task: Final = asyncio.create_task(gate.wait())
hook: Final = MagicMock(spec=SemanticToolFilterHook)
hook.index_build_task = build_task
hook.filter = SimpleNamespace(tool_router=None)
removed: Final = MagicMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.get_config_param",
AsyncMock(return_value=SimpleNamespace(param_value={"mcp_semantic_tool_filter": changed_settings})),
)
monkeypatch.setattr(litellm.logging_callback_manager, "get_custom_loggers_for_type", lambda _: [hook])
monkeypatch.setattr(litellm.logging_callback_manager, "remove_callbacks_by_type", removed)
pc: Final = ProxyConfig()
pc._last_semantic_filter_config = settings
try:
await pc._init_semantic_filter_settings_in_db(prisma_client=MagicMock())
await asyncio.sleep(0)
assert build_task.cancelled()
removed.assert_called_once()
finally:
gate.set()
if not build_task.done():
build_task.cancel()