fix: backfill empty functionResponse.name from preceding functionCall

when Amp or Claude Code sends functionResponse with an empty name in Gemini
conversation history, the Gemini API rejects the request with 400
"Name cannot be empty". this fix backfills empty names from the
corresponding preceding functionCall parts using positional matching.

covers all three Gemini translator paths:
- gemini/gemini (direct API key)
- antigravity/gemini (OAuth)
- gemini-cli/gemini (Gemini CLI)

also switches fixCLIToolResponse pending group matching from LIFO to
FIFO to correctly handle multiple sequential tool call groups.

fixes #1903
This commit is contained in:
Aikins Laryea
2026-03-12 00:00:38 +00:00
parent cf74ed2f0c
commit 861537c9bd
5 changed files with 604 additions and 59 deletions

View File

@@ -7,6 +7,7 @@ package gemini
import (
"fmt"
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
@@ -116,6 +117,17 @@ func ConvertGeminiRequestToGeminiCLI(_ string, inputRawJSON []byte, _ bool) []by
// FunctionCallGroup represents a group of function calls and their responses
type FunctionCallGroup struct {
ResponsesNeeded int
CallNames []string // ordered function call names for backfilling empty response names
}
// backfillFunctionResponseName ensures that a functionResponse JSON object has a non-empty name,
// falling back to fallbackName if the original is empty.
func backfillFunctionResponseName(raw string, fallbackName string) string {
name := gjson.Get(raw, "functionResponse.name").String()
if strings.TrimSpace(name) == "" && fallbackName != "" {
raw, _ = sjson.Set(raw, "functionResponse.name", fallbackName)
}
return raw
}
// fixCLIToolResponse performs sophisticated tool response format conversion and grouping.
@@ -165,31 +177,28 @@ func fixCLIToolResponse(input string) (string, error) {
if len(responsePartsInThisContent) > 0 {
collectedResponses = append(collectedResponses, responsePartsInThisContent...)
// Check if any pending groups can be satisfied
for i := len(pendingGroups) - 1; i >= 0; i-- {
group := pendingGroups[i]
if len(collectedResponses) >= group.ResponsesNeeded {
// Take the needed responses for this group
groupResponses := collectedResponses[:group.ResponsesNeeded]
collectedResponses = collectedResponses[group.ResponsesNeeded:]
// Check if pending groups can be satisfied (FIFO: oldest group first)
for len(pendingGroups) > 0 && len(collectedResponses) >= pendingGroups[0].ResponsesNeeded {
group := pendingGroups[0]
pendingGroups = pendingGroups[1:]
// Create merged function response content
functionResponseContent := `{"parts":[],"role":"function"}`
for _, response := range groupResponses {
if !response.IsObject() {
log.Warnf("failed to parse function response")
continue
}
functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", response.Raw)
// Take the needed responses for this group
groupResponses := collectedResponses[:group.ResponsesNeeded]
collectedResponses = collectedResponses[group.ResponsesNeeded:]
// Create merged function response content
functionResponseContent := `{"parts":[],"role":"function"}`
for ri, response := range groupResponses {
if !response.IsObject() {
log.Warnf("failed to parse function response")
continue
}
raw := backfillFunctionResponseName(response.Raw, group.CallNames[ri])
functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", raw)
}
if gjson.Get(functionResponseContent, "parts.#").Int() > 0 {
contentsWrapper, _ = sjson.SetRaw(contentsWrapper, "contents.-1", functionResponseContent)
}
// Remove this group as it's been satisfied
pendingGroups = append(pendingGroups[:i], pendingGroups[i+1:]...)
break
if gjson.Get(functionResponseContent, "parts.#").Int() > 0 {
contentsWrapper, _ = sjson.SetRaw(contentsWrapper, "contents.-1", functionResponseContent)
}
}
@@ -198,15 +207,15 @@ func fixCLIToolResponse(input string) (string, error) {
// If this is a model with function calls, create a new group
if role == "model" {
functionCallsCount := 0
var callNames []string
parts.ForEach(func(_, part gjson.Result) bool {
if part.Get("functionCall").Exists() {
functionCallsCount++
callNames = append(callNames, part.Get("functionCall.name").String())
}
return true
})
if functionCallsCount > 0 {
if len(callNames) > 0 {
// Add the model content
if !value.IsObject() {
log.Warnf("failed to parse model content")
@@ -216,7 +225,8 @@ func fixCLIToolResponse(input string) (string, error) {
// Create a new group for tracking responses
group := &FunctionCallGroup{
ResponsesNeeded: functionCallsCount,
ResponsesNeeded: len(callNames),
CallNames: callNames,
}
pendingGroups = append(pendingGroups, group)
} else {
@@ -246,12 +256,16 @@ func fixCLIToolResponse(input string) (string, error) {
collectedResponses = collectedResponses[group.ResponsesNeeded:]
functionResponseContent := `{"parts":[],"role":"function"}`
for _, response := range groupResponses {
for ri, response := range groupResponses {
if !response.IsObject() {
log.Warnf("failed to parse function response")
continue
}
functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", response.Raw)
raw := response.Raw
if ri < len(group.CallNames) {
raw = backfillFunctionResponseName(raw, group.CallNames[ri])
}
functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", raw)
}
if gjson.Get(functionResponseContent, "parts.#").Int() > 0 {