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