Merge pull request #2076 from aikins01/fix/backfill-empty-function-response-names
fix: backfill empty functionResponse.name from preceding functionCall
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,8 @@ 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 {
|
||||
partRaw := parseFunctionResponseRaw(response, group.CallNames[ri])
|
||||
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,13 @@ 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 := backfillFunctionResponseName(response.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