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
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 @@ -368,10 +369,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 @@ -434,10 +441,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
}
return []openai.ChatCompletionMessageParamUnion{openai.UserMessage(contents)}, nil
if name := sanitizeAuthorName(msg.AuthorName); name != "" {
usr.Name = openai.String(name)
}
return []openai.ChatCompletionMessageParamUnion{{OfUser: &usr}}, nil

case message.RoleAssistant:
var contents []openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion
Expand Down Expand Up @@ -477,12 +490,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 @@ -505,6 +520,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
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()
}

func addUsage(contents []message.Content, usage openai.CompletionUsage) []message.Content {
details := message.UsageDetails{
InputTokenCount: usage.PromptTokens,
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 @@ -1595,3 +1595,114 @@ func TestChatEmptyChoices_NonStreaming(t *testing.T) {
t.Errorf("expected usage input=12 total=12 to be surfaced, got %+v", usage)
}
}

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)
}
}
Loading