fix(translator): normalize message-level system roles for Gemini

This commit is contained in:
sususu98
2026-06-02 16:48:58 +08:00
parent 87d813c56c
commit 68282c4aa7
6 changed files with 145 additions and 0 deletions

View File

@@ -308,6 +308,8 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
role := originalRole
if role == "assistant" {
role = "model"
} else if role == "system" {
role = "user"
}
clientContentJSON := []byte(`{"role":"","parts":[]}`)
clientContentJSON, _ = sjson.SetBytes(clientContentJSON, "role", role)

View File

@@ -133,6 +133,53 @@ func TestConvertClaudeRequestToAntigravity_StripsClaudeCodeAttribution(t *testin
}
}
func TestConvertClaudeRequestToAntigravity_ConvertsMessageSystemRoleToUserContent(t *testing.T) {
inputJSON := []byte(`{
"model": "gemini-3.5-flash",
"system": [{"type": "text", "text": "Top-level rules"}],
"messages": [
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
{"role": "system", "content": "String mid-conversation rule"},
{"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]}
]
}`)
output := ConvertClaudeRequestToAntigravity("gemini-3-flash-agent", inputJSON, false)
outputStr := string(output)
if systemContent := gjson.Get(outputStr, `request.contents.#(role=="system")`); systemContent.Exists() {
t.Fatalf("system role should not be emitted in request.contents: %s", systemContent.Raw)
}
contents := gjson.Get(outputStr, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("Expected the user and message-level system turns in request.contents, got %d: %s", len(contents), gjson.Get(outputStr, "request.contents").Raw)
}
if got := contents[0].Get("role").String(); got != "user" {
t.Fatalf("Expected first content role user, got %q", got)
}
if got := contents[1].Get("role").String(); got != "user" {
t.Fatalf("Expected message-level system content to be downgraded to user role, got %q", got)
}
if got := contents[1].Get("parts.0.text").String(); got != "String mid-conversation rule" {
t.Fatalf("Unexpected string message-level system content text: %q", got)
}
if got := contents[2].Get("role").String(); got != "user" {
t.Fatalf("Expected array message-level system content to be downgraded to user role, got %q", got)
}
if got := contents[2].Get("parts.0.text").String(); got != "Array mid-conversation rule" {
t.Fatalf("Unexpected array message-level system content text: %q", got)
}
parts := gjson.Get(outputStr, "request.systemInstruction.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.Get(outputStr, "request.systemInstruction.parts").Raw)
}
if got := parts[0].Get("text").String(); got != "Top-level rules" {
t.Fatalf("Unexpected first system part: %q", got)
}
}
func testNonAnthropicRawSignature(t *testing.T) string {
t.Helper()

View File

@@ -77,6 +77,8 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
role := roleResult.String()
if role == "assistant" {
role = "model"
} else if role == "system" {
role = "user"
}
contentJSON := []byte(`{"role":"","parts":[]}`)

View File

@@ -61,3 +61,49 @@ func TestConvertClaudeRequestToCLI_StripsClaudeCodeAttribution(t *testing.T) {
t.Fatalf("Unexpected system part: %q", got)
}
}
func TestConvertClaudeRequestToCLI_ConvertsMessageSystemRoleToUserContent(t *testing.T) {
inputJSON := []byte(`{
"model": "gemini-3-flash-preview",
"system": [{"type": "text", "text": "Top-level rules"}],
"messages": [
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
{"role": "system", "content": "String mid-conversation rule"},
{"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]}
]
}`)
output := ConvertClaudeRequestToCLI("gemini-3-flash-preview", inputJSON, false)
if systemContent := gjson.GetBytes(output, `request.contents.#(role=="system")`); systemContent.Exists() {
t.Fatalf("system role should not be emitted in request.contents: %s", systemContent.Raw)
}
contents := gjson.GetBytes(output, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("Expected the user and message-level system turns in request.contents, got %d: %s", len(contents), gjson.GetBytes(output, "request.contents").Raw)
}
if got := contents[0].Get("role").String(); got != "user" {
t.Fatalf("Expected first content role user, got %q", got)
}
if got := contents[1].Get("role").String(); got != "user" {
t.Fatalf("Expected message-level string system content to be downgraded to user role, got %q", got)
}
if got := contents[1].Get("parts.0.text").String(); got != "String mid-conversation rule" {
t.Fatalf("Unexpected string message-level system content text: %q", got)
}
if got := contents[2].Get("role").String(); got != "user" {
t.Fatalf("Expected message-level array system content to be downgraded to user role, got %q", got)
}
if got := contents[2].Get("parts.0.text").String(); got != "Array mid-conversation rule" {
t.Fatalf("Unexpected array message-level system content text: %q", got)
}
parts := gjson.GetBytes(output, "request.systemInstruction.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.GetBytes(output, "request.systemInstruction.parts").Raw)
}
if got := parts[0].Get("text").String(); got != "Top-level rules" {
t.Fatalf("Unexpected first system part: %q", got)
}
}

View File

@@ -71,6 +71,8 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
role := roleResult.String()
if role == "assistant" {
role = "model"
} else if role == "system" {
role = "user"
}
contentJSON := []byte(`{"role":"","parts":[]}`)

View File

@@ -107,6 +107,52 @@ func TestConvertClaudeRequestToGemini_StripsClaudeCodeAttribution(t *testing.T)
}
}
func TestConvertClaudeRequestToGemini_ConvertsMessageSystemRoleToUserContent(t *testing.T) {
inputJSON := []byte(`{
"model": "gemini-3-flash-preview",
"system": [{"type": "text", "text": "Top-level rules"}],
"messages": [
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
{"role": "system", "content": "String mid-conversation rule"},
{"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]}
]
}`)
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
if systemContent := gjson.GetBytes(output, `contents.#(role=="system")`); systemContent.Exists() {
t.Fatalf("system role should not be emitted in contents: %s", systemContent.Raw)
}
contents := gjson.GetBytes(output, "contents").Array()
if len(contents) != 3 {
t.Fatalf("Expected the user and message-level system turns in contents, got %d: %s", len(contents), gjson.GetBytes(output, "contents").Raw)
}
if got := contents[0].Get("role").String(); got != "user" {
t.Fatalf("Expected first content role user, got %q", got)
}
if got := contents[1].Get("role").String(); got != "user" {
t.Fatalf("Expected message-level string system content to be downgraded to user role, got %q", got)
}
if got := contents[1].Get("parts.0.text").String(); got != "String mid-conversation rule" {
t.Fatalf("Unexpected string message-level system content text: %q", got)
}
if got := contents[2].Get("role").String(); got != "user" {
t.Fatalf("Expected message-level array system content to be downgraded to user role, got %q", got)
}
if got := contents[2].Get("parts.0.text").String(); got != "Array mid-conversation rule" {
t.Fatalf("Unexpected array message-level system content text: %q", got)
}
parts := gjson.GetBytes(output, "system_instruction.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.GetBytes(output, "system_instruction.parts").Raw)
}
if got := parts[0].Get("text").String(); got != "Top-level rules" {
t.Fatalf("Unexpected first system part: %q", got)
}
}
func TestConvertClaudeRequestToGemini_SkipsEmptyTextParts(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-5-sonnet",