From 52558381c69dba77e9ef5271d8fbc0d5252a8911 Mon Sep 17 00:00:00 2001 From: liuyuzhou666 <173587013+liuyuzhou666@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:08:08 +0800 Subject: [PATCH] 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. --- .../chatcompletions_responses_bridge.go | 58 ++++++++++++++++++- .../chatcompletions_responses_bridge_test.go | 34 ++++++++--- ...etions_responses_tool_output_media_test.go | 2 +- 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index cacd76018f..ff3234b277 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -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 diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go index 6060d02166..ae07360abc 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go @@ -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":" 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, `" switched model"`, string(messages[2].Content)) + assert.JSONEq(t, `"be terse"`, string(messages[3].Content)) } func TestResponsesToChatCompletionsRequest_TextFormatJsonObject(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_tool_output_media_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_tool_output_media_test.go index 46c9c8b4cd..8492d4e059 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_tool_output_media_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_tool_output_media_test.go @@ -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))