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:
parent
5b0634286c
commit
ce0f526bf1
2 changed files with 75 additions and 3 deletions
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/openai/openai-go/v3/responses"
|
"github.com/openai/openai-go/v3/responses"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"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 {
|
} 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{
|
input = append(input, responses.ResponseInputItemUnionParam{
|
||||||
OfInputMessage: &responses.ResponseInputItemMessageParam{
|
OfInputMessage: &responses.ResponseInputItemMessageParam{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
|
|
@ -46,8 +50,10 @@ func TranslateMessages(messages []protocoltypes.Message) (input responses.Respon
|
||||||
} else {
|
} else {
|
||||||
input = append(input, responses.ResponseInputItemUnionParam{
|
input = append(input, responses.ResponseInputItemUnionParam{
|
||||||
OfMessage: &responses.EasyInputMessageParam{
|
OfMessage: &responses.EasyInputMessageParam{
|
||||||
Role: responses.EasyInputMessageRoleUser,
|
Role: responses.EasyInputMessageRoleUser,
|
||||||
Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)},
|
Content: responses.EasyInputMessageContentUnionParam{
|
||||||
|
OfString: openai.Opt(messageutil.ApplyUserNamePrefix(msg)),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -577,3 +577,69 @@ func TestTranslateTools_SerializesToJSON(t *testing.T) {
|
||||||
t.Errorf("JSON should contain web_search, got: %s", s)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue