From ce0f526bf1dce93b9f11c9cb9e15688d97e33c60 Mon Sep 17 00:00:00 2001 From: maxiaoyang <2768753269@qq.com> Date: Wed, 29 Apr 2026 21:05:31 +0800 Subject: [PATCH] feat(providers/openai_responses_common): prefix user content with sender name The OpenAI Responses API (used by Codex and the Azure responses endpoint) is a separate wire format from Chat Completions. The SDK type used here, EasyInputMessageParam, does not currently surface a per-message name field, so the same in-content prefix fallback used for Anthropic and Bedrock applies here too. Both branches of the user-message path are covered: - The plain-text branch (OfMessage / EasyInputMessageParam) prefixes msg.Content via messageutil.ApplyUserNamePrefix. - The multipart branch (OfInputMessage / ResponseInputItemMessageParam, used when media is attached) prefixes the text portion before passing it to BuildMultipartContent. The function-call-output branch (msg.ToolCallID set) is intentionally left untouched; tool results are not user utterances and must not gain a sender prefix. Tests cover all three paths (plain-text prefix, multipart prefix, tool result untouched) by JSON-marshalling the resulting input and asserting on the wire bytes. Refs #2702. --- .../responses_common.go | 12 +++- .../responses_common_test.go | 66 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/pkg/providers/openai_responses_common/responses_common.go b/pkg/providers/openai_responses_common/responses_common.go index 17b731ed4..403b73800 100644 --- a/pkg/providers/openai_responses_common/responses_common.go +++ b/pkg/providers/openai_responses_common/responses_common.go @@ -11,6 +11,7 @@ import ( "github.com/openai/openai-go/v3/responses" "github.com/sipeed/picoclaw/pkg/providers/common" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -36,7 +37,10 @@ func TranslateMessages(messages []protocoltypes.Message) (input responses.Respon }, }) } else if len(msg.Media) > 0 { - content := BuildMultipartContent(msg.Content, msg.Media) + // Render sender attribution as a `[name] ` prefix on the + // text portion of the multipart payload. The persisted + // message is not mutated. + content := BuildMultipartContent(messageutil.ApplyUserNamePrefix(msg), msg.Media) input = append(input, responses.ResponseInputItemUnionParam{ OfInputMessage: &responses.ResponseInputItemMessageParam{ Role: "user", @@ -46,8 +50,10 @@ func TranslateMessages(messages []protocoltypes.Message) (input responses.Respon } else { input = append(input, responses.ResponseInputItemUnionParam{ OfMessage: &responses.EasyInputMessageParam{ - Role: responses.EasyInputMessageRoleUser, - Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, + Role: responses.EasyInputMessageRoleUser, + Content: responses.EasyInputMessageContentUnionParam{ + OfString: openai.Opt(messageutil.ApplyUserNamePrefix(msg)), + }, }, }) } diff --git a/pkg/providers/openai_responses_common/responses_common_test.go b/pkg/providers/openai_responses_common/responses_common_test.go index ace91edf0..c1d88e12d 100644 --- a/pkg/providers/openai_responses_common/responses_common_test.go +++ b/pkg/providers/openai_responses_common/responses_common_test.go @@ -577,3 +577,69 @@ func TestTranslateTools_SerializesToJSON(t *testing.T) { t.Errorf("JSON should contain web_search, got: %s", s) } } + +// --- Sender attribution (Name field) tests --- + +// TestTranslateMessages_UserNamePrefixed verifies that sender attribution +// from Message.Name is rendered as a `[name] ` prefix on plain-text user +// messages sent to the OpenAI Responses API. The API supports per-message +// name natively, but the SDK type used here (EasyInputMessageParam) does +// not surface it, so prefixing is the consistent fallback shared with +// Anthropic / Bedrock adapters. +func TestTranslateMessages_UserNamePrefixed(t *testing.T) { + msgs := []protocoltypes.Message{ + {Role: "user", Content: "My name is Alice", Name: "U_alice"}, + } + input, _ := TranslateMessages(msgs) + if len(input) != 1 || input[0].OfMessage == nil { + t.Fatalf("expected one EasyInputMessage, got %+v", input) + } + data, err := json.Marshal(input) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + if !strings.Contains(string(data), "[U_alice] My name is Alice") { + t.Errorf("expected prefixed content in payload, got: %s", string(data)) + } +} + +// TestTranslateMessages_UserNamePrefixedMultipart verifies the same +// behavior on the multipart code path used when media is attached. +func TestTranslateMessages_UserNamePrefixedMultipart(t *testing.T) { + msgs := []protocoltypes.Message{ + { + Role: "user", + Content: "look at this", + Name: "U_alice", + Media: []string{"data:image/png;base64,abc"}, + }, + } + input, _ := TranslateMessages(msgs) + if len(input) != 1 || input[0].OfInputMessage == nil { + t.Fatalf("expected InputMessage with multipart content, got %+v", input) + } + data, err := json.Marshal(input) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + if !strings.Contains(string(data), "[U_alice] look at this") { + t.Errorf("expected prefixed text part in payload, got: %s", string(data)) + } +} + +// TestTranslateMessages_ToolResultNotPrefixed protects against accidentally +// prefixing tool result outputs (msg.ToolCallID set) — those are function +// outputs, not user utterances. +func TestTranslateMessages_ToolResultNotPrefixed(t *testing.T) { + msgs := []protocoltypes.Message{ + {Role: "user", Content: `{"temp":72}`, ToolCallID: "call_1", Name: "alice"}, + } + input, _ := TranslateMessages(msgs) + if len(input) != 1 || input[0].OfFunctionCallOutput == nil { + t.Fatalf("expected FunctionCallOutput, got %+v", input) + } + data, _ := json.Marshal(input) + if strings.Contains(string(data), "[alice]") { + t.Errorf("tool result must not carry sender prefix, got: %s", string(data)) + } +}