From 97a0edff47bcb229a173c875cf28471da04d362a Mon Sep 17 00:00:00 2001 From: tejas ghatte Date: Tue, 14 Apr 2026 10:08:03 +0530 Subject: [PATCH] fix: gemini finish reason in responses --- core/changelog.md | 1 + core/providers/gemini/chat.go | 8 +++- core/providers/gemini/gemini_test.go | 64 ++++++++++++++++++++++++++++ core/providers/gemini/responses.go | 38 +++++++++++++++-- core/providers/gemini/types.go | 14 ++++++ core/providers/gemini/utils.go | 56 +++++++++++++++--------- transports/changelog.md | 1 + 7 files changed, 157 insertions(+), 25 deletions(-) diff --git a/core/changelog.md b/core/changelog.md index 38f89fedac..3431d11bc8 100644 --- a/core/changelog.md +++ b/core/changelog.md @@ -1,3 +1,4 @@ - fix: Gemini provider - handle content block tool outputs in Responses API path - fix: case-insensitive `anthropic-beta` merge in `MergeBetaHeaders` - fix: Bedrock provider - emit message_stop event for Anthropic invoke stream [@tefimov](https://github.com/tefimov) +- fix: gemini preserves thinkingLevel parameters during round-trip and finish reason mapping diff --git a/core/providers/gemini/chat.go b/core/providers/gemini/chat.go index da562f9348..9d56a3d12b 100644 --- a/core/providers/gemini/chat.go +++ b/core/providers/gemini/chat.go @@ -504,7 +504,13 @@ func isErrorFinishReason(reason FinishReason) bool { reason == FinishReasonProhibitedContent || reason == FinishReasonSPII || reason == FinishReasonImageSafety || - reason == FinishReasonUnexpectedToolCall + reason == FinishReasonUnexpectedToolCall || + reason == FinishReasonMissingThoughtSignature || + reason == FinishReasonMalformedResponse || + reason == FinishReasonImageProhibitedContent || + reason == FinishReasonImageRecitation || + reason == FinishReasonTooManyToolCalls || + reason == FinishReasonNoImage } // createErrorResponse creates a complete BifrostChatResponse for error cases diff --git a/core/providers/gemini/gemini_test.go b/core/providers/gemini/gemini_test.go index ecaec0cf70..e1fb192f66 100644 --- a/core/providers/gemini/gemini_test.go +++ b/core/providers/gemini/gemini_test.go @@ -1,6 +1,7 @@ package gemini_test import ( + "context" "encoding/base64" "encoding/json" "os" @@ -2922,3 +2923,66 @@ func TestThinkingBudgetEffortUsesModelRange(t *testing.T) { "flash effort budget must not exceed model maximum 24576") }) } + +// Regression: GenAI /generateContent path must not turn thinkingLevel into a derived +// thinkingBudget (which changes Gemini 3.x behavior). Inbound should set effort only; +// outbound for Gemini 3+ should emit thinkingLevel again. +func TestGenAIThinkingLevel_RoundTripPreservesLevelNotBudget(t *testing.T) { + level := "MiNiMaL" + geminiReq := &gemini.GeminiGenerationRequest{ + Model: "gemini-3-flash-preview", + GenerationConfig: gemini.GenerationConfig{ + ThinkingConfig: &gemini.GenerationConfigThinkingConfig{ + IncludeThoughts: true, + ThinkingLevel: &level, + }, + }, + } + + bifrostCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + bifrostReq := geminiReq.ToBifrostResponsesRequest(bifrostCtx) + require.NotNil(t, bifrostReq.Params) + require.NotNil(t, bifrostReq.Params.Reasoning) + require.NotNil(t, bifrostReq.Params.Reasoning.Effort) + assert.Equal(t, "minimal", *bifrostReq.Params.Reasoning.Effort) + assert.Nil(t, bifrostReq.Params.Reasoning.MaxTokens, "thinkingLevel must not populate reasoning max_tokens") + + roundTrip, err := gemini.ToGeminiResponsesRequest(bifrostReq) + require.NoError(t, err) + require.NotNil(t, roundTrip) + require.NotNil(t, roundTrip.GenerationConfig.ThinkingConfig) + tc := roundTrip.GenerationConfig.ThinkingConfig + require.NotNil(t, tc.ThinkingLevel) + assert.Equal(t, "minimal", *tc.ThinkingLevel) + assert.Nil(t, tc.ThinkingBudget, "round-trip must not synthesize thinkingBudget from level-only config") +} + +// Regression: MAX_TOKENS from Gemini must survive Gemini → Bifrost → Gemini on the GenAI path +// (StopReason used to be dropped, so clients saw STOP instead of MAX_TOKENS). +func TestGenAIFinishReasonMaxTokens_PersistsThroughBifrostRoundTrip(t *testing.T) { + geminiResp := &gemini.GenerateContentResponse{ + ModelVersion: "gemini-2.5-flash", + Candidates: []*gemini.Candidate{ + { + Index: 0, + FinishReason: gemini.FinishReasonMaxTokens, + Content: &gemini.Content{ + Role: "model", + Parts: []*gemini.Part{ + {Text: "partial essay..."}, + }, + }, + }, + }, + } + + bifrostResp := geminiResp.ToResponsesBifrostResponsesResponse() + require.NotNil(t, bifrostResp) + require.NotNil(t, bifrostResp.StopReason) + assert.Equal(t, "length", *bifrostResp.StopReason) + + out := gemini.ToGeminiResponsesResponse(bifrostResp) + require.NotNil(t, out) + require.Len(t, out.Candidates, 1) + assert.Equal(t, gemini.FinishReasonMaxTokens, out.Candidates[0].FinishReason) +} diff --git a/core/providers/gemini/responses.go b/core/providers/gemini/responses.go index 8f2377cc78..3be24a3145 100644 --- a/core/providers/gemini/responses.go +++ b/core/providers/gemini/responses.go @@ -160,6 +160,30 @@ func (response *GenerateContentResponse) ToResponsesBifrostResponsesResponse() * // Convert candidates to Responses output messages if len(response.Candidates) > 0 { + candidate := response.Candidates[0] + + // Persist finish reason as Bifrost canonical stop_reason + if candidate.FinishReason != "" && candidate.FinishReason != FinishReasonUnspecified { + stopReason := ConvertGeminiFinishReasonToBifrost(candidate.FinishReason) + bifrostResp.StopReason = &stopReason + + if isErrorFinishReason(candidate.FinishReason) { + failedStatus := "failed" + bifrostResp.Status = &failedStatus + + errMsg := candidate.FinishMessage + if errMsg == "" { + errMsg = string(candidate.FinishReason) + } + bifrostResp.Error = &schemas.ResponsesResponseError{ + Code: stopReason, + Message: errMsg, + } + + return bifrostResp + } + } + outputMessages := convertGeminiCandidatesToResponsesOutput(response.Candidates) if len(outputMessages) > 0 { bifrostResp.Output = outputMessages @@ -409,8 +433,10 @@ func ToGeminiResponsesResponse(bifrostResp *schemas.BifrostResponsesResponse) *G }, } - // Determine finish reason based on incomplete details - if bifrostResp.IncompleteDetails != nil { + // Determine finish reason: prefer StopReason (Bifrost canonical), fall back to IncompleteDetails + if bifrostResp.StopReason != nil { + candidate.FinishReason = ConvertBifrostFinishReasonToGemini(*bifrostResp.StopReason) + } else if bifrostResp.IncompleteDetails != nil { switch bifrostResp.IncompleteDetails.Reason { case "max_tokens": candidate.FinishReason = FinishReasonMaxTokens @@ -692,8 +718,12 @@ func ToGeminiResponsesStreamResponse(bifrostResp *schemas.BifrostResponsesStream streamResp.UsageMetadata = ConvertBifrostResponsesUsageToGeminiUsageMetadata(bifrostResp.Response.Usage) } - // Set finish reason - candidate.FinishReason = FinishReasonStop + // Derive finish reason from StopReason when present + if bifrostResp.Response.StopReason != nil { + candidate.FinishReason = ConvertBifrostFinishReasonToGemini(*bifrostResp.Response.StopReason) + } else { + candidate.FinishReason = FinishReasonStop + } // Attach grounding metadata if we buffered web search data if state.HasWebSearch && state.WebSearchCall != nil { diff --git a/core/providers/gemini/types.go b/core/providers/gemini/types.go index 75cf9f504f..29935ca23b 100644 --- a/core/providers/gemini/types.go +++ b/core/providers/gemini/types.go @@ -82,6 +82,20 @@ const ( FinishReasonImageSafety FinishReason = "IMAGE_SAFETY" // The tool call generated by the model is invalid. FinishReasonUnexpectedToolCall FinishReason = "UNEXPECTED_TOOL_CALL" + // Image generation stopped because generated images contain prohibited content. + FinishReasonImageProhibitedContent FinishReason = "IMAGE_PROHIBITED_CONTENT" + // Image generation stopped due to other miscellaneous issues. + FinishReasonImageOther FinishReason = "IMAGE_OTHER" + // The model was expected to generate an image, but none was generated. + FinishReasonNoImage FinishReason = "NO_IMAGE" + // Image generation stopped due to recitation. + FinishReasonImageRecitation FinishReason = "IMAGE_RECITATION" + // Model called too many tools consecutively, thus the system exited execution. + FinishReasonTooManyToolCalls FinishReason = "TOO_MANY_TOOL_CALLS" + // Request has at least one thought signature missing. + FinishReasonMissingThoughtSignature FinishReason = "MISSING_THOUGHT_SIGNATURE" + // Finished due to malformed response. + FinishReasonMalformedResponse FinishReason = "MALFORMED_RESPONSE" ) type GeminiGenerationRequest struct { diff --git a/core/providers/gemini/utils.go b/core/providers/gemini/utils.go index 321ec85329..afb410d022 100644 --- a/core/providers/gemini/utils.go +++ b/core/providers/gemini/utils.go @@ -197,8 +197,7 @@ func (r *GeminiGenerationRequest) convertGenerationConfigToResponsesParameters() level := *config.ThinkingConfig.ThinkingLevel var effort string - // Map Gemini thinking level to Bifrost effort - switch level { + switch strings.ToLower(level) { case "minimal": effort = "minimal" case "low": @@ -212,12 +211,6 @@ func (r *GeminiGenerationRequest) convertGenerationConfigToResponsesParameters() } params.Reasoning.Effort = schemas.Ptr(effort) - - // Also convert to budget for compatibility - if effort != "none" { - budget, _ := providerUtils.GetBudgetTokensFromReasoningEffort(effort, budgetRange.Min, budgetRange.Max) - params.Reasoning.MaxTokens = schemas.Ptr(budget) - } } } if config.CandidateCount > 0 { @@ -545,18 +538,33 @@ func convertFileDataToBytes(fileData string) ([]byte, string) { var ( // Maps Gemini finish reasons to Bifrost format geminiFinishReasonToBifrost = map[FinishReason]string{ - FinishReasonStop: "stop", - FinishReasonMaxTokens: "length", - FinishReasonSafety: "content_filter", - FinishReasonRecitation: "content_filter", - FinishReasonLanguage: "content_filter", - FinishReasonOther: "stop", - FinishReasonBlocklist: "content_filter", - FinishReasonProhibitedContent: "content_filter", - FinishReasonSPII: "content_filter", - FinishReasonMalformedFunctionCall: "stop", - FinishReasonImageSafety: "content_filter", - FinishReasonUnexpectedToolCall: "tool_calls", + FinishReasonStop: "stop", + FinishReasonMaxTokens: "length", + FinishReasonSafety: "content_filter", + FinishReasonRecitation: "content_filter", + FinishReasonLanguage: "content_filter", + FinishReasonOther: "stop", + FinishReasonBlocklist: "content_filter", + FinishReasonProhibitedContent: "content_filter", + FinishReasonSPII: "content_filter", + FinishReasonMalformedFunctionCall: "stop", + FinishReasonImageSafety: "content_filter", + FinishReasonImageProhibitedContent: "content_filter", + FinishReasonImageOther: "stop", + FinishReasonNoImage: "stop", + FinishReasonImageRecitation: "content_filter", + FinishReasonUnexpectedToolCall: "stop", + FinishReasonTooManyToolCalls: "stop", + FinishReasonMissingThoughtSignature: "stop", + FinishReasonMalformedResponse: "stop", + } + + // Maps Bifrost canonical finish reasons back to the most representative Gemini finish reason + bifrostToGeminiFinishReason = map[string]FinishReason{ + "stop": FinishReasonStop, + "length": FinishReasonMaxTokens, + "content_filter": FinishReasonSafety, + "tool_calls": FinishReasonStop, } ) @@ -568,6 +576,14 @@ func ConvertGeminiFinishReasonToBifrost(providerReason FinishReason) string { return string(providerReason) } +// ConvertBifrostFinishReasonToGemini converts Bifrost canonical finish reasons back to Gemini format. +func ConvertBifrostFinishReasonToGemini(bifrostReason string) FinishReason { + if geminiReason, ok := bifrostToGeminiFinishReason[bifrostReason]; ok { + return geminiReason + } + return FinishReasonStop +} + // ConvertGeminiUsageMetadataToChatUsage converts Gemini usage metadata to Bifrost chat LLM usage func ConvertGeminiUsageMetadataToChatUsage(metadata *GenerateContentResponseUsageMetadata) *schemas.BifrostLLMUsage { if metadata == nil { diff --git a/transports/changelog.md b/transports/changelog.md index 0058eed660..4739b360ba 100644 --- a/transports/changelog.md +++ b/transports/changelog.md @@ -1,2 +1,3 @@ - fix: case-insensitive `anthropic-beta` merge in `MergeBetaHeaders` - fix: Bedrock integration - update to use InvokeModelRawChunks for multi-event support [@tefimov](https://github.com/tefimov) +- fix: gemini preserves thinkingLevel parameters during round-trip and finish reason mapping