mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 05:21:20 +00:00
Merge pull request #7094 from liuyuzhou666/fix/responses-chat-developer-role-mapping
fix(apicompat): 修复 Responses 降级 Chat Completions 时的 system 消息顺序问题
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user