fix(azure_ai): strip provider_specific_fields recursively in tool_calls

The Anthropic /v1/messages adapter attaches the thought signature at
tool_calls[].function.provider_specific_fields
(adapters/transformation.py), not just at the top level of the assistant
message. The previous commit popped provider_specific_fields at the top
level only, so the nested copy inside tool_calls survived serialization
and strict upstreams (Fireworks-on-Azure) still rejected it with
Extra inputs are not permitted, field:
'messages[n].tool_calls[m].function.provider_specific_fields'.
Because Claude Code does multi-turn tool use, this is the next failure a
user hits after thinking_blocks is fixed.

Split the stripped fields into two sets: top-level output-only fields
(thinking_blocks, reasoning_content) removed with a plain pop, and fields
that can nest (cache_control, provider_specific_fields) removed
recursively via filter_value_from_dict so nested tool-call signatures are
caught too. reasoning_content is accepted on input by the Fireworks
backend but is dropped here as a defensive default for a generic
multi-backend provider; the direct fireworks_ai provider strips
thinking_blocks and the mistral provider (MistralConfig) strips both, and
this applies the union since Azure AI Foundry fronts multiple backends
with different strictness.

Added a regression test for the nested
tool_calls[].function.provider_specific_fields case; it fails against a
top-level-only pop and passes only with the recursive strip.
This commit is contained in:
vrlo 2026-07-17 17:15:17 +02:00
parent ec4c331684
commit bb46cc678c
2 changed files with 95 additions and 11 deletions

View file

@ -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].<field>'``. ``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].<field>'``. 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)

View file

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