Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import logging
import re
import sys
from collections.abc import (
AsyncIterable,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 []

Expand All @@ -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"]
):
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading