From 9b9e67052eb46f0285ea282959ad169e1d8917f3 Mon Sep 17 00:00:00 2001 From: Leonardo Freitas dos Santos Date: Wed, 26 Aug 2026 21:10:13 +0200 Subject: [PATCH] fix(responses): lift additional_tools input items into tools on the chat bridge An additional_tools input item carries tool definitions but no content, so the Responses -> Chat Completions conversion dropped it silently and the model was offered no tools at all. Lift the nested tools into the converted tool list. Codex CLI emits this shape, so a Codex session against any provider without a native Responses config lost its entire toolset with no error and no log line. Lifting makes those tools live, so the /v1/responses allowlist and guardrail extractor has to see them too; otherwise a nested tool reaches the model without passing tool authorization. The bridge and the extractor now share one parser so they cannot disagree about what the effective tool list is. Co-Authored-By: Claude Opus 5 (1M context) --- .../llms/base_llm/responses/transformation.py | 60 ++++++ .../guardrail_translation/handler.py | 96 +++++++++- .../transformation.py | 30 ++- ...test_openai_responses_guardrail_handler.py | 171 +++++++++++++++--- .../proxy/test_tools_allowlist_enforcement.py | 146 ++++++++++++++- .../test_additional_tools_lifting.py | 118 ++++++++++++ 6 files changed, 576 insertions(+), 45 deletions(-) create mode 100644 tests/test_litellm/responses/litellm_completion_transformation/test_additional_tools_lifting.py diff --git a/litellm/llms/base_llm/responses/transformation.py b/litellm/llms/base_llm/responses/transformation.py index 1365941fe2a..622e35f5996 100644 --- a/litellm/llms/base_llm/responses/transformation.py +++ b/litellm/llms/base_llm/responses/transformation.py @@ -1,5 +1,6 @@ import types from abc import ABC, abstractmethod +from collections.abc import Iterable, Mapping, Sequence from typing import TYPE_CHECKING, Any, Final, cast import httpx @@ -14,6 +15,65 @@ from litellm.types.responses.main import * from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders +# Codex CLI ships tool definitions inside an input item of this type rather than the +# top-level ``tools`` array. One parser is shared by every caller so they cannot +# disagree: the chat bridge lifts these tools so the model sees them, and the +# authorization/guardrail extractors read them so a nested tool cannot slip past an +# allowlist that only inspects ``tools``. +ADDITIONAL_TOOLS_INPUT_ITEM_TYPE: Final = "additional_tools" +NAMESPACE_TOOL_TYPE: Final = "namespace" + + +def _tools_held_by(item: object, container_type: str) -> tuple[object, ...] | None: + """Tools held by ``item`` when it is a container of ``container_type``, else ``None``. + + Returns an empty tuple for a malformed container, so callers can still recognise it. + """ + if not isinstance(item, Mapping): + return None + entry: Final = cast("Mapping[str, object]", item) # cast-ok: request items reach here as untyped mappings + if entry.get("type") != container_type: + return None + nested: Final = entry.get("tools") + if isinstance(nested, Sequence) and not isinstance(nested, (str, bytes)): + return tuple(cast("Sequence[object]", nested)) # cast-ok: nested tool entries are untyped + return () + + +def additional_tools_of(item: object) -> tuple[object, ...] | None: + """Nested tools when ``item`` is an ``additional_tools`` input item, else ``None``.""" + return _tools_held_by(item, ADDITIONAL_TOOLS_INPUT_ITEM_TYPE) + + +def flatten_namespace_tools(tools: Iterable[object]) -> tuple[object, ...]: + """Expand ``namespace`` containers into the tools they hold, leaving others as-is. + + Codex groups its tools under namespaces (``functions``, ``collaboration``). A + namespace entry carries only the group name, so anything reading tool *names* -- + allowlist and guardrail extraction -- must look at the leaves or it sees nothing + enforceable at all. + """ + flattened: Final[list[object]] = [] # mutable-ok: accumulator, returned as a tuple + for tool in tools: + members = _tools_held_by(tool, NAMESPACE_TOOL_TYPE) # rebind-ok: loop-local; Final is invalid in a loop + if members is None: + flattened.append(tool) + else: + flattened.extend(members) + return tuple(flattened) + + +def additional_tools_in(input: object) -> tuple[object, ...]: + """Every tool nested in ``additional_tools`` items, leaving ``input`` untouched. + + For callers that must see the effective tool list without rewriting the request, + such as allowlist and guardrail extraction. + """ + if not isinstance(input, list): + return () + return tuple(tool for item in input for tool in (additional_tools_of(item) or ())) + + if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 7c5d8ac99ad..9715d399ca6 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -28,7 +28,7 @@ Output: response.output is List[GenericResponseOutputItem] where each has: - text: str """ -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Final, Union, cast from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall @@ -41,6 +41,10 @@ from litellm.completion_extras.litellm_responses_transformation.transformation i OpenAiResponsesToChatCompletionStreamIterator, ) from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation +from litellm.llms.base_llm.responses.transformation import ( + additional_tools_in, + flatten_namespace_tools, +) from litellm.responses.litellm_completion_transformation.transformation import ( LiteLLMCompletionResponsesConfig, ) @@ -174,9 +178,7 @@ class OpenAIResponsesHandler(BaseTranslation): task_mappings=task_mappings, ) - # Extract and transform tools if present - if "tools" in data and data["tools"]: - self._extract_and_transform_tools(data["tools"], tools_to_check) + mergeable_tool_count: Final = self._collect_tools_for_guardrail(data, input_data, tools_to_check) # Step 2: Apply guardrail to all texts in batch if texts_to_check: @@ -203,6 +205,8 @@ class OpenAIResponsesHandler(BaseTranslation): data, original_tools_list, guardrailed_inputs.get("tools"), + mergeable_tool_count=mergeable_tool_count, + inspected_tool_count=len(tools_to_check), ) # Step 3: Map guardrail responses back to original input structure @@ -218,9 +222,21 @@ class OpenAIResponsesHandler(BaseTranslation): def extract_request_tool_names(self, data: dict) -> list[str]: """Extract tool names from Responses API request (tools[].name for function - and custom, tools[].server_label for mcp).""" + and custom, tools[].server_label for mcp). + + Covers tools nested in ``additional_tools`` input items as well: the chat + bridge lifts those into the effective tool list, so an allowlist that read + only ``tools`` could be walked past by nesting a disallowed tool in ``input``. + + ``namespace`` containers are expanded to their leaves. Codex groups its tools + that way, and a namespace entry carries only the group name -- reading it + directly yields no enforceable tool name at all. + """ names: Final[list[str]] = [] - for tool in data.get("tools") or []: + candidates: Final = flatten_namespace_tools( + (*(data.get("tools") or ()), *additional_tools_in(data.get("input"))) + ) + for tool in candidates: if not isinstance(tool, dict): continue if tool.get("type") in ("function", "custom") and tool.get("name"): @@ -248,6 +264,52 @@ class OpenAIResponsesHandler(BaseTranslation): ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools) tools_to_check.extend(cast(list[ChatCompletionToolParam], transformed_tools)) + def _collect_tools_for_guardrail( + self, + data: Mapping[str, object], + input_data: object, + tools_to_check: list[ChatCompletionToolParam], # mutable-ok: extended in place for the caller + ) -> int: + """Fill ``tools_to_check`` for the guardrail and return the mergeable boundary. + + Top-level ``data["tools"]`` go in first, then tools nested in ``additional_tools`` + input items. The nested ones matter because the chat bridge lifts them into the + live tool list, so a guardrail that only saw ``data["tools"]`` would never get to + block them -- and the previous gate skipped tool extraction entirely when + ``tools`` was empty, which is exactly the shape Codex sends. + + The return value is how many leading entries came from ``data["tools"]`` and may + therefore be merged back into the request. + """ + if data.get("tools"): + self._extract_and_transform_tools(data["tools"], tools_to_check) + mergeable: Final = len(tools_to_check) + nested: Final = additional_tools_in(input_data) + if nested: + nested_list: Final = list(nested) # mutable-ok: the transform takes a list + self._extract_and_transform_tools( + cast("list[FunctionToolParam | OpenAIMcpServerTool]", nested_list), # cast-ok: untyped request data + tools_to_check, + ) + return mergeable + + @staticmethod + def _mergeable_guardrailed_tools( + guardrailed_tools: list[ChatCompletionToolParam], # mutable-ok: matches the guardrail payload type + mergeable_tool_count: int | None, + inspected_tool_count: int | None, + ) -> list[ChatCompletionToolParam]: # mutable-ok: fed straight into _remap_tools_to_responses_api_format + """Guardrail output with the inspection-only nested tools removed. + + Merging those back would hoist them into the request and hand the model two + copies, since the chat bridge lifts them as well. Tools the guardrail appended + past ``inspected_tool_count`` are kept, matching ``_merge_tools_after_guardrail``. + """ + if mergeable_tool_count is None: + return guardrailed_tools + tail: Final = guardrailed_tools[inspected_tool_count:] if inspected_tool_count is not None else () + return [*guardrailed_tools[:mergeable_tool_count], *tail] # mutable-ok: drops inspection-only + def _remap_tools_to_responses_api_format(self, guardrailed_tools: list[Any]) -> list[dict[str, object]]: """ Remap guardrail-returned tools (Chat Completion format) back to @@ -291,11 +353,25 @@ class OpenAIResponsesHandler(BaseTranslation): data: dict, original_tools: list[dict[str, object]], guardrailed_tools: list[ChatCompletionToolParam] | None, + mergeable_tool_count: int | None = None, + inspected_tool_count: int | None = None, ) -> None: - """Remap guardrailed tools to Responses API format and merge with original, then set data['tools'].""" - if guardrailed_tools is not None: - remapped: Final = self._remap_tools_to_responses_api_format(guardrailed_tools) - data["tools"] = self._merge_tools_after_guardrail(original_tools, remapped) + """Remap guardrailed tools to Responses API format and merge with original, then set data['tools']. + + ``mergeable_tool_count`` is how many leading entries came from ``data["tools"]``. + Anything after it was a tool nested in an ``additional_tools`` input item, sent to + the guardrail for inspection only: merging those would hoist them into the request + and hand the model two copies of each, since the chat bridge lifts them as well. + Tools the guardrail appended past ``inspected_tool_count`` are still kept, matching + ``_merge_tools_after_guardrail``. + """ + if guardrailed_tools is None: + return + checked: Final = self._mergeable_guardrailed_tools( + guardrailed_tools, mergeable_tool_count, inspected_tool_count + ) + remapped: Final = self._remap_tools_to_responses_api_format(checked) + data["tools"] = self._merge_tools_after_guardrail(original_tools, remapped) def _extract_input_text_and_images( self, diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 1381ad36d12..f0d19d23271 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -37,6 +37,7 @@ from litellm.litellm_core_utils.get_supported_openai_params import ( get_supported_openai_params, ) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.responses.transformation import additional_tools_of from litellm.responses.litellm_completion_transformation.session_handler import ( ResponsesSessionHandler, ) @@ -271,6 +272,28 @@ class LiteLLMCompletionResponsesConfig: ) return supported_params is not None and "web_search_options" not in supported_params + @staticmethod + def _lift_additional_tools( + input: str | ResponseInputParam, + ) -> tuple[str | ResponseInputParam, tuple[object, ...]]: + """Pull tool definitions out of ``additional_tools`` input items. + + Such items carry no ``content``, so input-to-messages conversion below drops + them and their tools never reach the provider. Codex CLI emits them. + """ + if not isinstance(input, list): + return input, () + + nested_per_item: Final = tuple(additional_tools_of(item) for item in input) + if all(nested is None for nested in nested_per_item): + return input, () + + # mutable-ok: the input->messages conversion narrows on isinstance(input, list), + # so handing it a tuple silently yields no messages at all. + kept: Final = [item for item, nested in zip(input, nested_per_item) if nested is None] + lifted: Final = tuple(tool for nested in nested_per_item if nested is not None for tool in nested) + return kept, lifted + @staticmethod def transform_responses_api_request_to_chat_completion_request( model: str, @@ -284,11 +307,16 @@ class LiteLLMCompletionResponsesConfig: """ Transform a Responses API request into a Chat Completion request """ + input, lifted_tools = LiteLLMCompletionResponsesConfig._lift_additional_tools(input) + ( tools, web_search_options, ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - responses_api_request.get("tools") or [] + cast( # cast-ok: additional_tools sits outside the input-item union, so lifted entries are untyped + "list[FunctionToolParam | OpenAIMcpServerTool]", + (*(responses_api_request.get("tools") or ()), *lifted_tools), + ) ) if web_search_options is not None and LiteLLMCompletionResponsesConfig._should_drop_derived_web_search_options( 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 447175b09a6..005b0e3a343 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 @@ -146,15 +146,9 @@ class TestOpenAIResponsesHandlerInputProcessing: result = await handler.process_input_messages(data, guardrail) - assert ( - result["input"][0]["content"][0]["text"] - == "Describe this image [GUARDRAILED]" - ) + assert result["input"][0]["content"][0]["text"] == "Describe this image [GUARDRAILED]" # Image URL should remain unchanged - assert ( - result["input"][0]["content"][1]["image_url"]["url"] - == "https://example.com/image.jpg" - ) + assert result["input"][0]["content"][1]["image_url"]["url"] == "https://example.com/image.jpg" @pytest.mark.asyncio async def test_process_input_with_empty_content(self): @@ -572,10 +566,7 @@ class TestOpenAIResponsesHandlerToolCallExtraction: assert tool_call["id"] == "call_4SjsMeA6DUHwGKaE87ZojgOF" assert tool_call["type"] == "function" assert tool_call["function"]["name"] == "get_current_weather" - assert ( - tool_call["function"]["arguments"] - == '{"location":"Boston, MA","unit":"celsius"}' - ) + assert tool_call["function"]["arguments"] == '{"location":"Boston, MA","unit":"celsius"}' assert tool_call["index"] == 0 def test_extract_tool_call_from_dict_format(self): @@ -616,10 +607,7 @@ class TestOpenAIResponsesHandlerToolCallExtraction: assert tool_call["id"] == "call_4SjsMeA6DUHwGKaE87ZojgOF" assert tool_call["type"] == "function" assert tool_call["function"]["name"] == "get_current_weather" - assert ( - tool_call["function"]["arguments"] - == '{"location":"Boston, MA","unit":"celsius"}' - ) + assert tool_call["function"]["arguments"] == '{"location":"Boston, MA","unit":"celsius"}' @pytest.mark.asyncio async def test_process_output_response_with_tool_calls(self): @@ -1020,9 +1008,7 @@ class TestOpenAIResponsesHandlerStreamingOutputProcessing: logging_obj: Optional[Any] = None, ) -> GenericGuardrailAPIInputs: texts = inputs.get("texts", []) - inputs["texts"] = [ - t.replace("", "john@example.com") for t in texts - ] + inputs["texts"] = [t.replace("", "john@example.com") for t in texts] return inputs handler = OpenAIResponsesHandler() @@ -1058,15 +1044,11 @@ class TestOpenAIResponsesHandlerStreamingOutputProcessing: litellm_logging_obj=None, ) - completed_chunk = next( - c - for c in result - if isinstance(c, dict) and c.get("type") == "response.completed" - ) + completed_chunk = next(c for c in result if isinstance(c, dict) and c.get("type") == "response.completed") output_text = completed_chunk["response"]["output"][0]["content"][0]["text"] - assert ( - output_text == "send to john@example.com" - ), f"Expected PII token to be unmasked in response.completed output, got: {output_text!r}" + assert output_text == "send to john@example.com", ( + f"Expected PII token to be unmasked in response.completed output, got: {output_text!r}" + ) @pytest.mark.asyncio async def test_process_output_streaming_response_pass_through_unchanged(self): @@ -1145,9 +1127,7 @@ class TestGetStructuredMessages: } result = handler.get_structured_messages(data) assert result is not None - has_system = any( - isinstance(msg, dict) and msg.get("role") == "system" for msg in result - ) + has_system = any(isinstance(msg, dict) and msg.get("role") == "system" for msg in result) assert has_system, f"Expected system message from instructions, got: {result}" def test_should_return_none_when_no_input(self): @@ -1229,3 +1209,134 @@ class TestOpenAIResponsesHandlerToolInjection: names = [t.get("name") for t in result["tools"]] assert "get_weather" in names assert "injected_tool" in names + + +class ToolRecordingGuardrail(CustomGuardrail): + """Records the tools the handler hands to the guardrail, and returns them unchanged.""" + + def __init__(self, guardrail_name: str = "recorder"): + super().__init__(guardrail_name=guardrail_name) + self.seen_tools: List[Any] = [] + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + self.seen_tools = list(inputs.get("tools") or []) + return inputs + + +def _additional_tools_item(name: str = "restricted_tool") -> dict: + """The Codex "responses lite" shape: tools nested in an input item, top-level empty.""" + return { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "namespace", + "name": "functions", + "description": "Local tools", + "tools": [ + { + "type": "function", + "name": name, + "description": "x", + "parameters": {"type": "object", "properties": {}}, + } + ], + } + ], + } + + +class TestOpenAIResponsesHandlerAdditionalToolsGuardrailing: + """Tools nested in an ``additional_tools`` input item must reach the guardrail. + + The Chat Completions bridge lifts them into the live tool list, so a guardrail that + only inspected ``data["tools"]`` never got the chance to block them -- and the old + gate skipped tool extraction entirely when ``tools`` was empty, which is exactly the + shape Codex sends (VERIA finding on PR #38388). + """ + + @pytest.mark.asyncio + async def test_nested_tools_are_sent_to_the_guardrail(self): + handler = OpenAIResponsesHandler() + guardrail = ToolRecordingGuardrail() + data = { + "input": [ + _additional_tools_item(), + {"role": "user", "content": "hi", "type": "message"}, + ], + "tools": [], + "model": "gpt-4", + } + + await handler.process_input_messages(data, guardrail) + + names = [(t.get("function") or {}).get("name") for t in guardrail.seen_tools] + # The name is namespace-prefixed because the tool transform expands a namespace + # container into "__", the same form the bridge sends the model. + assert "functions__restricted_tool" in names, "guardrail must see tools nested in input" + + @pytest.mark.asyncio + async def test_nested_tools_are_not_merged_into_request_tools(self): + """Inspection only. Merging them here would hoist them at the proxy layer and the + bridge would lift them again, handing the model two copies.""" + handler = OpenAIResponsesHandler() + guardrail = ToolRecordingGuardrail() + data = { + "input": [ + _additional_tools_item(), + {"role": "user", "content": "hi", "type": "message"}, + ], + "tools": [], + "model": "gpt-4", + } + + result = await handler.process_input_messages(data, guardrail) + + names = [t.get("name") for t in (result.get("tools") or [])] + assert names == [], "nested tools must not be hoisted into the request here" + + @pytest.mark.asyncio + async def test_top_level_tools_still_survive_alongside_nested_ones(self): + handler = OpenAIResponsesHandler() + guardrail = ToolRecordingGuardrail() + data = { + "input": [ + _additional_tools_item(), + {"role": "user", "content": "hi", "type": "message"}, + ], + "tools": [{"type": "function", "name": "get_weather", "parameters": {"type": "object", "properties": {}}}], + "model": "gpt-4", + } + + result = await handler.process_input_messages(data, guardrail) + + seen = [(t.get("function") or {}).get("name") for t in guardrail.seen_tools] + assert "get_weather" in seen and "functions__restricted_tool" in seen + assert [t.get("name") for t in result["tools"]] == ["get_weather"] + + @pytest.mark.asyncio + async def test_guardrail_appended_tool_still_survives_with_nested_tools_present(self): + """The trim that drops inspection-only tools must not also drop injected ones.""" + handler = OpenAIResponsesHandler() + guardrail = ToolAppendingGuardrail(guardrail_name="test") + data = { + "input": [ + _additional_tools_item(), + {"role": "user", "content": "hi", "type": "message"}, + ], + "tools": [{"type": "function", "name": "get_weather", "parameters": {"type": "object", "properties": {}}}], + "model": "gpt-4", + } + + result = await handler.process_input_messages(data, guardrail) + + names = [t.get("name") for t in result["tools"]] + assert "get_weather" in names + assert "injected_tool" in names + assert "restricted_tool" not in names diff --git a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py index 31f5fbf606b..1ce8ff26f15 100644 --- a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py +++ b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py @@ -58,10 +58,95 @@ class TestExtractRequestToolNames: {"type": "function", "name": "get_current_weather", "description": "x"}, ] } + assert extract_request_tool_names("/v1/responses", data) == ["get_current_weather"] + + def test_openai_responses_additional_tools_input_items(self): + """Codex CLI nests its tool definitions in an ``additional_tools`` input item + and leaves top-level ``tools`` empty. The Chat Completions bridge lifts those + into the effective tool list, so extraction must see them; otherwise a + restricted key walks past the allowlist by nesting a disallowed tool -- an + MCP reference with require_approval "never" included (VERIA finding on + PR #38388).""" + data = { + "tools": [{"type": "function", "name": "get_current_weather"}], + "input": [ + {"role": "user", "content": "hi"}, + { + "type": "additional_tools", + "role": "developer", + "tools": [ + {"type": "custom", "name": "exec", "description": "x"}, + {"type": "mcp", "server_label": "dmcp", "require_approval": "never"}, + ], + }, + ], + } assert extract_request_tool_names("/v1/responses", data) == [ - "get_current_weather" + "get_current_weather", + "exec", + "dmcp", ] + def test_openai_responses_codex_namespaced_tools_are_extracted(self): + """The real Codex 0.149 wire shape: nine tools grouped under two ``namespace`` + containers inside an ``additional_tools`` item, with top-level ``tools`` empty. + A namespace entry carries only the group name, so without descending into it the + allowlist extracts nothing enforceable at all.""" + data = { + "tools": [], + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [ + {"type": "custom", "name": "exec"}, + {"type": "function", "name": "wait"}, + {"type": "function", "name": "request_user_input"}, + ], + }, + { + "type": "namespace", + "name": "collaboration", + "tools": [ + {"type": "function", "name": "followup_task"}, + {"type": "function", "name": "interrupt_agent"}, + {"type": "function", "name": "list_agents"}, + {"type": "function", "name": "send_message"}, + {"type": "function", "name": "spawn_agent"}, + {"type": "function", "name": "wait_agent"}, + ], + }, + ], + }, + {"role": "user", "content": "hi"}, + ], + } + assert extract_request_tool_names("/v1/responses", data) == [ + "exec", + "wait", + "request_user_input", + "followup_task", + "interrupt_agent", + "list_agents", + "send_message", + "spawn_agent", + "wait_agent", + ] + + def test_openai_responses_top_level_namespace_tools_are_extracted(self): + data = { + "tools": [{"type": "namespace", "name": "functions", "tools": [{"type": "function", "name": "read_file"}]}] + } + assert extract_request_tool_names("/v1/responses", data) == ["read_file"] + + def test_openai_responses_string_input_is_ignored(self): + data = {"tools": [{"type": "function", "name": "get_current_weather"}], "input": "hi"} + assert extract_request_tool_names("/v1/responses", data) == ["get_current_weather"] + def test_openai_responses_mcp_tools(self): data = { "tools": [ @@ -103,9 +188,7 @@ class TestExtractRequestToolNames: }, ] } - assert extract_request_tool_names("/generate_content", data) == [ - "schedule_meeting" - ] + assert extract_request_tool_names("/generate_content", data) == ["schedule_meeting"] def test_mcp_call_tool_name(self): data = {"name": "my_tool", "arguments": {}} @@ -219,3 +302,58 @@ class TestCheckToolsAllowlist: team_object=None, route="/v1/chat/completions", ) + + @pytest.mark.asyncio + async def test_disallowed_tool_nested_in_additional_tools_raises_on_responses_route(self): + """The bridge lifts nested tools into the request, so nesting must not be a + way around the allowlist (VERIA finding on PR #38388).""" + token = _token(metadata={"allowed_tools": ["other_tool"]}) + body = { + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [{"type": "custom", "name": "restricted_tool"}], + }, + ], + } + with pytest.raises(ProxyException) as exc_info: + await check_tools_allowlist( + request_body=body, + valid_token=token, + team_object=None, + route="/v1/responses", + ) + assert exc_info.value.type == ProxyErrorTypes.tool_access_denied + assert "restricted_tool" in str(exc_info.value.message) + + @pytest.mark.asyncio + async def test_disallowed_tool_inside_a_namespace_raises_on_responses_route(self): + """The shape Codex actually sends: disallowed tool inside a namespace, inside an + additional_tools input item (VERIA finding on PR #38388).""" + token = _token(metadata={"allowed_tools": ["other_tool"]}) + body = { + "tools": [], + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "namespace", + "name": "collaboration", + "tools": [{"type": "function", "name": "spawn_agent"}], + } + ], + } + ], + } + with pytest.raises(ProxyException) as exc_info: + await check_tools_allowlist( + request_body=body, + valid_token=token, + team_object=None, + route="/v1/responses", + ) + assert exc_info.value.type == ProxyErrorTypes.tool_access_denied + assert "spawn_agent" in str(exc_info.value.message) diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_additional_tools_lifting.py b/tests/test_litellm/responses/litellm_completion_transformation/test_additional_tools_lifting.py new file mode 100644 index 00000000000..b9c7cce7f83 --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_additional_tools_lifting.py @@ -0,0 +1,118 @@ +"""``additional_tools`` input items carry tool definitions that belong in ``tools``. + +The item has no ``content``, so input-to-messages conversion drops it and the tools +never reach the provider. Codex CLI emits this shape. +""" + +import json +from typing import Any + +import pytest + +from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, +) + +lift = LiteLLMCompletionResponsesConfig._lift_additional_tools + + +def _fn(name: str) -> dict[str, Any]: + return { + "type": "function", + "name": name, + "description": f"{name} tool", + "parameters": {"type": "object", "properties": {}}, + "strict": False, + } + + +def _item() -> dict[str, Any]: + """Shaped like a real Codex 0.149 request: namespaces wrapping nested tools.""" + return { + "type": "additional_tools", + "role": "developer", + "tools": [ + {"type": "namespace", "name": "functions", "description": "Local", "tools": [_fn("wait")]}, + {"type": "namespace", "name": "collaboration", "description": "Agents", "tools": [_fn("spawn_agent")]}, + ], + } + + +class TestLiftAdditionalTools: + def test_lifts_tools_and_strips_the_item(self): + item = _item() + kept, lifted = lift([{"role": "user", "content": "hi"}, item]) + assert kept == [{"role": "user", "content": "hi"}] + assert lifted == tuple(item["tools"]) + + def test_multiple_items_lift_in_order(self): + first = {"type": "additional_tools", "role": "developer", "tools": [_fn("a")]} + second = {"type": "additional_tools", "role": "developer", "tools": [_fn("b")]} + kept, lifted = lift([first, {"role": "user", "content": "x"}, second]) + assert kept == [{"role": "user", "content": "x"}] + assert [t["name"] for t in lifted] == ["a", "b"] + + def test_string_input_passes_through(self): + assert lift("just a prompt") == ("just a prompt", ()) + + def test_input_without_the_item_is_returned_unchanged(self): + original = [{"role": "user", "content": "hi"}] + kept, lifted = lift(original) + assert kept is original + assert lifted == () + + def test_non_mapping_items_are_left_alone(self): + """Input lists can carry non-mapping entries; they are not tool containers.""" + original = ["a bare string", 42, None, {"role": "user", "content": "hi"}] + kept, lifted = lift(original) + assert kept is original + assert lifted == () + + @pytest.mark.parametrize("malformed", [{}, {"tools": None}, {"tools": "not-a-list"}]) + def test_malformed_item_is_still_stripped(self, malformed): + kept, lifted = lift([{"type": "additional_tools", **malformed}, {"role": "user", "content": "hi"}]) + assert kept == [{"role": "user", "content": "hi"}] + assert lifted == () + + +class TestAdditionalToolsThroughTheBridge: + @staticmethod + def _bridge(input_, tools=None): + return LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request( + model="gpt-5.6-sol", + input=input_, + responses_api_request={"tools": tools} if tools is not None else {}, + custom_llm_provider="bedrock", + ) + + def test_nested_tools_reach_the_chat_request(self): + request = self._bridge([_item()], tools=[]) + assert request.get("tools"), "lifted tools must reach the chat request" + serialized = json.dumps(request["tools"]) + assert "wait" in serialized + assert "spawn_agent" in serialized + + def test_top_level_tools_are_preserved_alongside_lifted_ones(self): + request = self._bridge([_item()], tools=[_fn("already_here")]) + names = json.dumps(request["tools"]) + assert "already_here" in names + assert "spawn_agent" in names + + def test_request_without_tools_still_sends_none(self): + """A request that genuinely has no tools must not gain ``tools: []`` — + some providers reject an empty array.""" + request = self._bridge([{"role": "user", "content": "hi"}]) + assert "tools" not in request + assert "tool_choice" not in request + + def test_user_messages_survive_the_lift(self): + """Regression: the surviving input must stay a list. The input-to-messages + conversion narrows on isinstance(input, list), so returning a tuple produced + zero messages and Bedrock rejected the request outright.""" + request = self._bridge( + [_item(), {"role": "user", "content": "read a file for me"}], + tools=[], + ) + contents = json.dumps(request["messages"]) + assert request["messages"], "the user turn must survive" + assert "read a file for me" in contents