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
|
// FunctionCallGroup represents a group of function calls and their responses
|
||||||
type FunctionCallGroup struct {
|
type FunctionCallGroup struct {
|
||||||
ResponsesNeeded int
|
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.
|
// parseFunctionResponseRaw attempts to normalize a function response part into a JSON object string.
|
||||||
// Falls back to a minimal "functionResponse" object when parsing fails.
|
// 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) {
|
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")
|
log.Debugf("parse function response failed, using fallback")
|
||||||
funcResp := response.Get("functionResponse")
|
funcResp := response.Get("functionResponse")
|
||||||
if funcResp.Exists() {
|
if funcResp.Exists() {
|
||||||
fr := `{"functionResponse":{"name":"","response":{"result":""}}}`
|
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())
|
fr, _ = sjson.Set(fr, "functionResponse.response.result", funcResp.Get("response").String())
|
||||||
if id := funcResp.Get("id").String(); id != "" {
|
if id := funcResp.Get("id").String(); id != "" {
|
||||||
fr, _ = sjson.Set(fr, "functionResponse.id", id)
|
fr, _ = sjson.Set(fr, "functionResponse.id", id)
|
||||||
@@ -159,7 +170,12 @@ func parseFunctionResponseRaw(response gjson.Result) string {
|
|||||||
return fr
|
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())
|
fr, _ = sjson.Set(fr, "functionResponse.response.result", response.String())
|
||||||
return fr
|
return fr
|
||||||
}
|
}
|
||||||
@@ -211,18 +227,19 @@ func fixCLIToolResponse(input string) (string, error) {
|
|||||||
if len(responsePartsInThisContent) > 0 {
|
if len(responsePartsInThisContent) > 0 {
|
||||||
collectedResponses = append(collectedResponses, responsePartsInThisContent...)
|
collectedResponses = append(collectedResponses, responsePartsInThisContent...)
|
||||||
|
|
||||||
// Check if any pending groups can be satisfied
|
// Check if pending groups can be satisfied (FIFO: oldest group first)
|
||||||
for i := len(pendingGroups) - 1; i >= 0; i-- {
|
for len(pendingGroups) > 0 && len(collectedResponses) >= pendingGroups[0].ResponsesNeeded {
|
||||||
group := pendingGroups[i]
|
group := pendingGroups[0]
|
||||||
if len(collectedResponses) >= group.ResponsesNeeded {
|
pendingGroups = pendingGroups[1:]
|
||||||
|
|
||||||
// Take the needed responses for this group
|
// Take the needed responses for this group
|
||||||
groupResponses := collectedResponses[:group.ResponsesNeeded]
|
groupResponses := collectedResponses[:group.ResponsesNeeded]
|
||||||
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
||||||
|
|
||||||
// Create merged function response content
|
// Create merged function response content
|
||||||
functionResponseContent := `{"parts":[],"role":"function"}`
|
functionResponseContent := `{"parts":[],"role":"function"}`
|
||||||
for _, response := range groupResponses {
|
for ri, response := range groupResponses {
|
||||||
partRaw := parseFunctionResponseRaw(response)
|
partRaw := parseFunctionResponseRaw(response, group.CallNames[ri])
|
||||||
if partRaw != "" {
|
if partRaw != "" {
|
||||||
functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", partRaw)
|
functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", partRaw)
|
||||||
}
|
}
|
||||||
@@ -231,11 +248,6 @@ func fixCLIToolResponse(input string) (string, error) {
|
|||||||
if gjson.Get(functionResponseContent, "parts.#").Int() > 0 {
|
if gjson.Get(functionResponseContent, "parts.#").Int() > 0 {
|
||||||
contentsWrapper, _ = sjson.SetRaw(contentsWrapper, "contents.-1", functionResponseContent)
|
contentsWrapper, _ = sjson.SetRaw(contentsWrapper, "contents.-1", functionResponseContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove this group as it's been satisfied
|
|
||||||
pendingGroups = append(pendingGroups[:i], pendingGroups[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true // Skip adding this content, responses are merged
|
return true // Skip adding this content, responses are merged
|
||||||
@@ -243,15 +255,15 @@ func fixCLIToolResponse(input string) (string, error) {
|
|||||||
|
|
||||||
// If this is a model with function calls, create a new group
|
// If this is a model with function calls, create a new group
|
||||||
if role == "model" {
|
if role == "model" {
|
||||||
functionCallsCount := 0
|
var callNames []string
|
||||||
parts.ForEach(func(_, part gjson.Result) bool {
|
parts.ForEach(func(_, part gjson.Result) bool {
|
||||||
if part.Get("functionCall").Exists() {
|
if part.Get("functionCall").Exists() {
|
||||||
functionCallsCount++
|
callNames = append(callNames, part.Get("functionCall.name").String())
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
if functionCallsCount > 0 {
|
if len(callNames) > 0 {
|
||||||
// Add the model content
|
// Add the model content
|
||||||
if !value.IsObject() {
|
if !value.IsObject() {
|
||||||
log.Warnf("failed to parse model content")
|
log.Warnf("failed to parse model content")
|
||||||
@@ -261,7 +273,8 @@ func fixCLIToolResponse(input string) (string, error) {
|
|||||||
|
|
||||||
// Create a new group for tracking responses
|
// Create a new group for tracking responses
|
||||||
group := &FunctionCallGroup{
|
group := &FunctionCallGroup{
|
||||||
ResponsesNeeded: functionCallsCount,
|
ResponsesNeeded: len(callNames),
|
||||||
|
CallNames: callNames,
|
||||||
}
|
}
|
||||||
pendingGroups = append(pendingGroups, group)
|
pendingGroups = append(pendingGroups, group)
|
||||||
} else {
|
} else {
|
||||||
@@ -291,8 +304,8 @@ func fixCLIToolResponse(input string) (string, error) {
|
|||||||
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
||||||
|
|
||||||
functionResponseContent := `{"parts":[],"role":"function"}`
|
functionResponseContent := `{"parts":[],"role":"function"}`
|
||||||
for _, response := range groupResponses {
|
for ri, response := range groupResponses {
|
||||||
partRaw := parseFunctionResponseRaw(response)
|
partRaw := parseFunctionResponseRaw(response, group.CallNames[ri])
|
||||||
if partRaw != "" {
|
if partRaw != "" {
|
||||||
functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", 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())
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"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
|
// FunctionCallGroup represents a group of function calls and their responses
|
||||||
type FunctionCallGroup struct {
|
type FunctionCallGroup struct {
|
||||||
ResponsesNeeded int
|
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.
|
// fixCLIToolResponse performs sophisticated tool response format conversion and grouping.
|
||||||
@@ -165,32 +177,29 @@ func fixCLIToolResponse(input string) (string, error) {
|
|||||||
if len(responsePartsInThisContent) > 0 {
|
if len(responsePartsInThisContent) > 0 {
|
||||||
collectedResponses = append(collectedResponses, responsePartsInThisContent...)
|
collectedResponses = append(collectedResponses, responsePartsInThisContent...)
|
||||||
|
|
||||||
// Check if any pending groups can be satisfied
|
// Check if pending groups can be satisfied (FIFO: oldest group first)
|
||||||
for i := len(pendingGroups) - 1; i >= 0; i-- {
|
for len(pendingGroups) > 0 && len(collectedResponses) >= pendingGroups[0].ResponsesNeeded {
|
||||||
group := pendingGroups[i]
|
group := pendingGroups[0]
|
||||||
if len(collectedResponses) >= group.ResponsesNeeded {
|
pendingGroups = pendingGroups[1:]
|
||||||
|
|
||||||
// Take the needed responses for this group
|
// Take the needed responses for this group
|
||||||
groupResponses := collectedResponses[:group.ResponsesNeeded]
|
groupResponses := collectedResponses[:group.ResponsesNeeded]
|
||||||
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
||||||
|
|
||||||
// Create merged function response content
|
// Create merged function response content
|
||||||
functionResponseContent := `{"parts":[],"role":"function"}`
|
functionResponseContent := `{"parts":[],"role":"function"}`
|
||||||
for _, response := range groupResponses {
|
for ri, response := range groupResponses {
|
||||||
if !response.IsObject() {
|
if !response.IsObject() {
|
||||||
log.Warnf("failed to parse function response")
|
log.Warnf("failed to parse function response")
|
||||||
continue
|
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 {
|
if gjson.Get(functionResponseContent, "parts.#").Int() > 0 {
|
||||||
contentsWrapper, _ = sjson.SetRaw(contentsWrapper, "contents.-1", functionResponseContent)
|
contentsWrapper, _ = sjson.SetRaw(contentsWrapper, "contents.-1", functionResponseContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove this group as it's been satisfied
|
|
||||||
pendingGroups = append(pendingGroups[:i], pendingGroups[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true // Skip adding this content, responses are merged
|
return true // Skip adding this content, responses are merged
|
||||||
@@ -198,15 +207,15 @@ func fixCLIToolResponse(input string) (string, error) {
|
|||||||
|
|
||||||
// If this is a model with function calls, create a new group
|
// If this is a model with function calls, create a new group
|
||||||
if role == "model" {
|
if role == "model" {
|
||||||
functionCallsCount := 0
|
var callNames []string
|
||||||
parts.ForEach(func(_, part gjson.Result) bool {
|
parts.ForEach(func(_, part gjson.Result) bool {
|
||||||
if part.Get("functionCall").Exists() {
|
if part.Get("functionCall").Exists() {
|
||||||
functionCallsCount++
|
callNames = append(callNames, part.Get("functionCall.name").String())
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
if functionCallsCount > 0 {
|
if len(callNames) > 0 {
|
||||||
// Add the model content
|
// Add the model content
|
||||||
if !value.IsObject() {
|
if !value.IsObject() {
|
||||||
log.Warnf("failed to parse model content")
|
log.Warnf("failed to parse model content")
|
||||||
@@ -216,7 +225,8 @@ func fixCLIToolResponse(input string) (string, error) {
|
|||||||
|
|
||||||
// Create a new group for tracking responses
|
// Create a new group for tracking responses
|
||||||
group := &FunctionCallGroup{
|
group := &FunctionCallGroup{
|
||||||
ResponsesNeeded: functionCallsCount,
|
ResponsesNeeded: len(callNames),
|
||||||
|
CallNames: callNames,
|
||||||
}
|
}
|
||||||
pendingGroups = append(pendingGroups, group)
|
pendingGroups = append(pendingGroups, group)
|
||||||
} else {
|
} else {
|
||||||
@@ -246,12 +256,13 @@ func fixCLIToolResponse(input string) (string, error) {
|
|||||||
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
||||||
|
|
||||||
functionResponseContent := `{"parts":[],"role":"function"}`
|
functionResponseContent := `{"parts":[],"role":"function"}`
|
||||||
for _, response := range groupResponses {
|
for ri, response := range groupResponses {
|
||||||
if !response.IsObject() {
|
if !response.IsObject() {
|
||||||
log.Warnf("failed to parse function response")
|
log.Warnf("failed to parse function response")
|
||||||
continue
|
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 {
|
if gjson.Get(functionResponseContent, "parts.#").Int() > 0 {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ package gemini
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -95,6 +97,71 @@ func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte
|
|||||||
out = []byte(strJson)
|
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")
|
out = common.AttachDefaultSafetySettings(out, "safetySettings")
|
||||||
return out
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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