mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-20 22:51:24 +08:00
fix(gemini): preserve function call pairing for interrupted calls and replay failures
- Synthesize function response parts for interrupted or missing OpenAI Responses tool calls to maintain strict Gemini call-response pairing. - Preserve function response ordering matching pending call IDs across parallel and partial tool execution turns. - Degrade gracefully to the original request payload when Antigravity reasoning replay breaks Gemini function call pairing. Closes: #5682
This commit is contained in:
@@ -268,6 +268,8 @@ func prepareAntigravityGeminiReasoningReplayPayload(ctx context.Context, modelNa
|
||||
// the pairing diagnosis below with an untyped error.
|
||||
logAntigravityReasoningReplayDegraded(scope, "invalidate", errDelete)
|
||||
}
|
||||
log.Warnf("antigravity executor: reasoning replay broke Gemini function call pairing (%v); degrading to original payload", errPairing)
|
||||
return payload, scope, nil
|
||||
}
|
||||
return payload, scope, statusErr{code: http.StatusBadRequest, msg: fmt.Sprintf("antigravity executor: invalid Gemini function call history: %v", errPairing)}
|
||||
}
|
||||
|
||||
@@ -1943,3 +1943,31 @@ func TestPrepareAntigravityGeminiReasoningReplayStillRejectsBrokenPairing(t *tes
|
||||
t.Fatalf("error = %v, want structural pairing rejection", errPrepare)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareAntigravityGeminiReasoningReplayDegradesWhenReplayBreaksPairing(t *testing.T) {
|
||||
internalcache.ClearAntigravityReasoningReplayCache()
|
||||
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
|
||||
|
||||
const model = "gemini-3.6-flash-high"
|
||||
const args = `{"file_path":"/tmp/a"}`
|
||||
payload := []byte(`{"sessionId":"sess-pairing-break","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"name":"Read","args":` + args + `}}]},{"role":"user","parts":[{"functionResponse":{"name":"Read","response":{"result":"ok"}}}]}]}}`)
|
||||
payload = normalizeAntigravityGeminiFunctionResponseRoles(payload)
|
||||
if err := internalsignature.ValidateGeminiFunctionCallPairing(payload); err != nil {
|
||||
t.Fatalf("original payload pairing invalid: %v", err)
|
||||
}
|
||||
|
||||
item := []byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"targetOccurrence":0,"name":"Write","args":` + args + `,"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"}`)
|
||||
sessionKey := antigravityReasoningReplayScopeFromPayload(model, payload).sessionKey
|
||||
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) {
|
||||
t.Fatal("failed to cache replay item")
|
||||
}
|
||||
opts := cliproxyexecutor.Options{}
|
||||
|
||||
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload)
|
||||
if errPrepare != nil {
|
||||
t.Fatalf("prepareAntigravityGeminiReasoningReplayPayload error: %v, want graceful degradation to original payload", errPrepare)
|
||||
}
|
||||
if !bytes.Equal(out, payload) {
|
||||
t.Fatalf("out = %s, want original payload %s", out, payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,3 +596,149 @@ func TestConvertOpenAIResponsesRequestToAntigravity_FunctionCallOutputAlternateI
|
||||
t.Fatalf("functionResponse.id = %q, want call_bash_1", responseID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_InterruptedFunctionCallPreservesPairing(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.8-flash-high",
|
||||
"input": [
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"List the files."}]},
|
||||
{"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]},
|
||||
{"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"},
|
||||
{"type":"function_call_output","call_id":"c2","output":"/tmp\n"}
|
||||
],
|
||||
"tools": [{"type":"function","name":"shell","description":"Runs a shell command.","strict":false,
|
||||
"parameters":{"type":"object","properties":{"command":{"type":"array","items":{"type":"string"}}},
|
||||
"required":["command"],"additionalProperties":false}}],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": false,
|
||||
"store": false,
|
||||
"stream": false
|
||||
}`
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false)
|
||||
rawRequest := gjson.GetBytes(out, "request").Raw
|
||||
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
|
||||
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity request: %v; output=%s", errPair, out)
|
||||
}
|
||||
|
||||
c1Resp := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse")
|
||||
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
|
||||
t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw)
|
||||
}
|
||||
c2Resp := gjson.GetBytes(out, "request.contents.5.parts.0.functionResponse")
|
||||
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
|
||||
t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_ParallelInterruptedFunctionCallPreservesPairingAndOrder(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.8-flash-high",
|
||||
"input": [
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]},
|
||||
{"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"},
|
||||
{"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"},
|
||||
{"type":"function_call_output","call_id":"c2","output":"/tmp\n"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]},
|
||||
{"type":"function_call","call_id":"c3","name":"shell","arguments":"{\"command\":[\"whoami\"]}"},
|
||||
{"type":"function_call_output","call_id":"c3","output":"root\n"}
|
||||
],
|
||||
"tools": [{"type":"function","name":"shell","description":"Runs a shell command.","strict":false,
|
||||
"parameters":{"type":"object","properties":{"command":{"type":"array","items":{"type":"string"}}},
|
||||
"required":["command"],"additionalProperties":false}}],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": false,
|
||||
"store": false,
|
||||
"stream": false
|
||||
}`
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false)
|
||||
rawRequest := gjson.GetBytes(out, "request").Raw
|
||||
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
|
||||
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity request: %v; output=%s", errPair, out)
|
||||
}
|
||||
|
||||
c1Resp := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse")
|
||||
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
|
||||
t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw)
|
||||
}
|
||||
c2Resp := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse")
|
||||
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
|
||||
t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_TrailingPartialParallelCallsPreservesPairingAndOrder(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.8-flash-high",
|
||||
"input": [
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]},
|
||||
{"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"},
|
||||
{"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"},
|
||||
{"type":"function_call_output","call_id":"c2","output":"/tmp\n"}
|
||||
],
|
||||
"tools": [{"type":"function","name":"shell","description":"Runs a shell command.","strict":false,
|
||||
"parameters":{"type":"object","properties":{"command":{"type":"array","items":{"type":"string"}}},
|
||||
"required":["command"],"additionalProperties":false}}],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": false,
|
||||
"store": false,
|
||||
"stream": false
|
||||
}`
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false)
|
||||
rawRequest := gjson.GetBytes(out, "request").Raw
|
||||
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
|
||||
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity trailing partial parallel request: %v; output=%s", errPair, out)
|
||||
}
|
||||
|
||||
c1Resp := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse")
|
||||
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
|
||||
t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw)
|
||||
}
|
||||
c2Resp := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse")
|
||||
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
|
||||
t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_InterruptedMessageBeforeRealOutputPreservesPairingAndOrder(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.8-flash-high",
|
||||
"input": [
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]},
|
||||
{"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"},
|
||||
{"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]},
|
||||
{"type":"function_call_output","call_id":"c2","output":"/tmp\n"}
|
||||
],
|
||||
"tools": [{"type":"function","name":"shell","description":"Runs a shell command.","strict":false,
|
||||
"parameters":{"type":"object","properties":{"command":{"type":"array","items":{"type":"string"}}},
|
||||
"required":["command"],"additionalProperties":false}}],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": false,
|
||||
"store": false,
|
||||
"stream": false
|
||||
}`
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false)
|
||||
rawRequest := gjson.GetBytes(out, "request").Raw
|
||||
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
|
||||
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity interrupted message before real output request: %v; output=%s", errPair, out)
|
||||
}
|
||||
|
||||
// The user message precedes the completed tool response turn
|
||||
stopText := gjson.GetBytes(out, "request.contents.2.parts.0.text").String()
|
||||
if stopText != "Stop, do something else instead." {
|
||||
t.Fatalf("unexpected text in request.contents[2]: %q", stopText)
|
||||
}
|
||||
c1Resp := gjson.GetBytes(out, "request.contents.3.parts.0.functionResponse")
|
||||
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
|
||||
t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw)
|
||||
}
|
||||
c2Resp := gjson.GetBytes(out, "request.contents.3.parts.1.functionResponse")
|
||||
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
|
||||
t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +143,29 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
|
||||
|
||||
hasEncounteredConversation = true
|
||||
if _, isAssistantOutput := openAIResponsesAssistantVisibleText(item); !isAssistantOutput {
|
||||
if len(pendingFunctionCallIDs) > 0 {
|
||||
anyHasFutureOutput := false
|
||||
for _, callID := range pendingFunctionCallIDs {
|
||||
if responsesHasMatchingOutput(normalized[i:], callID) {
|
||||
anyHasFutureOutput = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyHasFutureOutput {
|
||||
var synthesizedParts [][]byte
|
||||
for _, callID := range pendingFunctionCallIDs {
|
||||
synthesizedParts = append(synthesizedParts, buildOpenAIResponsesSynthesizedFunctionResponsePart(callID, functionNamesByCallID))
|
||||
}
|
||||
if len(synthesizedParts) > 0 {
|
||||
contentItems = append(contentItems, geminiContent("user", synthesizedParts))
|
||||
}
|
||||
pendingFunctionCallIDs = nil
|
||||
}
|
||||
}
|
||||
if len(pendingDeveloperParts) > 0 {
|
||||
contentItems = append(contentItems, geminiContent("user", pendingDeveloperParts))
|
||||
pendingDeveloperParts = nil
|
||||
}
|
||||
pendingFunctionCallIDs = nil
|
||||
}
|
||||
|
||||
// Handle regular messages
|
||||
@@ -286,15 +304,57 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
|
||||
|
||||
case "function_call_output", "custom_tool_call_output":
|
||||
hasEncounteredConversation = true
|
||||
orderedOutputs, consumedIndexes, remainingPending := collectOpenAIResponsesFunctionCallOutputs(normalized, i, pendingFunctionCallIDs)
|
||||
pendingFunctionCallIDs = remainingPending
|
||||
orderedOutputs, consumedIndexes, _ := collectOpenAIResponsesFunctionCallOutputs(normalized, i, pendingFunctionCallIDs)
|
||||
for consumedIndex := range consumedIndexes {
|
||||
consumedFunctionOutputIndexes[consumedIndex] = true
|
||||
}
|
||||
responseParts := make([][]byte, 0, len(orderedOutputs))
|
||||
for _, output := range orderedOutputs {
|
||||
responseParts = append(responseParts, buildOpenAIResponsesFunctionResponseParts(output, functionNamesByCallID)...)
|
||||
end := i + len(consumedIndexes)
|
||||
hasSubsequent := responsesHasSubsequentTurn(normalized[end:])
|
||||
|
||||
outputByCallID := make(map[string]gjson.Result)
|
||||
var extraOutputs []gjson.Result
|
||||
anyMatched := false
|
||||
for _, out := range orderedOutputs {
|
||||
id := extractOpenAIResponsesCallID(out)
|
||||
if id != "" {
|
||||
outputByCallID[id] = out
|
||||
} else {
|
||||
extraOutputs = append(extraOutputs, out)
|
||||
}
|
||||
}
|
||||
for _, pendingID := range pendingFunctionCallIDs {
|
||||
if _, ok := outputByCallID[pendingID]; ok {
|
||||
anyMatched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
responseParts := make([][]byte, 0, len(pendingFunctionCallIDs)+len(extraOutputs))
|
||||
stillPending := make([]string, 0, len(pendingFunctionCallIDs))
|
||||
|
||||
for _, pendingID := range pendingFunctionCallIDs {
|
||||
if out, ok := outputByCallID[pendingID]; ok {
|
||||
responseParts = append(responseParts, buildOpenAIResponsesFunctionResponseParts(out, functionNamesByCallID)...)
|
||||
delete(outputByCallID, pendingID)
|
||||
} else if (hasSubsequent || anyMatched) && !responsesHasMatchingOutput(normalized[end:], pendingID) {
|
||||
responseParts = append(responseParts, buildOpenAIResponsesSynthesizedFunctionResponsePart(pendingID, functionNamesByCallID))
|
||||
} else {
|
||||
stillPending = append(stillPending, pendingID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, out := range orderedOutputs {
|
||||
id := extractOpenAIResponsesCallID(out)
|
||||
if _, remaining := outputByCallID[id]; remaining {
|
||||
responseParts = append(responseParts, buildOpenAIResponsesFunctionResponseParts(out, functionNamesByCallID)...)
|
||||
delete(outputByCallID, id)
|
||||
}
|
||||
}
|
||||
for _, out := range extraOutputs {
|
||||
responseParts = append(responseParts, buildOpenAIResponsesFunctionResponseParts(out, functionNamesByCallID)...)
|
||||
}
|
||||
|
||||
pendingFunctionCallIDs = stillPending
|
||||
if len(responseParts) > 0 {
|
||||
contentItems = append(contentItems, geminiContent("user", responseParts))
|
||||
}
|
||||
@@ -908,6 +968,48 @@ func extractOpenAIResponsesCallID(node gjson.Result) string {
|
||||
return translatorcommon.ExtractResponsesCallID(node)
|
||||
}
|
||||
|
||||
func buildOpenAIResponsesSynthesizedFunctionResponsePart(callID string, functionNamesByCallID map[string]string) []byte {
|
||||
functionName := "unknown"
|
||||
if matchedName, ok := functionNamesByCallID[callID]; ok && matchedName != "" {
|
||||
functionName = matchedName
|
||||
}
|
||||
functionResponse := []byte(`{"functionResponse":{"name":"","response":{"result":"call interrupted, no output"}}}`)
|
||||
functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.name", util.SanitizeFunctionName(functionName))
|
||||
if callID != "" {
|
||||
functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.id", callID)
|
||||
}
|
||||
return functionResponse
|
||||
}
|
||||
|
||||
func responsesHasMatchingOutput(items []gjson.Result, callID string) bool {
|
||||
if callID == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range items {
|
||||
typ := item.Get("type").String()
|
||||
if typ == "function_call_output" || typ == "custom_tool_call_output" {
|
||||
if extractOpenAIResponsesCallID(item) == callID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func responsesHasSubsequentTurn(items []gjson.Result) bool {
|
||||
for _, item := range items {
|
||||
typ := item.Get("type").String()
|
||||
role := item.Get("role").String()
|
||||
if typ == "message" || (typ == "" && role != "") {
|
||||
return true
|
||||
}
|
||||
if typ == "function_call" || typ == "custom_tool_call" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildOpenAIResponsesFunctionResponseParts(item gjson.Result, functionNamesByCallID map[string]string) [][]byte {
|
||||
callID := extractOpenAIResponsesCallID(item)
|
||||
functionName := "unknown"
|
||||
|
||||
@@ -2283,3 +2283,122 @@ func TestConvertOpenAIResponsesRequestToGemini_AllPendingCallsReservedByFutureEx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_InterruptedFunctionCallPreservesPairing(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.8-flash-high",
|
||||
"input": [
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"List the files."}]},
|
||||
{"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]},
|
||||
{"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"},
|
||||
{"type":"function_call_output","call_id":"c2","output":"/tmp\n"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(inputJSON), false)
|
||||
if err := internalsignature.ValidateGeminiFunctionCallPairing(result); err != nil {
|
||||
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Gemini request: %v; output=%s", err, result)
|
||||
}
|
||||
|
||||
// Verify synthesized response for c1
|
||||
c1Resp := gjson.GetBytes(result, "contents.2.parts.0.functionResponse")
|
||||
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
|
||||
t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw)
|
||||
}
|
||||
// Verify real response for c2
|
||||
c2Resp := gjson.GetBytes(result, "contents.5.parts.0.functionResponse")
|
||||
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
|
||||
t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_ParallelInterruptedFunctionCallPreservesPairingAndOrder(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.8-flash-high",
|
||||
"input": [
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]},
|
||||
{"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"},
|
||||
{"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"},
|
||||
{"type":"function_call_output","call_id":"c2","output":"/tmp\n"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]},
|
||||
{"type":"function_call","call_id":"c3","name":"shell","arguments":"{\"command\":[\"whoami\"]}"},
|
||||
{"type":"function_call_output","call_id":"c3","output":"root\n"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(inputJSON), false)
|
||||
if err := internalsignature.ValidateGeminiFunctionCallPairing(result); err != nil {
|
||||
t.Fatalf("ValidateGeminiFunctionCallPairing failed on parallel interrupted request: %v; output=%s", err, result)
|
||||
}
|
||||
|
||||
// In the response turn for [c1, c2], c1 must be first (synthesized) and c2 must be second (real)
|
||||
c1Resp := gjson.GetBytes(result, "contents.2.parts.0.functionResponse")
|
||||
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
|
||||
t.Fatalf("unexpected response part 0 for c1: %s", c1Resp.Raw)
|
||||
}
|
||||
c2Resp := gjson.GetBytes(result, "contents.2.parts.1.functionResponse")
|
||||
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
|
||||
t.Fatalf("unexpected response part 1 for c2: %s", c2Resp.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_TrailingPartialParallelCallsPreservesPairingAndOrder(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.8-flash-high",
|
||||
"input": [
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]},
|
||||
{"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"},
|
||||
{"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"},
|
||||
{"type":"function_call_output","call_id":"c2","output":"/tmp\n"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(inputJSON), false)
|
||||
if err := internalsignature.ValidateGeminiFunctionCallPairing(result); err != nil {
|
||||
t.Fatalf("ValidateGeminiFunctionCallPairing failed on trailing partial parallel request: %v; output=%s", err, result)
|
||||
}
|
||||
|
||||
// In the response turn for [c1, c2], c1 must be first (synthesized) and c2 must be second (real)
|
||||
c1Resp := gjson.GetBytes(result, "contents.2.parts.0.functionResponse")
|
||||
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
|
||||
t.Fatalf("unexpected response part 0 for c1: %s", c1Resp.Raw)
|
||||
}
|
||||
c2Resp := gjson.GetBytes(result, "contents.2.parts.1.functionResponse")
|
||||
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
|
||||
t.Fatalf("unexpected response part 1 for c2: %s", c2Resp.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_InterruptedMessageBeforeRealOutputPreservesPairingAndOrder(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.8-flash-high",
|
||||
"input": [
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]},
|
||||
{"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"},
|
||||
{"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]},
|
||||
{"type":"function_call_output","call_id":"c2","output":"/tmp\n"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(inputJSON), false)
|
||||
if err := internalsignature.ValidateGeminiFunctionCallPairing(result); err != nil {
|
||||
t.Fatalf("ValidateGeminiFunctionCallPairing failed on interrupted message before real output request: %v; output=%s", err, result)
|
||||
}
|
||||
|
||||
// The user message precedes the completed tool response turn
|
||||
stopText := gjson.GetBytes(result, "contents.2.parts.0.text").String()
|
||||
if stopText != "Stop, do something else instead." {
|
||||
t.Fatalf("unexpected text in contents[2]: %q", stopText)
|
||||
}
|
||||
// In the response turn for [c1, c2], c1 must be first (synthesized) and c2 must be second (real)
|
||||
c1Resp := gjson.GetBytes(result, "contents.3.parts.0.functionResponse")
|
||||
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
|
||||
t.Fatalf("unexpected response part 0 for c1: %s", c1Resp.Raw)
|
||||
}
|
||||
c2Resp := gjson.GetBytes(result, "contents.3.parts.1.functionResponse")
|
||||
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
|
||||
t.Fatalf("unexpected response part 1 for c2: %s", c2Resp.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user