diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go index 2ac94eff..4fb0bf00 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go @@ -36,6 +36,7 @@ type geminiToResponsesState struct { ResponseID string CreatedAt int64 Started bool + Completed bool // message aggregation MsgOpened bool @@ -162,12 +163,14 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, } rawJSON = bytes.TrimSpace(rawJSON) - if len(rawJSON) == 0 { + if len(rawJSON) == 0 || st.Completed { return [][]byte{} } - doneOnly := bytes.Equal(rawJSON, []byte("[DONE]")) - if doneOnly { - rawJSON = []byte(`{}`) + if bytes.Equal(rawJSON, []byte("[DONE]")) { + if !st.Started { + return [][]byte{} + } + rawJSON = []byte(`{"candidates":[{"finishReason":"STOP"}]}`) } root := gjson.ParseBytes(rawJSON) @@ -341,13 +344,6 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, } } - if doneOnly { - if st.Started { - openReasoning() - } - return out - } - // Initialize per-response fields and emit created/in_progress once if !st.Started { st.ResponseID = root.Get("responseId").String() @@ -826,6 +822,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, } out = append(out, emitEvent("response.completed", completed)) + st.Completed = true } return out diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go index a3683fe6..4e86ef16 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go @@ -789,38 +789,67 @@ func TestConvertGeminiResponseToOpenAIResponses_LateThoughtSignatureIsImmutable( } } -func TestConvertGeminiResponseToOpenAIResponses_DoneFlushesUnsignedReasoningWithoutCompletion(t *testing.T) { +func TestConvertGeminiResponseToOpenAIResponses_DoneFinalizesStartedStreamExactlyOnce(t *testing.T) { var param any - var out [][]byte - out = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"unsigned thought","thought":true}]}}],"responseId":"done-flush"}}`), ¶m)...) - out = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte("[DONE]"), ¶m)...) - var addedSignature string + ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"unsigned thought","thought":true}]}}],"responseId":"done-finalize"}}`), ¶m) + out := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte("[DONE]"), ¶m) + var deltas []string - doneCount := 0 + outputDoneCount := 0 completedCount := 0 for _, chunk := range out { event, data := parseSSEEvent(t, chunk) switch event { - case "response.output_item.added": - if data.Get("item.type").String() == "reasoning" { - addedSignature = data.Get("item.encrypted_content").String() - } case "response.reasoning_summary_text.delta": deltas = append(deltas, data.Get("delta").String()) case "response.output_item.done": - doneCount++ + outputDoneCount++ case "response.completed": completedCount++ } } - if addedSignature != "" || strings.Join(deltas, "") != "unsigned thought" || doneCount != 0 || completedCount != 0 { - t.Fatalf("DONE flush malformed: added=%q deltas=%q done=%d completed=%d", addedSignature, deltas, doneCount, completedCount) + if strings.Join(deltas, "") != "unsigned thought" || outputDoneCount != 1 || completedCount != 1 { + t.Fatalf("DONE finalization malformed: deltas=%q output_done=%d completed=%d", deltas, outputDoneCount, completedCount) } if duplicate := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte("[DONE]"), ¶m); len(duplicate) != 0 { t.Fatalf("duplicate DONE emitted %d events", len(duplicate)) } } +func TestConvertGeminiResponseToOpenAIResponses_FinishReasonThenDoneDoesNotDuplicateCompletion(t *testing.T) { + var param any + out := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer"}]},"finishReason":"STOP"}],"responseId":"finish-then-done"}}`), ¶m) + + completedCount := 0 + for _, chunk := range out { + event, _ := parseSSEEvent(t, chunk) + if event == "response.completed" { + completedCount++ + } + } + if completedCount != 1 { + t.Fatalf("finish reason emitted %d completion events", completedCount) + } + if duplicate := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte("data: [DONE]"), ¶m); len(duplicate) != 0 { + t.Fatalf("DONE after finish reason emitted %d events", len(duplicate)) + } + if late := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(`{"candidates":[{"content":{"parts":[{"text":"late"}]}}]}`), ¶m); len(late) != 0 { + t.Fatalf("input after completion emitted %d events", len(late)) + } +} + +func TestConvertGeminiResponseToOpenAIResponses_BareDoneBeforeStartEmitsNothing(t *testing.T) { + var param any + out := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte("data: [DONE]"), ¶m) + if len(out) != 0 { + t.Fatalf("bare DONE emitted %d events", len(out)) + } + st := param.(*geminiToResponsesState) + if st.Started || st.Completed { + t.Fatalf("bare DONE changed stream state: started=%t completed=%t", st.Started, st.Completed) + } +} + func TestConvertGeminiResponseToOpenAIResponsesNonStream_VisibleSignatureCompletesReasoning(t *testing.T) { raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"hidden thought","thought":true},{"text":"visible answer","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp_nonstream_active"}`) out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)