559 lines
18 KiB
Go
559 lines
18 KiB
Go
package gemini
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/maximhq/bifrost/core/schemas"
|
|
)
|
|
|
|
// ToGeminiChatCompletionRequest converts a BifrostChatRequest to Gemini's generation request format for chat completion
|
|
func ToGeminiChatCompletionRequest(bifrostReq *schemas.BifrostChatRequest) (*GeminiGenerationRequest, error) {
|
|
if bifrostReq == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Create the base Gemini generation request
|
|
geminiReq := &GeminiGenerationRequest{
|
|
Model: bifrostReq.Model,
|
|
}
|
|
|
|
// Convert parameters to generation config
|
|
if bifrostReq.Params != nil {
|
|
geminiReq.ExtraParams = bifrostReq.Params.ExtraParams
|
|
var err error
|
|
geminiReq.GenerationConfig, err = convertParamsToGenerationConfig(bifrostReq.Params, []string{}, bifrostReq.Model)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Handle tool-related parameters
|
|
if len(bifrostReq.Params.Tools) > 0 {
|
|
geminiReq.Tools = convertBifrostToolsToGemini(bifrostReq.Params.Tools)
|
|
|
|
// Convert tool choice to tool config
|
|
if bifrostReq.Params.ToolChoice != nil {
|
|
geminiReq.ToolConfig = convertToolChoiceToToolConfig(bifrostReq.Params.ToolChoice)
|
|
}
|
|
}
|
|
|
|
// Handle extra parameters
|
|
if bifrostReq.Params.ExtraParams != nil {
|
|
// Safety settings
|
|
if safetySettings, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "safety_settings"); ok {
|
|
delete(geminiReq.ExtraParams, "safety_settings")
|
|
if settings, ok := SafeExtractSafetySettings(safetySettings); ok {
|
|
geminiReq.SafetySettings = settings
|
|
}
|
|
}
|
|
|
|
// Cached content
|
|
if cachedContent, ok := schemas.SafeExtractString(bifrostReq.Params.ExtraParams["cached_content"]); ok {
|
|
delete(geminiReq.ExtraParams, "cached_content")
|
|
geminiReq.CachedContent = cachedContent
|
|
}
|
|
|
|
// Labels
|
|
if labels, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "labels"); ok {
|
|
delete(geminiReq.ExtraParams, "labels")
|
|
if labelMap, ok := schemas.SafeExtractStringMap(labels); ok {
|
|
geminiReq.Labels = labelMap
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Convert chat completion messages to Gemini format
|
|
contents, systemInstruction := convertBifrostMessagesToGemini(bifrostReq.Input)
|
|
if systemInstruction != nil {
|
|
geminiReq.SystemInstruction = systemInstruction
|
|
}
|
|
geminiReq.Contents = contents
|
|
return geminiReq, nil
|
|
}
|
|
|
|
// ToBifrostChatResponse converts a GenerateContentResponse to a BifrostChatResponse
|
|
func (response *GenerateContentResponse) ToBifrostChatResponse() *schemas.BifrostChatResponse {
|
|
bifrostResp := &schemas.BifrostChatResponse{
|
|
ID: response.ResponseID,
|
|
Model: response.ModelVersion,
|
|
Object: "chat.completion",
|
|
}
|
|
|
|
// Set creation timestamp if available
|
|
if !response.CreateTime.IsZero() {
|
|
bifrostResp.Created = int(response.CreateTime.Unix())
|
|
}
|
|
|
|
// Handle empty candidates (filtered/malformed responses)
|
|
if len(response.Candidates) == 0 {
|
|
finishReason := ConvertGeminiFinishReasonToBifrost(FinishReasonMalformedFunctionCall)
|
|
return createErrorResponse(response, finishReason, false)
|
|
}
|
|
|
|
candidate := response.Candidates[0]
|
|
|
|
// Check for filtered finish reasons that indicate errors
|
|
if isErrorFinishReason(candidate.FinishReason) {
|
|
finishReason := ConvertGeminiFinishReasonToBifrost(candidate.FinishReason)
|
|
return createErrorResponse(response, finishReason, false)
|
|
}
|
|
|
|
// Collect all content and tool calls into a single message
|
|
var toolCalls []schemas.ChatAssistantMessageToolCall
|
|
var contentBlocks []schemas.ChatContentBlock
|
|
var reasoningDetails []schemas.ChatReasoningDetails
|
|
var contentStr *string
|
|
|
|
// Process candidate content to extract text, tool calls, and reasoning
|
|
if candidate.Content != nil && len(candidate.Content.Parts) > 0 {
|
|
for _, part := range candidate.Content.Parts {
|
|
// Handle thought/reasoning text separately - add to reasoning details
|
|
if part.Text != "" && part.Thought {
|
|
reasoningDetails = append(reasoningDetails, schemas.ChatReasoningDetails{
|
|
Index: len(reasoningDetails),
|
|
Type: schemas.BifrostReasoningDetailsTypeText,
|
|
Text: &part.Text,
|
|
})
|
|
continue
|
|
}
|
|
// Handle regular text
|
|
if part.Text != "" {
|
|
contentBlocks = append(contentBlocks, schemas.ChatContentBlock{
|
|
Type: schemas.ChatContentBlockTypeText,
|
|
Text: &part.Text,
|
|
})
|
|
// Add thought signature to reasoning details if present with text
|
|
if len(part.ThoughtSignature) > 0 {
|
|
thoughtSig := base64.StdEncoding.EncodeToString(part.ThoughtSignature)
|
|
reasoningDetails = append(reasoningDetails, schemas.ChatReasoningDetails{
|
|
Index: len(reasoningDetails),
|
|
Type: schemas.BifrostReasoningDetailsTypeEncrypted,
|
|
Signature: &thoughtSig,
|
|
})
|
|
}
|
|
}
|
|
if part.FunctionCall != nil {
|
|
function := schemas.ChatAssistantMessageToolCallFunction{
|
|
Name: &part.FunctionCall.Name,
|
|
}
|
|
|
|
if len(part.FunctionCall.Args) > 0 {
|
|
function.Arguments = string(part.FunctionCall.Args)
|
|
}
|
|
|
|
callID := part.FunctionCall.Name
|
|
if part.FunctionCall.ID != "" {
|
|
callID = part.FunctionCall.ID
|
|
}
|
|
|
|
// Embed thought signature into CallID if present (matches responses.go pattern)
|
|
if len(part.ThoughtSignature) > 0 && !strings.Contains(callID, thoughtSignatureSeparator) {
|
|
encoded := base64.RawURLEncoding.EncodeToString(part.ThoughtSignature)
|
|
callID = fmt.Sprintf("%s%s%s", callID, thoughtSignatureSeparator, encoded)
|
|
}
|
|
|
|
toolCall := schemas.ChatAssistantMessageToolCall{
|
|
Index: uint16(len(toolCalls)),
|
|
Type: schemas.Ptr(string(schemas.ChatToolChoiceTypeFunction)),
|
|
ID: &callID,
|
|
Function: function,
|
|
}
|
|
|
|
toolCalls = append(toolCalls, toolCall)
|
|
|
|
// Also add to reasoning details for backward compatibility
|
|
if len(part.ThoughtSignature) > 0 {
|
|
thoughtSig := base64.StdEncoding.EncodeToString(part.ThoughtSignature)
|
|
// Extract base ID without signature for reasoning detail lookup
|
|
baseCallID := callID
|
|
if strings.Contains(callID, thoughtSignatureSeparator) {
|
|
parts := strings.SplitN(callID, thoughtSignatureSeparator, 2)
|
|
if len(parts) == 2 {
|
|
baseCallID = parts[0]
|
|
}
|
|
}
|
|
reasoningDetails = append(reasoningDetails, schemas.ChatReasoningDetails{
|
|
Index: len(reasoningDetails),
|
|
Type: schemas.BifrostReasoningDetailsTypeEncrypted,
|
|
Signature: &thoughtSig,
|
|
ID: schemas.Ptr(fmt.Sprintf("tool_call_%s", baseCallID)),
|
|
})
|
|
}
|
|
}
|
|
|
|
if part.FunctionResponse != nil {
|
|
// Extract the output from the response
|
|
output := extractFunctionResponseOutput(part.FunctionResponse)
|
|
|
|
// Add as text content block
|
|
if output != "" {
|
|
contentBlocks = append(contentBlocks, schemas.ChatContentBlock{
|
|
Type: schemas.ChatContentBlockTypeText,
|
|
Text: &output,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Handle code execution results
|
|
if part.CodeExecutionResult != nil {
|
|
output := part.CodeExecutionResult.Output
|
|
if part.CodeExecutionResult.Outcome != OutcomeOK {
|
|
output = "Error: " + output
|
|
}
|
|
if output != "" {
|
|
contentBlocks = append(contentBlocks, schemas.ChatContentBlock{
|
|
Type: schemas.ChatContentBlockTypeText,
|
|
Text: &output,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Handle executable code
|
|
if part.ExecutableCode != nil {
|
|
codeContent := "```" + part.ExecutableCode.Language + "\n" + part.ExecutableCode.Code + "\n```"
|
|
contentBlocks = append(contentBlocks, schemas.ChatContentBlock{
|
|
Type: schemas.ChatContentBlockTypeText,
|
|
Text: &codeContent,
|
|
})
|
|
}
|
|
|
|
// Handle standalone thought signature (not associated with function call or text)
|
|
if len(part.ThoughtSignature) > 0 && part.FunctionCall == nil && part.Text == "" {
|
|
thoughtSig := base64.StdEncoding.EncodeToString(part.ThoughtSignature)
|
|
reasoningDetails = append(reasoningDetails, schemas.ChatReasoningDetails{
|
|
Index: len(reasoningDetails),
|
|
Type: schemas.BifrostReasoningDetailsTypeEncrypted,
|
|
Signature: &thoughtSig,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Build the choice with message
|
|
message := &schemas.ChatMessage{
|
|
Role: schemas.ChatMessageRoleAssistant,
|
|
}
|
|
|
|
if len(contentBlocks) == 1 && contentBlocks[0].Type == schemas.ChatContentBlockTypeText {
|
|
contentStr = contentBlocks[0].Text
|
|
contentBlocks = nil
|
|
}
|
|
|
|
message.Content = &schemas.ChatMessageContent{
|
|
ContentStr: contentStr,
|
|
ContentBlocks: contentBlocks,
|
|
}
|
|
|
|
if len(toolCalls) > 0 || len(reasoningDetails) > 0 {
|
|
message.ChatAssistantMessage = &schemas.ChatAssistantMessage{
|
|
ToolCalls: toolCalls,
|
|
ReasoningDetails: reasoningDetails,
|
|
}
|
|
}
|
|
|
|
// Convert finish reason to Bifrost format.
|
|
// Gemini uses "STOP" for both normal text completions and tool call responses —
|
|
// it has no dedicated finish reason for tool calls. Override to "tool_calls" when
|
|
// tool calls are present so downstream consumers see a uniform signal.
|
|
finishReason := ConvertGeminiFinishReasonToBifrost(candidate.FinishReason)
|
|
if len(toolCalls) > 0 && finishReason == "stop" {
|
|
finishReason = "tool_calls"
|
|
}
|
|
|
|
bifrostResp.Choices = append(bifrostResp.Choices, schemas.BifrostResponseChoice{
|
|
Index: 0,
|
|
FinishReason: &finishReason,
|
|
LogProbs: ConvertGeminiLogprobsResultToBifrost(candidate.LogprobsResult),
|
|
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
|
Message: message,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Set usage information
|
|
bifrostResp.Usage = ConvertGeminiUsageMetadataToChatUsage(response.UsageMetadata)
|
|
|
|
return bifrostResp
|
|
}
|
|
|
|
// GeminiStreamState tracks tool-call index across streaming chunks.
|
|
type GeminiStreamState struct {
|
|
nextToolCallIndex int
|
|
hadToolCalls bool // true if any tool calls were seen in this stream
|
|
}
|
|
|
|
// NewGeminiStreamState returns initialised stream state for one streaming response.
|
|
func NewGeminiStreamState() *GeminiStreamState {
|
|
return &GeminiStreamState{}
|
|
}
|
|
|
|
// ToBifrostChatCompletionStream converts a Gemini streaming response to a Bifrost Chat Completion Stream response
|
|
// Returns the response, error (if any), and a boolean indicating if this is the last chunk
|
|
func (response *GenerateContentResponse) ToBifrostChatCompletionStream(state *GeminiStreamState) (*schemas.BifrostChatResponse, *schemas.BifrostError, bool) {
|
|
if response == nil {
|
|
return nil, nil, false
|
|
}
|
|
|
|
if state == nil {
|
|
state = NewGeminiStreamState()
|
|
}
|
|
|
|
// Handle empty candidates (filtered/malformed responses)
|
|
if len(response.Candidates) == 0 {
|
|
finishReason := ConvertGeminiFinishReasonToBifrost(FinishReasonMalformedFunctionCall)
|
|
return createErrorResponse(response, finishReason, true), nil, true
|
|
}
|
|
|
|
candidate := response.Candidates[0]
|
|
|
|
// Check for filtered finish reasons that indicate errors
|
|
if isErrorFinishReason(candidate.FinishReason) {
|
|
finishReason := ConvertGeminiFinishReasonToBifrost(candidate.FinishReason)
|
|
return createErrorResponse(response, finishReason, true), nil, true
|
|
}
|
|
|
|
// Determine if this is the last chunk based on finish reason and usage metadata
|
|
isLastChunk := candidate.FinishReason != "" && response.UsageMetadata != nil
|
|
|
|
// Create the streaming response
|
|
streamResponse := &schemas.BifrostChatResponse{
|
|
ID: response.ResponseID,
|
|
Model: response.ModelVersion,
|
|
Object: "chat.completion.chunk",
|
|
}
|
|
|
|
// Set creation timestamp if available
|
|
if !response.CreateTime.IsZero() {
|
|
streamResponse.Created = int(response.CreateTime.Unix())
|
|
}
|
|
|
|
// Build delta content
|
|
delta := &schemas.ChatStreamResponseChoiceDelta{}
|
|
|
|
// Process content parts
|
|
if candidate.Content != nil && len(candidate.Content.Parts) > 0 {
|
|
// Set role from the first chunk (Gemini uses "model" for assistant)
|
|
if candidate.Content.Role != "" {
|
|
role := candidate.Content.Role
|
|
if role == string(RoleModel) {
|
|
role = string(schemas.ChatMessageRoleAssistant)
|
|
}
|
|
delta.Role = &role
|
|
}
|
|
|
|
var textContent string
|
|
var toolCalls []schemas.ChatAssistantMessageToolCall
|
|
var reasoningDetails []schemas.ChatReasoningDetails
|
|
|
|
for _, part := range candidate.Content.Parts {
|
|
switch {
|
|
case part.Text != "" && part.Thought:
|
|
// Thought/reasoning content - add to reasoning details
|
|
reasoningDetails = append(reasoningDetails, schemas.ChatReasoningDetails{
|
|
Index: len(reasoningDetails),
|
|
Type: schemas.BifrostReasoningDetailsTypeText,
|
|
Text: &part.Text,
|
|
})
|
|
|
|
case part.Text != "":
|
|
// Regular text content
|
|
textContent += part.Text
|
|
|
|
case part.FunctionCall != nil:
|
|
// Function call
|
|
jsonArgs := ""
|
|
if len(part.FunctionCall.Args) > 0 {
|
|
jsonArgs = string(part.FunctionCall.Args)
|
|
}
|
|
|
|
// Use ID if available, otherwise use function name
|
|
callID := part.FunctionCall.Name
|
|
if part.FunctionCall.ID != "" {
|
|
callID = part.FunctionCall.ID
|
|
}
|
|
|
|
// Embed thought signature into CallID if present
|
|
if len(part.ThoughtSignature) > 0 && !strings.Contains(callID, thoughtSignatureSeparator) {
|
|
encoded := base64.RawURLEncoding.EncodeToString(part.ThoughtSignature)
|
|
callID = fmt.Sprintf("%s%s%s", callID, thoughtSignatureSeparator, encoded)
|
|
}
|
|
|
|
toolCallIdx := state.nextToolCallIndex
|
|
state.nextToolCallIndex++
|
|
|
|
toolCall := schemas.ChatAssistantMessageToolCall{
|
|
Index: uint16(toolCallIdx),
|
|
Type: schemas.Ptr(string(schemas.ChatToolTypeFunction)),
|
|
ID: &callID,
|
|
Function: schemas.ChatAssistantMessageToolCallFunction{
|
|
Name: &part.FunctionCall.Name,
|
|
Arguments: jsonArgs,
|
|
},
|
|
}
|
|
|
|
toolCalls = append(toolCalls, toolCall)
|
|
|
|
// Also add thought signature to reasoning details if present
|
|
if len(part.ThoughtSignature) > 0 {
|
|
thoughtSig := base64.StdEncoding.EncodeToString(part.ThoughtSignature)
|
|
// Extract base ID without signature for reasoning detail lookup
|
|
baseCallID := callID
|
|
if strings.Contains(callID, thoughtSignatureSeparator) {
|
|
parts := strings.SplitN(callID, thoughtSignatureSeparator, 2)
|
|
if len(parts) == 2 {
|
|
baseCallID = parts[0]
|
|
}
|
|
}
|
|
reasoningDetails = append(reasoningDetails, schemas.ChatReasoningDetails{
|
|
Index: len(reasoningDetails),
|
|
Type: schemas.BifrostReasoningDetailsTypeEncrypted,
|
|
Signature: &thoughtSig,
|
|
ID: schemas.Ptr(fmt.Sprintf("tool_call_%s", baseCallID)),
|
|
})
|
|
}
|
|
|
|
case part.FunctionResponse != nil:
|
|
// Extract the output from the response and add to text content
|
|
output := extractFunctionResponseOutput(part.FunctionResponse)
|
|
if output != "" {
|
|
textContent += output
|
|
}
|
|
case part.CodeExecutionResult != nil:
|
|
output := part.CodeExecutionResult.Output
|
|
if part.CodeExecutionResult.Outcome != OutcomeOK {
|
|
output = "Error: " + output
|
|
}
|
|
if output != "" {
|
|
textContent += output
|
|
}
|
|
case part.ExecutableCode != nil:
|
|
codeContent := "```" + part.ExecutableCode.Language + "\n" + part.ExecutableCode.Code + "\n```"
|
|
textContent += codeContent
|
|
}
|
|
|
|
// Handle thought signature separately (not part of the switch since it can co-exist with other types)
|
|
if len(part.ThoughtSignature) > 0 && part.FunctionCall == nil {
|
|
thoughtSig := base64.StdEncoding.EncodeToString(part.ThoughtSignature)
|
|
reasoningDetails = append(reasoningDetails, schemas.ChatReasoningDetails{
|
|
Index: len(reasoningDetails),
|
|
Type: schemas.BifrostReasoningDetailsTypeEncrypted,
|
|
Signature: &thoughtSig,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Set text content if present
|
|
if textContent != "" {
|
|
delta.Content = &textContent
|
|
}
|
|
|
|
// Set reasoning details if present
|
|
if len(reasoningDetails) > 0 {
|
|
delta.ReasoningDetails = reasoningDetails
|
|
}
|
|
|
|
// Set tool calls if present
|
|
if len(toolCalls) > 0 {
|
|
delta.ToolCalls = toolCalls
|
|
state.hadToolCalls = true
|
|
}
|
|
}
|
|
|
|
// Check if delta has any content - if not and it's not the last chunk, skip it
|
|
hasDeltaContent := delta.Role != nil || delta.Content != nil || len(delta.ToolCalls) > 0 || len(delta.ReasoningDetails) > 0
|
|
if !hasDeltaContent && !isLastChunk {
|
|
return nil, nil, false
|
|
}
|
|
|
|
// Build the choice
|
|
var finishReason *string
|
|
if isLastChunk && candidate.FinishReason != "" {
|
|
reason := ConvertGeminiFinishReasonToBifrost(candidate.FinishReason)
|
|
// Gemini uses "STOP" for both text completions and tool call responses.
|
|
// Override to "tool_calls" when tool calls were seen in this stream for uniformity.
|
|
if (len(delta.ToolCalls) > 0 || state.hadToolCalls) && reason == "stop" {
|
|
reason = "tool_calls"
|
|
}
|
|
finishReason = &reason
|
|
}
|
|
|
|
choice := schemas.BifrostResponseChoice{
|
|
Index: int(candidate.Index),
|
|
FinishReason: finishReason,
|
|
LogProbs: ConvertGeminiLogprobsResultToBifrost(candidate.LogprobsResult),
|
|
ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{
|
|
Delta: delta,
|
|
},
|
|
}
|
|
|
|
streamResponse.Choices = []schemas.BifrostResponseChoice{choice}
|
|
|
|
// Add usage information if this is the last chunk
|
|
if isLastChunk && response.UsageMetadata != nil {
|
|
streamResponse.Usage = ConvertGeminiUsageMetadataToChatUsage(response.UsageMetadata)
|
|
}
|
|
|
|
return streamResponse, nil, isLastChunk
|
|
}
|
|
|
|
// isErrorFinishReason checks if a finish reason indicates a filtered or error response
|
|
func isErrorFinishReason(reason FinishReason) bool {
|
|
return reason == FinishReasonSafety ||
|
|
reason == FinishReasonRecitation ||
|
|
reason == FinishReasonMalformedFunctionCall ||
|
|
reason == FinishReasonBlocklist ||
|
|
reason == FinishReasonProhibitedContent ||
|
|
reason == FinishReasonSPII ||
|
|
reason == FinishReasonImageSafety ||
|
|
reason == FinishReasonUnexpectedToolCall ||
|
|
reason == FinishReasonMissingThoughtSignature ||
|
|
reason == FinishReasonMalformedResponse ||
|
|
reason == FinishReasonImageProhibitedContent ||
|
|
reason == FinishReasonImageRecitation ||
|
|
reason == FinishReasonTooManyToolCalls ||
|
|
reason == FinishReasonNoImage
|
|
}
|
|
|
|
// createErrorResponse creates a complete BifrostChatResponse for error cases
|
|
func createErrorResponse(response *GenerateContentResponse, finishReason string, isStream bool) *schemas.BifrostChatResponse {
|
|
var choice schemas.BifrostResponseChoice
|
|
if isStream {
|
|
choice = schemas.BifrostResponseChoice{
|
|
Index: 0,
|
|
FinishReason: &finishReason,
|
|
ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{
|
|
Delta: &schemas.ChatStreamResponseChoiceDelta{},
|
|
},
|
|
}
|
|
} else {
|
|
choice = schemas.BifrostResponseChoice{
|
|
Index: 0,
|
|
FinishReason: &finishReason,
|
|
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
|
Message: &schemas.ChatMessage{
|
|
Role: schemas.ChatMessageRoleAssistant,
|
|
Content: &schemas.ChatMessageContent{},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
objectType := "chat.completion"
|
|
if isStream {
|
|
objectType = "chat.completion.chunk"
|
|
}
|
|
|
|
errorResp := &schemas.BifrostChatResponse{
|
|
ID: response.ResponseID,
|
|
Model: response.ModelVersion,
|
|
Object: objectType,
|
|
Choices: []schemas.BifrostResponseChoice{choice},
|
|
Usage: ConvertGeminiUsageMetadataToChatUsage(response.UsageMetadata),
|
|
}
|
|
|
|
if !response.CreateTime.IsZero() {
|
|
errorResp.Created = int(response.CreateTime.Unix())
|
|
}
|
|
|
|
return errorResp
|
|
}
|