From b6e7bf048e70d38163e383de956d85f7d4718fa4 Mon Sep 17 00:00:00 2001 From: alvinttang Date: Mon, 1 Jun 2026 00:03:27 +0800 Subject: [PATCH] fix(openai): omit tool 'strict' field by default to fix OpenAI-compatible servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI-compatible chat-completion servers (vLLM, Qwen-Tongyi/DashScope, Mistral via LiteLLM, ...) reject any unknown field on the function tool definition with: extra_forbidden — 'loc': ('body', 'tools', 0, 'function', 'strict') ``convert_tools`` was emitting ``strict: false`` for every tool that did not explicitly opt in, breaking these endpoints. OpenAI's own API treats ``strict`` as optional with an implicit default of ``False`` when the key is absent — so dropping the key for the non-strict case preserves OpenAI semantics while restoring compatibility with everyone else. Behaviour: * Tool with ``strict=False`` (default) -> ``strict`` key absent. * Tool with ``strict=True`` -> ``strict: true`` preserved. Refs #5814 --- .../models/openai/_openai_client.py | 24 +++---- .../tests/models/test_openai_model_client.py | 64 +++++++++++++++++-- 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/models/openai/_openai_client.py b/python/packages/autogen-ext/src/autogen_ext/models/openai/_openai_client.py index a80e912534ab..f5b395301dfe 100644 --- a/python/packages/autogen-ext/src/autogen_ext/models/openai/_openai_client.py +++ b/python/packages/autogen-ext/src/autogen_ext/models/openai/_openai_client.py @@ -252,19 +252,19 @@ def convert_tools( assert isinstance(tool, dict) tool_schema = tool - result.append( - ChatCompletionToolParam( - type="function", - function=FunctionDefinition( - name=tool_schema["name"], - description=(tool_schema["description"] if "description" in tool_schema else ""), - parameters=( - cast(FunctionParameters, tool_schema["parameters"]) if "parameters" in tool_schema else {} - ), - strict=(tool_schema["strict"] if "strict" in tool_schema else False), - ), - ) + # Only emit ``strict`` when the tool explicitly opts in. OpenAI-compatible + # servers (vLLM, Qwen-Tongyi, Mistral via LiteLLM, ...) reject any + # ``strict`` field on function definitions with ``extra_forbidden`` errors, + # and OpenAI itself treats the field as optional with a default of False. + # See https://github.com/microsoft/autogen/issues/5814. + function_def = FunctionDefinition( + name=tool_schema["name"], + description=(tool_schema["description"] if "description" in tool_schema else ""), + parameters=(cast(FunctionParameters, tool_schema["parameters"]) if "parameters" in tool_schema else {}), ) + if tool_schema.get("strict"): + function_def["strict"] = True + result.append(ChatCompletionToolParam(type="function", function=function_def)) # Check if all tools have valid names. for tool_param in result: assert_valid_name(tool_param["function"]["name"]) diff --git a/python/packages/autogen-ext/tests/models/test_openai_model_client.py b/python/packages/autogen-ext/tests/models/test_openai_model_client.py index ba79795d1ed7..c28cfff3c38f 100644 --- a/python/packages/autogen-ext/tests/models/test_openai_model_client.py +++ b/python/packages/autogen-ext/tests/models/test_openai_model_client.py @@ -539,6 +539,57 @@ async def run(self, args: MyArgs, cancellation_token: CancellationToken) -> MyRe assert converted_tool_schema[0] == converted_tool_schema[1] +def test_convert_tools_omits_strict_when_not_enabled() -> None: + """Regression test for #5814. + + OpenAI-compatible servers (vLLM, Qwen-Tongyi, Mistral via LiteLLM, etc.) + reject the ``strict`` field on function tool definitions with + ``'type': 'extra_forbidden', 'loc': ('body', 'tools', 0, 'function', 'strict')``. + When ``strict`` is not explicitly enabled, ``convert_tools`` must omit the + key entirely from the emitted ``function`` payload — sending ``strict: false`` + breaks these servers and OpenAI's own default is "omitted". + """ + + def my_function(arg: str, other: Annotated[int, "int arg"]) -> MyResult: + return MyResult(result="test") + + tool = FunctionTool(my_function, description="Function tool.") # strict defaults to False + converted = convert_tools([tool]) + + assert len(converted) == 1 + function_def = converted[0]["function"] + assert "strict" not in function_def, ( + "convert_tools must not emit 'strict' on function definitions when the tool " + "did not opt into strict mode; some OpenAI-compatible servers reject " + "unknown fields. Got: " + repr(dict(function_def)) + ) + + # Passing the raw schema (without 'strict') must also omit the key. + schema_only = { + "name": "raw_schema_tool", + "description": "schema-only", + "parameters": {"type": "object", "properties": {}, "required": []}, + } + converted_schema = convert_tools([schema_only]) # type: ignore[list-item] + assert "strict" not in converted_schema[0]["function"] + + +def test_convert_tools_includes_strict_when_enabled() -> None: + """When a tool explicitly opts in to ``strict`` mode, ``convert_tools`` + must propagate ``strict=True`` so that OpenAI's structured-output guarantees + are preserved.""" + + def my_function(arg: str, other: Annotated[int, "int arg"]) -> MyResult: + return MyResult(result="test") + + tool = FunctionTool(my_function, description="Strict tool.", strict=True) + converted = convert_tools([tool]) + + assert len(converted) == 1 + function_def = converted[0]["function"] + assert function_def.get("strict") is True + + @pytest.mark.asyncio async def test_json_mode(monkeypatch: pytest.MonkeyPatch) -> None: model = "gpt-4.1-nano-2025-04-14" @@ -1535,12 +1586,17 @@ async def mock_create( echo_tool = FunctionTool(_echo_function, description="echo tool.") model_client = OpenAIChatCompletionClient(model=model, api_key="") + def _expected_function_payload(tool: FunctionTool) -> Dict[str, Any]: + # ``strict`` is intentionally omitted by ``convert_tools`` when the tool + # does not opt in (see #5814 and convert_tools tests above). + return {k: v for k, v in tool.schema.items() if k != "strict"} + # Single tool call create_result = await model_client.create(messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool]) assert create_result.content == [FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function")] # Verify that the tool schema was passed to the model client. kwargs = mock.calls[0] - assert kwargs["tools"] == [{"function": pass_tool.schema, "type": "function"}] + assert kwargs["tools"] == [{"function": _expected_function_payload(pass_tool), "type": "function"}] # Verify finish reason assert create_result.finish_reason == "function_calls" @@ -1556,9 +1612,9 @@ async def mock_create( # Verify that the tool schema was passed to the model client. kwargs = mock.calls[1] assert kwargs["tools"] == [ - {"function": pass_tool.schema, "type": "function"}, - {"function": fail_tool.schema, "type": "function"}, - {"function": echo_tool.schema, "type": "function"}, + {"function": _expected_function_payload(pass_tool), "type": "function"}, + {"function": _expected_function_payload(fail_tool), "type": "function"}, + {"function": _expected_function_payload(echo_tool), "type": "function"}, ] # Verify finish reason assert create_result.finish_reason == "function_calls"