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.
This commit is contained in:
maxiaoyang 2026-04-29 21:03:48 +08:00
parent 7129c97dd2
commit 1895fc69ae
2 changed files with 110 additions and 1 deletions

View file

@ -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
}

View file

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