Files
bifrost/core/providers/gemini/gemini_test.go
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

2989 lines
103 KiB
Go

package gemini_test
import (
"context"
"encoding/base64"
"encoding/json"
"os"
"strings"
"testing"
"github.com/maximhq/bifrost/core/internal/llmtests"
"github.com/maximhq/bifrost/core/providers/gemini"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/maximhq/bifrost/core/schemas"
)
func TestGemini(t *testing.T) {
t.Parallel()
if strings.TrimSpace(os.Getenv("GEMINI_API_KEY")) == "" {
t.Skip("Skipping Gemini tests because GEMINI_API_KEY is not set")
}
client, ctx, cancel, err := llmtests.SetupTest()
if err != nil {
t.Fatalf("Error initializing test setup: %v", err)
}
defer cancel()
defer client.Shutdown()
testConfig := llmtests.ComprehensiveTestConfig{
Provider: schemas.Gemini,
ChatModel: "gemini-2.0-flash",
Fallbacks: []schemas.Fallback{
{Provider: schemas.Gemini, Model: "gemini-2.5-flash"},
},
VisionModel: "gemini-2.5-flash",
EmbeddingModel: "gemini-embedding-001",
TranscriptionModel: "gemini-2.5-flash",
SpeechSynthesisModel: "gemini-2.5-flash-preview-tts",
ImageGenerationModel: "gemini-2.5-flash-image",
ImageEditModel: "gemini-3-pro-image-preview",
SpeechSynthesisFallbacks: []schemas.Fallback{
{Provider: schemas.Gemini, Model: "gemini-2.5-pro-preview-tts"},
},
ReasoningModel: "gemini-3-pro-preview",
VideoGenerationModel: "veo-3.1-generate-preview",
PassthroughModel: "gemini-2.5-flash",
Scenarios: llmtests.TestScenarios{
TextCompletion: false, // Not supported
SimpleChat: true,
CompletionStream: true,
MultiTurnConversation: true,
ToolCalls: true,
ToolCallsStreaming: true,
MultipleToolCalls: true,
MultipleToolCallsStreaming: true,
End2EndToolCalling: true,
AutomaticFunctionCall: true,
WebSearchTool: true,
ImageURL: false,
ImageBase64: true,
MultipleImages: false,
ImageGeneration: true,
ImageGenerationStream: false,
ImageEdit: true,
VideoGeneration: false, // disabled for now because of long running operations
VideoRetrieve: false,
VideoDownload: false,
FileBase64: true,
FileURL: false, // supported files via gemini files api
CompleteEnd2End: true,
Embedding: true,
Transcription: false,
TranscriptionStream: false,
SpeechSynthesis: true,
SpeechSynthesisStream: true,
Reasoning: true,
ListModels: true,
BatchCreate: true,
BatchList: true,
BatchRetrieve: true,
BatchCancel: true,
BatchResults: true,
FileUpload: true,
FileList: true,
FileRetrieve: true,
FileDelete: true,
FileContent: false,
FileBatchInput: true,
CountTokens: true,
StructuredOutputs: true, // Structured outputs with nullable enum support
PassthroughAPI: true,
},
}
t.Run("GeminiTests", func(t *testing.T) {
llmtests.RunAllComprehensiveTests(t, client, ctx, testConfig)
})
}
// TestEmptyCandidatesRegression is a regression test for PR #1018
// Ensures empty/filtered candidates never return empty choices arrays
func TestEmptyCandidatesRegression(t *testing.T) {
tests := []struct {
name string
response *gemini.GenerateContentResponse
isStream bool
expectFinish string
}{
{
name: "EmptyCandidates_NonStream",
response: &gemini.GenerateContentResponse{
ResponseID: "test-1",
ModelVersion: "gemini-2.0-flash",
Candidates: []*gemini.Candidate{}, // Empty - the bug case
UsageMetadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 10,
TotalTokenCount: 10,
},
},
isStream: false,
expectFinish: "stop",
},
{
name: "EmptyCandidates_Stream",
response: &gemini.GenerateContentResponse{
ResponseID: "test-2",
ModelVersion: "gemini-2.0-flash",
Candidates: []*gemini.Candidate{},
UsageMetadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 10,
TotalTokenCount: 10,
},
},
isStream: true,
expectFinish: "stop",
},
{
name: "SafetyFilter_NonStream",
response: &gemini.GenerateContentResponse{
ResponseID: "test-3",
ModelVersion: "gemini-2.0-flash",
Candidates: []*gemini.Candidate{
{
Index: 0,
FinishReason: gemini.FinishReasonSafety,
Content: &gemini.Content{Role: string(gemini.RoleModel), Parts: []*gemini.Part{}},
},
},
},
isStream: false,
expectFinish: "content_filter",
},
{
name: "MalformedFunctionCall_Stream",
response: &gemini.GenerateContentResponse{
ResponseID: "test-4",
ModelVersion: "gemini-2.0-flash",
Candidates: []*gemini.Candidate{
{
Index: 0,
FinishReason: gemini.FinishReasonMalformedFunctionCall,
Content: &gemini.Content{Role: string(gemini.RoleModel), Parts: []*gemini.Part{}},
},
},
},
isStream: true,
expectFinish: "stop",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var bifrostResp *schemas.BifrostChatResponse
if tt.isStream {
bifrostResp, _, _ = tt.response.ToBifrostChatCompletionStream(gemini.NewGeminiStreamState())
} else {
bifrostResp = tt.response.ToBifrostChatResponse()
}
// Critical: Choices must NEVER be empty (this was the PR #1018 bug)
require.NotNil(t, bifrostResp, "Response should not be nil")
require.NotEmpty(t, bifrostResp.Choices, "Empty choices array")
require.Len(t, bifrostResp.Choices, 1, "Should have exactly one error choice")
// Verify error signal
choice := bifrostResp.Choices[0]
require.NotNil(t, choice.FinishReason, "finish_reason must be set")
assert.Equal(t, tt.expectFinish, *choice.FinishReason, "finish_reason should signal the error type")
// Verify message structure exists
if !tt.isStream {
require.NotNil(t, choice.ChatNonStreamResponseChoice, "Non-stream should have message")
require.NotNil(t, choice.ChatNonStreamResponseChoice.Message, "Should have message object")
assert.Equal(t, schemas.ChatMessageRoleAssistant, choice.ChatNonStreamResponseChoice.Message.Role)
}
})
}
}
func TestToBifrostEmbeddingResponsePreservesPrecision(t *testing.T) {
const want = 0.12345678901234568
resp := gemini.ToBifrostEmbeddingResponse(&gemini.GeminiEmbeddingResponse{
Embeddings: []gemini.GeminiEmbedding{
{
Values: []float64{want},
},
},
}, "gemini-embedding-001")
require.NotNil(t, resp)
got := resp.Data[0].Embedding.EmbeddingArray[0]
assert.Equal(t, want, got)
assert.NotEqual(t, float64(float32(want)), got)
}
// TestThoughtSignatureInToolCalls tests that thought signatures are properly embedded in tool call IDs
// for both streaming and non-streaming responses to enable round-trip compatibility
func TestThoughtSignatureInToolCalls(t *testing.T) {
thoughtSig := []byte{0x01, 0x02, 0x03, 0x04, 0x05} // Sample signature
tests := []struct {
name string
response *gemini.GenerateContentResponse
isStream bool
}{
{
name: "NonStream_ToolCallWithThoughtSignature",
response: &gemini.GenerateContentResponse{
ResponseID: "test-non-stream",
ModelVersion: "gemini-3-pro-preview",
Candidates: []*gemini.Candidate{
{
Index: 0,
FinishReason: gemini.FinishReasonStop,
Content: &gemini.Content{
Role: string(gemini.RoleModel),
Parts: []*gemini.Part{
{
FunctionCall: &gemini.FunctionCall{
Name: "get_weather",
ID: "call_123",
Args: json.RawMessage(`{"location":"San Francisco"}`),
},
ThoughtSignature: thoughtSig,
},
},
},
},
},
},
isStream: false,
},
{
name: "Stream_ToolCallWithThoughtSignature",
response: &gemini.GenerateContentResponse{
ResponseID: "test-stream",
ModelVersion: "gemini-3-pro-preview",
Candidates: []*gemini.Candidate{
{
Index: 0,
FinishReason: gemini.FinishReasonStop,
Content: &gemini.Content{
Role: string(gemini.RoleModel),
Parts: []*gemini.Part{
{
FunctionCall: &gemini.FunctionCall{
Name: "get_weather",
ID: "call_456",
Args: json.RawMessage(`{"location":"New York"}`),
},
ThoughtSignature: thoughtSig,
},
},
},
},
},
UsageMetadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 10,
TotalTokenCount: 20,
},
},
isStream: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var bifrostResp *schemas.BifrostChatResponse
if tt.isStream {
bifrostResp, _, _ = tt.response.ToBifrostChatCompletionStream(gemini.NewGeminiStreamState())
} else {
bifrostResp = tt.response.ToBifrostChatResponse()
}
require.NotNil(t, bifrostResp, "Response should not be nil")
require.NotEmpty(t, bifrostResp.Choices, "Should have choices")
choice := bifrostResp.Choices[0]
// Get tool calls from appropriate response type
var toolCalls []schemas.ChatAssistantMessageToolCall
if tt.isStream {
require.NotNil(t, choice.ChatStreamResponseChoice, "Stream should have delta")
require.NotNil(t, choice.ChatStreamResponseChoice.Delta, "Should have delta")
toolCalls = choice.ChatStreamResponseChoice.Delta.ToolCalls
} else {
require.NotNil(t, choice.ChatNonStreamResponseChoice, "Non-stream should have message")
require.NotNil(t, choice.ChatNonStreamResponseChoice.Message, "Should have message")
require.NotNil(t, choice.ChatNonStreamResponseChoice.Message.ChatAssistantMessage, "Should have assistant message")
toolCalls = choice.ChatNonStreamResponseChoice.Message.ChatAssistantMessage.ToolCalls
}
// Critical: Tool call ID must contain embedded thought signature
require.Len(t, toolCalls, 1, "Should have exactly one tool call")
toolCall := toolCalls[0]
require.NotNil(t, toolCall.ID, "Tool call must have ID")
// Verify thought signature is embedded in the ID (format: "call_id_ts_base64sig")
assert.Contains(t, *toolCall.ID, "_ts_", "Tool call ID must contain thought signature separator")
// Verify we can extract the thought signature from the ID for round-trip
parts := strings.SplitN(*toolCall.ID, "_ts_", 2)
require.Len(t, parts, 2, "Should be able to split ID into base and signature")
assert.NotEmpty(t, parts[1], "Signature part should not be empty")
// Verify reasoning details also contain the signature (backward compatibility)
var reasoningDetails []schemas.ChatReasoningDetails
if tt.isStream {
reasoningDetails = choice.ChatStreamResponseChoice.Delta.ReasoningDetails
} else {
reasoningDetails = choice.ChatNonStreamResponseChoice.Message.ChatAssistantMessage.ReasoningDetails
}
assert.NotEmpty(t, reasoningDetails, "Should have reasoning details")
foundEncrypted := false
for _, detail := range reasoningDetails {
if detail.Type == schemas.BifrostReasoningDetailsTypeEncrypted && detail.Signature != nil {
foundEncrypted = true
break
}
}
assert.True(t, foundEncrypted, "Should have encrypted reasoning detail with signature")
})
}
}
func TestMissingThoughtSignatureUsesBypassSentinel(t *testing.T) {
result, err := gemini.ToGeminiChatCompletionRequest(&schemas.BifrostChatRequest{
Model: "gemini-3.1-pro-preview",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("What is the weather?")},
},
{
Role: schemas.ChatMessageRoleAssistant,
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{{
ID: schemas.Ptr("call_1"),
Type: schemas.Ptr("function"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("get_weather"),
Arguments: `{"location":"Boston"}`,
},
}},
},
},
{
Role: schemas.ChatMessageRoleTool,
ChatToolMessage: &schemas.ChatToolMessage{ToolCallID: schemas.Ptr("call_1")},
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr(`{"temperature":"10C"}`)},
},
},
})
require.Len(t, result.Contents, 3)
require.Len(t, result.Contents[1].Parts, 1)
assert.Equal(t, []byte("skip_thought_signature_validator"), result.Contents[1].Parts[0].ThoughtSignature)
encoded, err := json.Marshal(result)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"thoughtSignature":"skip_thought_signature_validator"`)
assert.NotContains(t, string(encoded), `"thoughtSignature":"c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I="`)
}
func TestEmbeddedThoughtSignatureDoesNotUseBypassSentinel(t *testing.T) {
thoughtSig := base64.RawURLEncoding.EncodeToString([]byte{0x01, 0x02, 0x03})
callID := "call_1_ts_" + thoughtSig
result, err := gemini.ToGeminiChatCompletionRequest(&schemas.BifrostChatRequest{
Model: "gemini-3.1-pro-preview",
Input: []schemas.ChatMessage{{
Role: schemas.ChatMessageRoleAssistant,
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{{
ID: schemas.Ptr(callID),
Type: schemas.Ptr("function"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("get_weather"),
Arguments: `{"location":"Boston"}`,
},
}},
},
}},
})
require.Len(t, result.Contents, 1)
require.Len(t, result.Contents[0].Parts, 1)
assert.NotEqual(t, []byte("skip_thought_signature_validator"), result.Contents[0].Parts[0].ThoughtSignature)
encoded, err := json.Marshal(result)
require.NoError(t, err)
assert.NotContains(t, string(encoded), `"thoughtSignature":"skip_thought_signature_validator"`)
}
func TestThoughtSignatureBypassSentinelRoundTripsThroughJSON(t *testing.T) {
part := gemini.Part{ThoughtSignature: []byte("skip_thought_signature_validator")}
encoded, err := json.Marshal(part)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"thoughtSignature":"skip_thought_signature_validator"`)
var decoded gemini.Part
require.NoError(t, json.Unmarshal(encoded, &decoded))
assert.Equal(t, []byte("skip_thought_signature_validator"), decoded.ThoughtSignature)
}
// TestBifrostToGeminiToolConversion tests the conversion of tools from Bifrost to Gemini format
func TestBifrostToGeminiToolConversion(t *testing.T) {
tests := []struct {
name string
input *schemas.BifrostChatRequest
validate func(t *testing.T, result *gemini.GeminiGenerationRequest)
}{
{
name: "ComprehensiveToolWithArrayAndEnum",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Test comprehensive tool"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "search_products",
Description: schemas.Ptr("Search for products with filters"),
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("query", map[string]interface{}{
"type": "string",
"description": "Search query",
}),
schemas.KV("category", map[string]interface{}{
"type": "string",
"description": "Product category",
"enum": []interface{}{"electronics", "books", "clothing"},
}),
schemas.KV("tags", map[string]interface{}{
"type": "array",
"description": "Filter tags",
"items": map[string]interface{}{
"type": "string",
"description": "A tag",
},
}),
),
Required: []string{"query"},
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Tools, 1)
fd := result.Tools[0].FunctionDeclarations[0]
// Basic validation
assert.Equal(t, "search_products", fd.Name)
assert.Equal(t, "Search for products with filters", fd.Description)
assert.Equal(t, []string{"query"}, fd.Parameters.Required)
// String property
queryProp := fd.Parameters.Properties["query"]
assert.Equal(t, gemini.Type("string"), queryProp.Type)
// Enum property
categoryProp := fd.Parameters.Properties["category"]
assert.Equal(t, gemini.Type("string"), categoryProp.Type)
assert.Equal(t, []string{"electronics", "books", "clothing"}, categoryProp.Enum)
// Array with items (the critical bug fix)
tagsProp := fd.Parameters.Properties["tags"]
assert.Equal(t, gemini.Type("array"), tagsProp.Type)
require.NotNil(t, tagsProp.Items, "items field must be present - this was the bug")
assert.Equal(t, gemini.Type("string"), tagsProp.Items.Type)
},
},
{
name: "ComplexNestedStructures",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Test nested structures"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "process_order",
Description: schemas.Ptr("Process customer order"),
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("customer", map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
},
"email": map[string]interface{}{
"type": "string",
},
},
"required": []string{"name", "email"},
}),
schemas.KV("items", map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"product_id": map[string]interface{}{
"type": "string",
},
"quantity": map[string]interface{}{
"type": "integer",
},
},
"required": []string{"product_id", "quantity"},
},
}),
),
Required: []string{"customer", "items"},
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Tools, 1)
fd := result.Tools[0].FunctionDeclarations[0]
// Nested object
customerProp := fd.Parameters.Properties["customer"]
assert.Equal(t, gemini.Type("object"), customerProp.Type)
assert.Contains(t, customerProp.Properties, "name")
assert.Contains(t, customerProp.Properties, "email")
assert.Equal(t, []string{"name", "email"}, customerProp.Required)
// Array of objects
itemsProp := fd.Parameters.Properties["items"]
assert.Equal(t, gemini.Type("array"), itemsProp.Type)
require.NotNil(t, itemsProp.Items, "array items must be present")
assert.Equal(t, gemini.Type("object"), itemsProp.Items.Type)
assert.Contains(t, itemsProp.Items.Properties, "product_id")
assert.Contains(t, itemsProp.Items.Properties, "quantity")
assert.Equal(t, []string{"product_id", "quantity"}, itemsProp.Items.Required)
},
},
{
// This test reproduces the bug where nested properties inside array items
// are *OrderedMap (from JSON deserialization) instead of map[string]interface{}.
// The old code only handled map[string]interface{}, silently dropping properties
// while keeping required, causing Gemini to reject with "property is not defined".
name: "NestedOrderedMapPropertiesInArrayItems",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Test nested OrderedMap properties"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "browser_fill_form",
Description: schemas.Ptr("Fill form fields"),
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
// Use OrderedMap for the nested items.properties to simulate
// JSON deserialization, which stores nested objects as *OrderedMap
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("fields", map[string]interface{}{
"type": "array",
"description": "Fields to fill in",
"items": schemas.NewOrderedMapFromPairs(
schemas.KV("type", "object"),
schemas.KV("properties", schemas.NewOrderedMapFromPairs(
schemas.KV("name", map[string]interface{}{
"type": "string",
"description": "Human-readable field name",
}),
schemas.KV("ref", map[string]interface{}{
"type": "string",
"description": "Target field reference",
}),
schemas.KV("value", map[string]interface{}{
"type": "string",
"description": "Value to fill",
}),
)),
schemas.KV("required", []interface{}{"name", "ref", "value"}),
schemas.KV("additionalProperties", false),
),
}),
),
Required: []string{"fields"},
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Tools, 1)
fd := result.Tools[0].FunctionDeclarations[0]
assert.Equal(t, "browser_fill_form", fd.Name)
fieldsProp := fd.Parameters.Properties["fields"]
assert.Equal(t, gemini.Type("array"), fieldsProp.Type)
require.NotNil(t, fieldsProp.Items, "array items must be present")
assert.Equal(t, gemini.Type("object"), fieldsProp.Items.Type)
// This is the critical assertion: nested properties inside items must
// be preserved even when they come as *OrderedMap from JSON deserialization.
require.NotNil(t, fieldsProp.Items.Properties, "nested properties must not be nil - this was the bug")
assert.Contains(t, fieldsProp.Items.Properties, "name")
assert.Contains(t, fieldsProp.Items.Properties, "ref")
assert.Contains(t, fieldsProp.Items.Properties, "value")
assert.Equal(t, []string{"name", "ref", "value"}, fieldsProp.Items.Required)
},
},
{
name: "EmptyItemsObject",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Edge case test"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "test_tool",
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("data", map[string]interface{}{
"type": "array",
"items": map[string]interface{}{}, // Empty items object
}),
),
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
fd := result.Tools[0].FunctionDeclarations[0]
dataProp := fd.Parameters.Properties["data"]
// Even empty items should be converted (not nil)
assert.NotNil(t, dataProp.Items, "empty items object should still be present")
},
},
{
name: "ToolWithValidationConstraints",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Test validation constraints"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "validate_input",
Description: schemas.Ptr("Validate input with constraints"),
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("username", map[string]interface{}{
"type": "string",
"description": "Username with length constraints",
"minLength": float64(3),
"maxLength": float64(20),
"pattern": "^[a-zA-Z0-9_]+$",
}),
schemas.KV("age", map[string]interface{}{
"type": "integer",
"minimum": float64(0),
"maximum": float64(150),
}),
schemas.KV("tags", map[string]interface{}{
"type": "array",
"minItems": float64(1),
"maxItems": float64(5),
"items": map[string]interface{}{
"type": "string",
},
}),
),
Required: []string{"username"},
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Tools, 1)
fd := result.Tools[0].FunctionDeclarations[0]
// Validate string constraints
usernameProp := fd.Parameters.Properties["username"]
assert.Equal(t, gemini.Type("string"), usernameProp.Type)
require.NotNil(t, usernameProp.MinLength, "minLength should be set")
assert.Equal(t, int64(3), *usernameProp.MinLength)
require.NotNil(t, usernameProp.MaxLength, "maxLength should be set")
assert.Equal(t, int64(20), *usernameProp.MaxLength)
assert.Equal(t, "^[a-zA-Z0-9_]+$", usernameProp.Pattern)
// Validate number constraints
ageProp := fd.Parameters.Properties["age"]
assert.Equal(t, gemini.Type("integer"), ageProp.Type)
require.NotNil(t, ageProp.Minimum, "minimum should be set")
assert.Equal(t, float64(0), *ageProp.Minimum)
require.NotNil(t, ageProp.Maximum, "maximum should be set")
assert.Equal(t, float64(150), *ageProp.Maximum)
// Validate array constraints
tagsProp := fd.Parameters.Properties["tags"]
assert.Equal(t, gemini.Type("array"), tagsProp.Type)
require.NotNil(t, tagsProp.MinItems, "minItems should be set")
assert.Equal(t, int64(1), *tagsProp.MinItems)
require.NotNil(t, tagsProp.MaxItems, "maxItems should be set")
assert.Equal(t, int64(5), *tagsProp.MaxItems)
},
},
{
name: "ToolWithAnyOfUnionTypes",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Test anyOf union types"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "process_id",
Description: schemas.Ptr("Process ID that can be string or number"),
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("id", map[string]interface{}{
"anyOf": []interface{}{
map[string]interface{}{"type": "string"},
map[string]interface{}{"type": "integer"},
},
"description": "ID that can be string or integer",
}),
),
Required: []string{"id"},
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Tools, 1)
fd := result.Tools[0].FunctionDeclarations[0]
// Validate anyOf is preserved
idProp := fd.Parameters.Properties["id"]
require.NotNil(t, idProp.AnyOf, "anyOf should be set")
require.Len(t, idProp.AnyOf, 2, "anyOf should have 2 options")
assert.Equal(t, gemini.Type("string"), idProp.AnyOf[0].Type)
assert.Equal(t, gemini.Type("integer"), idProp.AnyOf[1].Type)
},
},
{
name: "ToolWithTopLevelItems",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Test top-level items field"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "process_list",
Description: schemas.Ptr("Process a list of items"),
Parameters: &schemas.ToolFunctionParameters{
Type: "array",
Items: schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
schemas.KV("description", "Item in the list"),
),
MinItems: schemas.Ptr(int64(1)),
MaxItems: schemas.Ptr(int64(10)),
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Tools, 1)
fd := result.Tools[0].FunctionDeclarations[0]
// Validate top-level array schema
assert.Equal(t, gemini.Type("array"), fd.Parameters.Type)
require.NotNil(t, fd.Parameters.Items, "items should be set on top-level array")
assert.Equal(t, gemini.Type("string"), fd.Parameters.Items.Type)
require.NotNil(t, fd.Parameters.MinItems, "minItems should be set")
assert.Equal(t, int64(1), *fd.Parameters.MinItems)
require.NotNil(t, fd.Parameters.MaxItems, "maxItems should be set")
assert.Equal(t, int64(10), *fd.Parameters.MaxItems)
},
},
{
name: "ToolWithMiscFields",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Test misc fields"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "config_tool",
Description: schemas.Ptr("Tool with misc schema fields"),
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Title: schemas.Ptr("ConfigParameters"),
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("enabled", map[string]interface{}{
"type": "boolean",
"default": true,
"nullable": true,
"title": "Enabled Flag",
}),
schemas.KV("format_type", map[string]interface{}{
"type": "string",
"format": "email",
}),
),
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Tools, 1)
fd := result.Tools[0].FunctionDeclarations[0]
// Validate title at top level
assert.Equal(t, "ConfigParameters", fd.Parameters.Title)
// Validate misc fields on properties
enabledProp := fd.Parameters.Properties["enabled"]
assert.Equal(t, true, enabledProp.Default)
require.NotNil(t, enabledProp.Nullable, "nullable should be set")
assert.True(t, *enabledProp.Nullable)
assert.Equal(t, "Enabled Flag", enabledProp.Title)
formatTypeProp := fd.Parameters.Properties["format_type"]
assert.Equal(t, "email", formatTypeProp.Format)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := gemini.ToGeminiChatCompletionRequest(tt.input)
require.NoError(t, err)
require.NotNil(t, result, "Conversion should not return nil")
tt.validate(t, result)
})
}
}
func TestBifrostToGeminiToolConversion_PropertyOrdering(t *testing.T) {
input := &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("test")},
}},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "AnswerResponseModel",
Description: schemas.Ptr("Extract answer"),
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("chain_of_thought", map[string]interface{}{"type": "string", "description": "Reasoning"}),
schemas.KV("answer", map[string]interface{}{"type": "string", "description": "The answer"}),
schemas.KV("citations", map[string]interface{}{"type": "array"}),
),
Required: []string{"chain_of_thought", "answer"},
},
},
}},
},
}
result, err := gemini.ToGeminiChatCompletionRequest(input)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result.Tools, 1)
fd := result.Tools[0].FunctionDeclarations[0]
// CoT: PropertyOrdering preserves client's intended field order
assert.Equal(t, []string{"chain_of_thought", "answer", "citations"}, fd.Parameters.PropertyOrdering,
"PropertyOrdering should preserve original property order")
// All properties present in map
assert.Len(t, fd.Parameters.Properties, 3)
assert.Contains(t, fd.Parameters.Properties, "chain_of_thought")
assert.Contains(t, fd.Parameters.Properties, "answer")
assert.Contains(t, fd.Parameters.Properties, "citations")
}
func TestBifrostToGeminiToolConversion_NestedPropertyOrdering(t *testing.T) {
input := &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("test")},
}},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "nested_tool",
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("output", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "object"),
schemas.KV("properties", schemas.NewOrderedMapFromPairs(
schemas.KV("verdict", schemas.NewOrderedMapFromPairs(schemas.KV("type", "string"))),
schemas.KV("score", schemas.NewOrderedMapFromPairs(schemas.KV("type", "number"))),
schemas.KV("explanation", schemas.NewOrderedMapFromPairs(schemas.KV("type", "string"))),
)),
)),
schemas.KV("reasoning", map[string]interface{}{"type": "string"}),
),
},
},
}},
},
}
result, err := gemini.ToGeminiChatCompletionRequest(input)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result.Tools, 1)
fd := result.Tools[0].FunctionDeclarations[0]
// Top-level property ordering
assert.Equal(t, []string{"output", "reasoning"}, fd.Parameters.PropertyOrdering)
// Nested property ordering
outputSchema := fd.Parameters.Properties["output"]
require.NotNil(t, outputSchema)
assert.Equal(t, []string{"verdict", "score", "explanation"}, outputSchema.PropertyOrdering,
"nested PropertyOrdering should preserve original order")
}
// TestStructuredOutputConversion tests that response_format with json_schema is properly converted to Gemini's responseJsonSchema
func TestStructuredOutputConversion(t *testing.T) {
tests := []struct {
name string
input *schemas.BifrostChatRequest
validate func(t *testing.T, result *gemini.GeminiGenerationRequest)
}{
{
name: "JSONSchemaWithUnionTypes_ConvertedToAnyOf",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.5-pro",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Extract information: User ID is 12345, Status is \"active\""),
},
},
},
Params: &schemas.ChatParameters{
ResponseFormat: schemas.Ptr[interface{}](map[string]interface{}{
"type": "json_schema",
"json_schema": map[string]interface{}{
"name": "UserInfo",
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"user_id": map[string]interface{}{
"type": []interface{}{"string", "integer"},
"description": "User ID as string or integer",
},
"status": map[string]interface{}{
"type": "string",
"enum": []interface{}{"active", "inactive"},
},
},
"required": []interface{}{"user_id", "status"},
"additionalProperties": false,
},
},
}),
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
// Verify ResponseMIMEType is set
assert.Equal(t, "application/json", result.GenerationConfig.ResponseMIMEType, "responseMimeType should be application/json")
// Verify ResponseJSONSchema is set
assert.NotNil(t, result.GenerationConfig.ResponseJSONSchema, "responseJsonSchema should be set")
// Validate the schema structure
schemaMap, ok := result.GenerationConfig.ResponseJSONSchema.(map[string]interface{})
require.True(t, ok, "ResponseJSONSchema should be a map")
// Check properties
properties, ok := schemaMap["properties"].(map[string]interface{})
require.True(t, ok, "properties should be a map")
// Validate user_id property - should be converted to anyOf
userID, ok := properties["user_id"].(map[string]interface{})
require.True(t, ok, "user_id should exist in properties")
// user_id should have anyOf instead of type array
anyOf, hasAnyOf := userID["anyOf"]
assert.True(t, hasAnyOf, "user_id should have anyOf for union types")
anyOfSlice, ok := anyOf.([]interface{})
require.True(t, ok, "anyOf should be a slice")
require.Len(t, anyOfSlice, 2, "anyOf should have 2 branches for string and integer")
// Verify the anyOf branches
stringBranch := anyOfSlice[0].(map[string]interface{})
assert.Equal(t, "string", stringBranch["type"])
integerBranch := anyOfSlice[1].(map[string]interface{})
assert.Equal(t, "integer", integerBranch["type"])
// Validate status property - should remain unchanged
status, ok := properties["status"].(map[string]interface{})
require.True(t, ok, "status should exist in properties")
assert.Equal(t, "string", status["type"])
enum := status["enum"].([]interface{})
assert.Len(t, enum, 2)
},
},
{
name: "JSONSchemaWithNullableType_KeptAsArray",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.5-pro",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Extract nullable field"),
},
},
},
Params: &schemas.ChatParameters{
ResponseFormat: schemas.Ptr[interface{}](map[string]interface{}{
"type": "json_schema",
"json_schema": map[string]interface{}{
"name": "NullableData",
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": []interface{}{"string", "null"},
},
},
},
},
}),
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
schemaMap := result.GenerationConfig.ResponseJSONSchema.(map[string]interface{})
properties := schemaMap["properties"].(map[string]interface{})
name := properties["name"].(map[string]interface{})
// Nullable types should be kept as array (Gemini supports this)
typeVal := name["type"]
typeSlice, ok := typeVal.([]interface{})
require.True(t, ok, "type should remain as array for nullable types")
require.Len(t, typeSlice, 2)
assert.Contains(t, typeSlice, "string")
assert.Contains(t, typeSlice, "null")
},
},
{
name: "JSONSchemaComplex",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.5-pro",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Extract nested data"),
},
},
},
Params: &schemas.ChatParameters{
ResponseFormat: schemas.Ptr[interface{}](map[string]interface{}{
"type": "json_schema",
"json_schema": map[string]interface{}{
"name": "ComplexData",
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"items": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "integer",
},
"name": map[string]interface{}{
"type": "string",
},
},
"required": []interface{}{"id", "name"},
},
},
},
"required": []interface{}{"items"},
},
},
}),
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
assert.Equal(t, "application/json", result.GenerationConfig.ResponseMIMEType)
assert.NotNil(t, result.GenerationConfig.ResponseJSONSchema)
schemaMap := result.GenerationConfig.ResponseJSONSchema.(map[string]interface{})
properties := schemaMap["properties"].(map[string]interface{})
items := properties["items"].(map[string]interface{})
// Validate array items
assert.Equal(t, "array", items["type"])
itemsSchema := items["items"].(map[string]interface{})
assert.Equal(t, "object", itemsSchema["type"])
// Validate nested properties
nestedProps := itemsSchema["properties"].(map[string]interface{})
assert.Contains(t, nestedProps, "id")
assert.Contains(t, nestedProps, "name")
},
},
{
name: "JSONObjectFormat",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.5-pro",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Return JSON"),
},
},
},
Params: &schemas.ChatParameters{
ResponseFormat: schemas.Ptr[interface{}](map[string]interface{}{
"type": "json_object",
}),
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
// json_object should only set ResponseMIMEType without schema
assert.Equal(t, "application/json", result.GenerationConfig.ResponseMIMEType)
assert.Nil(t, result.GenerationConfig.ResponseJSONSchema)
assert.Nil(t, result.GenerationConfig.ResponseSchema)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := gemini.ToGeminiChatCompletionRequest(tt.input)
require.NoError(t, err)
require.NotNil(t, result, "Conversion should not return nil")
tt.validate(t, result)
})
}
}
// TestResponsesStructuredOutputConversion tests that Responses API text config with union types is properly handled
func TestResponsesStructuredOutputConversion(t *testing.T) {
tests := []struct {
name string
input *schemas.BifrostResponsesRequest
validate func(t *testing.T, result *gemini.GeminiGenerationRequest)
}{
{
name: "ResponsesAPI_UnionTypes_ConvertedToAnyOf",
input: &schemas.BifrostResponsesRequest{
Provider: schemas.Gemini,
Model: "gemini-2.5-pro",
Input: []schemas.ResponsesMessage{
{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentStr: schemas.Ptr("Extract info with union types"),
},
},
},
Params: &schemas.ResponsesParameters{
Text: &schemas.ResponsesTextConfig{
Format: &schemas.ResponsesTextConfigFormat{
Type: "json_schema",
Name: schemas.Ptr("UserInfo"),
JSONSchema: &schemas.ResponsesTextConfigFormatJSONSchema{
Type: schemas.Ptr("object"),
Properties: &map[string]interface{}{
"user_id": map[string]interface{}{
"type": []interface{}{"string", "integer"},
"description": "User ID as string or integer",
},
"status": map[string]interface{}{
"type": "string",
"enum": []interface{}{"active", "inactive"},
},
},
Required: []string{"user_id", "status"},
AdditionalProperties: &schemas.AdditionalPropertiesStruct{
AdditionalPropertiesBool: schemas.Ptr(false),
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
// Verify ResponseMIMEType is set
assert.Equal(t, "application/json", result.GenerationConfig.ResponseMIMEType)
assert.NotNil(t, result.GenerationConfig.ResponseJSONSchema)
// Validate the schema structure
schemaMap, ok := result.GenerationConfig.ResponseJSONSchema.(map[string]interface{})
require.True(t, ok, "ResponseJSONSchema should be a map")
properties, ok := schemaMap["properties"].(map[string]interface{})
require.True(t, ok, "properties should be a map")
// Validate user_id property - should be converted to anyOf
userID, ok := properties["user_id"].(map[string]interface{})
require.True(t, ok, "user_id should exist in properties")
// user_id should have anyOf instead of type array
anyOf, hasAnyOf := userID["anyOf"]
assert.True(t, hasAnyOf, "user_id should have anyOf for union types in Responses API")
anyOfSlice, ok := anyOf.([]interface{})
require.True(t, ok, "anyOf should be a slice")
require.Len(t, anyOfSlice, 2, "anyOf should have 2 branches for string and integer")
// Verify the anyOf branches
stringBranch := anyOfSlice[0].(map[string]interface{})
assert.Equal(t, "string", stringBranch["type"])
integerBranch := anyOfSlice[1].(map[string]interface{})
assert.Equal(t, "integer", integerBranch["type"])
},
},
{
name: "ResponsesAPI_NullableType_KeptAsArray",
input: &schemas.BifrostResponsesRequest{
Provider: schemas.Gemini,
Model: "gemini-2.5-pro",
Input: []schemas.ResponsesMessage{
{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentStr: schemas.Ptr("Extract nullable field"),
},
},
},
Params: &schemas.ResponsesParameters{
Text: &schemas.ResponsesTextConfig{
Format: &schemas.ResponsesTextConfigFormat{
Type: "json_schema",
Name: schemas.Ptr("NullableData"),
JSONSchema: &schemas.ResponsesTextConfigFormatJSONSchema{
Type: schemas.Ptr("object"),
Properties: &map[string]interface{}{
"name": map[string]interface{}{
"type": []interface{}{"string", "null"},
},
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
schemaMap := result.GenerationConfig.ResponseJSONSchema.(map[string]interface{})
properties := schemaMap["properties"].(map[string]interface{})
name := properties["name"].(map[string]interface{})
// Nullable types should be kept as array (Gemini supports this)
typeVal := name["type"]
typeSlice, ok := typeVal.([]interface{})
require.True(t, ok, "type should remain as array for nullable types in Responses API")
require.Len(t, typeSlice, 2)
assert.Contains(t, typeSlice, "string")
assert.Contains(t, typeSlice, "null")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := gemini.ToGeminiResponsesRequest(tt.input)
require.NoError(t, err)
require.NotNil(t, result, "Responses API conversion should not return nil")
tt.validate(t, result)
})
}
}
// TestParallelFunctionCallingConversion tests that multiple consecutive tool responses are properly grouped
func TestParallelFunctionCallingConversion(t *testing.T) {
tests := []struct {
name string
input *schemas.BifrostChatRequest
validate func(t *testing.T, result *gemini.GeminiGenerationRequest)
}{
{
name: "SingleToolResponse_NotGrouped",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("What's the weather?"),
},
},
{
Role: schemas.ChatMessageRoleAssistant,
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{
{
ID: schemas.Ptr("call_1"),
Type: schemas.Ptr("function"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("get_weather"),
Arguments: `{"location":"Tokyo"}`,
},
},
},
},
},
{
Role: schemas.ChatMessageRoleTool,
ChatToolMessage: &schemas.ChatToolMessage{
ToolCallID: schemas.Ptr("call_1"),
},
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr(`{"temperature":22,"condition":"sunny"}`),
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.NotNil(t, result)
require.Len(t, result.Contents, 3, "Should have 3 Contents: user, assistant with tool calls, tool response")
// Validate tool response content (last Content)
toolResponseContent := result.Contents[2]
assert.Equal(t, "model", toolResponseContent.Role, "Tool responses use 'model' role in Gemini")
require.Len(t, toolResponseContent.Parts, 1, "Should have exactly 1 part for single tool response")
// Verify ONLY functionResponse part (no text part)
part := toolResponseContent.Parts[0]
assert.Empty(t, part.Text, "Tool response should NOT have text part")
require.NotNil(t, part.FunctionResponse, "Tool response must have functionResponse")
assert.Equal(t, "call_1", part.FunctionResponse.ID)
assert.Equal(t, "get_weather", part.FunctionResponse.Name)
},
},
{
name: "ParallelFunctionCalling_TwoToolResponses_Grouped",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("What's the weather and time in Tokyo?"),
},
},
{
Role: schemas.ChatMessageRoleAssistant,
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{
{
ID: schemas.Ptr("call_1"),
Type: schemas.Ptr("function"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("get_weather"),
Arguments: `{"location":"Tokyo"}`,
},
},
{
ID: schemas.Ptr("call_2"),
Type: schemas.Ptr("function"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("get_time"),
Arguments: `{"timezone":"Asia/Tokyo"}`,
},
},
},
},
},
{
Role: schemas.ChatMessageRoleTool,
ChatToolMessage: &schemas.ChatToolMessage{
ToolCallID: schemas.Ptr("call_1"),
},
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr(`{"temperature":22,"condition":"sunny"}`),
},
},
{
Role: schemas.ChatMessageRoleTool,
ChatToolMessage: &schemas.ChatToolMessage{
ToolCallID: schemas.Ptr("call_2"),
},
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr(`{"time":"10:30 AM","date":"2026-01-20"}`),
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.NotNil(t, result)
require.Len(t, result.Contents, 3, "Should have 3 Contents: user, assistant with tool calls, grouped tool responses")
// Validate grouped tool responses (last Content)
toolResponseContent := result.Contents[2]
assert.Equal(t, "model", toolResponseContent.Role, "Grouped tool responses use 'model' role")
require.Len(t, toolResponseContent.Parts, 2, "Should have exactly 2 parts for 2 tool responses (parallel calling)")
// Verify first tool response - ONLY functionResponse
part1 := toolResponseContent.Parts[0]
assert.Empty(t, part1.Text, "Tool response 1 should NOT have text part")
require.NotNil(t, part1.FunctionResponse, "Tool response 1 must have functionResponse")
assert.Equal(t, "call_1", part1.FunctionResponse.ID)
assert.Equal(t, "get_weather", part1.FunctionResponse.Name)
// Verify second tool response - ONLY functionResponse
part2 := toolResponseContent.Parts[1]
assert.Empty(t, part2.Text, "Tool response 2 should NOT have text part")
require.NotNil(t, part2.FunctionResponse, "Tool response 2 must have functionResponse")
assert.Equal(t, "call_2", part2.FunctionResponse.ID)
assert.Equal(t, "get_time", part2.FunctionResponse.Name)
},
},
{
name: "ParallelFunctionCalling_ThreeToolResponses_AllGrouped",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Get weather, time, and news for Tokyo"),
},
},
{
Role: schemas.ChatMessageRoleAssistant,
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{
{ID: schemas.Ptr("call_1"), Type: schemas.Ptr("function"), Function: schemas.ChatAssistantMessageToolCallFunction{Name: schemas.Ptr("get_weather"), Arguments: `{}`}},
{ID: schemas.Ptr("call_2"), Type: schemas.Ptr("function"), Function: schemas.ChatAssistantMessageToolCallFunction{Name: schemas.Ptr("get_time"), Arguments: `{}`}},
{ID: schemas.Ptr("call_3"), Type: schemas.Ptr("function"), Function: schemas.ChatAssistantMessageToolCallFunction{Name: schemas.Ptr("get_news"), Arguments: `{}`}},
},
},
},
{
Role: schemas.ChatMessageRoleTool,
ChatToolMessage: &schemas.ChatToolMessage{ToolCallID: schemas.Ptr("call_1")},
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr(`{"temperature":22}`)},
},
{
Role: schemas.ChatMessageRoleTool,
ChatToolMessage: &schemas.ChatToolMessage{ToolCallID: schemas.Ptr("call_2")},
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr(`{"time":"10:30"}`)},
},
{
Role: schemas.ChatMessageRoleTool,
ChatToolMessage: &schemas.ChatToolMessage{ToolCallID: schemas.Ptr("call_3")},
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr(`{"headline":"Breaking"}`)},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Contents, 3, "Should have 3 Contents: user, assistant with tool calls, grouped tool responses")
toolResponseContent := result.Contents[2]
assert.Equal(t, "model", toolResponseContent.Role)
require.Len(t, toolResponseContent.Parts, 3, "Should have exactly 3 parts for 3 tool responses")
// Verify all are functionResponse only (no text)
for i, part := range toolResponseContent.Parts {
assert.Empty(t, part.Text, "Tool response %d should NOT have text part", i+1)
require.NotNil(t, part.FunctionResponse, "Tool response %d must have functionResponse", i+1)
}
},
},
{
name: "MixedMessages_ToolResponsesFollowedByUser_ProperGrouping",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("First question"),
},
},
{
Role: schemas.ChatMessageRoleAssistant,
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{
{ID: schemas.Ptr("call_1"), Type: schemas.Ptr("function"), Function: schemas.ChatAssistantMessageToolCallFunction{Name: schemas.Ptr("tool1"), Arguments: `{}`}},
{ID: schemas.Ptr("call_2"), Type: schemas.Ptr("function"), Function: schemas.ChatAssistantMessageToolCallFunction{Name: schemas.Ptr("tool2"), Arguments: `{}`}},
},
},
},
{
Role: schemas.ChatMessageRoleTool,
ChatToolMessage: &schemas.ChatToolMessage{ToolCallID: schemas.Ptr("call_1")},
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr(`{"result":"1"}`)},
},
{
Role: schemas.ChatMessageRoleTool,
ChatToolMessage: &schemas.ChatToolMessage{ToolCallID: schemas.Ptr("call_2")},
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr(`{"result":"2"}`)},
},
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Follow up question"),
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Contents, 4, "Should have 4 Contents: user, assistant with tool calls, grouped tool responses, user")
// First user message
assert.Equal(t, "user", result.Contents[0].Role)
// Assistant with tool calls
assert.Equal(t, "model", result.Contents[1].Role)
// Grouped tool responses
toolContent := result.Contents[2]
assert.Equal(t, "model", toolContent.Role)
require.Len(t, toolContent.Parts, 2, "Tool responses should be grouped")
for _, part := range toolContent.Parts {
assert.NotNil(t, part.FunctionResponse)
assert.Empty(t, part.Text)
}
// Second user message (should trigger flushing of tool responses)
assert.Equal(t, "user", result.Contents[3].Role)
},
},
{
name: "ToolResponsesAtEnd_ProperlyFlushed",
input: &schemas.BifrostChatRequest{
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Question"),
},
},
{
Role: schemas.ChatMessageRoleAssistant,
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{
{ID: schemas.Ptr("call_1"), Type: schemas.Ptr("function"), Function: schemas.ChatAssistantMessageToolCallFunction{Name: schemas.Ptr("tool1"), Arguments: `{}`}},
{ID: schemas.Ptr("call_2"), Type: schemas.Ptr("function"), Function: schemas.ChatAssistantMessageToolCallFunction{Name: schemas.Ptr("tool2"), Arguments: `{}`}},
},
},
},
{
Role: schemas.ChatMessageRoleTool,
ChatToolMessage: &schemas.ChatToolMessage{ToolCallID: schemas.Ptr("call_1")},
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr(`{"result":"1"}`)},
},
{
Role: schemas.ChatMessageRoleTool,
ChatToolMessage: &schemas.ChatToolMessage{ToolCallID: schemas.Ptr("call_2")},
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr(`{"result":"2"}`)},
},
// No message after tool responses - they're at the end
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Contents, 3, "Should have 3 Contents: user, assistant with tool calls, grouped tool responses")
// Grouped tool responses at the end should still be flushed
toolContent := result.Contents[2]
assert.Equal(t, "model", toolContent.Role)
require.Len(t, toolContent.Parts, 2, "Tool responses at end should be grouped and flushed")
for _, part := range toolContent.Parts {
assert.NotNil(t, part.FunctionResponse)
assert.Empty(t, part.Text)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := gemini.ToGeminiChatCompletionRequest(tt.input)
require.NoError(t, err)
require.NotNil(t, result, "Conversion should not return nil")
tt.validate(t, result)
})
}
}
// TestResponsesAPIParallelFunctionCalling tests parallel function calling for Responses API
func TestResponsesAPIParallelFunctionCalling(t *testing.T) {
tests := []struct {
name string
input *schemas.BifrostResponsesRequest
validate func(t *testing.T, result *gemini.GeminiGenerationRequest)
}{
{
name: "ResponsesAPI_ParallelFunctionCalling_TwoOutputs_Grouped",
input: &schemas.BifrostResponsesRequest{
Provider: schemas.Gemini,
Model: "gemini-2.0-flash",
Input: []schemas.ResponsesMessage{
{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentStr: schemas.Ptr("What's the weather and time?"),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_1"),
Name: schemas.Ptr("get_weather"),
Arguments: schemas.Ptr(`{"location":"Tokyo"}`),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_2"),
Name: schemas.Ptr("get_time"),
Arguments: schemas.Ptr(`{"timezone":"Asia/Tokyo"}`),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_1"),
Name: schemas.Ptr("get_weather"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: schemas.Ptr(`{"temperature":22,"condition":"sunny"}`),
},
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_2"),
Name: schemas.Ptr("get_time"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: schemas.Ptr(`{"time":"10:30 AM"}`),
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.NotNil(t, result)
// Find the Content with function responses
var toolResponseContent *gemini.Content
for i := range result.Contents {
content := &result.Contents[i]
if len(content.Parts) > 0 && content.Parts[0].FunctionResponse != nil {
toolResponseContent = content
break
}
}
require.NotNil(t, toolResponseContent, "Should have a Content with function responses")
assert.Equal(t, "model", toolResponseContent.Role, "Function responses use 'model' role")
require.Len(t, toolResponseContent.Parts, 2, "Should have exactly 2 parts for 2 function outputs (parallel calling)")
// Verify first function response - ONLY functionResponse
part1 := toolResponseContent.Parts[0]
assert.Empty(t, part1.Text, "Function response 1 should NOT have text part")
require.NotNil(t, part1.FunctionResponse, "Function response 1 must have functionResponse")
assert.Equal(t, "call_1", part1.FunctionResponse.ID)
assert.Equal(t, "get_weather", part1.FunctionResponse.Name)
// Verify second function response - ONLY functionResponse
part2 := toolResponseContent.Parts[1]
assert.Empty(t, part2.Text, "Function response 2 should NOT have text part")
require.NotNil(t, part2.FunctionResponse, "Function response 2 must have functionResponse")
assert.Equal(t, "call_2", part2.FunctionResponse.ID)
assert.Equal(t, "get_time", part2.FunctionResponse.Name)
},
},
{
name: "ResponsesAPI_SingleFunctionOutput_NotGrouped",
input: &schemas.BifrostResponsesRequest{
Provider: schemas.Gemini,
Model: "gemini-2.0-flash",
Input: []schemas.ResponsesMessage{
{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentStr: schemas.Ptr("What's the weather?"),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_1"),
Name: schemas.Ptr("get_weather"),
Arguments: schemas.Ptr(`{}`),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_1"),
Name: schemas.Ptr("get_weather"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: schemas.Ptr(`{"temperature":22}`),
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
// Find the Content with function response
var toolResponseContent *gemini.Content
for i := range result.Contents {
content := &result.Contents[i]
if len(content.Parts) > 0 && content.Parts[0].FunctionResponse != nil {
toolResponseContent = content
break
}
}
require.NotNil(t, toolResponseContent)
assert.Equal(t, "model", toolResponseContent.Role)
require.Len(t, toolResponseContent.Parts, 1, "Single function output should have 1 part")
// Verify ONLY functionResponse part (no text/content)
part := toolResponseContent.Parts[0]
assert.Empty(t, part.Text, "Function response should NOT have text part")
require.NotNil(t, part.FunctionResponse, "Function response must have functionResponse")
},
},
{
name: "ResponsesAPI_MixedMessages_ProperGrouping",
input: &schemas.BifrostResponsesRequest{
Provider: schemas.Gemini,
Model: "gemini-2.0-flash",
Input: []schemas.ResponsesMessage{
{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentStr: schemas.Ptr("First question"),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_1"),
Name: schemas.Ptr("tool1"),
Arguments: schemas.Ptr(`{}`),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_2"),
Name: schemas.Ptr("tool2"),
Arguments: schemas.Ptr(`{}`),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_1"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: schemas.Ptr(`{"result":"1"}`),
},
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_2"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: schemas.Ptr(`{"result":"2"}`),
},
},
},
{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentStr: schemas.Ptr("Follow up question"),
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
// Find grouped function responses
var groupedToolContent *gemini.Content
for i := range result.Contents {
content := &result.Contents[i]
if len(content.Parts) >= 2 && content.Parts[0].FunctionResponse != nil {
groupedToolContent = content
break
}
}
require.NotNil(t, groupedToolContent, "Should have grouped function responses")
assert.Equal(t, "model", groupedToolContent.Role)
require.Len(t, groupedToolContent.Parts, 2, "Function outputs should be grouped before user message")
// Verify both are functionResponse only
for _, part := range groupedToolContent.Parts {
assert.Empty(t, part.Text)
assert.NotNil(t, part.FunctionResponse)
}
},
},
{
name: "ResponsesAPI_FunctionCallOutput_ContentBlocks",
input: &schemas.BifrostResponsesRequest{
Provider: schemas.Gemini,
Model: "gemini-2.0-flash",
Input: []schemas.ResponsesMessage{
{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentStr: schemas.Ptr("List browser tabs"),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_tabs"),
Name: schemas.Ptr("browser_tabs"),
Arguments: schemas.Ptr(`{"action":"list"}`),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_tabs"),
Output: &schemas.ResponsesToolMessageOutputStruct{
// Output as content blocks (Anthropic Responses API format)
ResponsesFunctionToolCallOutputBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: schemas.Ptr("### Open tabs\n- 0: (current) [Google] (https://google.com)\n- 1: [GitHub] (https://github.com)\n"),
},
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
// Find the Content with function response
var toolResponseContent *gemini.Content
for i := range result.Contents {
content := &result.Contents[i]
if len(content.Parts) > 0 && content.Parts[0].FunctionResponse != nil {
toolResponseContent = content
break
}
}
require.NotNil(t, toolResponseContent, "Should have a content with functionResponse")
require.Len(t, toolResponseContent.Parts, 1)
part := toolResponseContent.Parts[0]
require.NotNil(t, part.FunctionResponse, "Part must have functionResponse")
assert.Equal(t, "call_tabs", part.FunctionResponse.ID)
assert.Equal(t, "browser_tabs", part.FunctionResponse.Name)
// Verify the response data contains the tool output (not empty)
require.NotNil(t, part.FunctionResponse.Response, "FunctionResponse.Response must not be nil")
responseStr := string(part.FunctionResponse.Response)
assert.Contains(t, responseStr, "Open tabs", "Response should contain the tool output text")
assert.Contains(t, responseStr, "Google", "Response should contain tab content")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := gemini.ToGeminiResponsesRequest(tt.input)
require.NoError(t, err)
require.NotNil(t, result, "Responses API conversion should not return nil")
tt.validate(t, result)
})
}
}
// TestBifrostResponsesToGeminiToolConversion tests the conversion of tools from Bifrost Responses API to Gemini format
func TestBifrostResponsesToGeminiToolConversion(t *testing.T) {
tests := []struct {
name string
input *schemas.BifrostResponsesRequest
validate func(t *testing.T, result *gemini.GeminiGenerationRequest)
}{
{
name: "ResponsesAPI_ArrayWithItems",
input: &schemas.BifrostResponsesRequest{
Provider: schemas.Gemini,
Model: "gemini-2.0-flash",
Input: []schemas.ResponsesMessage{
{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentStr: schemas.Ptr("Test array items"),
},
},
},
Params: &schemas.ResponsesParameters{
Tools: []schemas.ResponsesTool{
{
Type: schemas.ResponsesToolTypeFunction,
Name: schemas.Ptr("filter_data"),
Description: schemas.Ptr("Filter data with criteria"),
ResponsesToolFunction: &schemas.ResponsesToolFunction{
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("filters", map[string]interface{}{
"type": "array",
"description": "List of filters",
"items": map[string]interface{}{
"type": "string",
"description": "Filter criterion",
},
}),
schemas.KV("sort_order", map[string]interface{}{
"type": "string",
"enum": []interface{}{"asc", "desc"},
}),
),
Required: []string{"filters"},
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Tools, 1)
fd := result.Tools[0].FunctionDeclarations[0]
assert.Equal(t, "filter_data", fd.Name)
assert.Equal(t, "Filter data with criteria", fd.Description)
// Array with items - critical test
filtersProp := fd.Parameters.Properties["filters"]
assert.Equal(t, gemini.Type("array"), filtersProp.Type)
require.NotNil(t, filtersProp.Items, "items field must be present in Responses API conversion")
assert.Equal(t, gemini.Type("string"), filtersProp.Items.Type)
assert.Equal(t, "Filter criterion", filtersProp.Items.Description)
// Enum validation
sortProp := fd.Parameters.Properties["sort_order"]
assert.Equal(t, []string{"asc", "desc"}, sortProp.Enum)
},
},
{
name: "ResponsesAPI_ComplexNestedArrayOfObjects",
input: &schemas.BifrostResponsesRequest{
Provider: schemas.Gemini,
Model: "gemini-2.0-flash",
Input: []schemas.ResponsesMessage{
{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentStr: schemas.Ptr("Complex test"),
},
},
},
Params: &schemas.ResponsesParameters{
Tools: []schemas.ResponsesTool{
{
Type: schemas.ResponsesToolTypeFunction,
Name: schemas.Ptr("batch_update"),
Description: schemas.Ptr("Update multiple records"),
ResponsesToolFunction: &schemas.ResponsesToolFunction{
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("updates", map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
},
"fields": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
},
"status": map[string]interface{}{
"type": "string",
"enum": []string{"active", "inactive"},
},
},
},
},
"required": []string{"id", "fields"},
},
}),
),
Required: []string{"updates"},
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
require.Len(t, result.Tools, 1)
fd := result.Tools[0].FunctionDeclarations[0]
updatesProp := fd.Parameters.Properties["updates"]
assert.Equal(t, gemini.Type("array"), updatesProp.Type)
// Nested object in array items
require.NotNil(t, updatesProp.Items)
assert.Equal(t, gemini.Type("object"), updatesProp.Items.Type)
assert.Contains(t, updatesProp.Items.Properties, "id")
assert.Contains(t, updatesProp.Items.Properties, "fields")
assert.Equal(t, []string{"id", "fields"}, updatesProp.Items.Required)
// Deeply nested object
fieldsProp := updatesProp.Items.Properties["fields"]
assert.Equal(t, gemini.Type("object"), fieldsProp.Type)
assert.Contains(t, fieldsProp.Properties, "name")
assert.Contains(t, fieldsProp.Properties, "status")
// Nested enum
statusProp := fieldsProp.Properties["status"]
assert.Equal(t, []string{"active", "inactive"}, statusProp.Enum)
},
},
{
name: "ResponsesAPI_EmptyItems",
input: &schemas.BifrostResponsesRequest{
Provider: schemas.Gemini,
Model: "gemini-2.0-flash",
Input: []schemas.ResponsesMessage{
{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentStr: schemas.Ptr("Edge case"),
},
},
},
Params: &schemas.ResponsesParameters{
Tools: []schemas.ResponsesTool{
{
Type: schemas.ResponsesToolTypeFunction,
Name: schemas.Ptr("edge_case_tool"),
ResponsesToolFunction: &schemas.ResponsesToolFunction{
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("any_array", map[string]interface{}{
"type": "array",
"items": map[string]interface{}{}, // Empty items
}),
),
},
},
},
},
},
},
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
fd := result.Tools[0].FunctionDeclarations[0]
arrayProp := fd.Parameters.Properties["any_array"]
// Empty items should still be converted
assert.NotNil(t, arrayProp.Items, "empty items must be present in Responses API")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := gemini.ToGeminiResponsesRequest(tt.input)
require.NoError(t, err)
require.NotNil(t, result, "Responses API conversion should not return nil")
tt.validate(t, result)
})
}
}
func TestConvertGeminiUsageMetadataToChatUsage(t *testing.T) {
tests := []struct {
name string
metadata *gemini.GenerateContentResponseUsageMetadata
expected *schemas.BifrostLLMUsage
}{
{
name: "CompleteModalityBreakdown",
metadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 6,
CandidatesTokenCount: 42,
TotalTokenCount: 48,
PromptTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityText, TokenCount: 6},
{Modality: gemini.ModalityAudio, TokenCount: 0},
{Modality: gemini.ModalityImage, TokenCount: 0},
},
CandidatesTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityText, TokenCount: 1},
},
ThoughtsTokenCount: 41,
},
expected: &schemas.BifrostLLMUsage{
PromptTokens: 6,
CompletionTokens: 83,
TotalTokens: 48,
PromptTokensDetails: &schemas.ChatPromptTokensDetails{
TextTokens: 6,
AudioTokens: 0,
ImageTokens: 0,
},
CompletionTokensDetails: &schemas.ChatCompletionTokensDetails{
TextTokens: 1,
ReasoningTokens: 41,
},
},
},
{
name: "MultimodalInputWithCache",
metadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 150,
CandidatesTokenCount: 50,
TotalTokenCount: 200,
CachedContentTokenCount: 100,
PromptTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityText, TokenCount: 50},
{Modality: gemini.ModalityImage, TokenCount: 100},
},
CandidatesTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityText, TokenCount: 50},
},
},
expected: &schemas.BifrostLLMUsage{
PromptTokens: 150,
CompletionTokens: 50,
TotalTokens: 200,
PromptTokensDetails: &schemas.ChatPromptTokensDetails{
TextTokens: 50,
ImageTokens: 100,
CachedReadTokens: 100,
},
CompletionTokensDetails: &schemas.ChatCompletionTokensDetails{
TextTokens: 50,
},
},
},
{
name: "AudioOutputGeneration",
metadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 20,
CandidatesTokenCount: 80,
TotalTokenCount: 100,
PromptTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityText, TokenCount: 20},
},
CandidatesTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityAudio, TokenCount: 80},
},
},
expected: &schemas.BifrostLLMUsage{
PromptTokens: 20,
CompletionTokens: 80,
TotalTokens: 100,
PromptTokensDetails: &schemas.ChatPromptTokensDetails{
TextTokens: 20,
},
CompletionTokensDetails: &schemas.ChatCompletionTokensDetails{
AudioTokens: 80,
},
},
},
{
name: "ImageOutputGeneration",
metadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 30,
CandidatesTokenCount: 120,
TotalTokenCount: 150,
PromptTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityText, TokenCount: 30},
},
CandidatesTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityImage, TokenCount: 120},
},
},
expected: &schemas.BifrostLLMUsage{
PromptTokens: 30,
CompletionTokens: 120,
TotalTokens: 150,
PromptTokensDetails: &schemas.ChatPromptTokensDetails{
TextTokens: 30,
},
CompletionTokensDetails: &schemas.ChatCompletionTokensDetails{
ImageTokens: func() *int { v := 120; return &v }(),
},
},
},
{
name: "BasicUsageNoDetails",
metadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 10,
CandidatesTokenCount: 20,
TotalTokenCount: 30,
},
expected: &schemas.BifrostLLMUsage{
PromptTokens: 10,
CompletionTokens: 20,
TotalTokens: 30,
},
},
{
name: "NilMetadata",
metadata: nil,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := gemini.ConvertGeminiUsageMetadataToChatUsage(tt.metadata)
if tt.expected == nil {
assert.Nil(t, result)
return
}
require.NotNil(t, result)
assert.Equal(t, tt.expected.PromptTokens, result.PromptTokens)
assert.Equal(t, tt.expected.CompletionTokens, result.CompletionTokens)
assert.Equal(t, tt.expected.TotalTokens, result.TotalTokens)
// Check prompt token details
if tt.expected.PromptTokensDetails != nil {
require.NotNil(t, result.PromptTokensDetails)
assert.Equal(t, tt.expected.PromptTokensDetails.TextTokens, result.PromptTokensDetails.TextTokens)
assert.Equal(t, tt.expected.PromptTokensDetails.AudioTokens, result.PromptTokensDetails.AudioTokens)
assert.Equal(t, tt.expected.PromptTokensDetails.ImageTokens, result.PromptTokensDetails.ImageTokens)
assert.Equal(t, tt.expected.PromptTokensDetails.CachedReadTokens, result.PromptTokensDetails.CachedReadTokens)
} else {
assert.Nil(t, result.PromptTokensDetails)
}
// Check completion token details
if tt.expected.CompletionTokensDetails != nil {
require.NotNil(t, result.CompletionTokensDetails)
assert.Equal(t, tt.expected.CompletionTokensDetails.TextTokens, result.CompletionTokensDetails.TextTokens)
assert.Equal(t, tt.expected.CompletionTokensDetails.AudioTokens, result.CompletionTokensDetails.AudioTokens)
assert.Equal(t, tt.expected.CompletionTokensDetails.ReasoningTokens, result.CompletionTokensDetails.ReasoningTokens)
if tt.expected.CompletionTokensDetails.ImageTokens != nil {
require.NotNil(t, result.CompletionTokensDetails.ImageTokens)
assert.Equal(t, *tt.expected.CompletionTokensDetails.ImageTokens, *result.CompletionTokensDetails.ImageTokens)
} else {
assert.Nil(t, result.CompletionTokensDetails.ImageTokens)
}
} else {
assert.Nil(t, result.CompletionTokensDetails)
}
})
}
}
// TestConvertGeminiUsageMetadataToResponsesUsage tests the conversion of Gemini usage metadata to Bifrost responses usage
func TestConvertGeminiUsageMetadataToResponsesUsage(t *testing.T) {
tests := []struct {
name string
metadata *gemini.GenerateContentResponseUsageMetadata
expected *schemas.ResponsesResponseUsage
}{
{
name: "CompleteModalityBreakdown",
metadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 100,
CandidatesTokenCount: 50,
TotalTokenCount: 150,
PromptTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityText, TokenCount: 60},
{Modality: gemini.ModalityAudio, TokenCount: 20},
{Modality: gemini.ModalityImage, TokenCount: 20},
},
CandidatesTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityText, TokenCount: 40},
{Modality: gemini.ModalityAudio, TokenCount: 10},
},
ThoughtsTokenCount: 5,
},
expected: &schemas.ResponsesResponseUsage{
TotalTokens: 150,
InputTokens: 100,
OutputTokens: 55,
InputTokensDetails: &schemas.ResponsesResponseInputTokens{
TextTokens: 60,
AudioTokens: 20,
ImageTokens: 20,
},
OutputTokensDetails: &schemas.ResponsesResponseOutputTokens{
TextTokens: 40,
AudioTokens: 10,
ReasoningTokens: 5,
},
},
},
{
name: "WithCachedTokens",
metadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 200,
CandidatesTokenCount: 100,
TotalTokenCount: 300,
CachedContentTokenCount: 150,
PromptTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityText, TokenCount: 200},
},
CandidatesTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityText, TokenCount: 100},
},
},
expected: &schemas.ResponsesResponseUsage{
TotalTokens: 300,
InputTokens: 200,
OutputTokens: 100,
InputTokensDetails: &schemas.ResponsesResponseInputTokens{
TextTokens: 200,
CachedReadTokens: 150,
},
OutputTokensDetails: &schemas.ResponsesResponseOutputTokens{
TextTokens: 100,
},
},
},
{
name: "AudioOnlyOutput",
metadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 50,
CandidatesTokenCount: 200,
TotalTokenCount: 250,
PromptTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityText, TokenCount: 50},
},
CandidatesTokensDetails: []*gemini.ModalityTokenCount{
{Modality: gemini.ModalityAudio, TokenCount: 200},
},
},
expected: &schemas.ResponsesResponseUsage{
TotalTokens: 250,
InputTokens: 50,
OutputTokens: 200,
InputTokensDetails: &schemas.ResponsesResponseInputTokens{
TextTokens: 50,
},
OutputTokensDetails: &schemas.ResponsesResponseOutputTokens{
AudioTokens: 200,
},
},
},
{
name: "BasicUsageNoDetails",
metadata: &gemini.GenerateContentResponseUsageMetadata{
PromptTokenCount: 10,
CandidatesTokenCount: 20,
TotalTokenCount: 30,
},
expected: &schemas.ResponsesResponseUsage{
TotalTokens: 30,
InputTokens: 10,
OutputTokens: 20,
InputTokensDetails: &schemas.ResponsesResponseInputTokens{},
OutputTokensDetails: &schemas.ResponsesResponseOutputTokens{},
},
},
{
name: "NilMetadata",
metadata: nil,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := gemini.ConvertGeminiUsageMetadataToResponsesUsage(tt.metadata)
if tt.expected == nil {
assert.Nil(t, result)
return
}
require.NotNil(t, result)
assert.Equal(t, tt.expected.TotalTokens, result.TotalTokens)
assert.Equal(t, tt.expected.InputTokens, result.InputTokens)
assert.Equal(t, tt.expected.OutputTokens, result.OutputTokens)
// Check input token details
if tt.expected.InputTokensDetails != nil {
require.NotNil(t, result.InputTokensDetails)
assert.Equal(t, tt.expected.InputTokensDetails.TextTokens, result.InputTokensDetails.TextTokens)
assert.Equal(t, tt.expected.InputTokensDetails.AudioTokens, result.InputTokensDetails.AudioTokens)
assert.Equal(t, tt.expected.InputTokensDetails.ImageTokens, result.InputTokensDetails.ImageTokens)
assert.Equal(t, tt.expected.InputTokensDetails.CachedReadTokens, result.InputTokensDetails.CachedReadTokens)
}
// Check output token details
if tt.expected.OutputTokensDetails != nil {
require.NotNil(t, result.OutputTokensDetails)
assert.Equal(t, tt.expected.OutputTokensDetails.TextTokens, result.OutputTokensDetails.TextTokens)
assert.Equal(t, tt.expected.OutputTokensDetails.AudioTokens, result.OutputTokensDetails.AudioTokens)
assert.Equal(t, tt.expected.OutputTokensDetails.ReasoningTokens, result.OutputTokensDetails.ReasoningTokens)
}
})
}
}
// TestGeminiToolInputKeyOrderPreservation verifies that multiple parallel tool calls
// preserve the client's original key ordering after conversion to Gemini format.
func TestGeminiToolInputKeyOrderPreservation(t *testing.T) {
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Gemini,
Model: "gemini-2.0-flash",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("test")},
},
{
Role: schemas.ChatMessageRoleAssistant,
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{
{
Index: 0,
Type: schemas.Ptr("function"),
ID: schemas.Ptr("toolu_001"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("bash"),
Arguments: `{"description":"Find references quickly","timeout":30000,"command":"grep -r auth_injector ."}`,
},
},
{
Index: 1,
Type: schemas.Ptr("function"),
ID: schemas.Ptr("toolu_002"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("bash"),
Arguments: `{"command":"git diff main...HEAD --stat","description":"Show diff"}`,
},
},
},
},
},
},
}
result, err := gemini.ToGeminiChatCompletionRequest(bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
// Collect all FunctionCall parts
var argsList []json.RawMessage
for _, content := range result.Contents {
for _, part := range content.Parts {
if part.FunctionCall != nil {
argsList = append(argsList, part.FunctionCall.Args)
}
}
}
require.Len(t, argsList, 2, "expected 2 FunctionCall parts")
// Block 0: keys should be description, timeout, command (NOT alphabetical)
s0 := string(argsList[0])
descIdx := strings.Index(s0, `"description"`)
timeoutIdx := strings.Index(s0, `"timeout"`)
commandIdx := strings.Index(s0, `"command"`)
require.NotEqual(t, -1, descIdx, "block 0: missing description key in: %s", s0)
require.NotEqual(t, -1, timeoutIdx, "block 0: missing timeout key in: %s", s0)
require.NotEqual(t, -1, commandIdx, "block 0: missing command key in: %s", s0)
assert.True(t, descIdx < timeoutIdx && timeoutIdx < commandIdx,
"block 0: key order not preserved, expected description < timeout < command in: %s", s0)
// Block 1: keys should be command, description (NOT alphabetical)
s1 := string(argsList[1])
commandIdx = strings.Index(s1, `"command"`)
descIdx = strings.Index(s1, `"description"`)
require.NotEqual(t, -1, commandIdx, "block 1: missing command key in: %s", s1)
require.NotEqual(t, -1, descIdx, "block 1: missing description key in: %s", s1)
assert.True(t, commandIdx < descIdx,
"block 1: key order not preserved, expected command < description in: %s", s1)
}
// minimalChatInput returns a single user message for use in budget tests.
func minimalChatInput() []schemas.ChatMessage {
return []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("Hello")},
},
}
}
// TestThinkingBudgetValidation_Chat verifies that ToGeminiChatCompletionRequest
// enforces per-model thinking budget bounds when max_tokens is set explicitly.
func TestThinkingBudgetValidation_Chat(t *testing.T) {
tests := []struct {
name string
model string
budget int
wantErr bool
wantDisabled bool // budget=0: expect IncludeThoughts=false
wantDynamic bool // budget=-1: expect ThinkingBudget=-1
wantBudget *int32 // expected ThinkingBudget value when no error
}{
// gemini-2.5-pro: valid range [128, 32768]
{
name: "pro_valid_budget",
model: "gemini-2.5-pro",
budget: 1000,
wantBudget: func() *int32 { v := int32(1000); return &v }(),
},
{
name: "pro_at_minimum",
model: "gemini-2.5-pro",
budget: 128,
wantBudget: func() *int32 { v := int32(128); return &v }(),
},
{
name: "pro_at_maximum",
model: "gemini-2.5-pro",
budget: 32768,
wantBudget: func() *int32 { v := int32(32768); return &v }(),
},
{
name: "pro_below_minimum",
model: "gemini-2.5-pro",
budget: 50,
wantErr: true,
},
{
name: "pro_above_maximum",
model: "gemini-2.5-pro",
budget: 40000,
wantErr: true,
},
// gemini-2.5-flash: valid range [0, 24576]
{
name: "flash_valid_budget",
model: "gemini-2.5-flash",
budget: 5000,
wantBudget: func() *int32 { v := int32(5000); return &v }(),
},
{
name: "flash_above_maximum",
model: "gemini-2.5-flash",
budget: 30000,
wantErr: true,
},
// budget=300 is valid for flash (min=0) — this is the key disambiguation test:
// the same budget is rejected for flash-lite (min=512) but accepted for flash.
{
name: "flash_budget_300_valid",
model: "gemini-2.5-flash",
budget: 300,
wantBudget: func() *int32 { v := int32(300); return &v }(),
},
// gemini-2.5-flash-lite: valid range [512, 24576]
// budget=300 must be rejected here even though it passes for flash.
{
name: "flash_lite_below_minimum",
model: "gemini-2.5-flash-lite",
budget: 300,
wantErr: true,
},
{
name: "flash_lite_valid_budget",
model: "gemini-2.5-flash-lite",
budget: 1000,
wantBudget: func() *int32 { v := int32(1000); return &v }(),
},
{
name: "flash_lite_above_maximum",
model: "gemini-2.5-flash-lite",
budget: 30000,
wantErr: true,
},
// Unknown model — no entry in thinkingBudgetRanges, validation is skipped.
{
name: "unknown_model_skips_validation",
model: "gemini-2.0-flash-thinking",
budget: 50, // would be rejected for pro (min=128) but unknown model is not validated
wantBudget: func() *int32 { v := int32(50); return &v }(),
},
// Special values — exempt from range checks on any model.
{
name: "budget_zero_disables_thinking",
model: "gemini-2.5-pro",
budget: 0,
wantDisabled: true,
},
{
name: "budget_minus_one_dynamic",
model: "gemini-2.5-flash",
budget: -1,
wantDynamic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &schemas.BifrostChatRequest{
Model: tt.model,
Input: minimalChatInput(),
Params: &schemas.ChatParameters{
Reasoning: &schemas.ChatReasoning{
MaxTokens: &tt.budget,
},
},
}
result, err := gemini.ToGeminiChatCompletionRequest(req)
if tt.wantErr {
require.Error(t, err, "expected error for budget %d on model %s", tt.budget, tt.model)
return
}
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.GenerationConfig.ThinkingConfig, "ThinkingConfig should be set")
tc := result.GenerationConfig.ThinkingConfig
switch {
case tt.wantDisabled:
assert.False(t, tc.IncludeThoughts, "IncludeThoughts should be false for budget=0")
require.NotNil(t, tc.ThinkingBudget)
assert.Equal(t, int32(0), *tc.ThinkingBudget)
case tt.wantDynamic:
require.NotNil(t, tc.ThinkingBudget)
assert.Equal(t, int32(-1), *tc.ThinkingBudget)
default:
require.NotNil(t, tc.ThinkingBudget)
assert.Equal(t, *tt.wantBudget, *tc.ThinkingBudget)
}
})
}
}
// TestThinkingBudgetValidation_Responses verifies the same budget validation
// for the Responses API path (ToGeminiResponsesRequest).
func TestThinkingBudgetValidation_Responses(t *testing.T) {
tests := []struct {
name string
model string
budget int
wantErr bool
wantDisabled bool
wantDynamic bool
wantBudget *int32
}{
// gemini-2.5-pro
{
name: "pro_valid_budget",
model: "gemini-2.5-pro",
budget: 2000,
wantBudget: func() *int32 { v := int32(2000); return &v }(),
},
{
name: "pro_below_minimum",
model: "gemini-2.5-pro",
budget: 100,
wantErr: true,
},
{
name: "pro_above_maximum",
model: "gemini-2.5-pro",
budget: 33000,
wantErr: true,
},
// gemini-2.5-flash-lite vs gemini-2.5-flash disambiguation
{
name: "flash_lite_below_minimum",
model: "gemini-2.5-flash-lite",
budget: 300,
wantErr: true,
},
{
name: "flash_budget_300_valid",
model: "gemini-2.5-flash",
budget: 300,
wantBudget: func() *int32 { v := int32(300); return &v }(),
},
// Special values
{
name: "budget_zero_disables_thinking",
model: "gemini-2.5-flash",
budget: 0,
wantDisabled: true,
},
{
name: "budget_minus_one_dynamic",
model: "gemini-2.5-pro",
budget: -1,
wantDynamic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &schemas.BifrostResponsesRequest{
Model: tt.model,
Params: &schemas.ResponsesParameters{
Reasoning: &schemas.ResponsesParametersReasoning{
MaxTokens: &tt.budget,
},
},
}
result, err := gemini.ToGeminiResponsesRequest(req)
if tt.wantErr {
require.Error(t, err, "expected error for budget %d on model %s", tt.budget, tt.model)
return
}
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.GenerationConfig.ThinkingConfig, "ThinkingConfig should be set")
tc := result.GenerationConfig.ThinkingConfig
switch {
case tt.wantDisabled:
assert.False(t, tc.IncludeThoughts)
require.NotNil(t, tc.ThinkingBudget)
assert.Equal(t, int32(0), *tc.ThinkingBudget)
case tt.wantDynamic:
require.NotNil(t, tc.ThinkingBudget)
assert.Equal(t, int32(-1), *tc.ThinkingBudget)
default:
require.NotNil(t, tc.ThinkingBudget)
assert.Equal(t, *tt.wantBudget, *tc.ThinkingBudget)
}
})
}
}
// TestThinkingBudgetEffortUsesModelRange verifies that effort-based budget
// calculation uses the correct model-specific range, not a global default.
// In particular, gemini-2.5-flash-lite (min=512) must not use gemini-2.5-flash's
// range (min=0), which would produce budgets below the model's minimum.
func TestThinkingBudgetEffortUsesModelRange(t *testing.T) {
effort := "low"
t.Run("flash_lite_budget_respects_min_512", func(t *testing.T) {
req := &schemas.BifrostChatRequest{
Model: "gemini-2.5-flash-lite",
Input: minimalChatInput(),
Params: &schemas.ChatParameters{
Reasoning: &schemas.ChatReasoning{Effort: &effort},
},
}
result, err := gemini.ToGeminiChatCompletionRequest(req)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.GenerationConfig.ThinkingConfig)
require.NotNil(t, result.GenerationConfig.ThinkingConfig.ThinkingBudget)
assert.GreaterOrEqual(t, *result.GenerationConfig.ThinkingConfig.ThinkingBudget, int32(512),
"flash-lite effort budget must be >= model minimum 512")
})
t.Run("flash_budget_may_start_from_zero", func(t *testing.T) {
req := &schemas.BifrostChatRequest{
Model: "gemini-2.5-flash",
Input: minimalChatInput(),
Params: &schemas.ChatParameters{
Reasoning: &schemas.ChatReasoning{Effort: &effort},
},
}
result, err := gemini.ToGeminiChatCompletionRequest(req)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.GenerationConfig.ThinkingConfig)
require.NotNil(t, result.GenerationConfig.ThinkingConfig.ThinkingBudget)
assert.GreaterOrEqual(t, *result.GenerationConfig.ThinkingConfig.ThinkingBudget, int32(0))
assert.LessOrEqual(t, *result.GenerationConfig.ThinkingConfig.ThinkingBudget, int32(24576),
"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)
}