mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
fix(gemini,antigravity): align parallel tool results with preceding tool calls
- Add `AlignClaudeToolResults` to order `tool_result` blocks to match the preceding `tool_use` IDs while preserving other content parts. - Apply tool result alignment in Claude-to-Gemini and Claude-to-Antigravity request translators. - Preserve mixed non-response parts when normalizing and reordering parallel function responses in Antigravity executor. Closes: #5199
This commit is contained in:
@@ -388,7 +388,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
|
||||
}
|
||||
|
||||
var calls, responses []functionRef
|
||||
var responseParts []json.RawMessage
|
||||
var responseParts, otherParts []json.RawMessage
|
||||
partCount := 0
|
||||
hasOtherPart := false
|
||||
parts.ForEach(func(_, part gjson.Result) bool {
|
||||
@@ -401,6 +401,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
|
||||
responseParts = append(responseParts, json.RawMessage(part.Raw))
|
||||
default:
|
||||
hasOtherPart = true
|
||||
otherParts = append(otherParts, json.RawMessage(part.Raw))
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -418,7 +419,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
|
||||
}
|
||||
return true
|
||||
}
|
||||
if hasOtherPart || len(calls) > 0 {
|
||||
if len(calls) > 0 {
|
||||
pending = nil
|
||||
return true
|
||||
}
|
||||
@@ -426,7 +427,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
|
||||
var contentJSON []byte
|
||||
contentChanged := false
|
||||
if len(pending) == len(responses) {
|
||||
ordered := make([]json.RawMessage, 0, len(responseParts))
|
||||
ordered := make([]json.RawMessage, 0, partCount)
|
||||
used := make([]bool, len(responses))
|
||||
for _, call := range pending {
|
||||
matched := -1
|
||||
@@ -447,6 +448,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
|
||||
ordered = append(ordered, responseParts[matched])
|
||||
}
|
||||
if len(ordered) == len(responseParts) {
|
||||
ordered = append(ordered, otherParts...)
|
||||
encoded, errMarshal := json.Marshal(ordered)
|
||||
if errMarshal == nil && !bytes.Equal(encoded, []byte(parts.Raw)) {
|
||||
contentJSON = []byte(content.Raw)
|
||||
@@ -458,7 +460,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
|
||||
}
|
||||
}
|
||||
pending = nil
|
||||
if content.Get("role").String() != "model" {
|
||||
if !hasOtherPart && content.Get("role").String() != "model" {
|
||||
if contentJSON == nil {
|
||||
contentJSON = []byte(content.Raw)
|
||||
}
|
||||
|
||||
@@ -800,12 +800,34 @@ func TestAntigravityExecutorCountTokensReconstructsCompactedClaudeToolCall(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAntigravityGeminiFunctionResponseRolesLeavesMixedUserContent(t *testing.T) {
|
||||
payload := []byte(`{"request":{"contents":[{"role":"user","parts":[{"functionResponse":{"name":"run","response":{"result":"ok"}}},{"text":"user follow-up"}]}]}}`)
|
||||
func TestNormalizeAntigravityGeminiFunctionResponseRolesOrdersMixedParallelResponses(t *testing.T) {
|
||||
payload := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"read","args":{"file":"one"}}},{"functionCall":{"id":"call-2","name":"read","args":{"file":"two"}}}]},{"role":"user","parts":[{"text":"results follow"},{"functionResponse":{"id":"call-2","name":"read","response":{"result":"two"}}},{"text":"continue"},{"functionResponse":{"id":"call-1","name":"read","response":{"result":"one"}}}]}]}}`)
|
||||
if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(payload); errValidate == nil {
|
||||
t.Fatal("permuted input unexpectedly passed function call pairing validation")
|
||||
}
|
||||
output := normalizeAntigravityGeminiFunctionResponseRoles(payload)
|
||||
if got := gjson.GetBytes(output, "request.contents.0.role").String(); got != "user" {
|
||||
if got := gjson.GetBytes(output, "request.contents.1.role").String(); got != "user" {
|
||||
t.Fatalf("mixed functionResponse/user content role = %q, want user; output=%s", got, output)
|
||||
}
|
||||
parts := gjson.GetBytes(output, "request.contents.1.parts").Array()
|
||||
if len(parts) != 4 {
|
||||
t.Fatalf("mixed response parts = %d, want 4; output=%s", len(parts), output)
|
||||
}
|
||||
if got := parts[0].Get("functionResponse.id").String(); got != "call-1" {
|
||||
t.Fatalf("first functionResponse.id = %q, want call-1; output=%s", got, output)
|
||||
}
|
||||
if got := parts[1].Get("functionResponse.id").String(); got != "call-2" {
|
||||
t.Fatalf("second functionResponse.id = %q, want call-2; output=%s", got, output)
|
||||
}
|
||||
if got := parts[2].Get("text").String(); got != "results follow" {
|
||||
t.Fatalf("first trailing text = %q; output=%s", got, output)
|
||||
}
|
||||
if got := parts[3].Get("text").String(); got != "continue" {
|
||||
t.Fatalf("second trailing text = %q; output=%s", got, output)
|
||||
}
|
||||
if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(output); errValidate != nil {
|
||||
t.Fatalf("normalized mixed parallel responses are invalid: %v; output=%s", errValidate, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAntigravityGeminiFunctionResponseRolesOrdersParallelResponses(t *testing.T) {
|
||||
|
||||
@@ -95,10 +95,6 @@ func randomAntigravityFunctionHistory(randomSource *rand.Rand) []byte {
|
||||
case 2:
|
||||
responseContent["role"] = " Model "
|
||||
}
|
||||
if randomSource.Intn(12) == 0 {
|
||||
orderedResponses = append(orderedResponses, map[string]any{"text": "mixed"})
|
||||
responseContent["parts"] = orderedResponses
|
||||
}
|
||||
contents = append(contents, responseContent)
|
||||
}
|
||||
payload, errMarshal := json.Marshal(map[string]any{"request": map[string]any{"contents": contents}})
|
||||
|
||||
@@ -1552,7 +1552,7 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresParallelClaudeToolProven
|
||||
const args2 = `{"file_path":"/tmp/b"}`
|
||||
clientID1 := util.GeminiClaudeToolUseID("native-read-1", "Read", args1)
|
||||
clientID2 := util.GeminiClaudeToolUseID("native-read-2", "Read", args2)
|
||||
payload := []byte(`{"sessionId":"sess-parallel-provenance","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID1 + `","name":"Read","args":{"file_path":"/tmp/a","offset":0}}},{"functionCall":{"id":"` + clientID2 + `","name":"Read","args":{"file_path":"/tmp/b","offset":0}}}]},{"role":"user","parts":[{"functionResponse":{"id":"` + clientID2 + `","name":"Read","response":{"result":"b"}}},{"functionResponse":{"id":"` + clientID1 + `","name":"Read","response":{"result":"a"}}}]}]}}`)
|
||||
payload := []byte(`{"sessionId":"sess-parallel-provenance","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID1 + `","name":"Read","args":{"file_path":"/tmp/a","offset":0}}},{"functionCall":{"id":"` + clientID2 + `","name":"Read","args":{"file_path":"/tmp/b","offset":0}}}]},{"role":"user","parts":[{"text":"results follow"},{"functionResponse":{"id":"` + clientID2 + `","name":"Read","response":{"result":"b"}}},{"functionResponse":{"id":"` + clientID1 + `","name":"Read","response":{"result":"a"}}},{"text":"continue"}]}]}}`)
|
||||
items := [][]byte{
|
||||
[]byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"targetOccurrence":0,"name":"Read","call_id":"native-read-1","args":` + args1 + `,"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"}`),
|
||||
[]byte(`{"type":"function_call_part","contentIndex":0,"partIndex":1,"targetOccurrence":0,"name":"Read","call_id":"native-read-2","args":` + args2 + `}`),
|
||||
@@ -1576,9 +1576,15 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresParallelClaudeToolProven
|
||||
t.Fatalf("signed/unsigned parallel provenance changed: %s", gjson.GetBytes(out, "request.contents.0").Raw)
|
||||
}
|
||||
responses := gjson.GetBytes(out, "request.contents.1.parts").Array()
|
||||
if len(responses) != 2 || responses[0].Get("functionResponse.id").String() != "native-read-1" || responses[1].Get("functionResponse.id").String() != "native-read-2" {
|
||||
if len(responses) != 4 || responses[0].Get("functionResponse.id").String() != "native-read-1" || responses[1].Get("functionResponse.id").String() != "native-read-2" {
|
||||
t.Fatalf("parallel responses were not normalized to native order: %s", gjson.GetBytes(out, "request.contents.1").Raw)
|
||||
}
|
||||
if responses[2].Get("text").String() != "results follow" || responses[3].Get("text").String() != "continue" {
|
||||
t.Fatalf("mixed user parts were not retained after parallel responses: %s", gjson.GetBytes(out, "request.contents.1").Raw)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "request.contents.1.role").String(); got != "user" {
|
||||
t.Fatalf("mixed response role = %q, want user; output=%s", got, out)
|
||||
}
|
||||
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil {
|
||||
t.Fatalf("parallel restored history is invalid: %v", errPairing)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user