From 4dce5f3a2b9ad983316216f7cdffd1d4bc2e6b08 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 10 Sep 2026 17:37:15 +0800 Subject: [PATCH] fix(translator): ignore null and empty finish reasons in openai to gemini response - Check that `finish_reason` is a non-empty string before mapping to Gemini `finishReason`. - Prevent chunks or messages with `null` or empty `finish_reason` from emitting unexpected completion statuses. Closes: #5651 --- .../openai/gemini/openai_gemini_response.go | 4 +- .../gemini/openai_gemini_response_test.go | 90 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/internal/translator/openai/gemini/openai_gemini_response.go b/internal/translator/openai/gemini/openai_gemini_response.go index 761bfa314..d00950348 100644 --- a/internal/translator/openai/gemini/openai_gemini_response.go +++ b/internal/translator/openai/gemini/openai_gemini_response.go @@ -201,7 +201,7 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR } // Handle finish reason - if finishReason := choice.Get("finish_reason"); finishReason.Exists() { + if finishReason := choice.Get("finish_reason"); finishReason.Type == gjson.String && finishReason.String() != "" { geminiFinishReason := mapOpenAIFinishReasonToGemini(finishReason.String()) template, _ = sjson.SetBytes(template, "candidates.0.finishReason", geminiFinishReason) @@ -606,7 +606,7 @@ func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, origina } // Handle finish reason - if finishReason := choice.Get("finish_reason"); finishReason.Exists() { + if finishReason := choice.Get("finish_reason"); finishReason.Type == gjson.String && finishReason.String() != "" { geminiFinishReason := mapOpenAIFinishReasonToGemini(finishReason.String()) out, _ = sjson.SetBytes(out, "candidates.0.finishReason", geminiFinishReason) } diff --git a/internal/translator/openai/gemini/openai_gemini_response_test.go b/internal/translator/openai/gemini/openai_gemini_response_test.go index cc7f3205a..ac660ea49 100644 --- a/internal/translator/openai/gemini/openai_gemini_response_test.go +++ b/internal/translator/openai/gemini/openai_gemini_response_test.go @@ -85,3 +85,93 @@ func TestConvertOpenAIResponseToGeminiNonStream_MultiChoicePartsOverlay(t *testi t.Fatalf("expected functionCall.id to be 'call_2', got %q", parts3[0].Get("functionCall.id").String()) } } + +func TestConvertOpenAIResponseToGeminiStream_NullFinishReasonIgnored(t *testing.T) { + var param any + + // Chunk 1: Contentless delta with explicit finish_reason: null (e.g. role declaration or pre-reasoning delta) + chunk1 := []byte(`{"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}`) + out1 := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, chunk1, ¶m) + for i, chunk := range out1 { + if fr := gjson.GetBytes(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" { + t.Fatalf("chunk1[%d] unexpectedly set finishReason = %q on non-final chunk; payload=%s", i, fr.String(), chunk) + } + } + + // Chunk 2: Reasoning delta with finish_reason: null + chunk2 := []byte(`{"choices":[{"index":0,"delta":{"reasoning_content":"thinking..."},"finish_reason":null}]}`) + out2 := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, chunk2, ¶m) + if len(out2) == 0 { + t.Fatalf("expected output for reasoning chunk, got 0 chunks") + } + if gotThought := gjson.GetBytes(out2[0], "candidates.0.content.parts.0.thought").Bool(); !gotThought { + t.Fatalf("expected thought: true on reasoning chunk, got false") + } + if gotText := gjson.GetBytes(out2[0], "candidates.0.content.parts.0.text").String(); gotText != "thinking..." { + t.Fatalf("expected reasoning text %q, got %q", "thinking...", gotText) + } + for i, chunk := range out2 { + if fr := gjson.GetBytes(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" { + t.Fatalf("chunk2[%d] unexpectedly set finishReason = %q on reasoning chunk; payload=%s", i, fr.String(), chunk) + } + } + + // Chunk 3: Contentless delta with finish_reason: "" (empty string should not be treated as stop) + chunk3 := []byte(`{"choices":[{"index":0,"delta":{},"finish_reason":""}]}`) + out3 := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, chunk3, ¶m) + for i, chunk := range out3 { + if fr := gjson.GetBytes(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" { + t.Fatalf("chunk3[%d] unexpectedly set finishReason = %q on contentless chunk with empty finish_reason; payload=%s", i, fr.String(), chunk) + } + } + + // Chunk 4: Content delta with finish_reason: null + chunk4 := []byte(`{"choices":[{"index":0,"delta":{"content":"hello world"},"finish_reason":null}]}`) + out4 := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, chunk4, ¶m) + if len(out4) == 0 { + t.Fatalf("expected output for content chunk, got 0 chunks") + } + if gotText := gjson.GetBytes(out4[0], "candidates.0.content.parts.0.text").String(); gotText != "hello world" { + t.Fatalf("expected content text %q, got %q", "hello world", gotText) + } + for i, chunk := range out4 { + if fr := gjson.GetBytes(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" { + t.Fatalf("chunk4[%d] unexpectedly set finishReason = %q on non-final content chunk; payload=%s", i, fr.String(), chunk) + } + } + + // Chunk 5: Final chunk with finish_reason: "stop" + chunk5 := []byte(`{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`) + out5 := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, chunk5, ¶m) + if len(out5) == 0 { + t.Fatalf("expected output for final chunk, got 0 chunks") + } + if got := gjson.GetBytes(out5[len(out5)-1], "candidates.0.finishReason").String(); got != "STOP" { + t.Fatalf("expected finishReason STOP on final chunk, got %q", got) + } +} + +func TestConvertOpenAIResponseToGeminiNonStream_NullFinishReasonIgnored(t *testing.T) { + testCases := []struct { + name string + payload []byte + }{ + { + name: "null finish_reason", + payload: []byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":null}]}`), + }, + { + name: "empty finish_reason", + payload: []byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":""}]}`), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + out := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, tc.payload, nil) + if fr := gjson.GetBytes(out, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" { + t.Fatalf("unexpected finishReason = %q on non-stream response; output=%s", fr.String(), out) + } + }) + } +}