|
10 | 10 | BedrockImageProcessor, |
11 | 11 | _convert_to_bedrock_tool_call_invoke, |
12 | 12 | ollama_pt, |
| 13 | + sanitize_messages_for_tool_calling, |
13 | 14 | ) |
14 | 15 |
|
15 | 16 |
|
@@ -1179,7 +1180,7 @@ def test_bedrock_tools_pt_does_not_handle_system_tool(): |
1179 | 1180 | System tools (nova_grounding) should be added via web_search_options, |
1180 | 1181 | not via the tools parameter directly. |
1181 | 1182 | """ |
1182 | | - |
| 1183 | + |
1183 | 1184 | from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt |
1184 | 1185 |
|
1185 | 1186 | # Regular function tools should still work |
@@ -1741,3 +1742,288 @@ def test_bedrock_tool_call_invoke_multiple_normal_tools(): |
1741 | 1742 | assert len(result) == 2 |
1742 | 1743 | assert result[0]["toolUse"]["toolUseId"] == "call_1" |
1743 | 1744 | assert result[1]["toolUse"]["toolUseId"] == "call_2" |
| 1745 | + |
| 1746 | + |
| 1747 | +# ======================================================================== |
| 1748 | +# Tool result deduplication tests (Case D in sanitize_messages_for_tool_calling) |
| 1749 | +# ======================================================================== |
| 1750 | + |
| 1751 | + |
| 1752 | +def test_sanitize_messages_deduplicates_tool_results(): |
| 1753 | + """ |
| 1754 | + Anthropic requires exactly one tool_result per tool_use. When conversation |
| 1755 | + history (e.g. from session resume) contains duplicate tool result messages |
| 1756 | + with the same tool_call_id, sanitize_messages_for_tool_calling should keep |
| 1757 | + only the last occurrence. |
| 1758 | +
|
| 1759 | + Without this fix, Anthropic rejects with: |
| 1760 | + each tool_use must have a single result. Found multiple tool_result |
| 1761 | + blocks with id: <id> |
| 1762 | + """ |
| 1763 | + original = litellm.modify_params |
| 1764 | + litellm.modify_params = True |
| 1765 | + try: |
| 1766 | + messages = [ |
| 1767 | + {"role": "user", "content": "What's the weather?"}, |
| 1768 | + { |
| 1769 | + "role": "assistant", |
| 1770 | + "content": None, |
| 1771 | + "tool_calls": [ |
| 1772 | + { |
| 1773 | + "id": "call_abc123", |
| 1774 | + "type": "function", |
| 1775 | + "function": { |
| 1776 | + "name": "get_weather", |
| 1777 | + "arguments": '{"city": "NYC"}', |
| 1778 | + }, |
| 1779 | + } |
| 1780 | + ], |
| 1781 | + }, |
| 1782 | + # First tool result (stale/duplicate) |
| 1783 | + { |
| 1784 | + "role": "tool", |
| 1785 | + "tool_call_id": "call_abc123", |
| 1786 | + "content": "Partial result...", |
| 1787 | + }, |
| 1788 | + # Second tool result (final/complete — should be kept) |
| 1789 | + { |
| 1790 | + "role": "tool", |
| 1791 | + "tool_call_id": "call_abc123", |
| 1792 | + "content": '{"temperature": 72, "condition": "sunny"}', |
| 1793 | + }, |
| 1794 | + ] |
| 1795 | + |
| 1796 | + result = sanitize_messages_for_tool_calling(messages) |
| 1797 | + |
| 1798 | + # Count tool messages with this ID — should be exactly 1 |
| 1799 | + tool_results = [ |
| 1800 | + m for m in result if m.get("role") == "tool" and m.get("tool_call_id") == "call_abc123" |
| 1801 | + ] |
| 1802 | + assert len(tool_results) == 1 |
| 1803 | + # Should keep the LAST occurrence (most complete) |
| 1804 | + assert tool_results[0]["content"] == '{"temperature": 72, "condition": "sunny"}' |
| 1805 | + finally: |
| 1806 | + litellm.modify_params = original |
| 1807 | + |
| 1808 | + |
| 1809 | +def test_sanitize_messages_preserves_unique_tool_results(): |
| 1810 | + """ |
| 1811 | + When each tool_call_id has exactly one tool_result, no deduplication should |
| 1812 | + occur. Messages should pass through unchanged. |
| 1813 | + """ |
| 1814 | + original = litellm.modify_params |
| 1815 | + litellm.modify_params = True |
| 1816 | + try: |
| 1817 | + messages = [ |
| 1818 | + {"role": "user", "content": "Get weather for two cities"}, |
| 1819 | + { |
| 1820 | + "role": "assistant", |
| 1821 | + "content": None, |
| 1822 | + "tool_calls": [ |
| 1823 | + { |
| 1824 | + "id": "call_1", |
| 1825 | + "type": "function", |
| 1826 | + "function": { |
| 1827 | + "name": "get_weather", |
| 1828 | + "arguments": '{"city": "NYC"}', |
| 1829 | + }, |
| 1830 | + }, |
| 1831 | + { |
| 1832 | + "id": "call_2", |
| 1833 | + "type": "function", |
| 1834 | + "function": { |
| 1835 | + "name": "get_weather", |
| 1836 | + "arguments": '{"city": "LA"}', |
| 1837 | + }, |
| 1838 | + }, |
| 1839 | + ], |
| 1840 | + }, |
| 1841 | + {"role": "tool", "tool_call_id": "call_1", "content": "72F"}, |
| 1842 | + {"role": "tool", "tool_call_id": "call_2", "content": "85F"}, |
| 1843 | + ] |
| 1844 | + |
| 1845 | + result = sanitize_messages_for_tool_calling(messages) |
| 1846 | + |
| 1847 | + tool_results = [m for m in result if m.get("role") == "tool"] |
| 1848 | + assert len(tool_results) == 2 |
| 1849 | + assert tool_results[0]["tool_call_id"] == "call_1" |
| 1850 | + assert tool_results[0]["content"] == "72F" |
| 1851 | + assert tool_results[1]["tool_call_id"] == "call_2" |
| 1852 | + assert tool_results[1]["content"] == "85F" |
| 1853 | + finally: |
| 1854 | + litellm.modify_params = original |
| 1855 | + |
| 1856 | + |
| 1857 | +def test_sanitize_messages_dedup_disabled_when_modify_params_false(): |
| 1858 | + """ |
| 1859 | + When litellm.modify_params is False, messages should be returned as-is |
| 1860 | + even if they contain duplicate tool results. |
| 1861 | + """ |
| 1862 | + original = litellm.modify_params |
| 1863 | + litellm.modify_params = False |
| 1864 | + try: |
| 1865 | + messages = [ |
| 1866 | + {"role": "user", "content": "Test"}, |
| 1867 | + { |
| 1868 | + "role": "assistant", |
| 1869 | + "content": None, |
| 1870 | + "tool_calls": [ |
| 1871 | + { |
| 1872 | + "id": "call_dup", |
| 1873 | + "type": "function", |
| 1874 | + "function": {"name": "test", "arguments": "{}"}, |
| 1875 | + } |
| 1876 | + ], |
| 1877 | + }, |
| 1878 | + {"role": "tool", "tool_call_id": "call_dup", "content": "first"}, |
| 1879 | + {"role": "tool", "tool_call_id": "call_dup", "content": "second"}, |
| 1880 | + ] |
| 1881 | + |
| 1882 | + result = sanitize_messages_for_tool_calling(messages) |
| 1883 | + |
| 1884 | + # Should be unchanged — no sanitization when modify_params=False |
| 1885 | + assert result == messages |
| 1886 | + finally: |
| 1887 | + litellm.modify_params = original |
| 1888 | + |
| 1889 | + |
| 1890 | +def test_sanitize_messages_dedup_scoped_per_turn_preserves_cross_turn(): |
| 1891 | + """ |
| 1892 | + When the same tool_call_id appears in two different assistant turns |
| 1893 | + (separated by a user message), both tool results must be preserved. |
| 1894 | + Deduplication should only apply within a single contiguous tool-result |
| 1895 | + block, not globally across the conversation. |
| 1896 | +
|
| 1897 | + Without per-turn scoping this would incorrectly drop the first tool result, |
| 1898 | + leaving the first assistant message without its required result (which |
| 1899 | + Anthropic would reject). |
| 1900 | + """ |
| 1901 | + original = litellm.modify_params |
| 1902 | + litellm.modify_params = True |
| 1903 | + try: |
| 1904 | + messages = [ |
| 1905 | + {"role": "user", "content": "First question"}, |
| 1906 | + { |
| 1907 | + "role": "assistant", |
| 1908 | + "content": None, |
| 1909 | + "tool_calls": [ |
| 1910 | + { |
| 1911 | + "id": "call_X", |
| 1912 | + "type": "function", |
| 1913 | + "function": {"name": "lookup", "arguments": '{"q": "a"}'}, |
| 1914 | + } |
| 1915 | + ], |
| 1916 | + }, |
| 1917 | + {"role": "tool", "tool_call_id": "call_X", "content": "result_turn_1"}, |
| 1918 | + {"role": "user", "content": "Second question"}, |
| 1919 | + { |
| 1920 | + "role": "assistant", |
| 1921 | + "content": None, |
| 1922 | + "tool_calls": [ |
| 1923 | + { |
| 1924 | + "id": "call_X", |
| 1925 | + "type": "function", |
| 1926 | + "function": {"name": "lookup", "arguments": '{"q": "b"}'}, |
| 1927 | + } |
| 1928 | + ], |
| 1929 | + }, |
| 1930 | + {"role": "tool", "tool_call_id": "call_X", "content": "result_turn_2"}, |
| 1931 | + ] |
| 1932 | + |
| 1933 | + result = sanitize_messages_for_tool_calling(messages) |
| 1934 | + |
| 1935 | + # Both tool results must survive — one per turn |
| 1936 | + tool_results = [ |
| 1937 | + m for m in result |
| 1938 | + if m.get("role") == "tool" and m.get("tool_call_id") == "call_X" |
| 1939 | + ] |
| 1940 | + assert len(tool_results) == 2, ( |
| 1941 | + f"Expected 2 tool results (one per turn), got {len(tool_results)}. " |
| 1942 | + "Dedup may be global instead of per-turn scoped." |
| 1943 | + ) |
| 1944 | + assert tool_results[0]["content"] == "result_turn_1" |
| 1945 | + assert tool_results[1]["content"] == "result_turn_2" |
| 1946 | + finally: |
| 1947 | + litellm.modify_params = original |
| 1948 | + |
| 1949 | + |
| 1950 | +def test_sanitize_messages_combined_case_a_and_case_d(): |
| 1951 | + """ |
| 1952 | + Combined Case A + Case D: an assistant message has two tool_calls — |
| 1953 | + one with a missing result (Case A should inject a dummy) and one with |
| 1954 | + duplicate results (Case D should deduplicate to keep only the last). |
| 1955 | +
|
| 1956 | + This validates that both sanitization passes compose correctly without |
| 1957 | + interfering with each other. |
| 1958 | + """ |
| 1959 | + original = litellm.modify_params |
| 1960 | + litellm.modify_params = True |
| 1961 | + try: |
| 1962 | + messages = [ |
| 1963 | + {"role": "user", "content": "Do two things"}, |
| 1964 | + { |
| 1965 | + "role": "assistant", |
| 1966 | + "content": None, |
| 1967 | + "tool_calls": [ |
| 1968 | + { |
| 1969 | + "id": "call_missing", |
| 1970 | + "type": "function", |
| 1971 | + "function": {"name": "tool_a", "arguments": "{}"}, |
| 1972 | + }, |
| 1973 | + { |
| 1974 | + "id": "call_duped", |
| 1975 | + "type": "function", |
| 1976 | + "function": {"name": "tool_b", "arguments": '{"q": "x"}'}, |
| 1977 | + }, |
| 1978 | + ], |
| 1979 | + }, |
| 1980 | + # No result for call_missing — Case A should inject a dummy |
| 1981 | + # Duplicate results for call_duped — Case D should keep last |
| 1982 | + {"role": "tool", "tool_call_id": "call_duped", "content": "stale_result"}, |
| 1983 | + {"role": "tool", "tool_call_id": "call_duped", "content": "fresh_result"}, |
| 1984 | + {"role": "user", "content": "Now summarize"}, |
| 1985 | + ] |
| 1986 | + |
| 1987 | + result = sanitize_messages_for_tool_calling(messages) |
| 1988 | + |
| 1989 | + # Collect tool results from the output |
| 1990 | + tool_results = [m for m in result if m.get("role") in ("tool", "function")] |
| 1991 | + |
| 1992 | + # Case A: call_missing should have a dummy result injected |
| 1993 | + missing_results = [ |
| 1994 | + m for m in tool_results if m.get("tool_call_id") == "call_missing" |
| 1995 | + ] |
| 1996 | + assert len(missing_results) == 1, ( |
| 1997 | + f"Expected 1 dummy result for call_missing (Case A), got {len(missing_results)}" |
| 1998 | + ) |
| 1999 | + |
| 2000 | + # Case D: call_duped should have exactly 1 result (the fresh one) |
| 2001 | + duped_results = [ |
| 2002 | + m for m in tool_results if m.get("tool_call_id") == "call_duped" |
| 2003 | + ] |
| 2004 | + assert len(duped_results) == 1, ( |
| 2005 | + f"Expected 1 result for call_duped after dedup (Case D), got {len(duped_results)}" |
| 2006 | + ) |
| 2007 | + assert duped_results[0]["content"] == "fresh_result", ( |
| 2008 | + f"Expected last-wins 'fresh_result', got '{duped_results[0]['content']}'" |
| 2009 | + ) |
| 2010 | + |
| 2011 | + # Verify tool results immediately follow the assistant message |
| 2012 | + asst_idx = next( |
| 2013 | + i for i, m in enumerate(result) if m.get("role") == "assistant" |
| 2014 | + ) |
| 2015 | + tool_msgs_after_asst = [ |
| 2016 | + m |
| 2017 | + for m in result[asst_idx + 1 :] |
| 2018 | + if m.get("role") in ("tool", "function") |
| 2019 | + ] |
| 2020 | + assert len(tool_msgs_after_asst) == 2, ( |
| 2021 | + f"Expected 2 tool results after assistant, got {len(tool_msgs_after_asst)}" |
| 2022 | + ) |
| 2023 | + # Both tool_call_ids should be present (order may vary) |
| 2024 | + tool_ids = {m["tool_call_id"] for m in tool_msgs_after_asst} |
| 2025 | + assert tool_ids == {"call_missing", "call_duped"}, ( |
| 2026 | + f"Expected tool_call_ids {{call_missing, call_duped}}, got {tool_ids}" |
| 2027 | + ) |
| 2028 | + finally: |
| 2029 | + litellm.modify_params = original |
0 commit comments