diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 83d087efad4..9997e5dc0c6 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -123,7 +123,7 @@ "limit": 5 }, "reportUnnecessaryIsInstance": { - "limit": 824 + "limit": 823 }, "reportUntypedBaseClass": { "limit": 0 diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 7fbc43c8783..caa20207a15 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -118,9 +118,13 @@ _TOOL_TYPES_NOT_SENT_TO_GUARDRAIL: Final = frozenset( _NAMESPACE_MEMBERS: Final = TypeAdapter(tuple[Mapping[str, object], ...]) -def _namespace_members(tool: Mapping[str, object]) -> tuple[Mapping[str, object], ...]: +def _namespace_members(tool: Mapping[str, object]) -> tuple[Mapping[str, object], ...] | None: + """``None`` when the namespace has no member list and is flattened to one function named after it.""" + raw_members: Final = tool.get("tools") + if not isinstance(raw_members, Sequence) or isinstance(raw_members, (str, bytes)): + return None try: - return _NAMESPACE_MEMBERS.validate_python(tool.get("tools") or ()) + return _NAMESPACE_MEMBERS.validate_python(raw_members) except ValidationError: return () @@ -133,17 +137,21 @@ def _qualified_member_name(namespace: str, member: Mapping[str, object]) -> str: return f"{namespace}__{member.get('name') or ''}" +def _namespace_function_names(tool: Mapping[str, object]) -> tuple[str, ...]: + namespace: Final = str(tool.get("name") or "") + members: Final = _namespace_members(tool) + if members is None: + return (namespace,) + return tuple(_qualified_member_name(namespace, member) for member in members if _is_function_member(member)) + + def _flattened_function_names(tools: Sequence[Mapping[str, object]]) -> tuple[str, ...]: """Names the guardrail sees for ``tools`` once flattened to Chat Completions format.""" top_level: Final = tuple( str(tool.get("name") or "") for tool in tools if tool.get("type") in ("function", "custom") ) nested: Final = tuple( - _qualified_member_name(str(tool.get("name") or ""), member) - for tool in tools - if tool.get("type") == "namespace" - for member in _namespace_members(tool) - if _is_function_member(member) + name for tool in tools if tool.get("type") == "namespace" for name in _namespace_function_names(tool) ) return top_level + nested @@ -153,6 +161,8 @@ def _merge_namespace_tool( ) -> Mapping[str, object] | None: namespace: Final = str(tool.get("name") or "") members: Final = _namespace_members(tool) + if members is None: + return tool if namespace in remapped_functions else None surviving: Final = tuple( member for member in members diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py index b4e206fbc92..7e624f39c53 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -1388,6 +1388,24 @@ class TestOpenAIResponsesHandlerNamespaceTools: assert result["tools"][2]["name"] == "injected_tool" assert not any(t["name"].startswith("mcp__confluence__") for t in result["tools"]) + @pytest.mark.asyncio + async def test_unchanged_guardrail_leaves_custom_mcp_and_memberless_namespace_tools_as_is(self): + handler = OpenAIResponsesHandler() + tools = [ + {"type": "custom", "name": "apply_patch", "description": "Apply a patch"}, + {"type": "mcp", "server_label": "docs", "server_url": "https://mcp.example.com"}, + {"type": "namespace", "name": "mcp__flat", "description": "Flattened to one function"}, + ] + data = { + "input": [{"role": "user", "content": "fetch page 1", "type": "message"}], + "tools": [dict(t) for t in tools], + "model": "gpt-5.3-codex", + } + + result = await handler.process_input_messages(data, MockPassThroughGuardrail(guardrail_name="test")) + + assert result["tools"] == tools + @pytest.mark.asyncio async def test_guardrail_can_still_drop_a_single_namespace_member(self): handler = OpenAIResponsesHandler() diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 1a9642c49ae..07bc874880f 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22358 + "limit": 22352 }, "LIT002": { - "limit": 26775 + "limit": 26774 }, "LIT003": { "limit": 269 @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16505 + "limit": 16504 }, "LIT011": { "limit": 5535