Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
929703d
fix(gemini): improve Gemini 3/3.5 thinking level mapping and split st…
zchryst Oct 2, 2026
cbcd99d
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage
zchryst Oct 5, 2026
f1e8682
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage
zchryst Oct 9, 2026
2e1eb35
fix(gemini): fall back to MaxTokens when effortToThinkingLevel is emp…
zchryst Oct 11, 2026
82d340b
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage and fix new…
zchryst Oct 14, 2026
b19f0d3
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage
zchryst Oct 16, 2026
c167aaa
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage and assert …
zchryst Oct 17, 2026
52438f3
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage
zchryst Oct 19, 2026
80b4a1f
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage
zchryst Oct 20, 2026
eec02dd
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage
zchryst Oct 21, 2026
4a9fc71
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage and restore…
zchryst Oct 22, 2026
0d912f9
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage
zchryst Oct 23, 2026
ed023bd
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage
zchryst Oct 24, 2026
33cf263
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage
zchryst Oct 25, 2026
86fbf90
test(gemini): add BenchmarkGemini3ThinkingAndStreamUsage with -benchmem
zchryst Oct 26, 2026
032a526
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage
zchryst Oct 27, 2026
9b5610c
Merge branch 'dev' into fix/gemini-3-5-thinking-and-usage and strip r…
zchryst Oct 28, 2026
9b4de89
chore(gemini): align passthrough_usage.go and lowestThinkingLevel wit…
zchryst Oct 28, 2026
114df37
fix(gemini): preserve stream modelVersion for search usage and tighte…
zchryst Oct 29, 2026
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
53 changes: 46 additions & 7 deletions core/providers/gemini/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ func (response *GenerateContentResponse) ToBifrostChatResponse() *schemas.Bifros
type GeminiStreamState struct {
nextToolCallIndex int
hadToolCalls bool // true if any tool calls were seen in this stream
hadFinishReason bool // true if a candidate finish reason was seen in this stream
groundingMetadata *GroundingMetadata
}

// NewGeminiStreamState returns initialised stream state for one streaming response.
Expand Down Expand Up @@ -521,8 +523,33 @@ func (response *GenerateContentResponse) toBifrostChatCompletionStreamDelta(stat
state = NewGeminiStreamState()
}

// Handle empty candidates (filtered/malformed responses)
// Handle empty candidates (filtered/malformed responses, or a trailing usage-only chunk)
if len(response.Candidates) == 0 {
if response.UsageMetadata != nil && state.hadFinishReason {
Comment thread
zchryst marked this conversation as resolved.
streamResponse := &schemas.BifrostChatResponse{
ID: response.ResponseID,
Model: response.ModelVersion,
Object: "chat.completion.chunk",
Choices: []schemas.BifrostResponseChoice{{
Index: 0,
ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{
Delta: &schemas.ChatStreamResponseChoiceDelta{},
},
}},
Usage: ConvertGeminiUsageMetadataToChatUsage(response.UsageMetadata),
}
if !response.CreateTime.IsZero() {
streamResponse.Created = int(response.CreateTime.Unix())
}
applyGeminiSearchQueryChatUsage(streamResponse.Usage, state.groundingMetadata, response.ModelVersion)
Comment thread
zchryst marked this conversation as resolved.
Outdated
if t := mapGeminiTrafficTypeToBifrost(response.UsageMetadata.TrafficType); t != nil {
streamResponse.ServiceTier = t
} else if response.UsageMetadata.ServiceTier != "" {
tier := mapGeminiServiceTierToBifrost(response.UsageMetadata.ServiceTier)
streamResponse.ServiceTier = &tier
}
return streamResponse, nil, true
}
finishReason := ConvertGeminiFinishReasonToBifrost(FinishReasonMalformedFunctionCall)
if response.promptBlockError() != nil {
finishReason = ConvertGeminiFinishReasonToBifrost(FinishReasonSafety)
Expand All @@ -538,8 +565,15 @@ func (response *GenerateContentResponse) toBifrostChatCompletionStreamDelta(stat
return createErrorResponse(response, finishReason, true), nil, true
}

if candidate.FinishReason != "" {
state.hadFinishReason = true
}
if candidate.GroundingMetadata != nil {
state.groundingMetadata = candidate.GroundingMetadata
}

// Determine if this is the last chunk based on finish reason and usage metadata
isLastChunk := candidate.FinishReason != "" && response.UsageMetadata != nil
isLastChunk := state.hadFinishReason && response.UsageMetadata != nil

// Create the streaming response
streamResponse := &schemas.BifrostChatResponse{
Expand Down Expand Up @@ -712,14 +746,14 @@ func (response *GenerateContentResponse) toBifrostChatCompletionStreamDelta(stat
}

// Check if delta has any content - if not and it's not the last chunk, skip it
hasDeltaContent := delta.Role != nil || delta.Content != nil || delta.Audio != nil || len(delta.ToolCalls) > 0 || len(delta.ReasoningDetails) > 0 || len(delta.Annotations) > 0
hasDeltaContent := delta.Role != nil || delta.Content != nil || delta.Audio != nil || len(delta.ToolCalls) > 0 || len(delta.ReasoningDetails) > 0 || len(delta.Annotations) > 0 || candidate.FinishReason != "" || response.UsageMetadata != nil
if !hasDeltaContent && !isLastChunk {
return nil, nil, false
}

// Build the choice
var finishReason *string
if isLastChunk && candidate.FinishReason != "" {
if candidate.FinishReason != "" {
reason := ConvertGeminiFinishReasonToBifrost(candidate.FinishReason)
// Gemini uses "STOP" for both text completions and tool call responses.
// Override to "tool_calls" when tool calls were seen in this stream for uniformity.
Expand All @@ -740,10 +774,15 @@ func (response *GenerateContentResponse) toBifrostChatCompletionStreamDelta(stat

streamResponse.Choices = []schemas.BifrostResponseChoice{choice}

// Add usage information if this is the last chunk
if isLastChunk && response.UsageMetadata != nil {
// Add usage information whenever present so running stream usage stays current
// and split finishReason / usageMetadata chunks both survive.
if response.UsageMetadata != nil {
streamResponse.Usage = ConvertGeminiUsageMetadataToChatUsage(response.UsageMetadata)
applyGeminiSearchQueryChatUsage(streamResponse.Usage, candidate.GroundingMetadata, response.ModelVersion)
grounding := candidate.GroundingMetadata
if grounding == nil {
grounding = state.groundingMetadata
}
applyGeminiSearchQueryChatUsage(streamResponse.Usage, grounding, response.ModelVersion)
if t := mapGeminiTrafficTypeToBifrost(response.UsageMetadata.TrafficType); t != nil {
streamResponse.ServiceTier = t
} else if response.UsageMetadata.ServiceTier != "" {
Expand Down
164 changes: 164 additions & 0 deletions core/providers/gemini/gemini_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6141,3 +6141,167 @@ func TestGenAIPartMediaResolution_SurvivesBatchConversion(t *testing.T) {
require.NotNil(t, got, "batch conversion must preserve per-part media resolution")
assert.Equal(t, "MEDIA_RESOLUTION_ULTRA_HIGH", got.Level)
}

func TestGemini35ThinkingAndStreamingUsage(t *testing.T) {
t.Run("EffortAndMaxTokensOnGemini3EmitsThinkingLevel", func(t *testing.T) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
for _, tc := range []struct {
model string
effort string
maxTokens int
summary *string
display *string
wantLevel string
wantInc bool
}{
{
model: "gemini-3.5-flash",
effort: "low",
maxTokens: 4096,
wantLevel: "low",
wantInc: true,
},
{
model: "au.gemini-3.5-flash",
effort: "minimal",
maxTokens: 1024,
summary: schemas.Ptr("none"),
display: schemas.Ptr("omitted"),
wantLevel: "minimal",
wantInc: false,
},
{
model: "gemini-3.5-pro",
effort: "minimal",
maxTokens: 2048,
wantLevel: "low",
wantInc: true,
},
{
model: "gemini-3.1-pro",
effort: "low",
maxTokens: 4096,
summary: schemas.Ptr("none"),
display: schemas.Ptr("omitted"),
wantLevel: "low",
wantInc: false,
},
} {
t.Run(tc.model, func(t *testing.T) {
chatReq := &schemas.BifrostChatRequest{
Model: tc.model,
Input: minimalChatInput(),
Params: &schemas.ChatParameters{
Reasoning: &schemas.ChatReasoning{
Effort: schemas.Ptr(tc.effort),
MaxTokens: schemas.Ptr(tc.maxTokens),
Display: tc.display,
},
},
}
chatOut, err := gemini.ToGeminiChatCompletionRequest(nil, chatReq)
require.NoError(t, err)
require.NotNil(t, chatOut.GenerationConfig.ThinkingConfig)
require.NotNil(t, chatOut.GenerationConfig.ThinkingConfig.ThinkingLevel)
assert.Equal(t, tc.wantLevel, *chatOut.GenerationConfig.ThinkingConfig.ThinkingLevel)
assert.Nil(t, chatOut.GenerationConfig.ThinkingConfig.ThinkingBudget)
assert.Equal(t, tc.wantInc, chatOut.GenerationConfig.ThinkingConfig.IncludeThoughts)

respReq := &schemas.BifrostResponsesRequest{
Model: tc.model,
Input: []schemas.ResponsesMessage{{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("Solve this")},
}},
Params: &schemas.ResponsesParameters{
Reasoning: &schemas.ResponsesParametersReasoning{
Effort: schemas.Ptr(tc.effort),
MaxTokens: schemas.Ptr(tc.maxTokens),
Summary: tc.summary,
},
},
}
respOut, err := gemini.ToGeminiResponsesRequest(nil, respReq)
require.NoError(t, err)
require.NotNil(t, respOut.GenerationConfig.ThinkingConfig)
require.NotNil(t, respOut.GenerationConfig.ThinkingConfig.ThinkingLevel)
assert.Equal(t, tc.wantLevel, *respOut.GenerationConfig.ThinkingConfig.ThinkingLevel)
assert.Nil(t, respOut.GenerationConfig.ThinkingConfig.ThinkingBudget)
assert.Equal(t, tc.wantInc, respOut.GenerationConfig.ThinkingConfig.IncludeThoughts)
})
}
})

t.Run("RegionalPrefixSupportsToolCombinationOnVertex", func(t *testing.T) {
req := &schemas.BifrostResponsesRequest{
Provider: schemas.Vertex,
Model: "au.gemini-3.5-flash",
Input: []schemas.ResponsesMessage{{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("weather in sydney?")},
}},
Params: &schemas.ResponsesParameters{
Tools: []schemas.ResponsesTool{
{Type: schemas.ResponsesToolTypeWebSearch, ResponsesToolWebSearch: &schemas.ResponsesToolWebSearch{}},
{
Type: schemas.ResponsesToolTypeFunction,
Name: schemas.Ptr("get_weather"),
ResponsesToolFunction: &schemas.ResponsesToolFunction{Parameters: &schemas.ToolFunctionParameters{Type: "object"}},
},
},
},
}
out, err := gemini.ToGeminiResponsesRequest(nil, req)
require.NoError(t, err)
require.Len(t, out.Tools, 2, "regional Gemini 3+ model must support combining function declarations and Google Search")
require.Len(t, out.Tools[0].FunctionDeclarations, 1)
assert.NotNil(t, out.Tools[1].GoogleSearch)
})

t.Run("SplitFinishReasonAndUsageMetadataStreamChunks", func(t *testing.T) {
state := gemini.NewGeminiStreamState()

chunk1 := &gemini.GenerateContentResponse{
ResponseID: "resp-split-1",
ModelVersion: "gemini-3.5-flash",
Candidates: []*gemini.Candidate{{
Index: 0,
FinishReason: gemini.FinishReasonStop,
Content: &gemini.Content{
Role: string(gemini.RoleModel),
Parts: []*gemini.Part{{Text: "Hello"}},
},
}},
}
deltas1, bifrostErr, isLast1 := chunk1.ToBifrostChatCompletionStream(state)
require.Nil(t, bifrostErr)
assert.False(t, isLast1, "finishReason chunk without usageMetadata must not terminate stream early")
require.Len(t, deltas1, 1)
require.Len(t, deltas1[0].Choices, 1)
require.NotNil(t, deltas1[0].Choices[0].FinishReason)
assert.Equal(t, "stop", *deltas1[0].Choices[0].FinishReason)

chunk2 := &gemini.GenerateContentResponse{
ResponseID: "resp-split-1",
ModelVersion: "gemini-3.5-flash",
Candidates: []*gemini.Candidate{},
UsageMetadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 2500,
CachedContentTokenCount: 2000,
CandidatesTokenCount: 10,
ThoughtsTokenCount: 120,
TotalTokenCount: 2630,
},
}
deltas2, bifrostErr, isLast2 := chunk2.ToBifrostChatCompletionStream(state)
require.Nil(t, bifrostErr)
assert.True(t, isLast2, "trailing usageMetadata chunk after finishReason must close stream")
require.Len(t, deltas2, 1)
require.NotNil(t, deltas2[0].Usage)
assert.Equal(t, 2500, deltas2[0].Usage.PromptTokens)
require.NotNil(t, deltas2[0].Usage.PromptTokensDetails)
assert.Equal(t, 2000, deltas2[0].Usage.PromptTokensDetails.CachedReadTokens)
require.NotNil(t, deltas2[0].Usage.CompletionTokensDetails)
assert.Equal(t, 120, deltas2[0].Usage.CompletionTokensDetails.ReasoningTokens)
assert.Equal(t, 2630, deltas2[0].Usage.TotalTokens)
})
}
1 change: 1 addition & 0 deletions core/providers/gemini/passthrough_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ func extractGeminiGenerateContentUsage(body []byte) *schemas.BifrostPassthroughU
}
if ru.OutputTokensDetails != nil {
usage.CompletionTokensDetails = &schemas.ChatCompletionTokensDetails{
TextTokens: ru.OutputTokensDetails.TextTokens,
ReasoningTokens: ru.OutputTokensDetails.ReasoningTokens,
AudioTokens: ru.OutputTokensDetails.AudioTokens,
}
Expand Down
83 changes: 28 additions & 55 deletions core/providers/gemini/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"maps"
"reflect"
"slices"
"strconv"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -4466,23 +4465,27 @@ func (r *GeminiGenerationRequest) convertParamsToGenerationConfigResponses(param
// Only set ThinkingConfig if the model actually supports thinking
caps := schemas.ResolveModelCaps(provider, capModel)
if params.Reasoning != nil && caps.SupportsReasoning(defaultSupportsReasoning(capModel)) {
includeThoughts := true
if params.Reasoning.Summary != nil && *params.Reasoning.Summary == "none" {
includeThoughts = false
}
config.ThinkingConfig = &GenerationConfigThinkingConfig{
IncludeThoughts: true,
IncludeThoughts: includeThoughts,
}

hasMaxTokens := params.Reasoning.MaxTokens != nil
hasEffort := params.Reasoning.Effort != nil
hasEffort := params.Reasoning.Effort != nil && *params.Reasoning.Effort != "none"
isDisabled := (params.Reasoning.Effort != nil && *params.Reasoning.Effort == "none") ||
(hasMaxTokens && *params.Reasoning.MaxTokens == 0)
supportsLevel := caps.SupportsReasoningEffort(isGemini3Plus(capModel)) // thinkingLevel vs thinkingBudget

// PRIORITY RULE: If both max_tokens and effort are present, use ONLY max_tokens (budget)
// This ensures we send only thinkingBudget to Gemini, not thinkingLevel

// Handle "none" effort explicitly (only if max_tokens not present)
if !hasMaxTokens && hasEffort && *params.Reasoning.Effort == "none" {
if isDisabled {
setThinkingBudgetZeroIfSupported(&config, caps)
} else if supportsLevel && hasEffort && effortToThinkingLevel(caps, *params.Reasoning.Effort) != "" {
// Gemini 3.0+ - use thinkingLevel (more native), including when cross-provider
// converters (e.g. Anthropic thinking.budget_tokens) populate both Effort and MaxTokens.
config.ThinkingConfig.ThinkingLevel = schemas.Ptr(effortToThinkingLevel(caps, *params.Reasoning.Effort))
} else if hasMaxTokens {
// User provided max_tokens - use thinkingBudget (all Gemini models support this)
// If both max_tokens and effort are present, we ignore effort and use ONLY max_tokens
budget := *params.Reasoning.MaxTokens
switch budget {
case 0:
Expand All @@ -4496,27 +4499,19 @@ func (r *GeminiGenerationRequest) convertParamsToGenerationConfigResponses(param
config.ThinkingConfig.ThinkingBudget = schemas.Ptr(int32(budget))
}
} else if hasEffort {
// User provided effort only (no max_tokens)
if supportsLevel {
// Gemini 3.0+ - use thinkingLevel (more native)
if level := effortToThinkingLevel(caps, *params.Reasoning.Effort); level != "" {
config.ThinkingConfig.ThinkingLevel = schemas.Ptr(level)
}
} else {
maxTokens := providerUtils.GetMaxOutputTokensOrDefault(provider, capModel, DefaultCompletionMaxTokens)
if config.MaxOutputTokens > 0 {
maxTokens = int(config.MaxOutputTokens)
}
budgetRange := getThinkingBudgetRange(caps, maxTokens)
// Gemini < 3.0 - must convert effort to budget
budgetTokens, err := providerUtils.GetBudgetTokensFromReasoningEffort(
*params.Reasoning.Effort,
budgetRange.Min,
budgetRange.Max,
)
if err == nil {
config.ThinkingConfig.ThinkingBudget = schemas.Ptr(int32(budgetTokens))
}
maxTokens := providerUtils.GetMaxOutputTokensOrDefault(provider, capModel, DefaultCompletionMaxTokens)
if config.MaxOutputTokens > 0 {
maxTokens = int(config.MaxOutputTokens)
}
budgetRange := getThinkingBudgetRange(caps, maxTokens)
// Gemini < 3.0 - must convert effort to budget
budgetTokens, err := providerUtils.GetBudgetTokensFromReasoningEffort(
*params.Reasoning.Effort,
budgetRange.Min,
budgetRange.Max,
)
if err == nil {
config.ThinkingConfig.ThinkingBudget = schemas.Ptr(int32(budgetTokens))
}
}
}
Expand Down Expand Up @@ -4620,29 +4615,7 @@ func responsesExtraParamsWithoutGenerationConfigKeys(extraParams map[string]inte
// hard 400, guessing "not capable" costs only the built-in tool, so the safe default is to
// drop rather than to send.
func modelSupportsToolCombination(model string) bool {
name := strings.ToLower(model)
// Strip a provider prefix ("vertex/gemini-3.6-flash") and any publisher path.
if idx := strings.LastIndex(name, "/"); idx != -1 {
name = name[idx+1:]
}
const prefix = "gemini-"
if !strings.HasPrefix(name, prefix) {
return false
}
// Read the leading major version: "3-flash-preview" -> 3, "2.5-pro" -> 2.
rest := name[len(prefix):]
end := 0
for end < len(rest) && rest[end] >= '0' && rest[end] <= '9' {
end++
}
if end == 0 {
return false
}
major, err := strconv.Atoi(rest[:end])
if err != nil {
return false
}
return major >= 3
return isGemini3Plus(model)
}

// convertResponsesToolsToGemini converts Responses tools to Gemini tools.
Expand Down Expand Up @@ -6005,4 +5978,4 @@ func generateSyntheticFunctionCallArgumentDeltas(argumentsJSON string, outputInd
}

return events
}
}
Loading