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:
parent
7129c97dd2
commit
1895fc69ae
2 changed files with 110 additions and 1 deletions
|
|
@ -69,9 +69,15 @@ func NewHTTPClient(proxy string) *http.Client {
|
||||||
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
||||||
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
||||||
// internal field that would be unknown to third-party endpoints.
|
// 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 {
|
type openaiMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,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.
|
// SerializeMessages converts internal Message structs to the OpenAI wire format.
|
||||||
// - Strips SystemParts (unknown to third-party endpoints)
|
// - Strips SystemParts (unknown to third-party endpoints)
|
||||||
// - Converts messages with Media to multipart content format (text + image_url parts)
|
// - 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 {
|
func SerializeMessages(messages []Message) []any {
|
||||||
out := make([]any, 0, len(messages))
|
out := make([]any, 0, len(messages))
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
|
|
@ -101,6 +108,7 @@ func SerializeMessages(messages []Message) []any {
|
||||||
out = append(out, openaiMessage{
|
out = append(out, openaiMessage{
|
||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
Content: m.Content,
|
Content: m.Content,
|
||||||
|
Name: m.Name,
|
||||||
ReasoningContent: m.ReasoningContent,
|
ReasoningContent: m.ReasoningContent,
|
||||||
ToolCalls: toolCalls,
|
ToolCalls: toolCalls,
|
||||||
ToolCallID: m.ToolCallID,
|
ToolCallID: m.ToolCallID,
|
||||||
|
|
@ -142,6 +150,9 @@ func SerializeMessages(messages []Message) []any {
|
||||||
"role": m.Role,
|
"role": m.Role,
|
||||||
"content": parts,
|
"content": parts,
|
||||||
}
|
}
|
||||||
|
if m.Name != "" {
|
||||||
|
msg["name"] = m.Name
|
||||||
|
}
|
||||||
if m.ToolCallID != "" {
|
if m.ToolCallID != "" {
|
||||||
msg["tool_call_id"] = m.ToolCallID
|
msg["tool_call_id"] = m.ToolCallID
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue