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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions core/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- fix: Gemini video reference fields map to instances [@vojthor](https://github.com/vojthor)
- fix: accept object-valued tool-call arguments (e.g. tool_search_call) on the Responses API streaming path
- fix: recover from idle-timeout timer-goroutine panic that could crash the process
- fix: deterministic MCP tool ordering for prompt cache stability (closes #2347)
- fix: pass through `gs://` image URLs on Vertex Gemini (closes #4402)
5 changes: 4 additions & 1 deletion core/providers/gemini/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ func ToGeminiBatchGenerateContentRequest(body map[string]interface{}) (GeminiBat
if err := sonic.Unmarshal(messagesBytes, &chatMessages); err != nil {
return geminiReq, fmt.Errorf("failed to unmarshal messages: %w", err)
}
contents, systemInstruction := convertBifrostMessagesToGemini(chatMessages)
contents, systemInstruction, err := convertBifrostMessagesToGemini(chatMessages)
if err != nil {
return geminiReq, fmt.Errorf("failed to convert messages: %w", err)
}
geminiReq.Contents = contents
geminiReq.SystemInstruction = systemInstruction
}
Expand Down
11 changes: 10 additions & 1 deletion core/providers/gemini/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import (

// ToGeminiChatCompletionRequest converts a BifrostChatRequest to Gemini's generation request format for chat completion
func ToGeminiChatCompletionRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.BifrostChatRequest) (*GeminiGenerationRequest, error) {
return ToGeminiChatCompletionRequestWithImageURLSchemes(ctx, bifrostReq, defaultGeminiImageURLSchemes...)
}

// ToGeminiChatCompletionRequestWithImageURLSchemes converts a BifrostChatRequest
// to Gemini format using the provider-specific allowlist for non-data image URLs.
func ToGeminiChatCompletionRequestWithImageURLSchemes(ctx *schemas.BifrostContext, bifrostReq *schemas.BifrostChatRequest, allowedImageURLSchemes ...string) (*GeminiGenerationRequest, error) {
if bifrostReq == nil {
return nil, nil
}
Expand Down Expand Up @@ -75,7 +81,10 @@ func ToGeminiChatCompletionRequest(ctx *schemas.BifrostContext, bifrostReq *sche
}
}
// Convert chat completion messages to Gemini format
contents, systemInstruction := convertBifrostMessagesToGemini(bifrostReq.Input)
contents, systemInstruction, err := convertBifrostMessagesToGemini(bifrostReq.Input, allowedImageURLSchemes...)
if err != nil {
return nil, err
}
if systemInstruction != nil {
geminiReq.SystemInstruction = systemInstruction
}
Expand Down
56 changes: 56 additions & 0 deletions core/providers/gemini/gemini_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,62 @@ func TestMissingThoughtSignatureUsesBypassSentinel(t *testing.T) {
assert.NotContains(t, string(encoded), `"thoughtSignature":"c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I="`)
}

func TestGeminiChatCompletionRejectsGCSImageURL(t *testing.T) {
_, err := gemini.ToGeminiChatCompletionRequest(nil, &schemas.BifrostChatRequest{
Model: "gemini-3-flash-preview",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentBlocks: []schemas.ChatContentBlock{
{
Type: schemas.ChatContentBlockTypeText,
Text: schemas.Ptr("Describe this image."),
},
{
Type: schemas.ChatContentBlockTypeImage,
ImageURLStruct: &schemas.ChatInputImage{
URL: "gs://my-bucket/xxx.png",
},
},
},
},
},
},
})

require.Error(t, err)
assert.Contains(t, err.Error(), `URL scheme "gs" is not allowed`)
}

func TestGeminiResponsesRejectsGCSImageURL(t *testing.T) {
_, err := gemini.ToGeminiResponsesRequest(nil, &schemas.BifrostResponsesRequest{
Model: "gemini-3-flash-preview",
Input: []schemas.ResponsesMessage{
{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: schemas.Ptr("Describe this image."),
},
{
Type: schemas.ResponsesInputMessageContentBlockTypeImage,
ResponsesInputMessageContentBlockImage: &schemas.ResponsesInputMessageContentBlockImage{
ImageURL: schemas.Ptr("gs://my-bucket/xxx.png"),
},
},
},
},
},
},
})

require.Error(t, err)
assert.Contains(t, err.Error(), `URL scheme "gs" is not allowed`)
}

func TestEmbeddedThoughtSignatureDoesNotUseBypassSentinel(t *testing.T) {
thoughtSig := base64.RawURLEncoding.EncodeToString([]byte{0x01, 0x02, 0x03})
callID := "call_1_ts_" + thoughtSig
Expand Down
30 changes: 22 additions & 8 deletions core/providers/gemini/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ func (request *GeminiGenerationRequest) ToBifrostResponsesRequest(ctx *schemas.B
}

func ToGeminiResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.BifrostResponsesRequest) (*GeminiGenerationRequest, error) {
return ToGeminiResponsesRequestWithImageURLSchemes(ctx, bifrostReq, defaultGeminiImageURLSchemes...)
}

// ToGeminiResponsesRequestWithImageURLSchemes converts a Bifrost Responses request
// to Gemini format using the provider-specific allowlist for non-data image URLs.
func ToGeminiResponsesRequestWithImageURLSchemes(ctx *schemas.BifrostContext, bifrostReq *schemas.BifrostResponsesRequest, allowedImageURLSchemes ...string) (*GeminiGenerationRequest, error) {
if bifrostReq == nil {
return nil, nil
}
Expand Down Expand Up @@ -119,7 +125,7 @@ func ToGeminiResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.B

// Convert ResponsesInput messages to Gemini contents
if bifrostReq.Input != nil {
contents, systemInstruction, err := convertResponsesMessagesToGeminiContents(bifrostReq.Input, capModel, bifrostReq.Provider)
contents, systemInstruction, err := convertResponsesMessagesToGeminiContents(bifrostReq.Input, capModel, bifrostReq.Provider, allowedImageURLSchemes...)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -3049,7 +3055,11 @@ func convertResponsesToolChoiceToGemini(toolChoice *schemas.ResponsesToolChoice)
// responses, where a tool returns images/files nested in functionResponse.parts). provider
// distinguishes Vertex AI from the Gemini Developer API, which differ in how multimodal
// function responses must be referenced (see the FunctionCallOutput handling below).
func convertResponsesMessagesToGeminiContents(messages []schemas.ResponsesMessage, model string, provider schemas.ModelProvider) ([]Content, *Content, error) {
func convertResponsesMessagesToGeminiContents(messages []schemas.ResponsesMessage, model string, provider schemas.ModelProvider, allowedImageURLSchemes ...string) ([]Content, *Content, error) {
if len(allowedImageURLSchemes) == 0 {
allowedImageURLSchemes = defaultGeminiImageURLSchemes
}

isVertex := provider == schemas.Vertex
// if only system / developer message is there, convert it to user message (since openai allows it)
if len(messages) == 1 && messages[0].Role != nil && (*messages[0].Role == schemas.ResponsesInputMessageRoleSystem || *messages[0].Role == schemas.ResponsesInputMessageRoleDeveloper) {
Expand All @@ -3062,7 +3072,7 @@ func convertResponsesMessagesToGeminiContents(messages []schemas.ResponsesMessag
}
if messages[0].Content.ContentBlocks != nil {
for _, block := range messages[0].Content.ContentBlocks {
part, err := convertContentBlockToGeminiPart(block)
part, err := convertContentBlockToGeminiPart(block, allowedImageURLSchemes...)
if err != nil {
return nil, nil, fmt.Errorf("failed to convert system message content block: %w", err)
}
Expand Down Expand Up @@ -3119,7 +3129,7 @@ func convertResponsesMessagesToGeminiContents(messages []schemas.ResponsesMessag
}
if msg.Content.ContentBlocks != nil {
for _, block := range msg.Content.ContentBlocks {
part, err := convertContentBlockToGeminiPart(block)
part, err := convertContentBlockToGeminiPart(block, allowedImageURLSchemes...)
if err != nil {
return nil, nil, fmt.Errorf("failed to convert system message content block: %w", err)
}
Expand Down Expand Up @@ -3271,7 +3281,7 @@ func convertResponsesMessagesToGeminiContents(messages []schemas.ResponsesMessag
if !supportsMultimodalToolOutput {
continue // older models can't accept media in a function response
}
mediaPart, err := convertContentBlockToGeminiPart(block)
mediaPart, err := convertContentBlockToGeminiPart(block, allowedImageURLSchemes...)
if err != nil {
return nil, nil, fmt.Errorf("failed to convert function output content block: %w", err)
}
Expand Down Expand Up @@ -3361,7 +3371,7 @@ func convertResponsesMessagesToGeminiContents(messages []schemas.ResponsesMessag

if msg.Content.ContentBlocks != nil {
for _, block := range msg.Content.ContentBlocks {
part, err := convertContentBlockToGeminiPart(block)
part, err := convertContentBlockToGeminiPart(block, allowedImageURLSchemes...)
if err != nil {
return nil, nil, fmt.Errorf("failed to convert message content block: %w", err)
}
Expand All @@ -3382,7 +3392,11 @@ func convertResponsesMessagesToGeminiContents(messages []schemas.ResponsesMessag
}

// convertContentBlockToGeminiPart converts a content block to Gemini part
func convertContentBlockToGeminiPart(block schemas.ResponsesMessageContentBlock) (*Part, error) {
func convertContentBlockToGeminiPart(block schemas.ResponsesMessageContentBlock, allowedImageURLSchemes ...string) (*Part, error) {
if len(allowedImageURLSchemes) == 0 {
allowedImageURLSchemes = defaultGeminiImageURLSchemes
}

switch block.Type {
case schemas.ResponsesInputMessageContentBlockTypeText,
schemas.ResponsesOutputMessageContentTypeText:
Expand Down Expand Up @@ -3428,7 +3442,7 @@ func convertContentBlockToGeminiPart(block schemas.ResponsesMessageContentBlock)
imageURL := *block.ResponsesInputMessageContentBlockImage.ImageURL

// Use existing utility functions to handle URL parsing
sanitizedURL, err := schemas.SanitizeImageURL(imageURL)
sanitizedURL, err := schemas.SanitizeImageURLWithAllowedSchemes(imageURL, allowedImageURLSchemes...)
if err != nil {
return nil, fmt.Errorf("failed to sanitize image URL: %w", err)
}
Expand Down
17 changes: 11 additions & 6 deletions core/providers/gemini/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"github.com/valyala/fasthttp"
)

var defaultGeminiImageURLSchemes = []string{"http", "https"}

// isGemini3Plus returns true if the model is Gemini 3.0 or higher
// Uses simple string operations for hot path performance
func isGemini3Plus(model string) bool {
Expand Down Expand Up @@ -1808,12 +1810,16 @@ func addSpeechConfigToGenerationConfig(config *GenerationConfig, voiceConfig *sc
}

// convertBifrostMessagesToGemini converts Bifrost messages to Gemini format
func convertBifrostMessagesToGemini(messages []schemas.ChatMessage) ([]Content, *Content) {
func convertBifrostMessagesToGemini(messages []schemas.ChatMessage, allowedImageURLSchemes ...string) ([]Content, *Content, error) {
if len(allowedImageURLSchemes) == 0 {
allowedImageURLSchemes = defaultGeminiImageURLSchemes
}

// if only system / developer message is there, convert it to user message (since openai allows it)
if len(messages) == 1 && (messages[0].Role == schemas.ChatMessageRoleSystem || messages[0].Role == schemas.ChatMessageRoleDeveloper) {
content := convertSystemChatMessageToGeminiUserContent(messages[0])
if len(content.Parts) > 0 {
return []Content{content}, nil
return []Content{content}, nil, nil
}
}

Expand Down Expand Up @@ -1999,10 +2005,9 @@ func convertBifrostMessagesToGemini(messages []schemas.ChatMessage) ([]Content,
imageURL := block.ImageURLStruct.URL

// Sanitize and parse the image URL
sanitizedURL, err := schemas.SanitizeImageURL(imageURL)
sanitizedURL, err := schemas.SanitizeImageURLWithAllowedSchemes(imageURL, allowedImageURLSchemes...)
if err != nil {
// Skip this block if URL is invalid
continue
return nil, nil, fmt.Errorf("failed to sanitize image URL: %w", err)
}

urlInfo := schemas.ExtractURLTypeInfo(sanitizedURL)
Expand Down Expand Up @@ -2164,7 +2169,7 @@ func convertBifrostMessagesToGemini(messages []schemas.ChatMessage) ([]Content,
}
}

return contents, systemInstruction
return contents, systemInstruction, nil
}

func convertSystemChatMessageToGeminiUserContent(message schemas.ChatMessage) Content {
Expand Down
2 changes: 1 addition & 1 deletion core/providers/gemini/videos.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ func ToGeminiVideoGenerationRequest(bifrostReq *schemas.BifrostVideoGenerationRe
// Handle input reference (image for image-to-video)
if bifrostReq.Input.InputReference != nil && *bifrostReq.Input.InputReference != "" {
// extract mime type and base64 string from input reference
sanitizedURL, err := schemas.SanitizeImageURL(*bifrostReq.Input.InputReference)
sanitizedURL, err := schemas.SanitizeImageURLWithAllowedSchemes(*bifrostReq.Input.InputReference, defaultGeminiImageURLSchemes...)
if err != nil {
return nil, fmt.Errorf("invalid input reference: %w", err)
}
Expand Down
44 changes: 44 additions & 0 deletions core/providers/vertex/utils_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package vertex

import (
"reflect"
"testing"

"github.com/maximhq/bifrost/core/providers/gemini"
providerUtils "github.com/maximhq/bifrost/core/providers/utils"
"github.com/maximhq/bifrost/core/schemas"
)
Expand Down Expand Up @@ -54,6 +56,36 @@ func TestGetVertexAPIHost(t *testing.T) {
}
}

func TestVertexGeminiImageURLSchemesAllowGCS(t *testing.T) {
result, err := gemini.ToGeminiChatCompletionRequestWithImageURLSchemes(nil, &schemas.BifrostChatRequest{
Model: "gemini-3-flash-preview",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentBlocks: []schemas.ChatContentBlock{
{
Type: schemas.ChatContentBlockTypeImage,
ImageURLStruct: &schemas.ChatInputImage{
URL: "gs://my-bucket/xxx.png",
},
},
},
},
},
},
}, geminiImageURLSchemes...)
if err != nil {
t.Fatalf("expected Vertex Gemini schemes to allow gs:// image URLs, got: %v", err)
}
if len(result.Contents) != 1 || len(result.Contents[0].Parts) != 1 || result.Contents[0].Parts[0].FileData == nil {
t.Fatalf("expected one fileData part, got %#v", result.Contents)
}
if result.Contents[0].Parts[0].FileData.FileURI != "gs://my-bucket/xxx.png" {
t.Fatalf("expected gs:// fileUri, got %q", result.Contents[0].Parts[0].FileData.FileURI)
}
}

func TestIsVertexMultiRegionEndpoint(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -497,3 +529,15 @@ func TestResolveVertexProjectNumber_AliasOverride(t *testing.T) {
t.Errorf("empty alias ProjectNumber should fall through: got %q, want %q", got, keyNumber)
}
}

// TestGeminiImageURLSchemesContract pins the exact allowlist that Vertex passes
// into the Gemini converter. vertex.go reuses geminiImageURLSchemes across
// ChatCompletion / ChatCompletionStream / Responses / ResponsesStream / CountTokens,
// so a regression that drops "gs" (or accidentally adds e.g. "file") is caught
// here without having to drive each provider entrypoint end-to-end.
func TestGeminiImageURLSchemesContract(t *testing.T) {
want := []string{"http", "https", "gs"}
if !reflect.DeepEqual(geminiImageURLSchemes, want) {
t.Fatalf("geminiImageURLSchemes = %v, want %v", geminiImageURLSchemes, want)
}
}
Loading