mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge pull request #32715 from BerriAI/litellm_lit4284_semantic_filter_fail_closed
fix(mcp): fail closed and surface semantic filter context window errors
This commit is contained in:
commit
220aad0e7f
11 changed files with 473 additions and 1 deletions
|
|
@ -95,6 +95,9 @@ class ExceptionCheckers:
|
|||
if "current length is" in _error_str_lowercase and "while limit is" in _error_str_lowercase:
|
||||
return True
|
||||
|
||||
if "maximum input length is" in _error_str_lowercase and "tokens" in _error_str_lowercase:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ Filters MCP tools semantically for /chat/completions and /responses endpoints.
|
|||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
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.utils import MCP_TOOL_PREFIX_SEPARATOR
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -15,6 +17,36 @@ if TYPE_CHECKING:
|
|||
from litellm.router import Router
|
||||
|
||||
|
||||
class SemanticToolFilterContextWindowError(Exception):
|
||||
"""Raised when the embedding model exceeds its context window, so semantic filtering cannot run."""
|
||||
|
||||
def __init__(self, embedding_model: str, stage: str, original_error: str):
|
||||
self.embedding_model = embedding_model
|
||||
self.stage = stage
|
||||
self.original_error = original_error
|
||||
super().__init__(
|
||||
f"MCP semantic tool filtering could not run: embedding model '{embedding_model}' "
|
||||
f"exceeded its context window while embedding {stage}. "
|
||||
f"The request was blocked instead of silently passing all tools through. "
|
||||
f"Switch to an embedding model with a larger context window, or disable "
|
||||
f"semantic tool filtering."
|
||||
)
|
||||
|
||||
|
||||
def _is_context_window_error(error: Optional[BaseException], max_depth: int = 5) -> bool:
|
||||
"""Detect a context-window overflow anywhere in an exception's cause chain."""
|
||||
current = error
|
||||
for _ in range(max_depth):
|
||||
if current is None:
|
||||
return False
|
||||
if isinstance(current, ContextWindowExceededError):
|
||||
return True
|
||||
if ExceptionCheckers.is_error_str_context_window_exceeded(str(current)):
|
||||
return True
|
||||
current = current.__cause__ or current.__context__
|
||||
return False
|
||||
|
||||
|
||||
class SemanticMCPToolFilter:
|
||||
"""Filters MCP tools using semantic similarity to reduce context window size."""
|
||||
|
||||
|
|
@ -42,6 +74,7 @@ class SemanticMCPToolFilter:
|
|||
self.embedding_model = embedding_model
|
||||
self.router_instance = litellm_router_instance
|
||||
self.tool_router: Optional["SemanticRouter"] = None
|
||||
self.context_window_error: Optional[str] = None
|
||||
self._tool_map: Dict[str, Any] = {} # MCPTool objects or OpenAI function dicts
|
||||
|
||||
async def build_router_from_mcp_registry(self) -> None:
|
||||
|
|
@ -111,6 +144,7 @@ class SemanticMCPToolFilter:
|
|||
return
|
||||
|
||||
try:
|
||||
self.context_window_error = None
|
||||
# Convert tools to routes
|
||||
routes = []
|
||||
self._tool_map = {}
|
||||
|
|
@ -143,6 +177,9 @@ class SemanticMCPToolFilter:
|
|||
except Exception as e:
|
||||
verbose_logger.error(f"Failed to build semantic router: {e}")
|
||||
self.tool_router = None
|
||||
if _is_context_window_error(e):
|
||||
self.context_window_error = str(e)
|
||||
return
|
||||
raise
|
||||
|
||||
async def filter_tools(
|
||||
|
|
@ -169,6 +206,13 @@ class SemanticMCPToolFilter:
|
|||
if not available_tools:
|
||||
return available_tools
|
||||
|
||||
if self.context_window_error is not None:
|
||||
raise SemanticToolFilterContextWindowError(
|
||||
embedding_model=self.embedding_model,
|
||||
stage="the MCP tool descriptions during semantic router build",
|
||||
original_error=self.context_window_error,
|
||||
)
|
||||
|
||||
if not query or not query.strip():
|
||||
return available_tools
|
||||
|
||||
|
|
@ -189,6 +233,16 @@ class SemanticMCPToolFilter:
|
|||
return self._get_tools_by_names(matched_tool_names, available_tools)
|
||||
|
||||
except Exception as e:
|
||||
if _is_context_window_error(e):
|
||||
verbose_logger.error(
|
||||
f"Semantic tool filter embedding exceeded its context window: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise SemanticToolFilterContextWindowError(
|
||||
embedding_model=self.embedding_model,
|
||||
stage="the user query",
|
||||
original_error=str(e),
|
||||
) from e
|
||||
verbose_logger.error(f"Semantic tool filter failed: {e}", exc_info=True)
|
||||
return available_tools
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ Reduces context window size and improves tool selection accuracy.
|
|||
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import (
|
||||
DEFAULT_MCP_SEMANTIC_FILTER_EMBEDDING_MODEL,
|
||||
|
|
@ -14,6 +16,9 @@ from litellm.constants import (
|
|||
DEFAULT_MCP_SEMANTIC_FILTER_TOP_K,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
|
||||
SemanticToolFilterContextWindowError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.caching.caching import DualCache
|
||||
|
|
@ -294,6 +299,8 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
)
|
||||
return data
|
||||
|
||||
except SemanticToolFilterContextWindowError as e:
|
||||
raise HTTPException(status_code=400, detail={"error": str(e)}) from e
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"Failed to expand MCP references: {e}", exc_info=True)
|
||||
return None
|
||||
|
|
@ -366,6 +373,8 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
|
||||
return data
|
||||
|
||||
except SemanticToolFilterContextWindowError as e:
|
||||
raise HTTPException(status_code=400, detail={"error": str(e)}) from e
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(f"Semantic tool filter hook failed: {e}. Proceeding with all tools.")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -78,6 +78,18 @@ context_window_test_cases = [
|
|||
"CerebrasException - Please reduce the length of the messages or completion. Current length is 50000 while limit is 40000",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"Invalid 'input[0]': maximum input length is 8192 tokens.",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"OpenAIException - Error code: 400 - {'error': {'message': \"Invalid 'input[0]': maximum input length is 8192 tokens.\", 'type': 'invalid_request_error'}}",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"Invalid 'metadata': maximum input length is 512 characters.",
|
||||
False,
|
||||
),
|
||||
# Negative cases (should return False)
|
||||
("A generic API error occurred.", False),
|
||||
("Invalid API Key provided.", False),
|
||||
|
|
|
|||
|
|
@ -1356,3 +1356,311 @@ def test_truncate_csv_at_tool_name_boundary_edges():
|
|||
assert _truncate_csv_at_tool_name_boundary(tool_names_csv="ab,cd,ef", max_length=5) == "ab,cd"
|
||||
assert _truncate_csv_at_tool_name_boundary(tool_names_csv="ab,cd,ef", max_length=4) == "ab"
|
||||
assert _truncate_csv_at_tool_name_boundary(tool_names_csv="single_name_longer_than_cap", max_length=10) == ""
|
||||
|
||||
|
||||
def _make_context_window_raising_router(state):
|
||||
"""
|
||||
Mock litellm Router whose embedding call raises ContextWindowExceededError
|
||||
once state["raise_context_error"] is flipped to True.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.types.utils import Embedding, EmbeddingResponse
|
||||
|
||||
def mock_embedding_sync(*args, **kwargs):
|
||||
if state["raise_context_error"]:
|
||||
raise litellm.ContextWindowExceededError(
|
||||
message="Invalid 'input[0]': maximum input length is 8192 tokens.",
|
||||
model="text-embedding-3-small",
|
||||
llm_provider="openai",
|
||||
)
|
||||
return EmbeddingResponse(
|
||||
data=[Embedding(embedding=[0.1] * 1536, index=0, object="embedding")],
|
||||
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)
|
||||
|
||||
mock_router = Mock()
|
||||
mock_router.embedding = mock_embedding_sync
|
||||
mock_router.aembedding = mock_embedding_async
|
||||
return mock_router
|
||||
|
||||
|
||||
def _make_context_window_filter(state, top_k: int = 3):
|
||||
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
|
||||
SemanticMCPToolFilter,
|
||||
)
|
||||
|
||||
return SemanticMCPToolFilter(
|
||||
embedding_model="text-embedding-3-small",
|
||||
litellm_router_instance=_make_context_window_raising_router(state),
|
||||
top_k=top_k,
|
||||
similarity_threshold=0.3,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_filter_fails_closed_on_query_time_context_window_error():
|
||||
"""
|
||||
Regression test (LIT-4284): a context-window overflow while embedding the
|
||||
user query must fail closed with a typed error instead of silently
|
||||
returning all tools (previously reported as N->N "success").
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
|
||||
SemanticToolFilterContextWindowError,
|
||||
)
|
||||
|
||||
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)
|
||||
]
|
||||
filter_instance._build_router(tools)
|
||||
assert filter_instance.tool_router is not None
|
||||
|
||||
state["raise_context_error"] = True
|
||||
with pytest.raises(SemanticToolFilterContextWindowError) as exc_info:
|
||||
await filter_instance.filter_tools(query="send an email", available_tools=tools)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "context window" in message
|
||||
assert "text-embedding-3-small" in message
|
||||
print("✅ Query-time context window overflow fails closed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_filter_records_build_time_context_window_error():
|
||||
"""
|
||||
Regression test (LIT-4284): a context-window overflow while embedding the
|
||||
tool descriptions at router-build time must be recorded (not raised out of
|
||||
the build, which previously left the hook unregistered and filtering
|
||||
silently disabled) and must fail subsequent filtering closed.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
|
||||
SemanticToolFilterContextWindowError,
|
||||
)
|
||||
|
||||
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)
|
||||
]
|
||||
filter_instance._build_router(tools)
|
||||
|
||||
assert filter_instance.tool_router is None
|
||||
assert filter_instance.context_window_error is not None
|
||||
|
||||
with pytest.raises(SemanticToolFilterContextWindowError) as exc_info:
|
||||
await filter_instance.filter_tools(query="send an email", available_tools=tools)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "context window" in message
|
||||
assert "tool descriptions" in message
|
||||
print("✅ Build-time context window overflow is recorded and fails closed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_filter_hook_fails_closed_on_context_window_error():
|
||||
"""
|
||||
Regression test (LIT-4284): the pre-call hook must reject the request with
|
||||
an actionable HTTP 400 when the embedding model overflows its context
|
||||
window, instead of forwarding all tools and emitting an N->N success
|
||||
header.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
|
||||
|
||||
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)
|
||||
]
|
||||
filter_instance._build_router(tools)
|
||||
hook = SemanticToolFilterHook(filter_instance)
|
||||
|
||||
state["raise_context_error"] = True
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"messages": [{"role": "user", "content": "Send an email"}],
|
||||
"tools": tools,
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await hook.async_pre_call_hook(
|
||||
user_api_key_dict=Mock(),
|
||||
cache=Mock(),
|
||||
data=data,
|
||||
call_type="completion",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
error_message = exc_info.value.detail["error"]
|
||||
assert "context window" in error_message
|
||||
assert "text-embedding-3-small" in error_message
|
||||
assert "larger context window" in error_message
|
||||
assert "maximum input length" not in error_message
|
||||
print("✅ Hook fails closed with actionable 400 on context window overflow")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_filter_hook_fails_closed_on_expanded_tools_context_window_error():
|
||||
"""
|
||||
Regression test (LIT-4284): the litellm_proxy MCP expansion path (driven
|
||||
by the dashboard test panel via /v1/responses) must also fail closed with
|
||||
an actionable HTTP 400 instead of being swallowed by the expansion
|
||||
catch-all.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
|
||||
|
||||
state = {"raise_context_error": False}
|
||||
filter_instance = _make_context_window_filter(state)
|
||||
|
||||
registry_tools = [
|
||||
MCPTool(name=f"srv-tool_{i}", description=f"Registry tool {i}", inputSchema={"type": "object"})
|
||||
for i in range(5)
|
||||
]
|
||||
filter_instance._build_router(registry_tools)
|
||||
|
||||
expanded_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"name": f"srv-tool_{i}",
|
||||
"description": f"Registry tool {i}",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
hook = SemanticToolFilterHook(filter_instance)
|
||||
hook._expand_mcp_tools = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=expanded_tools
|
||||
)
|
||||
|
||||
state["raise_context_error"] = True
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"input": [{"role": "user", "content": "Send an email", "type": "message"}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_url": "litellm_proxy",
|
||||
"require_approval": "never",
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await hook.async_pre_call_hook(
|
||||
user_api_key_dict=Mock(),
|
||||
cache=Mock(),
|
||||
data=data,
|
||||
call_type="aresponses",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
error_message = exc_info.value.detail["error"]
|
||||
assert "context window" in error_message
|
||||
assert "larger context window" in error_message
|
||||
assert "maximum input length" not in error_message
|
||||
print("✅ Expansion path fails closed with actionable 400 on context window overflow")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_filter_hook_ignores_build_error_for_native_only_tools():
|
||||
"""
|
||||
A recorded build-time context-window error must only block requests that
|
||||
rely on MCP tool filtering; requests carrying only native tools pass
|
||||
through untouched.
|
||||
"""
|
||||
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
|
||||
|
||||
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)
|
||||
]
|
||||
filter_instance._build_router(mcp_tools)
|
||||
assert filter_instance.context_window_error is not None
|
||||
|
||||
hook = SemanticToolFilterHook(filter_instance)
|
||||
|
||||
native_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "local_fn", "description": "A local function", "parameters": {}},
|
||||
}
|
||||
]
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"messages": [{"role": "user", "content": "Send an email"}],
|
||||
"tools": native_tools,
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
result = await hook.async_pre_call_hook(
|
||||
user_api_key_dict=Mock(),
|
||||
cache=Mock(),
|
||||
data=data,
|
||||
call_type="completion",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["tools"] == native_tools
|
||||
print("✅ Native-only requests pass through despite recorded build error")
|
||||
|
||||
|
||||
def test_is_context_window_error_detection_variants():
|
||||
"""
|
||||
_is_context_window_error must detect the overflow in every shape it
|
||||
reaches filter_tools in: the raw typed exception, the encoder's
|
||||
explicitly chained ValueError wrapper, an implicitly chained wrapper,
|
||||
and a bare error whose message carries a known overflow phrase; a
|
||||
generic error must not match.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
|
||||
_is_context_window_error,
|
||||
)
|
||||
|
||||
cwe = litellm.ContextWindowExceededError(
|
||||
message="Invalid 'input[0]': maximum input length is 8192 tokens.",
|
||||
model="text-embedding-3-small",
|
||||
llm_provider="openai",
|
||||
)
|
||||
assert _is_context_window_error(cwe)
|
||||
|
||||
try:
|
||||
raise ValueError("Internal_litellm_router API call failed") from cwe
|
||||
except ValueError as explicitly_chained:
|
||||
assert _is_context_window_error(explicitly_chained)
|
||||
|
||||
try:
|
||||
try:
|
||||
raise litellm.ContextWindowExceededError(
|
||||
message="overflow", model="m", llm_provider="openai"
|
||||
)
|
||||
except litellm.ContextWindowExceededError:
|
||||
raise ValueError("wrapper without explicit chaining")
|
||||
except ValueError as implicitly_chained:
|
||||
assert _is_context_window_error(implicitly_chained)
|
||||
|
||||
assert _is_context_window_error(ValueError("Invalid 'input[0]': maximum input length is 8192 tokens."))
|
||||
assert not _is_context_window_error(ValueError("A generic API error occurred."))
|
||||
assert not _is_context_window_error(None)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
const [testQuery, setTestQuery] = useState("");
|
||||
const [testModel, setTestModel] = useState<string>("gpt-4o");
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
|
||||
const schema = data?.field_schema;
|
||||
|
|
@ -113,6 +114,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
testQuery,
|
||||
setIsTesting,
|
||||
setTestResult,
|
||||
setTestError,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -285,6 +287,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
onTest={handleTest}
|
||||
filterEnabled={!!values.enabled}
|
||||
testResult={testResult}
|
||||
testError={testError}
|
||||
curlCommand={getCurlCommand(testModel, testQuery)}
|
||||
/>
|
||||
</Col>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const buildProps = (overrides: Partial<React.ComponentProps<typeof MCPSemanticFi
|
|||
onTest: vi.fn(),
|
||||
filterEnabled: true,
|
||||
testResult: null as TestResult | null,
|
||||
testError: null as string | null,
|
||||
curlCommand: "curl --location 'http://localhost:4000/v1/responses'",
|
||||
...overrides,
|
||||
});
|
||||
|
|
@ -134,6 +135,20 @@ describe("MCPSemanticFilterTestPanel", () => {
|
|||
expect(screen.queryByText("Results")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render an error banner with the backend message when testError is set", () => {
|
||||
const testError =
|
||||
"MCP semantic tool filtering could not run: embedding model 'text-embedding-3-small' exceeded its context window while embedding the user query. Switch to an embedding model with a larger context window, or disable semantic tool filtering.";
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ testError })} />);
|
||||
|
||||
expect(screen.getByText("Semantic filtering did not run")).toBeInTheDocument();
|
||||
expect(screen.getByText(testError)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the error banner when testError is null", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ testError: null })} />);
|
||||
expect(screen.queryByText("Semantic filtering did not run")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the curl command in the API Usage tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const curlCommand = "curl --location 'http://localhost:4000/v1/responses' --header 'Authorization: Bearer sk-1234'";
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ interface MCPSemanticFilterTestPanelProps {
|
|||
onTest: () => void;
|
||||
filterEnabled: boolean;
|
||||
testResult: TestResult | null;
|
||||
testError: string | null;
|
||||
curlCommand: string;
|
||||
}
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ export default function MCPSemanticFilterTestPanel({
|
|||
onTest,
|
||||
filterEnabled,
|
||||
testResult,
|
||||
testError,
|
||||
curlCommand,
|
||||
}: MCPSemanticFilterTestPanelProps) {
|
||||
return (
|
||||
|
|
@ -82,6 +84,16 @@ export default function MCPSemanticFilterTestPanel({
|
|||
/>
|
||||
)}
|
||||
|
||||
{testError && (
|
||||
<Alert
|
||||
type="error"
|
||||
message="Semantic filtering did not run"
|
||||
description={testError}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<div>
|
||||
<Typography.Title level={5}>Results</Typography.Title>
|
||||
|
|
|
|||
|
|
@ -27,12 +27,14 @@ describe("getCurlCommand", () => {
|
|||
describe("runSemanticFilterTest", () => {
|
||||
const mockSetIsTesting = vi.fn();
|
||||
const mockSetTestResult = vi.fn();
|
||||
const mockSetTestError = vi.fn();
|
||||
const baseArgs = {
|
||||
accessToken: "test-token",
|
||||
testModel: "gpt-4o",
|
||||
testQuery: "find relevant files",
|
||||
setIsTesting: mockSetIsTesting,
|
||||
setTestResult: mockSetTestResult,
|
||||
setTestError: mockSetTestError,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -112,4 +114,34 @@ describe("runSemanticFilterTest", () => {
|
|||
expect(NotificationManager.error).toHaveBeenCalledWith("Failed to test semantic filter");
|
||||
expect(mockSetIsTesting).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("should surface the backend error message via setTestError when the API call fails", async () => {
|
||||
const backendMessage =
|
||||
"MCP semantic tool filtering could not run: embedding model 'text-embedding-3-small' exceeded its context window while embedding the user query.";
|
||||
vi.mocked(testMCPSemanticFilter).mockRejectedValueOnce(new Error(backendMessage));
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(mockSetTestError).toHaveBeenLastCalledWith(backendMessage);
|
||||
});
|
||||
|
||||
it("should clear the previous test error before making a new request", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({
|
||||
data: {},
|
||||
headers: { filter: "5->2", tools: "tool-a,tool-b" },
|
||||
});
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(mockSetTestError).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetTestError).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("should fall back to a generic message when the thrown error has no message", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockRejectedValueOnce(new Error(""));
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(mockSetTestError).toHaveBeenLastCalledWith("Failed to test semantic filter");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,12 +29,14 @@ export const runSemanticFilterTest = async ({
|
|||
testQuery,
|
||||
setIsTesting,
|
||||
setTestResult,
|
||||
setTestError,
|
||||
}: {
|
||||
accessToken: string;
|
||||
testModel: string;
|
||||
testQuery: string;
|
||||
setIsTesting: (value: boolean) => void;
|
||||
setTestResult: (result: TestResult | null) => void;
|
||||
setTestError: (error: string | null) => void;
|
||||
}) => {
|
||||
if (!testQuery || !testModel || !accessToken) {
|
||||
NotificationManager.error("Please enter a query and select a model");
|
||||
|
|
@ -43,6 +45,7 @@ export const runSemanticFilterTest = async ({
|
|||
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
|
||||
try {
|
||||
const { headers } = await testMCPSemanticFilter(accessToken, testModel, testQuery);
|
||||
|
|
@ -57,6 +60,8 @@ export const runSemanticFilterTest = async ({
|
|||
NotificationManager.success("Semantic filter test completed successfully");
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
const message = error instanceof Error && error.message ? error.message : "Failed to test semantic filter";
|
||||
setTestError(message);
|
||||
NotificationManager.error("Failed to test semantic filter");
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createApiClient, ApiError } from "./client";
|
||||
import { createApiClient, ApiError, deriveErrorMessage } from "./client";
|
||||
|
||||
const okResponse = (data: unknown): Response =>
|
||||
({ ok: true, status: 200, text: async () => JSON.stringify(data) }) as unknown as Response;
|
||||
|
|
@ -101,3 +101,22 @@ describe("createApiClient", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveErrorMessage", () => {
|
||||
it("extracts error.message from a ProxyException body, the shape the proxy emits for a pre-call hook HTTPException", () => {
|
||||
const actionable =
|
||||
"MCP semantic tool filtering could not run: embedding model 'text-embedding-3-small' exceeded its context window while embedding the user query. The request was blocked instead of silently passing all tools through. Switch to an embedding model with a larger context window, or disable semantic tool filtering.";
|
||||
const wireBody = {
|
||||
error: { message: actionable, type: "None", param: "None", code: "400" },
|
||||
};
|
||||
expect(deriveErrorMessage(wireBody)).toBe(actionable);
|
||||
});
|
||||
|
||||
it("returns error directly when it is a plain string", () => {
|
||||
expect(deriveErrorMessage({ error: "flat error text" })).toBe("flat error text");
|
||||
});
|
||||
|
||||
it("falls back to a string detail field", () => {
|
||||
expect(deriveErrorMessage({ detail: "detail text" })).toBe("detail text");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue