Merge pull request #2310 from shellus/fix/claude-openai-system-top-level
fix: preserve OpenAI system messages as Claude top-level system
This commit is contained in:
@@ -165,29 +165,22 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
// Process messages and transform them to Claude Code format
|
// Process messages and transform them to Claude Code format
|
||||||
if messages := root.Get("messages"); messages.Exists() && messages.IsArray() {
|
if messages := root.Get("messages"); messages.Exists() && messages.IsArray() {
|
||||||
messageIndex := 0
|
messageIndex := 0
|
||||||
systemMessageIndex := -1
|
|
||||||
messages.ForEach(func(_, message gjson.Result) bool {
|
messages.ForEach(func(_, message gjson.Result) bool {
|
||||||
role := message.Get("role").String()
|
role := message.Get("role").String()
|
||||||
contentResult := message.Get("content")
|
contentResult := message.Get("content")
|
||||||
|
|
||||||
switch role {
|
switch role {
|
||||||
case "system":
|
case "system":
|
||||||
if systemMessageIndex == -1 {
|
|
||||||
systemMsg := []byte(`{"role":"user","content":[]}`)
|
|
||||||
out, _ = sjson.SetRawBytes(out, "messages.-1", systemMsg)
|
|
||||||
systemMessageIndex = messageIndex
|
|
||||||
messageIndex++
|
|
||||||
}
|
|
||||||
if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" {
|
if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" {
|
||||||
textPart := []byte(`{"type":"text","text":""}`)
|
textPart := []byte(`{"type":"text","text":""}`)
|
||||||
textPart, _ = sjson.SetBytes(textPart, "text", contentResult.String())
|
textPart, _ = sjson.SetBytes(textPart, "text", contentResult.String())
|
||||||
out, _ = sjson.SetRawBytes(out, fmt.Sprintf("messages.%d.content.-1", systemMessageIndex), textPart)
|
out, _ = sjson.SetRawBytes(out, "system.-1", textPart)
|
||||||
} else if contentResult.Exists() && contentResult.IsArray() {
|
} else if contentResult.Exists() && contentResult.IsArray() {
|
||||||
contentResult.ForEach(func(_, part gjson.Result) bool {
|
contentResult.ForEach(func(_, part gjson.Result) bool {
|
||||||
if part.Get("type").String() == "text" {
|
if part.Get("type").String() == "text" {
|
||||||
textPart := []byte(`{"type":"text","text":""}`)
|
textPart := []byte(`{"type":"text","text":""}`)
|
||||||
textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
|
textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
|
||||||
out, _ = sjson.SetRawBytes(out, fmt.Sprintf("messages.%d.content.-1", systemMessageIndex), textPart)
|
out, _ = sjson.SetRawBytes(out, "system.-1", textPart)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -269,6 +262,16 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Preserve a minimal conversational turn for system-only inputs.
|
||||||
|
// Claude payloads with top-level system instructions but no messages are risky for downstream validation.
|
||||||
|
if messageIndex == 0 {
|
||||||
|
system := gjson.GetBytes(out, "system")
|
||||||
|
if system.Exists() && system.IsArray() && len(system.Array()) > 0 {
|
||||||
|
fallbackMsg := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`)
|
||||||
|
out, _ = sjson.SetRawBytes(out, "messages.-1", fallbackMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tools mapping: OpenAI tools -> Claude Code tools
|
// Tools mapping: OpenAI tools -> Claude Code tools
|
||||||
|
|||||||
@@ -135,3 +135,111 @@ func TestConvertOpenAIRequestToClaude_ToolResultURLImageOnly(t *testing.T) {
|
|||||||
t.Fatalf("Unexpected image URL: %q", got)
|
t.Fatalf("Unexpected image URL: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertOpenAIRequestToClaude_SystemRoleBecomesTopLevelSystem(t *testing.T) {
|
||||||
|
inputJSON := `{
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are a helpful assistant."},
|
||||||
|
{"role": "user", "content": "Hello"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
|
||||||
|
system := resultJSON.Get("system")
|
||||||
|
if !system.IsArray() {
|
||||||
|
t.Fatalf("Expected top-level system array, got %s", system.Raw)
|
||||||
|
}
|
||||||
|
if len(system.Array()) != 1 {
|
||||||
|
t.Fatalf("Expected 1 system block, got %d. System: %s", len(system.Array()), system.Raw)
|
||||||
|
}
|
||||||
|
if got := system.Get("0.type").String(); got != "text" {
|
||||||
|
t.Fatalf("Expected system block type %q, got %q", "text", got)
|
||||||
|
}
|
||||||
|
if got := system.Get("0.text").String(); got != "You are a helpful assistant." {
|
||||||
|
t.Fatalf("Expected system text %q, got %q", "You are a helpful assistant.", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("Expected 1 non-system message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("role").String(); got != "user" {
|
||||||
|
t.Fatalf("Expected remaining message role %q, got %q", "user", got)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("content.0.text").String(); got != "Hello" {
|
||||||
|
t.Fatalf("Expected user text %q, got %q", "Hello", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertOpenAIRequestToClaude_MultipleSystemMessagesMergedIntoTopLevelSystem(t *testing.T) {
|
||||||
|
inputJSON := `{
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "Rule 1"},
|
||||||
|
{"role": "system", "content": [{"type": "text", "text": "Rule 2"}]},
|
||||||
|
{"role": "user", "content": "Hello"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
|
||||||
|
system := resultJSON.Get("system").Array()
|
||||||
|
if len(system) != 2 {
|
||||||
|
t.Fatalf("Expected 2 system blocks, got %d. System: %s", len(system), resultJSON.Get("system").Raw)
|
||||||
|
}
|
||||||
|
if got := system[0].Get("text").String(); got != "Rule 1" {
|
||||||
|
t.Fatalf("Expected first system text %q, got %q", "Rule 1", got)
|
||||||
|
}
|
||||||
|
if got := system[1].Get("text").String(); got != "Rule 2" {
|
||||||
|
t.Fatalf("Expected second system text %q, got %q", "Rule 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("Expected 1 non-system message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("role").String(); got != "user" {
|
||||||
|
t.Fatalf("Expected remaining message role %q, got %q", "user", got)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("content.0.text").String(); got != "Hello" {
|
||||||
|
t.Fatalf("Expected user text %q, got %q", "Hello", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertOpenAIRequestToClaude_SystemOnlyInputKeepsFallbackUserMessage(t *testing.T) {
|
||||||
|
inputJSON := `{
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are a helpful assistant."}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
|
||||||
|
system := resultJSON.Get("system").Array()
|
||||||
|
if len(system) != 1 {
|
||||||
|
t.Fatalf("Expected 1 system block, got %d. System: %s", len(system), resultJSON.Get("system").Raw)
|
||||||
|
}
|
||||||
|
if got := system[0].Get("text").String(); got != "You are a helpful assistant." {
|
||||||
|
t.Fatalf("Expected system text %q, got %q", "You are a helpful assistant.", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("Expected 1 fallback message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("role").String(); got != "user" {
|
||||||
|
t.Fatalf("Expected fallback message role %q, got %q", "user", got)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("content.0.type").String(); got != "text" {
|
||||||
|
t.Fatalf("Expected fallback content type %q, got %q", "text", got)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("content.0.text").String(); got != "" {
|
||||||
|
t.Fatalf("Expected fallback text %q, got %q", "", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user