From 1895fc69ae5ebc4b7bcd4b12d3cf65a0c7c29bcf Mon Sep 17 00:00:00 2001 From: maxiaoyang <2768753269@qq.com> Date: Wed, 29 Apr 2026 21:03:48 +0800 Subject: [PATCH] feat(providers/common): emit OpenAI Chat Completions name field SerializeMessages is the wire serializer shared by every OpenAI-style backend in the project (openai_compat, azure, deepseek, glm, qwen, groq, and other endpoints that accept the OpenAI Chat Completions schema). The OpenAI spec defines an optional `name` field on each message that disambiguates participants in multi-user conversations. This change adds the field to the openaiMessage wire struct (with omitempty) and the multipart map branch (only when set), so any Message.Name produced upstream is forwarded to the model. Sanitization upstream guarantees the wire-level constraint ^[a-zA-Z0-9_-]{1,64}$, so backends that strictly enforce it will not reject the request. Tests cover three properties: - name appears on the plain-text path when set - name appears on the multipart-with-media path when set - both paths omit the field entirely when Name is empty (legacy single-user channels stay byte-identical on the wire) Refs #2702. --- pkg/providers/common/common.go | 13 +++- pkg/providers/common/common_test.go | 98 +++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/pkg/providers/common/common.go b/pkg/providers/common/common.go index 5e03bc0c2..0ee4f5b4e 100644 --- a/pkg/providers/common/common.go +++ b/pkg/providers/common/common.go @@ -69,9 +69,15 @@ func NewHTTPClient(proxy string) *http.Client { // openaiMessage is the wire-format message for OpenAI-compatible APIs. // It mirrors protocoltypes.Message but omits SystemParts, which is an // internal field that would be unknown to third-party endpoints. +// +// Name carries the OpenAI Chat Completions optional `name` field, used to +// disambiguate participants in multi-user conversations. It is emitted +// only when set; the upstream value has already been sanitized to match +// the API constraint `^[a-zA-Z0-9_-]{1,64}$`. type openaiMessage struct { Role string `json:"role"` Content string `json:"content"` + Name string `json:"name,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` @@ -92,7 +98,8 @@ type openaiFunctionCall struct { // SerializeMessages converts internal Message structs to the OpenAI wire format. // - Strips SystemParts (unknown to third-party endpoints) // - Converts messages with Media to multipart content format (text + image_url parts) -// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages +// - Preserves ToolCallID, ToolCalls, ReasoningContent, and Name (sender +// attribution for multi-user sessions) for all messages func SerializeMessages(messages []Message) []any { out := make([]any, 0, len(messages)) for _, m := range messages { @@ -101,6 +108,7 @@ func SerializeMessages(messages []Message) []any { out = append(out, openaiMessage{ Role: m.Role, Content: m.Content, + Name: m.Name, ReasoningContent: m.ReasoningContent, ToolCalls: toolCalls, ToolCallID: m.ToolCallID, @@ -142,6 +150,9 @@ func SerializeMessages(messages []Message) []any { "role": m.Role, "content": parts, } + if m.Name != "" { + msg["name"] = m.Name + } if m.ToolCallID != "" { msg["tool_call_id"] = m.ToolCallID } diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index 3cf2f4285..04b266a3e 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -800,3 +800,101 @@ func TestParseResponse_WithFunctionThoughtSignature(t *testing.T) { ) } } + +// --- SerializeMessages: sender attribution (Name field) tests --- + +// TestSerializeMessages_NameFieldInPlainPath verifies that the OpenAI Chat +// Completions `name` field is emitted on plain-text user messages when +// providers.Message.Name is set. This is the primary multi-user attribution +// channel for OpenAI-compatible endpoints. +func TestSerializeMessages_NameFieldInPlainPath(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "My name is Alice", Name: "U07AB12C3DEF"}, + {Role: "user", Content: "What's my name?", Name: "U99XY99ZZZZZ"}, + } + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var msgs []map[string]any + if err := json.Unmarshal(data, &msgs); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("len(msgs) = %d, want 2", len(msgs)) + } + if msgs[0]["name"] != "U07AB12C3DEF" { + t.Errorf("msgs[0].name = %v, want U07AB12C3DEF", msgs[0]["name"]) + } + if msgs[1]["name"] != "U99XY99ZZZZZ" { + t.Errorf("msgs[1].name = %v, want U99XY99ZZZZZ", msgs[1]["name"]) + } +} + +// TestSerializeMessages_NameFieldInMultipartPath verifies that the `name` +// field is emitted on the multipart map representation used when a user +// message also carries Media (images/audio). +func TestSerializeMessages_NameFieldInMultipartPath(t *testing.T) { + messages := []Message{ + { + Role: "user", + Content: "look at this", + Name: "alice", + Media: []string{"data:image/png;base64,abc"}, + }, + } + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var msgs []map[string]any + if err := json.Unmarshal(data, &msgs); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if msgs[0]["name"] != "alice" { + t.Errorf("msgs[0].name = %v, want alice", msgs[0]["name"]) + } + // Sanity: multipart content array still present + if _, ok := msgs[0]["content"].([]any); !ok { + t.Errorf("msgs[0].content = %T, want array", msgs[0]["content"]) + } +} + +// TestSerializeMessages_NameFieldOmittedWhenEmpty verifies that the `name` +// key is absent (not just empty) for messages without sender attribution, +// preserving wire-level backward compatibility. +func TestSerializeMessages_NameFieldOmittedWhenEmpty(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "hi"}, + } + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + if strings.Contains(string(data), `"name"`) { + t.Errorf("plain-path serialization should omit empty name; got %s", string(data)) + } +} + +// TestSerializeMessages_NameFieldOmittedWhenEmptyMultipart is the multipart +// counterpart of the omit-when-empty check. +func TestSerializeMessages_NameFieldOmittedWhenEmptyMultipart(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "look", Media: []string{"data:image/png;base64,abc"}}, + } + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + if strings.Contains(string(data), `"name"`) { + t.Errorf("multipart-path serialization should omit empty name; got %s", string(data)) + } +}