fix(apicompat): merge leading system messages and downgrade mid-conversation system from Responses bridge

The Responses to Chat Completions bridge mapped both the instructions field and role developer items to system messages. Codex always sends both, and also injects developer notices mid-conversation when switching models, so the forwarded request contained consecutive leading system messages and mid-conversation system messages. Strict Qwen-family upstreams reject this with HTTP 400 "System message must be at the beginning."

Leading system/developer messages are now merged into a single leading system message, and later system/developer messages are downgraded to user so the content is preserved without violating upstream role ordering.
This commit is contained in:
liuyuzhou666 committed 2026-09-13 14:08:57 +08:00
1 parent bdb42e22f8
commit 52558381c6
3 files changed
+84 -10

No files matched your search

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