Files
CLIProxyAPI/internal/runtime/executor/helps/gemini_content_turns_test.go
sususu98 62f5a2798c fix(executor): prepend empty user turn for model-first Gemini/Antigravity requests (#4959) (#5048)
* fix(executor): prepend empty user turn for model-first requests targeting Gemini/Antigravity (#4959)

When forwarding sliced conversation histories or tool calls across OpenAI Responses,
OpenAI Chat Completions, Claude Messages, and native Gemini, native Gemini and Antigravity
Gemini endpoints require that conversation contents begin with a user turn.

Normalize leading turns at the executor boundary rather than the translator layer:
- Prepend an empty user turn ({"role":"user","parts":[{"text":""}]}) for Gemini, Gemini Vertex,
  AI Studio, and Antigravity Gemini generation and CountTokens requests if the first turn is 'model'.
- Keep Antigravity Claude requests untouched to avoid adapter 400 errors.
- Ensure normalization runs after payload rules so payload index overrides target the original turns.
- Use no-copy GJSON inspection to keep overhead zero on valid user-first requests.

* fix(executor): inject Antigravity leading user after reasoning replay (#4959)

Replay can insert a model functionCall at contents[0] for sliced
tool-result history. Run the empty-user prepend on the final
requestPayload, after sanitize and prepareAntigravityGeminiReasoningReplayPayload.
2026-08-19 18:56:43 +08:00

100 lines
3.1 KiB
Go

package helps
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
var leadingGeminiUserContentOutput []byte
func TestEnsureGeminiLeadingUserContentReusesLargeValidPayload(t *testing.T) {
input := []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"video/mp4","data":"` + strings.Repeat("A", 4<<20) + `"}}]}]}`)
output := EnsureGeminiLeadingUserContent(input, "contents")
if &output[0] != &input[0] {
t.Fatal("valid request should reuse the input payload")
}
result := testing.Benchmark(func(b *testing.B) {
for b.Loop() {
leadingGeminiUserContentOutput = EnsureGeminiLeadingUserContent(input, "contents")
}
})
if allocated := result.AllocedBytesPerOp(); allocated >= 1<<20 {
t.Fatalf("valid 4 MiB request allocated %d bytes/op, want less than 1 MiB", allocated)
}
}
func TestEnsureGeminiLeadingUserContent(t *testing.T) {
tests := []struct {
name string
inputJSON string
path string
wantRoles string
wantLeadingEmpty bool
}{
{
name: "user first is unchanged",
inputJSON: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
path: "contents",
wantRoles: "user",
},
{
name: "leading model functionCall gets empty user",
inputJSON: `{"contents":[{"role":"model","parts":[{"functionCall":{"name":"run"}}]},{"role":"user","parts":[{"functionResponse":{"name":"run"}}]}]}`,
path: "contents",
wantRoles: "user,model,user",
wantLeadingEmpty: true,
},
{
name: "leading model text gets empty user and preserves following turns",
inputJSON: `{"contents":[{"role":"model","parts":[{"text":"answer"}]},{"role":"user","parts":[{"text":"continue"}]}]}`,
path: "contents",
wantRoles: "user,model,user",
wantLeadingEmpty: true,
},
{
name: "nested contents are normalized",
inputJSON: `{"request":{"contents":[{"role":"model","parts":[{"text":"answer"}]},{"role":"user","parts":[{"text":"continue"}]}]}}`,
path: "request.contents",
wantRoles: "request.user,model,user",
wantLeadingEmpty: true,
},
{
name: "empty contents are unchanged",
inputJSON: `{"contents":[]}`,
path: "contents",
wantRoles: "",
},
{
name: "missing contents are unchanged",
inputJSON: `{"model":"test"}`,
path: "contents",
wantRoles: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := EnsureGeminiLeadingUserContent([]byte(tt.inputJSON), tt.path)
contents := gjson.GetBytes(out, tt.path).Array()
roles := make([]string, 0, len(contents))
for _, content := range contents {
roles = append(roles, content.Get("role").String())
}
expectedRoles := strings.TrimPrefix(tt.wantRoles, "request.")
if got := strings.Join(roles, ","); got != expectedRoles {
t.Fatalf("roles = %q, want %q; output=%s", got, expectedRoles, out)
}
if tt.wantLeadingEmpty {
text := gjson.GetBytes(out, tt.path+".0.parts.0.text")
if !text.Exists() || text.String() != "" {
t.Fatalf("leading empty user part missing; output=%s", out)
}
}
})
}
}