From 5b3c8c3a52c3aa72dacebebc10d6589cdcff7e5b Mon Sep 17 00:00:00 2001 From: Christian Feurer <3908180+christianfeurer@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:57:04 +0200 Subject: [PATCH] translator: repair Gemini requests ending with a model turn Gemini rejects requests whose last content has the model role with 400 "Requests ending with a model turn are not supported.". OpenAI-compatible clients such as OpenCode send such requests when they resume an interrupted session or retry after a cut-off response, which leaves the session stuck. openAIMessagesToGeminiContents now repairs the trailing model turn: empty trailing assistant messages are dropped, unanswered tool calls get an error function response, and any other trailing model turn is followed by a "Please continue." user turn (LiteLLM's default continue message). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Christian Feurer <3908180+christianfeurer@users.noreply.github.com> --- internal/translator/gemini_helper.go | 44 ++++++++- internal/translator/gemini_helper_test.go | 91 +++++++++++++++++++ .../translator/openai_gcpvertexai_test.go | 55 +++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) diff --git a/internal/translator/gemini_helper.go b/internal/translator/gemini_helper.go index 9d9178f2b5..87e922f15c 100644 --- a/internal/translator/gemini_helper.go +++ b/internal/translator/gemini_helper.go @@ -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. diff --git a/internal/translator/gemini_helper_test.go b/internal/translator/gemini_helper_test.go index b7e3f3bd1e..39dcbbe713 100644 --- a/internal/translator/gemini_helper_test.go +++ b/internal/translator/gemini_helper_test.go @@ -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 { diff --git a/internal/translator/openai_gcpvertexai_test.go b/internal/translator/openai_gcpvertexai_test.go index 439c82d219..1941fc3188 100644 --- a/internal/translator/openai_gcpvertexai_test.go +++ b/internal/translator/openai_gcpvertexai_test.go @@ -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 @@ -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 {