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
This commit is contained in:
Luis Pater
2026-09-10 17:37:15 +08:00
parent dde250f1c3
commit 4dce5f3a2b
2 changed files with 92 additions and 2 deletions

View File

@@ -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)
}

View File

@@ -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, &param)
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, &param)
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, &param)
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, &param)
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, &param)
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)
}
})
}
}