2989 lines
103 KiB
Go
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)
|
|
}
|