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:
@@ -138,20 +138,31 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
// 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
|
||||
}
|
||||
|
||||
// parseFunctionResponseRaw attempts to normalize a function response part into a JSON object string.
|
||||
// Falls back to a minimal "functionResponse" object when parsing fails.
|
||||
func parseFunctionResponseRaw(response gjson.Result) string {
|
||||
// fallbackName is used when the response's own name is empty.
|
||||
func parseFunctionResponseRaw(response gjson.Result, fallbackName string) string {
|
||||
if response.IsObject() && gjson.Valid(response.Raw) {
|
||||
return response.Raw
|
||||
raw := response.Raw
|
||||
name := response.Get("functionResponse.name").String()
|
||||
if strings.TrimSpace(name) == "" && fallbackName != "" {
|
||||
raw, _ = sjson.Set(raw, "functionResponse.name", fallbackName)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
log.Debugf("parse function response failed, using fallback")
|
||||
funcResp := response.Get("functionResponse")
|
||||
if funcResp.Exists() {
|
||||
fr := `{"functionResponse":{"name":"","response":{"result":""}}}`
|
||||
fr, _ = sjson.Set(fr, "functionResponse.name", funcResp.Get("name").String())
|
||||
name := funcResp.Get("name").String()
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = fallbackName
|
||||
}
|
||||
fr, _ = sjson.Set(fr, "functionResponse.name", name)
|
||||
fr, _ = sjson.Set(fr, "functionResponse.response.result", funcResp.Get("response").String())
|
||||
if id := funcResp.Get("id").String(); id != "" {
|
||||
fr, _ = sjson.Set(fr, "functionResponse.id", id)
|
||||
@@ -159,7 +170,12 @@ func parseFunctionResponseRaw(response gjson.Result) string {
|
||||
return fr
|
||||
}
|
||||
|
||||
fr := `{"functionResponse":{"name":"unknown","response":{"result":""}}}`
|
||||
useName := fallbackName
|
||||
if useName == "" {
|
||||
useName = "unknown"
|
||||
}
|
||||
fr := `{"functionResponse":{"name":"","response":{"result":""}}}`
|
||||
fr, _ = sjson.Set(fr, "functionResponse.name", useName)
|
||||
fr, _ = sjson.Set(fr, "functionResponse.response.result", response.String())
|
||||
return fr
|
||||
}
|
||||
@@ -211,30 +227,26 @@ 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 {
|
||||
partRaw := parseFunctionResponseRaw(response)
|
||||
if partRaw != "" {
|
||||
functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", partRaw)
|
||||
}
|
||||
// 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 {
|
||||
partRaw := parseFunctionResponseRaw(response, group.CallNames[ri])
|
||||
if partRaw != "" {
|
||||
functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", partRaw)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,15 +255,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")
|
||||
@@ -261,7 +273,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 {
|
||||
@@ -291,8 +304,12 @@ func fixCLIToolResponse(input string) (string, error) {
|
||||
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
||||
|
||||
functionResponseContent := `{"parts":[],"role":"function"}`
|
||||
for _, response := range groupResponses {
|
||||
partRaw := parseFunctionResponseRaw(response)
|
||||
for ri, response := range groupResponses {
|
||||
fallbackName := ""
|
||||
if ri < len(group.CallNames) {
|
||||
fallbackName = group.CallNames[ri]
|
||||
}
|
||||
partRaw := parseFunctionResponseRaw(response, fallbackName)
|
||||
if partRaw != "" {
|
||||
functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", partRaw)
|
||||
}
|
||||
|
||||
@@ -171,3 +171,257 @@ func TestFixCLIToolResponse_PreservesFunctionResponseParts(t *testing.T) {
|
||||
t.Errorf("Expected response.result 'Screenshot taken', got '%s'", funcResp.Get("response.result").String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixCLIToolResponse_BackfillsEmptyFunctionResponseName(t *testing.T) {
|
||||
// When the Amp client sends functionResponse with an empty name,
|
||||
// fixCLIToolResponse should backfill it from the corresponding functionCall.
|
||||
input := `{
|
||||
"model": "gemini-3-pro-preview",
|
||||
"request": {
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Bash", "args": {"cmd": "ls"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"output": "file1.txt"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := fixCLIToolResponse(input)
|
||||
if err != nil {
|
||||
t.Fatalf("fixCLIToolResponse failed: %v", err)
|
||||
}
|
||||
|
||||
contents := gjson.Get(result, "request.contents").Array()
|
||||
var funcContent gjson.Result
|
||||
for _, c := range contents {
|
||||
if c.Get("role").String() == "function" {
|
||||
funcContent = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if !funcContent.Exists() {
|
||||
t.Fatal("function role content should exist in output")
|
||||
}
|
||||
|
||||
name := funcContent.Get("parts.0.functionResponse.name").String()
|
||||
if name != "Bash" {
|
||||
t.Errorf("Expected backfilled name 'Bash', got '%s'", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixCLIToolResponse_BackfillsMultipleEmptyNames(t *testing.T) {
|
||||
// Parallel function calls: both responses have empty names.
|
||||
input := `{
|
||||
"model": "gemini-3-pro-preview",
|
||||
"request": {
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Read", "args": {"path": "/a"}}},
|
||||
{"functionCall": {"name": "Grep", "args": {"pattern": "x"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "content a"}}},
|
||||
{"functionResponse": {"name": "", "response": {"result": "match x"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := fixCLIToolResponse(input)
|
||||
if err != nil {
|
||||
t.Fatalf("fixCLIToolResponse failed: %v", err)
|
||||
}
|
||||
|
||||
contents := gjson.Get(result, "request.contents").Array()
|
||||
var funcContent gjson.Result
|
||||
for _, c := range contents {
|
||||
if c.Get("role").String() == "function" {
|
||||
funcContent = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if !funcContent.Exists() {
|
||||
t.Fatal("function role content should exist in output")
|
||||
}
|
||||
|
||||
parts := funcContent.Get("parts").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("Expected 2 function response parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
name0 := parts[0].Get("functionResponse.name").String()
|
||||
name1 := parts[1].Get("functionResponse.name").String()
|
||||
if name0 != "Read" {
|
||||
t.Errorf("Expected first response name 'Read', got '%s'", name0)
|
||||
}
|
||||
if name1 != "Grep" {
|
||||
t.Errorf("Expected second response name 'Grep', got '%s'", name1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixCLIToolResponse_PreservesExistingName(t *testing.T) {
|
||||
// When functionResponse already has a valid name, it should be preserved.
|
||||
input := `{
|
||||
"model": "gemini-3-pro-preview",
|
||||
"request": {
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Bash", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "Bash", "response": {"result": "ok"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := fixCLIToolResponse(input)
|
||||
if err != nil {
|
||||
t.Fatalf("fixCLIToolResponse failed: %v", err)
|
||||
}
|
||||
|
||||
contents := gjson.Get(result, "request.contents").Array()
|
||||
var funcContent gjson.Result
|
||||
for _, c := range contents {
|
||||
if c.Get("role").String() == "function" {
|
||||
funcContent = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if !funcContent.Exists() {
|
||||
t.Fatal("function role content should exist in output")
|
||||
}
|
||||
|
||||
name := funcContent.Get("parts.0.functionResponse.name").String()
|
||||
if name != "Bash" {
|
||||
t.Errorf("Expected preserved name 'Bash', got '%s'", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixCLIToolResponse_MoreResponsesThanCalls(t *testing.T) {
|
||||
// If there are more function responses than calls, unmatched extras are discarded by grouping.
|
||||
input := `{
|
||||
"model": "gemini-3-pro-preview",
|
||||
"request": {
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Bash", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "ok"}}},
|
||||
{"functionResponse": {"name": "", "response": {"result": "extra"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := fixCLIToolResponse(input)
|
||||
if err != nil {
|
||||
t.Fatalf("fixCLIToolResponse failed: %v", err)
|
||||
}
|
||||
|
||||
contents := gjson.Get(result, "request.contents").Array()
|
||||
var funcContent gjson.Result
|
||||
for _, c := range contents {
|
||||
if c.Get("role").String() == "function" {
|
||||
funcContent = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if !funcContent.Exists() {
|
||||
t.Fatal("function role content should exist in output")
|
||||
}
|
||||
|
||||
// First response should be backfilled from the call
|
||||
name0 := funcContent.Get("parts.0.functionResponse.name").String()
|
||||
if name0 != "Bash" {
|
||||
t.Errorf("Expected first response name 'Bash', got '%s'", name0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixCLIToolResponse_MultipleGroupsFIFO(t *testing.T) {
|
||||
// Two sequential function call groups should be matched FIFO.
|
||||
input := `{
|
||||
"model": "gemini-3-pro-preview",
|
||||
"request": {
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Read", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "file content"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Grep", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "function",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "match"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := fixCLIToolResponse(input)
|
||||
if err != nil {
|
||||
t.Fatalf("fixCLIToolResponse failed: %v", err)
|
||||
}
|
||||
|
||||
contents := gjson.Get(result, "request.contents").Array()
|
||||
var funcContents []gjson.Result
|
||||
for _, c := range contents {
|
||||
if c.Get("role").String() == "function" {
|
||||
funcContents = append(funcContents, c)
|
||||
}
|
||||
}
|
||||
if len(funcContents) != 2 {
|
||||
t.Fatalf("Expected 2 function contents, got %d", len(funcContents))
|
||||
}
|
||||
|
||||
name0 := funcContents[0].Get("parts.0.functionResponse.name").String()
|
||||
name1 := funcContents[1].Get("parts.0.functionResponse.name").String()
|
||||
if name0 != "Read" {
|
||||
t.Errorf("Expected first group name 'Read', got '%s'", name0)
|
||||
}
|
||||
if name1 != "Grep" {
|
||||
t.Errorf("Expected second group name 'Grep', got '%s'", name1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,9 +5,11 @@ 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"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
@@ -95,6 +97,71 @@ func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte
|
||||
out = []byte(strJson)
|
||||
}
|
||||
|
||||
// Backfill empty functionResponse.name from the preceding functionCall.name.
|
||||
// Amp may send function responses with empty names; the Gemini API rejects these.
|
||||
out = backfillEmptyFunctionResponseNames(out)
|
||||
|
||||
out = common.AttachDefaultSafetySettings(out, "safetySettings")
|
||||
return out
|
||||
}
|
||||
|
||||
// backfillEmptyFunctionResponseNames walks the contents array and for each
|
||||
// model turn containing functionCall parts, records the call names in order.
|
||||
// For the immediately following user/function turn containing functionResponse
|
||||
// parts, any empty name is replaced with the corresponding call name.
|
||||
func backfillEmptyFunctionResponseNames(data []byte) []byte {
|
||||
contents := gjson.GetBytes(data, "contents")
|
||||
if !contents.Exists() {
|
||||
return data
|
||||
}
|
||||
|
||||
out := data
|
||||
var pendingCallNames []string
|
||||
|
||||
contents.ForEach(func(contentIdx, content gjson.Result) bool {
|
||||
role := content.Get("role").String()
|
||||
|
||||
// Collect functionCall names from model turns
|
||||
if role == "model" {
|
||||
var names []string
|
||||
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("functionCall").Exists() {
|
||||
names = append(names, part.Get("functionCall.name").String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
if len(names) > 0 {
|
||||
pendingCallNames = names
|
||||
} else {
|
||||
pendingCallNames = nil
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Backfill empty functionResponse names from pending call names
|
||||
if len(pendingCallNames) > 0 {
|
||||
ri := 0
|
||||
content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool {
|
||||
if part.Get("functionResponse").Exists() {
|
||||
name := part.Get("functionResponse.name").String()
|
||||
if strings.TrimSpace(name) == "" {
|
||||
if ri < len(pendingCallNames) {
|
||||
out, _ = sjson.SetBytes(out,
|
||||
fmt.Sprintf("contents.%d.parts.%d.functionResponse.name", contentIdx.Int(), partIdx.Int()),
|
||||
pendingCallNames[ri])
|
||||
} else {
|
||||
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIdx.Int())
|
||||
}
|
||||
}
|
||||
ri++
|
||||
}
|
||||
return true
|
||||
})
|
||||
pendingCallNames = nil
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
193
internal/translator/gemini/gemini/gemini_gemini_request_test.go
Normal file
193
internal/translator/gemini/gemini/gemini_gemini_request_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBackfillEmptyFunctionResponseNames_Single(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Bash", "args": {"cmd": "ls"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"output": "file1.txt"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := backfillEmptyFunctionResponseNames(input)
|
||||
|
||||
name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
if name != "Bash" {
|
||||
t.Errorf("Expected backfilled name 'Bash', got '%s'", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEmptyFunctionResponseNames_Parallel(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Read", "args": {"path": "/a"}}},
|
||||
{"functionCall": {"name": "Grep", "args": {"pattern": "x"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "content a"}}},
|
||||
{"functionResponse": {"name": "", "response": {"result": "match x"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := backfillEmptyFunctionResponseNames(input)
|
||||
|
||||
name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
name1 := gjson.GetBytes(out, "contents.1.parts.1.functionResponse.name").String()
|
||||
if name0 != "Read" {
|
||||
t.Errorf("Expected first name 'Read', got '%s'", name0)
|
||||
}
|
||||
if name1 != "Grep" {
|
||||
t.Errorf("Expected second name 'Grep', got '%s'", name1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEmptyFunctionResponseNames_PreservesExisting(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Bash", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "Bash", "response": {"result": "ok"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := backfillEmptyFunctionResponseNames(input)
|
||||
|
||||
name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
if name != "Bash" {
|
||||
t.Errorf("Expected preserved name 'Bash', got '%s'", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToGemini_BackfillsEmptyName(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Bash", "args": {"cmd": "ls"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"output": "file1.txt"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToGemini("", input, false)
|
||||
|
||||
name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
if name != "Bash" {
|
||||
t.Errorf("Expected backfilled name 'Bash', got '%s'", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEmptyFunctionResponseNames_MoreResponsesThanCalls(t *testing.T) {
|
||||
// Extra responses beyond the call count should not panic and should be left unchanged.
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Bash", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "ok"}}},
|
||||
{"functionResponse": {"name": "", "response": {"result": "extra"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := backfillEmptyFunctionResponseNames(input)
|
||||
|
||||
name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
if name0 != "Bash" {
|
||||
t.Errorf("Expected first name 'Bash', got '%s'", name0)
|
||||
}
|
||||
// Second response has no matching call, should remain empty
|
||||
name1 := gjson.GetBytes(out, "contents.1.parts.1.functionResponse.name").String()
|
||||
if name1 != "" {
|
||||
t.Errorf("Expected second name to remain empty, got '%s'", name1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEmptyFunctionResponseNames_MultipleGroups(t *testing.T) {
|
||||
// Two sequential call/response groups should each get correct names.
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Read", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "content"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Grep", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "match"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := backfillEmptyFunctionResponseNames(input)
|
||||
|
||||
name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
name1 := gjson.GetBytes(out, "contents.3.parts.0.functionResponse.name").String()
|
||||
if name0 != "Read" {
|
||||
t.Errorf("Expected first group name 'Read', got '%s'", name0)
|
||||
}
|
||||
if name1 != "Grep" {
|
||||
t.Errorf("Expected second group name 'Grep', got '%s'", name1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user