This commit is contained in:
Leonardo Freitas dos Santos 2026-09-04 19:34:10 +00:00 • committed by GitHub
commit f11939956d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 558 additions and 56 deletions

View file

@ -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

View file

@ -56,9 +56,14 @@ from litellm.llms.base_llm.guardrail_translation.utils import (
stream_item_fingerprint,
stream_item_items,
)
from litellm.llms.base_llm.responses.transformation import (
additional_tools_in,
flatten_namespace_tools,
)
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
ResponseTools,
)
from litellm.types.llms.openai import (
AllMessageValues,
@ -242,9 +247,22 @@ class _RequestFields(NamedTuple):
instructions: str | None
def _guardrail_tool_identity(tool: object) -> str:
"""Stable key for a chat-format tool, used to spot inspection-only entries."""
if not isinstance(tool, Mapping):
return ""
entry: Final = cast("Mapping[str, object]", tool) # cast-ok: guardrail payloads are untyped
function: Final = entry.get("function")
name: Final = (
function.get("name") if isinstance(function, Mapping) else entry.get("name") or entry.get("server_label")
)
return f"{entry.get('type')}:{name}"
class _ExtractedInputs(NamedTuple):
inputs: GenericGuardrailAPIInputs
task_mappings: tuple[tuple[int, int | None], ...]
inspection_only_identities: frozenset[str] = frozenset()
def _patched_request_fields(
@ -380,7 +398,11 @@ class OpenAIResponsesHandler(BaseTranslation):
logging_obj=litellm_logging_obj,
)
self._apply_guardrailed_tools_to_data(
data, original_tools, flattened_tool_groups, guardrailed_inputs.get("tools")
data,
original_tools,
flattened_tool_groups,
guardrailed_inputs.get("tools"),
inspection_only_identities=extracted.inspection_only_identities,
)
written_back: Final = self._written_back_request_fields(data, structured_messages, guardrailed_inputs)
if written_back is not None:
@ -410,11 +432,22 @@ class OpenAIResponsesHandler(BaseTranslation):
texts_to_check: Final[list[str]] = []
images_to_check: Final[list[str]] = []
task_mappings: Final[list[tuple[int, int | None]]] = []
# Nested additional_tools go to the guardrail after the top-level groups, for
# inspection only: the chat bridge lifts them, so merging them back would hoist
# them into the request and hand the model two copies. _apply_guardrailed_tools_to_data
# merges only as many entries as flattened_tool_groups accounts for.
nested_chat_tools: Final = tuple( # inspection only; excluded from the merge below
chat_tool
for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(
cast("ResponseTools", list(additional_tools_in(data.get("input")))) # cast-ok: untyped request data
)
for chat_tool in form.chat_tools
)
tools_to_check: Final[list[ChatCompletionToolParam]] = list( # mutable-ok: guardrail inputs want a list
copy.deepcopy(
tuple(
cast(ChatCompletionToolParam, tool) # cast-ok: mcp tools ride along in the guardrail's tool list
for group in flattened_tool_groups
for group in (*flattened_tool_groups, nested_chat_tools)
for tool in group
)
)
@ -438,7 +471,11 @@ class OpenAIResponsesHandler(BaseTranslation):
model: Final = data.get("model")
if isinstance(model, str):
inputs["model"] = model
return _ExtractedInputs(inputs=inputs, task_mappings=tuple(task_mappings))
return _ExtractedInputs(
inputs=inputs,
task_mappings=tuple(task_mappings),
inspection_only_identities=frozenset(map(_guardrail_tool_identity, nested_chat_tools)),
)
@staticmethod
def _written_back_request_fields(
@ -458,9 +495,18 @@ 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, and descends into
``namespace`` containers. Codex sends both shapes and the chat bridge lifts
them into the live tool list, so an allowlist reading only top-level ``tools``
extracts no enforceable 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"):
@ -475,11 +521,21 @@ class OpenAIResponsesHandler(BaseTranslation):
original_tools: Sequence[Mapping[str, object]],
flattened_tool_groups: Sequence[Sequence[Mapping[str, object]]],
guardrailed_tools: Sequence[ChatCompletionToolParam] | None,
inspection_only_identities: frozenset[str] = frozenset(),
) -> None:
if guardrailed_tools is None:
return
# Drop the entries that came from additional_tools input items: the chat bridge
# lifts those, and merge_guardrailed_tools appends anything it does not own, so
# keeping them would put a second copy in the request. Identity-matched rather
# than position-sliced, so tools a guardrail *appended* still reach the model.
excluded: Final = inspection_only_identities
data["tools"] = list( # mutable-ok: downstream wants a list # rebind-ok: in-place request rewrite
merge_guardrailed_tools(original_tools, flattened_tool_groups, guardrailed_tools)
merge_guardrailed_tools(
original_tools,
flattened_tool_groups,
tuple(t for t in guardrailed_tools if _guardrail_tool_identity(t) not in excluded),
)
)
def _extract_input_text_and_images(

View file

@ -38,6 +38,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,
)
@ -283,6 +284,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,
@ -296,11 +319,17 @@ class LiteLLMCompletionResponsesConfig:
"""
Transform a Responses API request into a Chat Completion request
"""
_cfg = LiteLLMCompletionResponsesConfig
input, lifted_tools = _cfg._lift_additional_tools(input) # rebind-ok: lifted items must skip msg conversion
(
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(

View file

@ -152,15 +152,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):
@ -578,10 +572,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):
@ -622,10 +613,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):
@ -1044,9 +1032,7 @@ class TestOpenAIResponsesHandlerStreamingOutputProcessing:
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
texts = inputs.get("texts", [])
inputs["texts"] = [
t.replace("<TOKEN_1>", "john@example.com") for t in texts
]
inputs["texts"] = [t.replace("<TOKEN_1>", "john@example.com") for t in texts]
return inputs
handler = OpenAIResponsesHandler()
@ -1082,15 +1068,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):
@ -1243,9 +1225,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):
@ -1345,9 +1325,7 @@ class StructuredRewriteGuardrail(CustomGuardrail):
) -> GenericGuardrailAPIInputs:
messages = list(inputs.get("structured_messages") or [])
first_user = next(i for i, m in enumerate(messages) if m.get("role") == "user")
rewritten = [
{**m, "content": COMPRESSED_MARKER} if i == first_user else m for i, m in enumerate(messages)
]
rewritten = [{**m, "content": COMPRESSED_MARKER} if i == first_user else m for i, m in enumerate(messages)]
return {**inputs, "structured_messages": rewritten}
@ -1363,9 +1341,7 @@ class ToolOutputRewriteGuardrail(CustomGuardrail):
) -> GenericGuardrailAPIInputs:
messages = list(inputs.get("structured_messages") or [])
first_tool = next(i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "tool")
rewritten = [
{**m, "content": COMPRESSED_MARKER} if i == first_tool else m for i, m in enumerate(messages)
]
rewritten = [{**m, "content": COMPRESSED_MARKER} if i == first_tool else m for i, m in enumerate(messages)]
return {**inputs, "structured_messages": rewritten}
@ -1382,9 +1358,7 @@ class DroppingRewriteGuardrail(CustomGuardrail):
) -> GenericGuardrailAPIInputs:
messages = list(inputs.get("structured_messages") or [])
first_user = next(i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "user")
rewritten = [
{**m, "content": COMPRESSED_MARKER} if i == first_user else m for i, m in enumerate(messages)
]
rewritten = [{**m, "content": COMPRESSED_MARKER} if i == first_user else m for i, m in enumerate(messages)]
return {**inputs, "structured_messages": rewritten[:-1]}
@ -1769,9 +1743,7 @@ class SystemRewriteGuardrail(CustomGuardrail):
) -> GenericGuardrailAPIInputs:
messages = list(inputs.get("structured_messages") or [])
first = next(i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "system")
rewritten = [
{**m, "content": self.rewritten_content} if i == first else m for i, m in enumerate(messages)
]
rewritten = [{**m, "content": self.rewritten_content} if i == first else m for i, m in enumerate(messages)]
return {**inputs, "structured_messages": rewritten}
@ -2146,9 +2118,7 @@ class TestBuildBlockSseChunks:
{"type": "message", "id": "msg_live", "status": "in_progress", "role": "assistant", "content": []}
)
yielded = [
OutputItemAddedEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, output_index=0, item=open_item
),
OutputItemAddedEvent(type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, output_index=0, item=open_item),
ContentPartAddedEvent(
type=ResponsesAPIStreamEvents.CONTENT_PART_ADDED,
item_id="msg_live",
@ -2338,3 +2308,134 @@ class TestOpenAIResponsesHandlerStreamingScanKey:
def test_output_item_done_round_is_never_deduped(self):
done = {"type": "response.output_item.done", "sequence_number": 1, "item": {"type": "function_call"}}
assert OpenAIResponsesHandler().get_streaming_scan_key([self._delta(0, "hi"), done]) is None
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 "<namespace>__<tool>", 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

View file

@ -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": [
@ -129,9 +214,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": {}}
@ -273,3 +356,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)

View file

@ -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