Merge pull request #7094 from liuyuzhou666/fix/responses-chat-developer-role-mapping

fix(apicompat): 修复 Responses 降级 Chat Completions 时的 system 消息顺序问题
This commit is contained in:
Wesley Liddick
2026-09-15 08:53:50 +08:00
committed by GitHub
3 changed files with 84 additions and 10 deletions
@@ -339,7 +339,63 @@ func responsesInputToChatMessagesWithOptions(instructions string, inputRaw json.
if err != nil {
return nil, err
}
return normalizeChatMessagesWithToolOutputMedia(built, mediaByCallID), nil
normalized := normalizeChatMessagesWithToolOutputMedia(built, mediaByCallID)
return normalizeResponsesDerivedChatMessageRoles(normalized), nil
}
// normalizeResponsesDerivedChatMessageRoles rewrites the Chat Completions
// message list produced by the Responses bridge so that strict upstreams which
// only accept system content at the very start of the conversation accept it.
//
// The bridge turns the Responses `instructions` field and every role:"developer"
// item into a system message. Codex always sends both instructions and a leading
// developer item, and it also injects developer notices into the middle of the
// message history (for example when the user switches models). Forwarded as-is
// that produces two leading system messages and mid-conversation system
// messages, which Qwen-family upstreams reject with
// "System message must be at the beginning." (HTTP 400).
//
// Leading system/developer messages are therefore merged into a single leading
// system message, while later ones keep their position but are downgraded to
// user messages so the same text still reaches the model.
func normalizeResponsesDerivedChatMessageRoles(messages []ChatMessage) []ChatMessage {
isInstructionRole := func(role string) bool {
return role == "system" || role == "developer"
}
leading := 0
for leading < len(messages) && isInstructionRole(messages[leading].Role) {
leading++
}
out := make([]ChatMessage, 0, len(messages))
switch leading {
case 0:
// No leading instructions, nothing to merge.
case 1:
// A single leading prompt is already valid; keep its content byte for
// byte instead of round-tripping it through the text merge below.
out = append(out, messages[0])
default:
merged := make([]string, 0, leading)
for _, m := range messages[:leading] {
if text := strings.TrimSpace(chatMessageContentText(m.Content)); text != "" {
merged = append(merged, text)
}
}
if len(merged) > 0 {
content, _ := json.Marshal(strings.Join(merged, "\n\n"))
out = append(out, ChatMessage{Role: "system", Content: content})
}
}
for _, m := range messages[leading:] {
if isInstructionRole(m.Role) {
m.Role = "user"
}
out = append(out, m)
}
return out
}
// buildChatMessagesFromItems walks the Responses input items and appends the
@@ -96,7 +96,7 @@ func TestResponsesInputToChatMessages_EmptyRoleFallsBackToUser(t *testing.T) {
assert.Equal(t, "user", messages[0].Role)
}
func TestResponsesInputToChatMessages_DeveloperRoleTrimAndCaseInsensitive(t *testing.T) {
func TestResponsesInputToChatMessages_LeadingDeveloperRolesMergeIntoOneSystem(t *testing.T) {
input := json.RawMessage(`[
{"role":" Developer ","content":"one"},
{"role":"\tDEVELOPER\n","content":"two"}
@@ -104,9 +104,10 @@ func TestResponsesInputToChatMessages_DeveloperRoleTrimAndCaseInsensitive(t *tes
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 2)
require.Len(t, messages, 1)
assert.Equal(t, []string{"system", "system"}, chatMessageRoles(messages))
assert.Equal(t, []string{"system"}, chatMessageRoles(messages))
assert.JSONEq(t, `"one\n\ntwo"`, string(messages[0].Content))
}
func TestResponsesToChatCompletionsRequest_InstructionsAndInputDeveloperRole(t *testing.T) {
@@ -121,12 +122,29 @@ func TestResponsesToChatCompletionsRequest_InstructionsAndInputDeveloperRole(t *
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Messages, 3)
require.Len(t, out.Messages, 2)
assert.Equal(t, []string{"system", "system", "user"}, chatMessageRoles(out.Messages))
assert.JSONEq(t, `"Use concise answers."`, string(out.Messages[0].Content))
assert.JSONEq(t, `"Prefer JSON."`, string(out.Messages[1].Content))
assert.JSONEq(t, `"Hello"`, string(out.Messages[2].Content))
assert.Equal(t, []string{"system", "user"}, chatMessageRoles(out.Messages))
assert.JSONEq(t, `"Use concise answers.\n\nPrefer JSON."`, string(out.Messages[0].Content))
assert.JSONEq(t, `"Hello"`, string(out.Messages[1].Content))
}
func TestResponsesInputToChatMessages_MidConversationDeveloperBecomesUser(t *testing.T) {
input := json.RawMessage(`[
{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]},
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]},
{"type":"message","role":"developer","content":[{"type":"input_text","text":"<model_switch> switched model"}]},
{"type":"message","role":"system","content":[{"type":"input_text","text":"be terse"}]},
{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}
]`)
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 5)
assert.Equal(t, []string{"user", "assistant", "user", "user", "user"}, chatMessageRoles(messages))
assert.JSONEq(t, `"<model_switch> switched model"`, string(messages[2].Content))
assert.JSONEq(t, `"be terse"`, string(messages[3].Content))
}
func TestResponsesToChatCompletionsRequest_TextFormatJsonObject(t *testing.T) {
@@ -154,7 +154,7 @@ func TestResponsesToolOutputMedia_InterleavedMessagesFollowMediaBatch(t *testing
{"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
]`)
require.Equal(t, []string{"assistant", "tool", "user", "system", "user"}, chatMessageRoles(messages))
require.Equal(t, []string{"assistant", "tool", "user", "user", "user"}, chatMessageRoles(messages))
require.Equal(t, "[Tool output media for call call_A]", chatContentParts(t, messages[2])[0].Text)
require.JSONEq(t, `"approval saved"`, string(messages[3].Content))
require.JSONEq(t, `"continue"`, string(messages[4].Content))