Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions litellm/llms/base_llm/responses/transformation.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,6 +15,65 @@
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

Expand Down
68 changes: 62 additions & 6 deletions litellm/llms/openai/responses/guardrail_translation/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,14 @@
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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -380,7 +398,11 @@ async def process_input_messages(
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:
Expand Down Expand Up @@ -410,11 +432,22 @@ def _extract_guardrail_inputs(
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
)
)
Expand All @@ -438,7 +471,11 @@ def _extract_guardrail_inputs(
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(
Expand All @@ -458,9 +495,18 @@ def _written_back_request_fields(

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"):
Expand All @@ -475,11 +521,21 @@ def _apply_guardrailed_tools_to_data(
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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Nested tool filtering is discarded

If a guardrail removes a nested tool from guardrailed_tools, this filter changes nothing in input; the original additional_tools item remains and the chat bridge later lifts that rejected tool. An attacker can therefore retain a tool that a filtering guardrail removed. Rebuild the nested items from the guardrail result, or remove them from input and hoist the guardrailed versions exactly once.

)
)

def _extract_input_text_and_images(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
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,
)
Expand Down Expand Up @@ -283,6 +284,28 @@ def _should_drop_derived_web_search_options(model: str, custom_llm_provider: str
)
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,
Expand All @@ -296,11 +319,17 @@ def transform_responses_api_request_to_chat_completion_request(
"""
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(
Expand Down
Loading
Loading