diff --git a/litellm/llms/azure_ai/chat/transformation.py b/litellm/llms/azure_ai/chat/transformation.py index ce12b56b2a4..f3ea0beb021 100644 --- a/litellm/llms/azure_ai/chat/transformation.py +++ b/litellm/llms/azure_ai/chat/transformation.py @@ -29,9 +29,28 @@ class AzureFoundryErrorStrings(str, enum.Enum): SET_EXTRA_PARAMETERS_TO_PASS_THROUGH = "Set extra-parameters to 'pass-through'" -_OUTPUT_ONLY_MESSAGE_FIELDS: Tuple[str, ...] = ( +# Fields that only ever appear at the top level of a chat message and are +# removed with a plain ``pop``. ``thinking_blocks`` and ``reasoning_content`` +# are output-only reasoning fields; the Fireworks backend accepts +# ``reasoning_content`` on input, but the direct ``fireworks_ai`` provider +# strips ``thinking_blocks`` and Azure AI Foundry fronts multiple backends, so +# drop both as a defensive default for a generic multi-backend provider. +_TOP_LEVEL_OUTPUT_ONLY_FIELDS: Tuple[str, ...] = ( "thinking_blocks", "reasoning_content", +) + +# Fields that can nest inside a message (e.g. inside ``tool_calls[].function`` +# or content blocks) and must be removed recursively. ``cache_control`` is an +# Anthropic prompt-caching annotation; ``provider_specific_fields`` carries +# Anthropic thought signatures and is attached by the Anthropic +# ``/v1/messages`` adapter at ``tool_calls[].function.provider_specific_fields`` +# (``adapters/transformation.py``), so a top-level ``pop`` misses it and +# strict upstreams still reject it with +# ``Extra inputs are not permitted, field: +# 'messages[n].tool_calls[m].function.provider_specific_fields'``. +_RECURSIVELY_STRIPPED_FIELDS: Tuple[str, ...] = ( + "cache_control", "provider_specific_fields", ) @@ -46,14 +65,15 @@ def _strip_unsupported_message_fields(message: AllMessageValues) -> None: ``/v1/messages`` adapter attaches when replaying assistant turns from an Anthropic-format client (e.g. Claude Code) are not part of that schema and trigger ``400 Extra inputs are not permitted, field: - 'messages[n].'``. ``cache_control`` is an Anthropic prompt-caching - annotation with the same problem. ``thinking_blocks`` and - ``reasoning_content`` are output-only; ``provider_specific_fields`` carries - Anthropic thought signatures. None are valid OpenAI input, so drop them. + 'messages[n].'``. Top-level output-only fields are popped; fields + that can nest (``cache_control``, and ``provider_specific_fields`` which + the adapter attaches inside ``tool_calls[].function``) are stripped + recursively so nested tool-call signatures are removed too. """ - filter_value_from_dict(cast(dict, message), "cache_control") m = cast(dict, message) - for field in _OUTPUT_ONLY_MESSAGE_FIELDS: + for field in _RECURSIVELY_STRIPPED_FIELDS: + filter_value_from_dict(m, field) + for field in _TOP_LEVEL_OUTPUT_ONLY_FIELDS: m.pop(field, None) diff --git a/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py b/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py index 6ed6a604dc4..2cf875fdc9f 100644 --- a/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py +++ b/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py @@ -309,6 +309,11 @@ def test_transform_request_drops_thinking_blocks_for_fireworks_model(): The outgoing request payload built by ``transform_request`` must not carry it, or the Fireworks backend 400s with ``Extra inputs are not permitted, field: 'messages[2].thinking_blocks'``. + Also covers the nested ``tool_calls[].function.provider_specific_fields`` + signature the Anthropic ``/v1/messages`` adapter attaches on tool-use + replays, which otherwise 400s with + ``Extra inputs are not permitted, field: + 'messages[n].tool_calls[m].function.provider_specific_fields'``. """ config = AzureAIStudioConfig() messages = [ @@ -324,6 +329,17 @@ def test_transform_request_drops_thinking_blocks_for_fireworks_model(): "cache_control": {}, } ], + "tool_calls": [ + { + "id": "toolu_1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path": "foo.txt"}', + "provider_specific_fields": {"thought_signature": "sig"}, + }, + } + ], }, {"role": "user", "content": "go ahead"}, ] @@ -336,8 +352,56 @@ def test_transform_request_drops_thinking_blocks_for_fireworks_model(): headers={}, ) + def _assert_no_unsupported_fields(node): + if isinstance(node, dict): + assert "thinking_blocks" not in node + assert "reasoning_content" not in node + assert "provider_specific_fields" not in node + assert "cache_control" not in node + for value in node.values(): + _assert_no_unsupported_fields(value) + elif isinstance(node, list): + for item in node: + _assert_no_unsupported_fields(item) + for message in payload["messages"]: - assert "thinking_blocks" not in message - assert "reasoning_content" not in message - assert "provider_specific_fields" not in message - assert "cache_control" not in message + _assert_no_unsupported_fields(message) + + +def test_transform_messages_strips_nested_tool_call_provider_specific_fields(): + """Regression: the Anthropic ``/v1/messages`` adapter attaches the thought + signature at ``tool_calls[].function.provider_specific_fields`` + (``adapters/transformation.py``). A top-level ``pop`` of + ``provider_specific_fields`` from the message misses the nested copy, so + strict upstreams (Fireworks-on-Azure) still 400 with + ``Extra inputs are not permitted, field: + 'messages[1].tool_calls[0].function.provider_specific_fields'``. + ``_transform_messages`` must strip it recursively. + """ + config = AzureAIStudioConfig() + messages = [ + {"role": "user", "content": "Run the tool."}, + { + "role": "assistant", + "content": "Calling the tool now.", + "tool_calls": [ + { + "id": "toolu_1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path": "foo.txt"}', + "provider_specific_fields": {"thought_signature": "sig"}, + }, + } + ], + }, + {"role": "user", "content": "show me the result"}, + ] + + out = config._transform_messages(messages, model="glm-5.2") + + tool_call = out[1]["tool_calls"][0] + assert "provider_specific_fields" not in tool_call + assert "provider_specific_fields" not in tool_call["function"] + assert tool_call["function"]["name"] == "read_file"