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.
This commit is contained in:
maxiaoyang 2026-04-29 21:05:31 +08:00
parent 5b0634286c
commit ce0f526bf1
2 changed files with 75 additions and 3 deletions

View file

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

View file

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