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