🐛 fix(ai): 规范 OpenAI system 消息顺序

Fixes #663
This commit is contained in:
Syngnat
2026-07-16 22:39:33 +08:00
parent fa87c2ee9f
commit f2d622b53f
2 changed files with 154 additions and 0 deletions

View File

@@ -121,6 +121,7 @@ type openAIChatMessage struct {
}
func buildOpenAIMessages(reqMessages []ai.Message, modelName string, baseURL string) []openAIChatMessage {
reqMessages = normalizeOpenAISystemMessageOrder(reqMessages)
messages := make([]openAIChatMessage, len(reqMessages))
replayReasoningContent := shouldReplayReasoningContent(modelName, baseURL)
for i, m := range reqMessages {
@@ -172,6 +173,41 @@ func buildOpenAIMessages(reqMessages []ai.Message, modelName string, baseURL str
return messages
}
// normalizeOpenAISystemMessageOrder keeps strict OpenAI-compatible endpoints
// from rejecting a historical system message that appears after a user,
// assistant, or tool message. It preserves the relative order within both
// groups so tool-call sequences remain intact.
func normalizeOpenAISystemMessageOrder(messages []ai.Message) []ai.Message {
seenNonSystemMessage := false
needsReorder := false
for _, message := range messages {
if message.Role == "system" {
if seenNonSystemMessage {
needsReorder = true
break
}
continue
}
seenNonSystemMessage = true
}
if !needsReorder {
return messages
}
reordered := make([]ai.Message, 0, len(messages))
for _, message := range messages {
if message.Role == "system" {
reordered = append(reordered, message)
}
}
for _, message := range messages {
if message.Role != "system" {
reordered = append(reordered, message)
}
}
return reordered
}
func attachReasoningContent(msg *openAIChatMessage, source ai.Message, enabled bool) {
if enabled && source.Role == "assistant" && source.ReasoningContent != "" {
msg.ReasoningContent = source.ReasoningContent

View File

@@ -224,6 +224,124 @@ func TestOpenAIProviderChatUsesRequestMaxTokens(t *testing.T) {
}
}
func TestOpenAIProviderChatMovesSystemMessagesToRequestPrefix(t *testing.T) {
var received openAIChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
t.Fatalf("decode request body failed: %v", err)
}
seenNonSystemMessage := false
for _, message := range received.Messages {
if message.Role == "system" {
if seenNonSystemMessage {
http.Error(w, `{"error":{"message":"System message must be at the beginning."}}`, http.StatusBadRequest)
return
}
continue
}
seenNonSystemMessage = true
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
APIKey: "sk-test",
BaseURL: server.URL,
Model: "gpt-chat",
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
_, err = providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{
{Role: "user", Content: "first question"},
{Role: "system", Content: "follow the product rules"},
{Role: "assistant", Content: "first answer"},
{Role: "system", Content: "follow the workspace rules"},
{Role: "user", Content: "next question"},
},
})
if err != nil {
t.Fatalf("chat should normalize system message ordering, got %v", err)
}
gotRoles := make([]string, len(received.Messages))
for index, message := range received.Messages {
gotRoles[index] = message.Role
}
wantRoles := []string{"system", "system", "user", "assistant", "user"}
if len(gotRoles) != len(wantRoles) {
t.Fatalf("expected roles %v, got %v", wantRoles, gotRoles)
}
for index, want := range wantRoles {
if gotRoles[index] != want {
t.Fatalf("expected roles %v, got %v", wantRoles, gotRoles)
}
}
}
func TestOpenAIProviderChatStreamMovesSystemMessagesToRequestPrefix(t *testing.T) {
var received openAIChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
t.Fatalf("decode request body failed: %v", err)
}
seenNonSystemMessage := false
for _, message := range received.Messages {
if message.Role == "system" {
if seenNonSystemMessage {
http.Error(w, `{"error":{"message":"System message must be at the beginning."}}`, http.StatusBadRequest)
return
}
continue
}
seenNonSystemMessage = true
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"pong\"}}]}\n\ndata: [DONE]\n\n"))
}))
defer server.Close()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
APIKey: "sk-test",
BaseURL: server.URL,
Model: "gpt-chat",
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
var response string
err = providerInstance.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{
{Role: "user", Content: "first question"},
{Role: "system", Content: "follow the product rules"},
},
}, func(chunk ai.StreamChunk) {
response += chunk.Content
})
if err != nil {
t.Fatalf("chat stream should normalize system message ordering, got %v", err)
}
if response != "pong" {
t.Fatalf("expected streamed content pong, got %q", response)
}
if len(received.Messages) != 2 || received.Messages[0].Role != "system" || received.Messages[1].Role != "user" {
t.Fatalf("expected system message before user message, got %#v", received.Messages)
}
}
func TestOpenAIProviderChatRetriesWithoutImagesOnHTTP400(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {