From 3648bc155e8d2c760a6bd788208c271a3eb50010 Mon Sep 17 00:00:00 2001 From: sususu98 Date: Mon, 29 Jun 2026 13:56:57 +0800 Subject: [PATCH] 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. --- .../gemini_openai-responses_request.go | 30 +++++--- .../gemini_openai-responses_request_test.go | 75 +++++++++++++++++++ 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index baf617c6..c0ccdffd 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -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 } diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go index 2e2c7201..bd85ad98 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go @@ -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