Skip to content

Commit 36fea2b

Browse files
hustxiayangpokabookinflab
authored andcommitted
fix: fix tool-calls finish reason conversion in streaming mode for newer gemini models like gemini-3.5-flash (envoyproxy#2399)
**Description** ***Problem*** When the model returns a tool call, OpenAI expects the finish reason to be `tool_calls` while Gemini returns `STOP`. We convert it via ``` if len(toolCalls) > 0 { return openai.ChatCompletionChoicesFinishReasonToolCalls } ``` This was correct for streaming mode because the finish reason and the tool calls are in the same chunk in the previous Gemini models. However, for newer Gemini models like gemini-3.5-flash, they are split into **two** chunks. The `functionCall` chunk has **no** `finishReason`, and a **trailing** chunk carries `finishReason: STOP` with an empty text part and **no** `functionCall`: ```jsonc // raw GCP chunk 1 — has the tool call, no finishReason {"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_current_weather","args":{"...":"..."}}, "thoughtSignature":"..."}]}}], "usageMetadata":{"trafficType":"ON_DEMAND"}} // raw GCP chunk 2 — terminal STOP, empty text, no functionCall {"candidates":[{"content":{"parts":[{"text":""}]},"finishReason":"STOP"}], "usageMetadata":{"promptTokenCount":107,"candidatesTokenCount":31,"thoughtsTokenCount":148,"...":"..."}} ``` In this case, the conversion would be invalid. Thus, we need to remember whether the model returned a tool call during the streaming mode. --------- Signed-off-by: yxia216 <yxia216@bloomberg.net>
1 parent 93b438c commit 36fea2b

2 files changed

Lines changed: 77 additions & 0 deletions

File tree

internal/translator/openai_gcpvertexai.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ type openAIToGCPVertexAITranslatorV1ChatCompletion struct {
8282
bufferedBody []byte // Buffer for incomplete JSON chunks.
8383
requestModel internalapi.RequestModel
8484
toolCallIndex int64
85+
// streamedToolCall records whether any tool call has been emitted so far in
86+
// the streaming response. Newer Gemini models (e.g. gemini-3.5-flash,
87+
// gemini-3.1-flash-lite) stream the terminal STOP on a separate chunk that no
88+
// longer carries the functionCall part, so the finish_reason must be derived
89+
// from the whole stream, not just the current chunk.
90+
streamedToolCall bool
8591
// Redaction configuration for debug logging
8692
debugLogEnabled bool
8793
enableRedaction bool
@@ -444,7 +450,25 @@ func (o *openAIToGCPVertexAITranslatorV1ChatCompletion) geminiCandidatesToOpenAI
444450
} else {
445451
choice.Delta = &openai.ChatCompletionResponseChunkChoiceDelta{}
446452
}
453+
454+
// Track whether a tool call has been streamed at any point in the response.
455+
if len(toolCalls) > 0 {
456+
o.streamedToolCall = true
457+
}
458+
447459
choice.FinishReason = geminiFinishReasonToOpenAI(candidate.FinishReason, toolCalls)
460+
// Newer Gemini models (e.g. gemini-3.5-flash, gemini-3.1-flash-lite) stream
461+
// the terminal STOP on a separate chunk whose parts no longer contain the
462+
// functionCall (e.g. an empty text part), so the per-chunk toolCalls slice
463+
// is empty and geminiFinishReasonToOpenAI maps it to "stop". If a tool call
464+
// was streamed in an earlier chunk, the correct OpenAI finish_reason for the
465+
// completion is still "tool_calls". Older Gemini models carried the
466+
// functionCall and STOP in the same chunk, so this only affects the newer
467+
// split-chunk shape.
468+
if choice.FinishReason == openai.ChatCompletionChoicesFinishReasonStop && o.streamedToolCall {
469+
choice.FinishReason = openai.ChatCompletionChoicesFinishReasonToolCalls
470+
}
471+
448472
choices = append(choices, choice)
449473
}
450474

internal/translator/openai_gcpvertexai_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1944,6 +1944,59 @@ data: {"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather",
19441944
require.Equal(t, uint32(10), outputTokens)
19451945
}
19461946

1947+
func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_StreamingToolCallSplitFinishReason(t *testing.T) {
1948+
// Newer Gemini models (e.g. gemini-3.5-flash, gemini-3.1-flash-lite) stream
1949+
// the functionCall and the terminal STOP in separate chunks: the functionCall
1950+
// chunk carries no finishReason, and a trailing chunk carries finishReason=STOP
1951+
// with an empty text part (no functionCall). The completion's finish_reason
1952+
// must still be "tool_calls", not "stop". (Older Gemini models carried both in
1953+
// a single chunk; that case is covered by
1954+
// TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_StreamingToolCallWithSignature.)
1955+
translator := NewChatCompletionOpenAIToGCPVertexAITranslator("gemini-3.5-flash").(*openAIToGCPVertexAITranslatorV1ChatCompletion)
1956+
1957+
gcpStreamingChunk := `data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"location":"Paris"}},"thoughtSignature":"dG9vbGNhbGxzaWduYXR1cmU="}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"}}
1958+
1959+
data: {"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":15,"candidatesTokenCount":10,"totalTokenCount":25,"thoughtsTokenCount":8}}`
1960+
1961+
headerMut, body, tokenUsage, _, err := translator.handleStreamingResponse(
1962+
bytes.NewReader([]byte(gcpStreamingChunk)),
1963+
false,
1964+
nil,
1965+
)
1966+
1967+
require.Nil(t, headerMut)
1968+
require.NoError(t, err)
1969+
require.NotNil(t, body)
1970+
1971+
chatCompletionChunks := getChatCompletionResponseChunk(body)
1972+
// We expect 3 chunks: tool call (no finish_reason), terminal STOP, and usage.
1973+
require.Len(t, chatCompletionChunks, 3)
1974+
1975+
// First chunk carries the tool call and no finish_reason yet.
1976+
firstChunk := chatCompletionChunks[0]
1977+
require.Len(t, firstChunk.Choices, 1)
1978+
assert.Equal(t, openai.ChatCompletionChoicesFinishReason(""), firstChunk.Choices[0].FinishReason)
1979+
require.Len(t, firstChunk.Choices[0].Delta.ToolCalls, 1)
1980+
assert.Equal(t, "get_weather", firstChunk.Choices[0].Delta.ToolCalls[0].Function.Name)
1981+
assert.JSONEq(t, `{"location":"Paris"}`, firstChunk.Choices[0].Delta.ToolCalls[0].Function.Arguments)
1982+
1983+
// Second chunk carries the terminal STOP but no tool call. The finish_reason
1984+
// must be rewritten to "tool_calls" because a tool call was already streamed.
1985+
secondChunk := chatCompletionChunks[1]
1986+
require.Len(t, secondChunk.Choices, 1)
1987+
assert.Equal(t, openai.ChatCompletionChoicesFinishReason("tool_calls"), secondChunk.Choices[0].FinishReason)
1988+
assert.Empty(t, secondChunk.Choices[0].Delta.ToolCalls)
1989+
1990+
// Third chunk is usage.
1991+
thirdChunk := chatCompletionChunks[2]
1992+
assert.NotNil(t, thirdChunk.Usage)
1993+
1994+
// Completion tokens = candidatesTokenCount(10) + thoughtsTokenCount(8).
1995+
outputTokens, ok := tokenUsage.OutputTokens()
1996+
require.True(t, ok)
1997+
require.Equal(t, uint32(18), outputTokens)
1998+
}
1999+
19472000
func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_StreamingEndOfStream(t *testing.T) {
19482001
translator := NewChatCompletionOpenAIToGCPVertexAITranslator("gemini-2.0-flash-001").(*openAIToGCPVertexAITranslatorV1ChatCompletion)
19492002

0 commit comments

Comments
 (0)