Skip to content
Merged
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
60 changes: 50 additions & 10 deletions provider/openaiprovider/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"slices"
"strings"
"time"
"unicode"

"github.com/microsoft/agent-framework-go/agent"
"github.com/microsoft/agent-framework-go/agent/format/jsonformat"
Expand Down Expand Up @@ -370,10 +371,16 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara
if len(contents) == 0 {
return nil, nil
}
sys := openai.ChatCompletionSystemMessageParam{}
if len(contents) == 1 {
return []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(contents[0].Text)}, nil
sys.Content.OfString = openai.String(contents[0].Text)
} else {
sys.Content.OfArrayOfContentParts = contents
}
if name := sanitizeAuthorName(msg.AuthorName); name != "" {
sys.Name = openai.String(name)
}
return []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(contents)}, nil
return []openai.ChatCompletionMessageParamUnion{{OfSystem: &sys}}, nil

case message.RoleUser:
var contents []openai.ChatCompletionContentPartUnionParam
Expand Down Expand Up @@ -446,10 +453,16 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara
if len(contents) == 0 {
return nil, nil
}
usr := openai.ChatCompletionUserMessageParam{}
if len(contents) == 1 && contents[0].OfText != nil {
return []openai.ChatCompletionMessageParamUnion{openai.UserMessage(contents[0].OfText.Text)}, nil
usr.Content.OfString = openai.String(contents[0].OfText.Text)
} else {
usr.Content.OfArrayOfContentParts = contents
}
if name := sanitizeAuthorName(msg.AuthorName); name != "" {
usr.Name = openai.String(name)
}
return []openai.ChatCompletionMessageParamUnion{openai.UserMessage(contents)}, nil
return []openai.ChatCompletionMessageParamUnion{{OfUser: &usr}}, nil

case message.RoleAssistant:
var contents []openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion
Expand Down Expand Up @@ -489,12 +502,14 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara
} else {
content = openai.ChatCompletionAssistantMessageParamContentUnion{OfArrayOfContentParts: contents}
}
return []openai.ChatCompletionMessageParamUnion{{
OfAssistant: &openai.ChatCompletionAssistantMessageParam{
Content: content,
ToolCalls: toolCalls,
},
}}, nil
asst := openai.ChatCompletionAssistantMessageParam{
Content: content,
ToolCalls: toolCalls,
}
if name := sanitizeAuthorName(msg.AuthorName); name != "" {
asst.Name = openai.String(name)
}
return []openai.ChatCompletionMessageParamUnion{{OfAssistant: &asst}}, nil

case message.RoleTool:
// Each tool result needs its own separate message for OpenAI API compliance
Expand All @@ -515,6 +530,31 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara
}
}

// sanitizeAuthorName mirrors the .NET OpenAIChatClient.SanitizeAuthorName used
// for ChatMessage.AuthorName. The Chat Completions API only accepts a limited
// character set for the participant "name" field, so it keeps only alphanumeric
// characters and caps the result at 64 characters. It returns an empty string
// when the input is empty, whitespace-only, or entirely disallowed characters,
// in which case the caller leaves the name field unset.
func sanitizeAuthorName(name string) string {
if strings.TrimSpace(name) == "" {
return ""
}
const maxLen = 64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue: sanitizeAuthorName strips underscores, but upstream Python and .NET preserve them.

The Go implementation filters with unicode.IsLetter || unicode.IsDigit, which silently drops _.

Both upstream implementations explicitly keep underscores in allowed characters:

  • Python (_chat_completion_client.py): _INVALID_AUTHOR_NAME_RE = re.compile(r"[^a-zA-Z0-9_]+") — keeps [a-zA-Z0-9_].
  • .NET: The PR description itself cites .NET OpenAIChatClient.SanitizeAuthorName; the Python module's comment describes its character set as [a-zA-Z0-9_], matching the .NET original.

A participant named Agent_One would be sent as AgentOne by Go but as Agent_One by Python/.NET — a silent cross-SDK divergence for multi-agent group-chat flows that use underscore-separated agent names.

Suggested fix:

if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {

The existing test TestChatAuthorNamePropagation_NonStreaming (input "Agent One" → expected "AgentOne") does not cover this case because a space is stripped by all three implementations. Please add a test with AuthorName: "Agent_One" expecting "name": "Agent_One" in the outgoing request.

var b strings.Builder
n := 0
for _, r := range name {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
n++
if n >= maxLen {
break
}
}
}
return b.String()
}

// toolResultText renders a function-tool result for the OpenAI wire format. A
// non-string, non-raw result (e.g. a struct or map returned by a typed
// functool) is JSON-encoded rather than rendered with Go's %v, which would send
Expand Down
111 changes: 111 additions & 0 deletions provider/openaiprovider/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1936,6 +1936,117 @@ func TestChatEmptyChoices_NonStreaming(t *testing.T) {
}
}

func TestChatAuthorNamePropagation_NonStreaming(t *testing.T) {
const input = `
{
"messages": [
{"role": "system", "content": "You are helpful.", "name": "AgentOne"},
{"role": "user", "content": "hi", "name": "AgentOne"},
{"role": "assistant", "content": "hello", "name": "AgentOne"}
],
"model": "gpt-4o-mini"
}
`
const output = `
{
"id": "chatcmpl-author",
"object": "chat.completion",
"created": 1727894187,
"model": "gpt-4o-mini",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}
]
}
`
server := newTestServer(t, input, output)
defer server.Close()

a := newTestClient(server)

messages := []*message.Message{
{Role: message.RoleSystem, AuthorName: "Agent One", Contents: []message.Content{&message.TextContent{Text: "You are helpful."}}},
{Role: message.RoleUser, AuthorName: "Agent One", Contents: []message.Content{&message.TextContent{Text: "hi"}}},
{Role: message.RoleAssistant, AuthorName: "Agent One", Contents: []message.Content{&message.TextContent{Text: "hello"}}},
}
if _, err := a.Run(t.Context(), messages).Collect(); err != nil {
t.Fatalf("error = %v", err)
}
}

func TestChatAuthorNameSanitizationAndTruncation_NonStreaming(t *testing.T) {
// Disallowed characters are stripped and the result is capped at 64 runes.
const input = `
{
"messages": [
{"role": "user", "content": "hi", "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
],
"model": "gpt-4o-mini"
}
`
const output = `
{
"id": "chatcmpl-author",
"object": "chat.completion",
"created": 1727894187,
"model": "gpt-4o-mini",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}
]
}
`
server := newTestServer(t, input, output)
defer server.Close()

a := newTestClient(server)

// "!" is stripped, then 70 alphanumerics are truncated to 64.
authorName := "!" + strings.Repeat("a", 70)
messages := []*message.Message{
{Role: message.RoleUser, AuthorName: authorName, Contents: []message.Content{&message.TextContent{Text: "hi"}}},
}
if _, err := a.Run(t.Context(), messages).Collect(); err != nil {
t.Fatalf("error = %v", err)
}
}

func TestChatAuthorNameEmpty_NonStreaming(t *testing.T) {
// Empty or whitespace-only author names leave the name field unset.
const input = `
{
"messages": [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"}
],
"model": "gpt-4o-mini"
}
`
const output = `
{
"id": "chatcmpl-author",
"object": "chat.completion",
"created": 1727894187,
"model": "gpt-4o-mini",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}
]
}
`
server := newTestServer(t, input, output)
defer server.Close()

a := newTestClient(server)

messages := []*message.Message{
{Role: message.RoleSystem, AuthorName: "", Contents: []message.Content{&message.TextContent{Text: "You are helpful."}}},
{Role: message.RoleUser, AuthorName: " ", Contents: []message.Content{&message.TextContent{Text: "hi"}}},
{Role: message.RoleAssistant, AuthorName: " ", Contents: []message.Content{&message.TextContent{Text: "hello"}}},
}
if _, err := a.Run(t.Context(), messages).Collect(); err != nil {
t.Fatalf("error = %v", err)
}
}

// A structured (non-string) function-tool result — e.g. a struct returned by a
// typed functool — must be JSON-encoded in the request, not rendered with Go's
// %v, which would send an unparseable representation like "{Paris 20}" to the model.
Expand Down
Loading