fix(translator): align reasoning merge with Responses visible text rules

Recognize model-visible output_text even when message.role is user, accept
assistant string content, and preserve raw merged text without trimming.
Always merge the adjacent visible item into native Gemini reasoning turns so
trailing output is not dropped by prefill stripping.
This commit is contained in:
sususu98
2026-06-29 13:56:57 +08:00
parent 4b51f85c4c
commit 3648bc155e
2 changed files with 93 additions and 12 deletions
@@ -359,7 +359,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
signature := openAIResponsesGeminiThoughtSignature(item.Get("encrypted_content").String())
visibleText := ""
if useGeminiNativeReasoningLayout && i+1 < len(normalized) && !isTrailingOpenAIResponsesAssistantPrefill(normalized, i+1) {
if useGeminiNativeReasoningLayout && i+1 < len(normalized) {
next := normalized[i+1]
if visible, ok := openAIResponsesAssistantVisibleText(next); ok {
visibleText = visible
@@ -528,19 +528,25 @@ func openAIResponsesAssistantVisibleText(item gjson.Result) (string, bool) {
return "", false
}
switch strings.ToLower(strings.TrimSpace(itemRole)) {
case "assistant", "model":
default:
content := item.Get("content")
if !content.Exists() {
return "", false
}
contentArray := item.Get("content")
if !contentArray.Exists() || !contentArray.IsArray() {
if content.Type == gjson.String {
switch strings.ToLower(strings.TrimSpace(itemRole)) {
case "assistant", "model":
return content.String(), true
default:
return "", false
}
}
if !content.IsArray() {
return "", false
}
var textParts []string
contentArray.ForEach(func(_, contentItem gjson.Result) bool {
hasOutputText := false
content.ForEach(func(_, contentItem gjson.Result) bool {
contentType := contentItem.Get("type").String()
if contentType == "" {
contentType = "input_text"
@@ -548,14 +554,14 @@ func openAIResponsesAssistantVisibleText(item gjson.Result) (string, bool) {
if contentType != "output_text" {
return true
}
if text := strings.TrimSpace(contentItem.Get("text").String()); text != "" {
textParts = append(textParts, text)
}
hasOutputText = true
textParts = append(textParts, contentItem.Get("text").String())
return true
})
if len(textParts) == 0 {
if !hasOutputText {
return "", false
}
// output_text marks model-visible content even when message.role is "user".
return strings.Join(textParts, "\n"), true
}
@@ -285,6 +285,81 @@ func TestConvertOpenAIResponsesRequestToGemini_MergesReasoningWithAssistantVisib
t.Fatalf("visible thoughtSignature = %q, want preserved signature", got)
}
}
func TestConvertOpenAIResponsesRequestToGemini_MergesReasoningWithUserRoleOutputText(t *testing.T) {
inputJSON := `{
"model": "gemini-3.5-flash",
"input": [
{
"type": "reasoning",
"encrypted_content": "gemini#` + testResponsesGeminiThoughtSignature + `",
"summary": [{"type": "summary_text", "text": "reasoning summary"}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "output_text", "text": "visible from user role"}]
}
]
}`
output := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
contents := gjson.GetBytes(output, "contents").Array()
if len(contents) != 1 {
t.Fatalf("contents length = %d, want 1. Output: %s", len(contents), output)
}
if got := contents[0].Get("parts.1.text").String(); got != "visible from user role" {
t.Fatalf("visible text = %q", got)
}
}
func TestConvertOpenAIResponsesRequestToGemini_MergesReasoningWithAssistantStringContent(t *testing.T) {
inputJSON := `{
"model": "gemini-3.5-flash",
"input": [
{
"type": "reasoning",
"encrypted_content": "gemini#` + testResponsesGeminiThoughtSignature + `",
"summary": [{"type": "summary_text", "text": "reasoning summary"}]
},
{
"type": "message",
"role": "assistant",
"content": "string visible answer"
}
]
}`
output := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
if got := gjson.GetBytes(output, "contents.0.parts.1.text").String(); got != "string visible answer" {
t.Fatalf("visible text = %q", got)
}
}
func TestConvertOpenAIResponsesRequestToGemini_PreservesWhitespaceWhenMergingReasoning(t *testing.T) {
inputJSON := `{
"model": "gemini-3.5-flash",
"input": [
{
"type": "reasoning",
"encrypted_content": "gemini#` + testResponsesGeminiThoughtSignature + `",
"summary": [{"type": "summary_text", "text": "reasoning summary"}]
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": " lead trail "}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "next"}]
}
]
}`
output := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
if got := gjson.GetBytes(output, "contents.0.parts.1.text").String(); got != " lead trail " {
t.Fatalf("visible text = %q, want preserved whitespace", got)
}
}
func TestConvertOpenAIResponsesRequestToGemini_SystemAndDeveloperRoles(t *testing.T) {
tests := []struct {
name string