Skip to content
Open
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
44 changes: 43 additions & 1 deletion internal/translator/gemini_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,49 @@ func openAIMessagesToGeminiContents(messages []openai.ChatCompletionMessageParam
if len(gcpParts) > 0 {
gcpContents = append(gcpContents, genai.Content{Role: genai.RoleUser, Parts: gcpParts})
}
return gcpContents, systemInstruction, nil
return ensureGeminiContentsEndWithUserTurn(gcpContents), systemInstruction, nil
}

// geminiUserContinueText is the text of the user turn appended when a conversation ends with a model turn.
// It matches LiteLLM's DEFAULT_USER_CONTINUE_MESSAGE.
const geminiUserContinueText = "Please continue."

// geminiUnansweredToolCallError is the error reported back to the model for tool calls of a trailing
// model turn that never received a tool result.
const geminiUnansweredToolCallError = "tool call was not executed: no tool result was provided"

// ensureGeminiContentsEndWithUserTurn makes sure the contents do not end with a model turn, which Gemini
// rejects with "Requests ending with a model turn are not supported." OpenAI-compatible clients send such
// requests e.g. when they resume an interrupted session or when the previous response was cut off, so
// the request is repaired instead of being forwarded to a guaranteed 400:
// - a trailing model turn without parts (empty assistant message) is dropped,
// - unanswered function calls of a trailing model turn get an error function response, since Gemini
// expects a function call turn to be followed by the matching function responses,
// - any other trailing model turn is followed by a short user turn asking the model to continue.
func ensureGeminiContentsEndWithUserTurn(contents []genai.Content) []genai.Content {
if len(contents) == 0 || contents[len(contents)-1].Role != genai.RoleModel {
return contents
}
for len(contents) > 0 && contents[len(contents)-1].Role == genai.RoleModel && len(contents[len(contents)-1].Parts) == 0 {
contents = contents[:len(contents)-1]
}
if len(contents) > 0 && contents[len(contents)-1].Role != genai.RoleModel {
return contents
}

var responses []*genai.Part
if len(contents) > 0 {
for _, part := range contents[len(contents)-1].Parts {
if part.FunctionCall != nil {
responses = append(responses, genai.NewPartFromFunctionResponse(part.FunctionCall.Name,
map[string]any{"error": geminiUnansweredToolCallError}))
}
}
}
if len(responses) > 0 {
return append(contents, genai.Content{Role: genai.RoleUser, Parts: responses})
}
return append(contents, genai.Content{Role: genai.RoleUser, Parts: []*genai.Part{genai.NewPartFromText(geminiUserContinueText)}})
}

// mapDetailMediaResolution converts OpenAI image detail levels to Gemini media resolution levels.
Expand Down
91 changes: 91 additions & 0 deletions internal/translator/gemini_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,97 @@ func TestOpenAIMessagesToGeminiContents(t *testing.T) {
},
},
},
{
name: "conversation ending with user turn is unchanged",
messages: []openai.ChatCompletionMessageParamUnion{
{OfUser: &openai.ChatCompletionUserMessageParam{Role: openai.ChatMessageRoleUser, Content: openai.StringOrUserRoleContentUnion{Value: "Hi"}}},
{OfAssistant: &openai.ChatCompletionAssistantMessageParam{Role: openai.ChatMessageRoleAssistant, Content: openai.StringOrAssistantRoleContentUnion{Value: "Hello"}}},
{OfUser: &openai.ChatCompletionUserMessageParam{Role: openai.ChatMessageRoleUser, Content: openai.StringOrUserRoleContentUnion{Value: "How are you?"}}},
},
expectedContents: []genai.Content{
{Role: genai.RoleUser, Parts: []*genai.Part{{Text: "Hi"}}},
{Role: genai.RoleModel, Parts: []*genai.Part{{Text: "Hello"}}},
{Role: genai.RoleUser, Parts: []*genai.Part{{Text: "How are you?"}}},
},
},
{
name: "trailing assistant text message gets a continue user turn",
messages: []openai.ChatCompletionMessageParamUnion{
{OfUser: &openai.ChatCompletionUserMessageParam{Role: openai.ChatMessageRoleUser, Content: openai.StringOrUserRoleContentUnion{Value: "Hi"}}},
{OfAssistant: &openai.ChatCompletionAssistantMessageParam{Role: openai.ChatMessageRoleAssistant, Content: openai.StringOrAssistantRoleContentUnion{Value: "Hello"}}},
},
expectedContents: []genai.Content{
{Role: genai.RoleUser, Parts: []*genai.Part{{Text: "Hi"}}},
{Role: genai.RoleModel, Parts: []*genai.Part{{Text: "Hello"}}},
{Role: genai.RoleUser, Parts: []*genai.Part{{Text: geminiUserContinueText}}},
},
},
{
name: "trailing empty assistant message is dropped",
messages: []openai.ChatCompletionMessageParamUnion{
{OfUser: &openai.ChatCompletionUserMessageParam{Role: openai.ChatMessageRoleUser, Content: openai.StringOrUserRoleContentUnion{Value: "Hi"}}},
{OfAssistant: &openai.ChatCompletionAssistantMessageParam{Role: openai.ChatMessageRoleAssistant, Content: openai.StringOrAssistantRoleContentUnion{Value: ""}}},
},
expectedContents: []genai.Content{
{Role: genai.RoleUser, Parts: []*genai.Part{{Text: "Hi"}}},
},
},
{
name: "trailing empty assistant message after assistant text gets a continue user turn",
messages: []openai.ChatCompletionMessageParamUnion{
{OfUser: &openai.ChatCompletionUserMessageParam{Role: openai.ChatMessageRoleUser, Content: openai.StringOrUserRoleContentUnion{Value: "Hi"}}},
{OfAssistant: &openai.ChatCompletionAssistantMessageParam{Role: openai.ChatMessageRoleAssistant, Content: openai.StringOrAssistantRoleContentUnion{Value: "Hello"}}},
{OfAssistant: &openai.ChatCompletionAssistantMessageParam{Role: openai.ChatMessageRoleAssistant}},
},
expectedContents: []genai.Content{
{Role: genai.RoleUser, Parts: []*genai.Part{{Text: "Hi"}}},
{Role: genai.RoleModel, Parts: []*genai.Part{{Text: "Hello"}}},
{Role: genai.RoleUser, Parts: []*genai.Part{{Text: geminiUserContinueText}}},
},
},
{
name: "only an empty assistant message",
messages: []openai.ChatCompletionMessageParamUnion{
{OfAssistant: &openai.ChatCompletionAssistantMessageParam{Role: openai.ChatMessageRoleAssistant}},
},
expectedContents: []genai.Content{
{Role: genai.RoleUser, Parts: []*genai.Part{{Text: geminiUserContinueText}}},
},
},
{
name: "trailing assistant tool calls without tool results get error function responses",
messages: []openai.ChatCompletionMessageParamUnion{
{OfUser: &openai.ChatCompletionUserMessageParam{Role: openai.ChatMessageRoleUser, Content: openai.StringOrUserRoleContentUnion{Value: "List files"}}},
{OfAssistant: &openai.ChatCompletionAssistantMessageParam{
Role: openai.ChatMessageRoleAssistant,
Content: openai.StringOrAssistantRoleContentUnion{Value: "Running ls"},
ToolCalls: []openai.ChatCompletionMessageToolCallParam{
{
ID: ptr.To("call_1"),
Function: openai.ChatCompletionMessageToolCallFunctionParam{Name: "bash", Arguments: `{"command":"ls"}`},
Type: openai.ChatCompletionMessageToolCallTypeFunction,
},
{
ID: ptr.To("call_2"),
Function: openai.ChatCompletionMessageToolCallFunctionParam{Name: "read", Arguments: `{"path":"README.md"}`},
Type: openai.ChatCompletionMessageToolCallTypeFunction,
},
},
}},
},
expectedContents: []genai.Content{
{Role: genai.RoleUser, Parts: []*genai.Part{{Text: "List files"}}},
{Role: genai.RoleModel, Parts: []*genai.Part{
{FunctionCall: &genai.FunctionCall{Name: "bash", Args: map[string]any{"command": "ls"}}, ThoughtSignature: dummyThoughtSignature},
{FunctionCall: &genai.FunctionCall{Name: "read", Args: map[string]any{"path": "README.md"}}},
{Text: "Running ls"},
}},
{Role: genai.RoleUser, Parts: []*genai.Part{
{FunctionResponse: &genai.FunctionResponse{Name: "bash", Response: map[string]any{"error": geminiUnansweredToolCallError}}},
{FunctionResponse: &genai.FunctionResponse{Name: "read", Response: map[string]any{"error": geminiUnansweredToolCallError}}},
}},
},
},
}

for _, tc := range tests {
Expand Down
55 changes: 55 additions & 0 deletions internal/translator/openai_gcpvertexai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,37 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T)
}
}`)

wantBdyTrailingAssistant := []byte(`{
"contents": [
{
"parts": [
{
"text": "Tell me about AI Gateways"
}
],
"role": "user"
},
{
"parts": [
{
"text": "AI Gateways route requests to LLM providers."
}
],
"role": "model"
},
{
"parts": [
{
"text": "Please continue."
}
],
"role": "user"
}
],
"tools": null,
"generationConfig": {}
}`)

tests := []struct {
name string
modelNameOverride internalapi.ModelNameOverride
Expand Down Expand Up @@ -780,6 +811,30 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T)
},
wantBody: wantBdyWithEnterpriseWebSearch,
},
{
name: "request ending with an assistant message gets a continue user turn",
input: openai.ChatCompletionRequest{
Model: "gemini-pro",
Messages: []openai.ChatCompletionMessageParamUnion{
{
OfUser: &openai.ChatCompletionUserMessageParam{
Content: openai.StringOrUserRoleContentUnion{Value: "Tell me about AI Gateways"},
Role: openai.ChatMessageRoleUser,
},
},
{
OfAssistant: &openai.ChatCompletionAssistantMessageParam{
Content: openai.StringOrAssistantRoleContentUnion{Value: "AI Gateways route requests to LLM providers."},
Role: openai.ChatMessageRoleAssistant,
},
},
},
},
wantHeaderMut: []internalapi.Header{
{":path", "publishers/google/models/gemini-pro:generateContent"},
},
wantBody: wantBdyTrailingAssistant,
},
}

for _, tc := range tests {
Expand Down
Loading