Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/changelog.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion core/providers/gemini/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions core/providers/gemini/gemini_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gemini_test

import (
"context"
"encoding/base64"
"encoding/json"
"os"
Expand Down Expand Up @@ -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)
}
38 changes: 34 additions & 4 deletions core/providers/gemini/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

outputMessages := convertGeminiCandidatesToResponsesOutput(response.Candidates)
if len(outputMessages) > 0 {
bifrostResp.Output = outputMessages
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Comment thread
TejasGhatte marked this conversation as resolved.

// Attach grounding metadata if we buffered web search data
if state.HasWebSearch && state.WebSearchCall != nil {
Expand Down
14 changes: 14 additions & 0 deletions core/providers/gemini/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
56 changes: 36 additions & 20 deletions core/providers/gemini/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
)

Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions transports/changelog.md
Original file line number Diff line number Diff line change
@@ -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
Loading