mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
Merge pull request #41513 from BerriAI/litellm_internal_copy_31400
fix(bedrock): neutralize orphaned tool blocks instead of raising or injecting a dummy tool (internal copy of #31400)
This commit is contained in:
commit
d4a22acb66
4 changed files with 547 additions and 62 deletions
|
|
@ -244,6 +244,7 @@ telemetry = True
|
|||
max_tokens: int = DEFAULT_MAX_TOKENS # OpenAI Defaults
|
||||
drop_params = drop_params_env_flag(os.environ, verbose_logger)
|
||||
modify_params = bool(os.getenv("LITELLM_MODIFY_PARAMS", False))
|
||||
bedrock_neutralize_orphaned_tool_blocks: bool = True
|
||||
use_chat_completions_url_for_anthropic_messages: bool = bool(
|
||||
os.getenv("LITELLM_USE_CHAT_COMPLETIONS_URL_FOR_ANTHROPIC_MESSAGES", False)
|
||||
) # When True, routes OpenAI /v1/messages requests to chat/completions instead of the Responses API
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ from litellm.types.llms.openai import (
|
|||
AllMessageValues,
|
||||
ChatCompletionAnnotation,
|
||||
ChatCompletionAssistantMessage,
|
||||
ChatCompletionAssistantToolCall,
|
||||
ChatCompletionRedactedThinkingBlock,
|
||||
ChatCompletionResponseMessage,
|
||||
ChatCompletionSystemMessage,
|
||||
|
|
@ -205,6 +206,84 @@ class AmazonConverseConfig(BaseConfig):
|
|||
|
||||
return messages_copy
|
||||
|
||||
@staticmethod
|
||||
def _has_orphaned_tool_blocks(messages: list[AllMessageValues]) -> bool:
|
||||
return any(
|
||||
(m.get("role") == "assistant" and m.get("tool_calls")) or m.get("role") in ("tool", "function")
|
||||
for m in messages
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _neutralize_orphaned_tool_blocks(
|
||||
messages: list[AllMessageValues], optional_params: dict
|
||||
) -> list[AllMessageValues]:
|
||||
if optional_params.get("tools") or not AmazonConverseConfig._has_orphaned_tool_blocks(messages):
|
||||
return messages
|
||||
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
convert_content_list_to_str,
|
||||
)
|
||||
|
||||
def _tool_call_text(tool_call: ChatCompletionAssistantToolCall) -> str:
|
||||
function = tool_call.get("function") or {}
|
||||
name = function.get("name") or "unknown_tool"
|
||||
arguments = function.get("arguments") or ""
|
||||
call_id = tool_call.get("id")
|
||||
label = f"tool call {call_id}" if call_id else "tool call"
|
||||
return f"[{label}: {name}({arguments})]"
|
||||
|
||||
def _result_text(message: AllMessageValues) -> str:
|
||||
rendered = convert_content_list_to_str(message).strip()
|
||||
return rendered or "<non-text tool result omitted>"
|
||||
|
||||
guardrail_active: Final = "guardrailConfig" in optional_params
|
||||
|
||||
def _rewrite(message: AllMessageValues) -> AllMessageValues:
|
||||
role = message.get("role")
|
||||
tool_calls = message.get("tool_calls")
|
||||
if role == "assistant" and tool_calls:
|
||||
base_text: Final = convert_content_list_to_str(message)
|
||||
call_texts: Final = tuple(_tool_call_text(call) for call in tool_calls)
|
||||
text: Final = "\n".join(part for part in (base_text, *call_texts) if part)
|
||||
return ChatCompletionAssistantMessage(role="assistant", content=text)
|
||||
if role in ("tool", "function"):
|
||||
tool_call_id = message.get("tool_call_id")
|
||||
name = message.get("name")
|
||||
label = f"tool result for {tool_call_id or name or 'unknown'}"
|
||||
result_text: Final = f"[{label}: {_result_text(message)}]"
|
||||
# Tool results are externally controlled, so guard them wherever they
|
||||
# land in history; _convert_consecutive_user_messages_to_guarded_text
|
||||
# only covers the trailing user turn.
|
||||
content: Final = [{"type": "guarded_text", "text": result_text}] if guardrail_active else result_text
|
||||
return ChatCompletionUserMessage(role="user", content=content)
|
||||
return message
|
||||
|
||||
verbose_logger.warning(
|
||||
"litellm.bedrock: request has tool blocks in message history but no "
|
||||
"`tools=` param; neutralizing orphaned tool blocks to text so Bedrock "
|
||||
"accepts the request without a toolConfig. Non-text tool-result "
|
||||
"payloads are dropped. Pass `tools=` to preserve structured tool calling."
|
||||
)
|
||||
return [_rewrite(message) for message in messages]
|
||||
|
||||
@staticmethod
|
||||
def _handle_orphaned_tool_blocks(messages: list[AllMessageValues], optional_params: dict) -> list[AllMessageValues]:
|
||||
if litellm.bedrock_neutralize_orphaned_tool_blocks:
|
||||
return AmazonConverseConfig._neutralize_orphaned_tool_blocks(messages, optional_params)
|
||||
|
||||
if "tools" in optional_params or not has_tool_call_blocks(messages):
|
||||
return messages
|
||||
|
||||
if litellm.modify_params:
|
||||
optional_params["tools"] = add_dummy_tool(custom_llm_provider="bedrock_converse")
|
||||
return messages
|
||||
|
||||
raise litellm.utils.UnsupportedParamsError(
|
||||
message="Bedrock doesn't support tool calling without `tools=` param specified. Pass `tools=` param OR set `litellm.modify_params = True` // `litellm_settings::modify_params: True` to add dummy tool to the request.",
|
||||
model="",
|
||||
llm_provider="bedrock",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config(cls):
|
||||
return {
|
||||
|
|
@ -1609,20 +1688,6 @@ class AmazonConverseConfig(BaseConfig):
|
|||
drop_params: bool = False,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> CommonRequestObject:
|
||||
## VALIDATE REQUEST
|
||||
"""
|
||||
Bedrock doesn't support tool calling without `tools=` param specified.
|
||||
"""
|
||||
if "tools" not in optional_params and messages is not None and has_tool_call_blocks(messages):
|
||||
if litellm.modify_params:
|
||||
optional_params["tools"] = add_dummy_tool(custom_llm_provider="bedrock_converse")
|
||||
else:
|
||||
raise litellm.UnsupportedParamsError(
|
||||
message="Bedrock doesn't support tool calling without `tools=` param specified. Pass `tools=` param OR set `litellm.modify_params = True` // `litellm_settings::modify_params: True` to add dummy tool to the request.",
|
||||
model="",
|
||||
llm_provider="bedrock",
|
||||
)
|
||||
|
||||
# Drop thinking param if thinking is enabled but thinking_blocks are missing
|
||||
# This prevents the error: "Expected thinking or redacted_thinking, but found tool_use"
|
||||
#
|
||||
|
|
@ -1735,7 +1800,9 @@ class AmazonConverseConfig(BaseConfig):
|
|||
messages, system_content_blocks = self._transform_system_message(messages, model=model)
|
||||
|
||||
# Convert last user message to guarded_text if guardrailConfig is present
|
||||
messages = self._convert_consecutive_user_messages_to_guarded_text(messages, optional_params)
|
||||
messages = self._convert_consecutive_user_messages_to_guarded_text(
|
||||
self._handle_orphaned_tool_blocks(messages, optional_params), optional_params
|
||||
)
|
||||
## TRANSFORMATION ##
|
||||
|
||||
_data: Final[CommonRequestObject] = self._transform_request_helper(
|
||||
|
|
@ -1796,7 +1863,9 @@ class AmazonConverseConfig(BaseConfig):
|
|||
messages, system_content_blocks = self._transform_system_message(messages, model=model)
|
||||
|
||||
# Convert last user message to guarded_text if guardrailConfig is present
|
||||
messages = self._convert_consecutive_user_messages_to_guarded_text(messages, optional_params)
|
||||
messages = self._convert_consecutive_user_messages_to_guarded_text(
|
||||
self._handle_orphaned_tool_blocks(messages, optional_params), optional_params
|
||||
)
|
||||
|
||||
_data: Final[CommonRequestObject] = self._transform_request_helper(
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -261,7 +261,6 @@ def test_aaparallel_function_call_with_anthropic_thinking(model):
|
|||
|
||||
from litellm.types.utils import ChatCompletionMessageToolCall, Function, Message
|
||||
|
||||
|
||||
_PARALLEL_TOOL_HISTORY_MESSAGES = [
|
||||
{
|
||||
"role": "user",
|
||||
|
|
@ -293,20 +292,11 @@ _PARALLEL_TOOL_HISTORY_MESSAGES = [
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model, messages, expect_unsupported_params_error",
|
||||
"model, messages",
|
||||
[
|
||||
# Bedrock Converse still requires modify_params to inject the dummy tool.
|
||||
(
|
||||
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
_PARALLEL_TOOL_HISTORY_MESSAGES,
|
||||
True,
|
||||
),
|
||||
# Anthropic Messages API: dummy tool is injected without modify_params.
|
||||
(
|
||||
"claude-haiku-4-5-20251001",
|
||||
_PARALLEL_TOOL_HISTORY_MESSAGES,
|
||||
False,
|
||||
),
|
||||
# Anthropic Messages API: a dummy tool is injected without modify_params,
|
||||
# so tool history with no tools= completes instead of raising.
|
||||
("claude-haiku-4-5-20251001", _PARALLEL_TOOL_HISTORY_MESSAGES),
|
||||
(
|
||||
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
[
|
||||
|
|
@ -315,7 +305,6 @@ _PARALLEL_TOOL_HISTORY_MESSAGES = [
|
|||
"content": "What's the weather like in San Francisco, Tokyo, and Paris? - give me 3 responses",
|
||||
}
|
||||
],
|
||||
False,
|
||||
),
|
||||
(
|
||||
"claude-haiku-4-5-20251001",
|
||||
|
|
@ -325,48 +314,34 @@ _PARALLEL_TOOL_HISTORY_MESSAGES = [
|
|||
"content": "What's the weather like in San Francisco, Tokyo, and Paris? - give me 3 responses",
|
||||
}
|
||||
],
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parallel_function_call_anthropic_error_msg(
|
||||
model, messages, expect_unsupported_params_error
|
||||
):
|
||||
def test_parallel_function_call_anthropic_error_msg(model, messages):
|
||||
"""
|
||||
Tool history without an explicit ``tools`` param:
|
||||
Tool history without an explicit ``tools`` param must complete, not raise.
|
||||
|
||||
- Bedrock **Converse** still raises ``UnsupportedParamsError`` unless
|
||||
``litellm.modify_params`` is enabled (dummy tool is only added there).
|
||||
- **Anthropic** (and Bedrock Invoke via ``AnthropicConfig.transform_request``)
|
||||
always get a dummy tool so CLIs work with ``modify_params`` left off.
|
||||
|
||||
Reference Issue: https://github.com/BerriAI/litellm/issues/5747, https://github.com/BerriAI/litellm/issues/5388
|
||||
Anthropic (and Bedrock Invoke via ``AnthropicConfig.transform_request``)
|
||||
inject a dummy tool so CLIs work with ``modify_params`` left off. Bedrock
|
||||
Converse's no-raise behavior is covered offline in
|
||||
``tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py``
|
||||
(see #24158, #27138), which needs no live credentials.
|
||||
"""
|
||||
# Ensure modify_params is False so Bedrock Converse path still raises.
|
||||
# Force modify_params off as a clean baseline: it exercises the Anthropic
|
||||
# dummy-tool path, which injects regardless of modify_params
|
||||
# (other tests in this file set it to True and don't reset it)
|
||||
original_modify_params = litellm.modify_params
|
||||
litellm.modify_params = False
|
||||
try:
|
||||
litellm.set_verbose = True
|
||||
|
||||
if expect_unsupported_params_error:
|
||||
with pytest.raises(litellm.UnsupportedParamsError) as e:
|
||||
litellm.completion(
|
||||
model=model,
|
||||
messages=messages,
|
||||
temperature=0.2,
|
||||
seed=22,
|
||||
drop_params=True,
|
||||
)
|
||||
else:
|
||||
second_response = litellm.completion(
|
||||
model=model,
|
||||
messages=messages,
|
||||
temperature=0.2,
|
||||
seed=22,
|
||||
drop_params=True,
|
||||
) # get a new response from the model where it can see the function response
|
||||
print("second response\n", second_response)
|
||||
second_response = litellm.completion(
|
||||
model=model,
|
||||
messages=messages,
|
||||
temperature=0.2,
|
||||
seed=22,
|
||||
drop_params=True,
|
||||
) # get a new response from the model where it can see the function response
|
||||
print("second response\n", second_response)
|
||||
except litellm.InternalServerError as e:
|
||||
print(e)
|
||||
except litellm.RateLimitError as e:
|
||||
|
|
|
|||
|
|
@ -6534,6 +6534,446 @@ async def test_grounding_source_and_query_rendered_as_text():
|
|||
assert {"text": "What is the capital of Japan?"} in user_content
|
||||
|
||||
|
||||
def _orphaned_tool_history_messages():
|
||||
return [
|
||||
{"role": "user", "content": "What's the weather in Paris?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Paris"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_abc",
|
||||
"content": "Sunny, 25C",
|
||||
},
|
||||
{"role": "user", "content": "Summarize our conversation so far."},
|
||||
]
|
||||
|
||||
|
||||
def test_neutralize_orphaned_tool_blocks_rewrites_when_no_tools():
|
||||
"""No tools= but history has tool blocks: assistant tool_calls and the tool
|
||||
result must be rewritten to text, with the structured tool fields gone and
|
||||
tool_call_id preserved, so Bedrock accepts the request without a toolConfig
|
||||
(#24158, #27138)."""
|
||||
messages = _orphaned_tool_history_messages()
|
||||
|
||||
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
|
||||
messages, optional_params={}
|
||||
)
|
||||
|
||||
serialized = json.dumps(result)
|
||||
assert "tool_calls" not in serialized
|
||||
assert not any(m.get("role") in ("tool", "function") for m in result)
|
||||
assert "get_weather" in serialized
|
||||
# The arguments string contains quotes; after json.dumps the literal
|
||||
# '{"city": "Paris"}' is escaped, so assert on quote-free tokens that survive.
|
||||
assert "city" in serialized and "Paris" in serialized
|
||||
assert "Sunny, 25C" in serialized
|
||||
assert "[tool call call_abc: get_weather(" in result[1]["content"]
|
||||
assert "[tool result for call_abc: Sunny, 25C]" in result[2]["content"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tools_value", [[], None])
|
||||
def test_neutralize_orphaned_tool_blocks_rewrites_when_tools_empty(tools_value):
|
||||
"""tools=[] and tools=None are 'no usable tools'; the gate must be on
|
||||
truthiness, not key presence, or these slip through and still emit
|
||||
structured tool blocks with no toolConfig."""
|
||||
messages = _orphaned_tool_history_messages()
|
||||
|
||||
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
|
||||
messages, optional_params={"tools": tools_value}
|
||||
)
|
||||
|
||||
serialized = json.dumps(result)
|
||||
assert "tool_calls" not in serialized
|
||||
assert "get_weather" in serialized
|
||||
|
||||
|
||||
def test_neutralize_orphaned_tool_blocks_rewrites_tool_result_only_history():
|
||||
"""A role:"tool"-only history (no assistant tool_calls) must also be
|
||||
neutralized; has_tool_call_blocks misses this, but the factory still emits a
|
||||
lone toolResult with no toolConfig."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "tool", "tool_call_id": "call_xyz", "content": "lookup result"},
|
||||
]
|
||||
|
||||
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
|
||||
messages, optional_params={}
|
||||
)
|
||||
|
||||
assert not any(m.get("role") in ("tool", "function") for m in result)
|
||||
serialized = json.dumps(result)
|
||||
assert "lookup result" in serialized
|
||||
assert "call_xyz" in serialized
|
||||
|
||||
|
||||
def test_neutralize_orphaned_tool_blocks_non_text_result_marked_not_empty():
|
||||
"""Non-text tool-result payloads (image/file) collapse to an explicit
|
||||
marker, never an empty string (Bedrock rejects empty text blocks) and never
|
||||
a silent drop."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {"name": "render", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,AAAA"},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
|
||||
messages, optional_params={}
|
||||
)
|
||||
|
||||
rewritten = next(
|
||||
m for m in result if m.get("role") == "user" and m is not messages[0]
|
||||
)
|
||||
text = rewritten["content"]
|
||||
assert text.strip() # never empty
|
||||
assert "non-text tool result omitted" in text
|
||||
|
||||
|
||||
def test_neutralize_orphaned_tool_blocks_noop_when_tools_present():
|
||||
"""When a non-empty tools= is provided, tool blocks are legitimate and must
|
||||
be left untouched (returns the same object, no rewriting)."""
|
||||
messages = _orphaned_tool_history_messages()
|
||||
|
||||
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
|
||||
messages,
|
||||
optional_params={"tools": [{"type": "function", "function": {"name": "x"}}]},
|
||||
)
|
||||
|
||||
assert result is messages
|
||||
|
||||
|
||||
def test_neutralize_orphaned_tool_blocks_noop_when_no_tool_history():
|
||||
"""Plain conversation with no tool blocks is returned unchanged."""
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
|
||||
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
|
||||
messages, optional_params={}
|
||||
)
|
||||
|
||||
assert result is messages
|
||||
|
||||
|
||||
def test_neutralize_orphaned_tool_blocks_logs_warning(caplog):
|
||||
"""Neutralization must surface at WARNING level so a developer who forgot
|
||||
tools= sees it instead of a silent degrade."""
|
||||
messages = _orphaned_tool_history_messages()
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
AmazonConverseConfig._neutralize_orphaned_tool_blocks(
|
||||
messages, optional_params={}
|
||||
)
|
||||
|
||||
assert any(
|
||||
"neutralizing orphaned tool blocks" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def _assert_no_structured_tool_blocks(result):
|
||||
"""A valid Bedrock body for a neutralized request has no tool config AND no
|
||||
structured tool blocks in messages. Checking only toolConfig is insufficient:
|
||||
deleting the raise without rewriting still leaves toolUse/toolResult, the
|
||||
exact shape Bedrock rejects."""
|
||||
assert "toolConfig" not in result
|
||||
serialized = json.dumps(result)
|
||||
assert "toolUse" not in serialized
|
||||
assert "toolResult" not in serialized
|
||||
|
||||
|
||||
def test_transform_request_no_tools_with_tool_history_succeeds_24158(monkeypatch):
|
||||
"""#24158: a compaction-style call (tool blocks in history, no tools=) must
|
||||
not raise and must send no toolConfig or structured tool blocks, on
|
||||
default settings."""
|
||||
monkeypatch.setattr(litellm, "modify_params", False)
|
||||
config = AmazonConverseConfig()
|
||||
|
||||
result = config.transform_request(
|
||||
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
messages=_orphaned_tool_history_messages(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
_assert_no_structured_tool_blocks(result)
|
||||
serialized = json.dumps(result)
|
||||
assert "get_weather" in serialized
|
||||
assert "Sunny, 25C" in serialized
|
||||
|
||||
|
||||
def test_transform_request_tool_unsupported_model_no_toolconfig_27138(monkeypatch):
|
||||
"""#27138: a tool-incapable model with tool blocks in history and no tools=
|
||||
must not get a toolConfig/toolUse/toolResult injected (which Bedrock would
|
||||
400 on), even with modify_params on."""
|
||||
monkeypatch.setattr(litellm, "modify_params", True)
|
||||
config = AmazonConverseConfig()
|
||||
|
||||
result = config.transform_request(
|
||||
model="meta.llama3-2-3b-instruct-v1:0",
|
||||
messages=_orphaned_tool_history_messages(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
_assert_no_structured_tool_blocks(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tools_value", [[], None])
|
||||
def test_transform_request_empty_tools_with_tool_history(monkeypatch, tools_value):
|
||||
"""tools=[] / tools=None must be neutralized like no tools at all; a
|
||||
key-presence gate would skip them and emit toolUse/toolResult with no
|
||||
toolConfig."""
|
||||
monkeypatch.setattr(litellm, "modify_params", False)
|
||||
config = AmazonConverseConfig()
|
||||
|
||||
result = config.transform_request(
|
||||
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
messages=_orphaned_tool_history_messages(),
|
||||
optional_params={"tools": tools_value},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
_assert_no_structured_tool_blocks(result)
|
||||
|
||||
|
||||
def test_transform_request_tool_result_only_history(monkeypatch):
|
||||
"""A role:"tool"-only history (no assistant tool_calls) currently emits a
|
||||
lone toolResult with no toolConfig; it must be neutralized."""
|
||||
monkeypatch.setattr(litellm, "modify_params", False)
|
||||
config = AmazonConverseConfig()
|
||||
|
||||
result = config.transform_request(
|
||||
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
messages=[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "tool", "tool_call_id": "call_xyz", "content": "lookup result"},
|
||||
],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
_assert_no_structured_tool_blocks(result)
|
||||
assert "lookup result" in json.dumps(result)
|
||||
|
||||
|
||||
def test_transform_request_neutralized_tool_output_is_guarded(monkeypatch):
|
||||
"""With guardrailConfig present, a neutralized tool result that becomes the
|
||||
trailing user turn must be emitted as guardContent, not plain text, so
|
||||
untrusted tool output does not bypass the guardrail (neutralize must run
|
||||
before guarded-text conversion)."""
|
||||
monkeypatch.setattr(litellm, "modify_params", False)
|
||||
config = AmazonConverseConfig()
|
||||
|
||||
result = config.transform_request(
|
||||
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
messages=[
|
||||
{"role": "user", "content": "look it up"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "secret tool output"},
|
||||
],
|
||||
optional_params={
|
||||
"guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "1"}
|
||||
},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
_assert_no_structured_tool_blocks(result)
|
||||
serialized = json.dumps(result)
|
||||
assert "guardContent" in serialized
|
||||
assert "secret tool output" in serialized
|
||||
|
||||
|
||||
def test_transform_request_neutralized_tool_output_guarded_mid_history(monkeypatch):
|
||||
"""Regression: a neutralized tool result that is NOT the trailing turn (an
|
||||
assistant reply and a later user turn follow it) must still be guardContent.
|
||||
_convert_consecutive_user_messages_to_guarded_text only covers the trailing
|
||||
user turn, so neutralize itself must guard untrusted tool output regardless
|
||||
of position, else an attacker controlling the tool response bypasses the
|
||||
guardrail (bot review)."""
|
||||
monkeypatch.setattr(litellm, "modify_params", False)
|
||||
config = AmazonConverseConfig()
|
||||
|
||||
result = config.transform_request(
|
||||
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
messages=[
|
||||
{"role": "user", "content": "look it up"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "IGNORE_PRIOR malware"},
|
||||
{"role": "assistant", "content": "Here is the summary."},
|
||||
{"role": "user", "content": "thanks"},
|
||||
],
|
||||
optional_params={
|
||||
"guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "1"}
|
||||
},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
_assert_no_structured_tool_blocks(result)
|
||||
blocks = [block for message in result["messages"] for block in message["content"]]
|
||||
guarded_texts = [
|
||||
block["guardContent"]["text"]["text"] for block in blocks if "guardContent" in block
|
||||
]
|
||||
plain_texts = [block["text"] for block in blocks if "text" in block and "guardContent" not in block]
|
||||
assert any("malware" in text for text in guarded_texts), "mid-history tool output must be guarded"
|
||||
assert not any(
|
||||
"malware" in text for text in plain_texts
|
||||
), "mid-history tool output must not reach the model as unguarded text"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_transform_request_no_tools_with_tool_history(monkeypatch):
|
||||
"""Async is a separate request assembler; it must neutralize identically."""
|
||||
monkeypatch.setattr(litellm, "modify_params", False)
|
||||
config = AmazonConverseConfig()
|
||||
|
||||
result = await config._async_transform_request(
|
||||
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
messages=_orphaned_tool_history_messages(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
_assert_no_structured_tool_blocks(result)
|
||||
assert "get_weather" in json.dumps(result)
|
||||
|
||||
|
||||
def test_transform_request_with_tools_still_builds_toolconfig(monkeypatch):
|
||||
"""Guard: when a non-empty tools= IS provided, tool blocks are legitimate and
|
||||
a toolConfig must still be produced (neutralization must not regress this)."""
|
||||
monkeypatch.setattr(litellm, "modify_params", False)
|
||||
config = AmazonConverseConfig()
|
||||
|
||||
result = config.transform_request(
|
||||
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
messages=_orphaned_tool_history_messages(),
|
||||
optional_params={
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert "toolConfig" in result
|
||||
|
||||
|
||||
def test_transform_request_flag_off_restores_raise(monkeypatch):
|
||||
"""Opt-out: with bedrock_neutralize_orphaned_tool_blocks=False and
|
||||
modify_params=False, the legacy UnsupportedParamsError contract is restored."""
|
||||
monkeypatch.setattr(litellm, "bedrock_neutralize_orphaned_tool_blocks", False)
|
||||
monkeypatch.setattr(litellm, "modify_params", False)
|
||||
config = AmazonConverseConfig()
|
||||
|
||||
with pytest.raises(litellm.utils.UnsupportedParamsError, match="without `tools="):
|
||||
config.transform_request(
|
||||
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
messages=_orphaned_tool_history_messages(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
|
||||
def test_transform_request_flag_off_with_modify_params_restores_dummy_tool(monkeypatch):
|
||||
"""Opt-out: with the flag off and modify_params=True, the legacy dummy-tool
|
||||
injection is restored (a toolConfig is produced, not neutralized text)."""
|
||||
monkeypatch.setattr(litellm, "bedrock_neutralize_orphaned_tool_blocks", False)
|
||||
monkeypatch.setattr(litellm, "modify_params", True)
|
||||
config = AmazonConverseConfig()
|
||||
|
||||
result = config.transform_request(
|
||||
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
messages=_orphaned_tool_history_messages(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert "toolConfig" in result
|
||||
assert "dummy_tool" in json.dumps(result)
|
||||
|
||||
|
||||
def test_transform_request_flag_on_is_default(monkeypatch):
|
||||
"""Default-on: without touching the flag, neutralization is the behavior."""
|
||||
monkeypatch.setattr(litellm, "modify_params", False)
|
||||
config = AmazonConverseConfig()
|
||||
|
||||
assert litellm.bedrock_neutralize_orphaned_tool_blocks is True
|
||||
result = config.transform_request(
|
||||
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
messages=_orphaned_tool_history_messages(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
_assert_no_structured_tool_blocks(result)
|
||||
|
||||
|
||||
def _agentic_messages_with_ttl(ttl_target: str):
|
||||
"""A tool-loop conversation with `ttl: 1h` cache_control at `ttl_target`:
|
||||
'user', 'tool_call' (per-tool-call, on the assistant's tool call), or
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue