From af9850d0a81e7592d343ab9d4e6e4992cb6173ff Mon Sep 17 00:00:00 2001 From: itjuba Date: Wed, 15 Jul 2026 13:19:16 +0400 Subject: [PATCH] Python: Sanitize author_name for the Chat Completions message name field OpenAI validates the Chat Completions message 'name' against ^[^\s<|\/>]+$, so an agent display name containing a space (or < | \ / >) failed every request with a 400. Sanitize at the three assignment sites, mirroring SanitizeAuthorName in the .NET client (dotnet/extensions): remove characters outside [a-zA-Z0-9_], omit the name when nothing remains, truncate to 64 characters. Fixes #7126 Co-Authored-By: Claude Fable 5 --- .../_chat_completion_client.py | 30 ++++++-- .../test_openai_chat_completion_client.py | 70 +++++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 37c2e5b52dd..50c41741fae 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -4,6 +4,7 @@ import json import logging +import re import sys from collections.abc import ( AsyncIterable, @@ -95,6 +96,23 @@ DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION = "2024-12-01-preview" +# The Chat Completions API validates a message ``name`` against ``^[^\s<|\\/>]+$``, so an +# author name containing whitespace (or ``< | \ / >``) fails the whole request with a 400. +# Mirrors SanitizeAuthorName in the .NET client (dotnet/extensions): strip characters outside +# ``[a-zA-Z0-9_]``, drop the name entirely when nothing remains, truncate to 64 characters. +# See https://github.com/microsoft/agent-framework/issues/7126 +_INVALID_AUTHOR_NAME_RE = re.compile(r"[^a-zA-Z0-9_]+") +_MAX_AUTHOR_NAME_LENGTH = 64 + + +def _sanitize_author_name(name: str | None) -> str | None: + """Sanitize an author name for use as the Chat Completions message ``name`` field.""" + if not name: + return None + sanitized = _INVALID_AUTHOR_NAME_RE.sub("", name) + return sanitized[:_MAX_AUTHOR_NAME_LENGTH] if sanitized else None + + ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) @@ -879,8 +897,8 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: texts = [content.text for content in message.contents if content.type == "text" and content.text] if texts: sys_args: dict[str, Any] = {"role": message.role, "content": "\n".join(texts)} - if message.author_name: - sys_args["name"] = message.author_name + if author_name := _sanitize_author_name(message.author_name): + sys_args["name"] = author_name return [sys_args] return [] @@ -894,8 +912,8 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: args: dict[str, Any] = { "role": message.role, } - if message.author_name and message.role != "tool": - args["name"] = message.author_name + if message.role != "tool" and (author_name := _sanitize_author_name(message.author_name)): + args["name"] = author_name if "reasoning_details" in message.additional_properties and ( details := message.additional_properties["reasoning_details"] ): @@ -951,8 +969,8 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: "content": "", "reasoning_details": pending_reasoning, } - if message.author_name and message.role != "tool": - pending_args["name"] = message.author_name + if message.role != "tool" and (author_name := _sanitize_author_name(message.author_name)): + pending_args["name"] = author_name all_messages.append(pending_args) # Flatten text-only content lists to plain strings for broader diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 81d9963688b..d11fc729cbb 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -932,6 +932,76 @@ def test_prepare_message_with_unprotected_text_reasoning_content( assert prepared == [{"role": "assistant", "content": "Foundry summary"}] +def test_prepare_message_sanitizes_author_name( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that author_name is sanitized before being sent as the message ``name``. + + Regression test for https://github.com/microsoft/agent-framework/issues/7126 + OpenAI validates the Chat Completions message ``name`` against ``^[^\\s<|\\\\/>]+$``, so an + agent display name containing a space (e.g. "My Agent") previously failed every request + with a 400. Sanitization mirrors SanitizeAuthorName in the .NET client: characters outside + ``[a-zA-Z0-9_]`` are removed and the result is truncated to 64 characters. + """ + client = OpenAIChatCompletionClient() + + message = Message( + role="assistant", + contents=[Content.from_text(text="hello")], + author_name="My Agent", + ) + + prepared = client._prepare_message_for_openai(message) + + assert prepared == [{"role": "assistant", "name": "MyAgent", "content": "hello"}] + + # System/developer path sanitizes too. + system_message = Message( + role="system", + contents=[Content.from_text(text="be helpful")], + author_name="orchestrator/planner", + ) + + assert client._prepare_message_for_openai(system_message) == [ + {"role": "system", "content": "be helpful", "name": "orchestratorplanner"} + ] + + # A name with no valid characters is omitted rather than sent empty. + invalid_only = Message( + role="assistant", + contents=[Content.from_text(text="hello")], + author_name="<|/\\>", + ) + + assert client._prepare_message_for_openai(invalid_only) == [{"role": "assistant", "content": "hello"}] + + # Long names are truncated to 64 characters. + long_name = Message( + role="assistant", + contents=[Content.from_text(text="hello")], + author_name="a" * 100, + ) + + assert client._prepare_message_for_openai(long_name)[0]["name"] == "a" * 64 + + +def test_prepare_message_keeps_valid_author_name( + openai_unit_test_env: dict[str, str], +) -> None: + """A name that is already valid for the Chat Completions API is passed through unchanged.""" + client = OpenAIChatCompletionClient() + + message = Message( + role="assistant", + contents=[Content.from_text(text="hello")], + author_name="Agent_42", + ) + + assert client._prepare_message_for_openai(message) == [ + {"role": "assistant", "name": "Agent_42", "content": "hello"} + ] + + def test_prepare_message_with_only_text_reasoning_content( openai_unit_test_env: dict[str, str], ) -> None: