From 62f5a2798c52c7dc67dd69d553839d29c893995a Mon Sep 17 00:00:00 2001 From: sususu98 <33882693+sususu98@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:56:43 +0800 Subject: [PATCH] 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. --- .../runtime/executor/aistudio_executor.go | 1 + .../executor/aistudio_executor_test.go | 12 + .../runtime/executor/antigravity_executor.go | 10 + .../executor/antigravity_executor_execute.go | 2 + .../antigravity_executor_signature_test.go | 392 +++++++++++++++++- .../executor/antigravity_executor_stream.go | 1 + .../executor/antigravity_executor_tokens.go | 2 +- internal/runtime/executor/gemini_executor.go | 3 + .../runtime/executor/gemini_executor_test.go | 156 +++++++ .../executor/gemini_vertex_executor.go | 6 + .../executor/helps/gemini_content_turns.go | 39 ++ .../helps/gemini_content_turns_test.go | 99 +++++ 12 files changed, 716 insertions(+), 7 deletions(-) create mode 100644 internal/runtime/executor/helps/gemini_content_turns.go create mode 100644 internal/runtime/executor/helps/gemini_content_turns_test.go diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 86db0e1d..042704ff 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -487,6 +487,7 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe action = "streamGenerateContent" } payload, _ = sjson.DeleteBytes(payload, "session_id") + payload = helps.EnsureGeminiLeadingUserContent(payload, "contents") return payload, translatedPayload{payload: payload, action: action, toFormat: to}, nil } diff --git a/internal/runtime/executor/aistudio_executor_test.go b/internal/runtime/executor/aistudio_executor_test.go index ea5bd8df..c0543f37 100644 --- a/internal/runtime/executor/aistudio_executor_test.go +++ b/internal/runtime/executor/aistudio_executor_test.go @@ -39,6 +39,18 @@ func TestAIStudioTranslateRequestPreservesSummaryFromOriginalRequest(t *testing. } } +func TestAIStudioTranslateRequestPrependsLeadingUserForIssue4959ResponsesHistory(t *testing.T) { + executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil) + _, body, err := executor.translateRequest(context.Background(), cliproxyexecutor.Request{ + Model: "gemini-3.7-flash-high", + Payload: issue4959ResponsesModelFirstPayload(), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}, false) + if err != nil { + t.Fatalf("translateRequest() error = %v", err) + } + assertIssue4959LeadingUserContents(t, gjson.GetBytes(body.payload, "contents").Array()) +} + func TestAIStudioExecutorExecuteStartsTTFTBeforeRelayWait(t *testing.T) { const authID = "aistudio-ttft-auth" delay := 40 * time.Millisecond diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 945005c1..123913a1 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -345,6 +345,16 @@ func sanitizeAntigravityGeminiRequestSignatures(modelName string, rawJSON []byte return normalizeAntigravityGeminiFunctionResponseRoles(rawJSON) } +// ensureAntigravityGeminiLeadingUserContent prepends a synthetic empty user turn +// after every contents rewrite, including reasoning replay. Claude targets are +// left unchanged because the adapter rejects empty text parts. +func ensureAntigravityGeminiLeadingUserContent(modelName string, payload []byte) []byte { + if strings.Contains(strings.ToLower(modelName), "claude") { + return payload + } + return helps.EnsureGeminiLeadingUserContent(payload, "request.contents") +} + type antigravityContentEdit struct { index int64 start int diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go index 904a81f7..58419750 100644 --- a/internal/runtime/executor/antigravity_executor_execute.go +++ b/internal/runtime/executor/antigravity_executor_execute.go @@ -110,6 +110,7 @@ attemptLoop: return resp, err } } + requestPayload = ensureAntigravityGeminiLeadingUserContent(baseModel, requestPayload) httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, false, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata)) if errReq != nil { @@ -337,6 +338,7 @@ attemptLoop: return resp, err } } + requestPayload = ensureAntigravityGeminiLeadingUserContent(baseModel, requestPayload) httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata)) if errReq != nil { err = errReq diff --git a/internal/runtime/executor/antigravity_executor_signature_test.go b/internal/runtime/executor/antigravity_executor_signature_test.go index 532a2d2a..b0d4d473 100644 --- a/internal/runtime/executor/antigravity_executor_signature_test.go +++ b/internal/runtime/executor/antigravity_executor_signature_test.go @@ -39,6 +39,50 @@ func testFakeClaudeSignature() string { return base64.StdEncoding.EncodeToString([]byte{0x12, 0xFF, 0xFE, 0xFD}) } +func issue4959GeminiThoughtSignature() string { + return "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA" +} + +// issue4959ResponsesModelFirstPayload is the #4959 Responses history: a +// next:function reasoning carrier, then function_call / function_call_output, +// then trailing assistant turns. Trailing model turns are left for a follow-up. +func issue4959ResponsesModelFirstPayload() []byte { + carrier := "cpa-gemini-responses-carrier-v1:next:function:" + base64.RawStdEncoding.EncodeToString([]byte(issue4959GeminiThoughtSignature())) + return []byte(`{"model":"gemini-3.7-flash-high","input":[` + + `{"type":"reasoning","id":"rs_resp_test_detached_before_0","summary":[],"encrypted_content":"` + carrier + `"},` + + `{"type":"function_call","call_id":"call_bash_1","name":"Bash","arguments":"{\"command\":\"true\"}"},` + + `{"type":"function_call_output","call_id":"call_bash_1","output":"ok"},` + + `{"role":"assistant","content":[{"type":"output_text","text":"first"}]},` + + `{"role":"assistant","content":[{"type":"output_text","text":"second"}]}` + + `]}`) +} + +func contentHasNamedPart(content gjson.Result, partKind, name string) bool { + for _, part := range content.Get("parts").Array() { + if part.Get(partKind+".name").String() == name { + return true + } + } + return false +} + +func assertIssue4959LeadingUserContents(t *testing.T, contents []gjson.Result) { + t.Helper() + if len(contents) < 3 { + t.Fatalf("contents too short: %d", len(contents)) + } + leadingText := contents[0].Get("parts.0.text") + if contents[0].Get("role").String() != "user" || !leadingText.Exists() || leadingText.String() != "" { + t.Fatalf("synthetic leading user missing: %s", contents[0].Raw) + } + if contents[1].Get("role").String() != "model" || !contentHasNamedPart(contents[1], "functionCall", "Bash") { + t.Fatalf("function call is not immediately after the synthetic user: %s", contents[1].Raw) + } + if !contentHasNamedPart(contents[2], "functionResponse", "Bash") { + t.Fatalf("function response missing or moved: %s", contents[2].Raw) + } +} + func testAntigravityAuth(baseURL string) *cliproxyauth.Auth { return &cliproxyauth.Auth{ Attributes: map[string]string{ @@ -210,6 +254,338 @@ func TestAntigravityStreamObfuscatesSensitiveSystemInstruction(t *testing.T) { } } +func TestAntigravityStreamPrependsLeadingUserForGemini(t *testing.T) { + assertLeadingFunctionHistory := func(t *testing.T, body []byte) { + t.Helper() + contents := gjson.GetBytes(body, "request.contents").Array() + if len(contents) != 3 || contents[0].Get("role").String() != "user" { + t.Fatalf("upstream roles malformed: %s", body) + } + leadingText := contents[0].Get("parts.0.text") + if !leadingText.Exists() || leadingText.String() != "" { + t.Fatalf("synthetic leading user missing: %s", body) + } + if !contents[1].Get("parts.0.functionCall").Exists() || !contents[2].Get("parts.0.functionResponse").Exists() { + t.Fatalf("function history changed: %s", body) + } + } + + tests := []struct { + name string + format sdktranslator.Format + payload string + assert func(*testing.T, []byte) + }{ + { + name: "Gemini prepends user before leading function call", + format: sdktranslator.FormatGemini, + payload: `{"contents":[` + + `{"role":"model","parts":[{"functionCall":{"name":"run","args":{}}}]},` + + `{"role":"user","parts":[{"functionResponse":{"name":"run","response":{"result":"ok"}}}]}` + + `]}`, + assert: assertLeadingFunctionHistory, + }, + { + name: "OpenAI Chat prepends user before leading tool call", + format: sdktranslator.FormatOpenAI, + payload: `{"messages":[` + + `{"role":"assistant","tool_calls":[{"id":"call-1","type":"function","function":{"name":"run","arguments":"{}"}}]},` + + `{"role":"tool","tool_call_id":"call-1","content":"ok"}` + + `]}`, + assert: assertLeadingFunctionHistory, + }, + { + name: "OpenAI Responses prepends user before leading function call", + format: sdktranslator.FormatOpenAIResponse, + payload: `{"input":[` + + `{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}"},` + + `{"type":"function_call_output","call_id":"call-1","output":"ok"}` + + `]}`, + assert: assertLeadingFunctionHistory, + }, + { + name: "Claude prepends user before leading tool use", + format: sdktranslator.FormatClaude, + payload: `{"messages":[` + + `{"role":"assistant","content":[{"type":"tool_use","id":"run-call-1","name":"run","input":{}}]},` + + `{"role":"user","content":[{"type":"tool_result","tool_use_id":"run-call-1","content":"ok"}]}` + + `]}`, + assert: assertLeadingFunctionHistory, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + captured := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Errorf("read request body: %v", errRead) + return + } + captured <- body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}]}}\n\n")) + })) + defer server.Close() + + executor := NewAntigravityExecutor(&config.Config{RequestRetry: 1}) + auth := testAntigravityAuth(server.URL) + auth.Metadata["project_id"] = "project-1" + result, errExecute := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gemini-3.6-flash-high", + Payload: []byte(tt.payload), + }, cliproxyexecutor.Options{ + SourceFormat: tt.format, + ResponseFormat: tt.format, + Stream: true, + }) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error = %v", chunk.Err) + } + } + tt.assert(t, <-captured) + }) + } +} + +func TestAntigravityStreamPrependsLeadingUserForIssue4959ResponsesHistory(t *testing.T) { + captured := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Errorf("read request body: %v", errRead) + return + } + captured <- body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}]}}\n\n")) + })) + defer server.Close() + + executor := NewAntigravityExecutor(&config.Config{RequestRetry: 1}) + auth := testAntigravityAuth(server.URL) + auth.Metadata["project_id"] = "project-1" + result, errExecute := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gemini-3.7-flash-high", + Payload: issue4959ResponsesModelFirstPayload(), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + ResponseFormat: sdktranslator.FormatOpenAIResponse, + Stream: true, + }) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error = %v", chunk.Err) + } + } + assertIssue4959LeadingUserContents(t, gjson.GetBytes(<-captured, "request.contents").Array()) +} + +func TestAntigravityStreamPrependsLeadingUserAfterReplayInsertsFunctionCall(t *testing.T) { + cache.ClearAntigravityReasoningReplayCache() + t.Cleanup(cache.ClearAntigravityReasoningReplayCache) + + const sessionID = "replay-insert-at-zero" + const nativeID = "call-1" + const nativeArgs = `{}` + clientID := util.GeminiClaudeToolUseID(nativeID, "run", nativeArgs) + item := []byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"call_id":"` + nativeID + `","name":"run","args":` + nativeArgs + `,"thoughtSignature":"replay-inserted-call-signature-123456"}`) + if !cache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "responses:"+sessionID, [][]byte{item}) { + t.Fatal("failed to cache omitted function call") + } + + captured := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Errorf("read request body: %v", errRead) + return + } + captured <- body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}]}}\n\n")) + })) + defer server.Close() + + payload := []byte(`{"model":"gemini-3.6-flash-high","session_id":"` + sessionID + `","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"` + clientID + `","content":"ok"}]}],"tools":[{"name":"run","input_schema":{"type":"object"}}]}`) + executor := NewAntigravityExecutor(&config.Config{RequestRetry: 1}) + auth := testAntigravityAuth(server.URL) + auth.Metadata["project_id"] = "project-1" + result, errExecute := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gemini-3.6-flash-high", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + ResponseFormat: sdktranslator.FormatClaude, + Stream: true, + OriginalRequest: payload, + }) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error = %v", chunk.Err) + } + } + + body := <-captured + contents := gjson.GetBytes(body, "request.contents").Array() + if len(contents) != 3 { + t.Fatalf("contents len = %d, want 3; body=%s", len(contents), body) + } + leadingText := contents[0].Get("parts.0.text") + if contents[0].Get("role").String() != "user" || !leadingText.Exists() || leadingText.String() != "" { + t.Fatalf("synthetic leading user missing after replay insert: %s", contents[0].Raw) + } + if contents[1].Get("role").String() != "model" || contents[1].Get("parts.0.functionCall.id").String() != "call-1" { + t.Fatalf("replayed functionCall is not immediately after the synthetic user: %s", contents[1].Raw) + } + if !contentHasNamedPart(contents[2], "functionResponse", "run") { + t.Fatalf("functionResponse missing or moved: %s", contents[2].Raw) + } +} + +func TestAntigravityStreamDoesNotPrependLeadingUserForClaudeTarget(t *testing.T) { + tests := []struct { + name string + format sdktranslator.Format + payload string + }{ + { + name: "Gemini model-first history", + format: sdktranslator.FormatGemini, + payload: `{"contents":[` + + `{"role":"model","parts":[{"text":"prior answer"}]},` + + `{"role":"user","parts":[{"text":"continue"}]}` + + `]}`, + }, + { + name: "OpenAI Responses assistant-first history", + format: sdktranslator.FormatOpenAIResponse, + payload: `{"input":[` + + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"prior answer"}]},` + + `{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}` + + `]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + captured := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Errorf("read request body: %v", errRead) + return + } + captured <- body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}]}}\n\n")) + })) + defer server.Close() + + executor := NewAntigravityExecutor(&config.Config{RequestRetry: 1}) + auth := testAntigravityAuth(server.URL) + auth.Metadata["project_id"] = "project-1" + result, errExecute := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-4-6", + Payload: []byte(tt.payload), + }, cliproxyexecutor.Options{ + SourceFormat: tt.format, + ResponseFormat: tt.format, + Stream: true, + }) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error = %v", chunk.Err) + } + } + + body := <-captured + contents := gjson.GetBytes(body, "request.contents").Array() + if len(contents) != 2 || contents[0].Get("role").String() != "model" || contents[1].Get("role").String() != "user" { + t.Fatalf("Claude target history changed: %s", body) + } + if got := contents[0].Get("parts.0.text").String(); got != "prior answer" { + t.Fatalf("first Claude model turn = %q, want prior answer; body=%s", got, body) + } + }) + } +} + +func TestAntigravityCountTokensMatchesTargetLeadingUserPolicy(t *testing.T) { + tests := []struct { + name string + model string + wantRoles string + }{ + {name: "Gemini target prepends user", model: "gemini-3.6-flash-high", wantRoles: "user,model,user"}, + {name: "Claude target preserves history", model: "claude-sonnet-4-6", wantRoles: "model,user"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != antigravityCountTokensPath { + t.Fatalf("path = %q, want %q", r.URL.Path, antigravityCountTokensPath) + } + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read countTokens body: %v", errRead) + } + upstreamBody = append([]byte(nil), body...) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"totalTokens":42}`)) + })) + defer server.Close() + + executor := NewAntigravityExecutor(&config.Config{RequestRetry: 1}) + payload := []byte(`{"contents":[` + + `{"role":"model","parts":[{"text":"prior output"}]},` + + `{"role":"user","parts":[{"text":"continue"}]}` + + `]}`) + _, errCount := executor.CountTokens(context.Background(), testAntigravityAuth(server.URL), cliproxyexecutor.Request{ + Model: tt.model, + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatGemini, + ResponseFormat: sdktranslator.FormatGemini, + }) + if errCount != nil { + t.Fatalf("CountTokens() error = %v", errCount) + } + + contents := gjson.GetBytes(upstreamBody, "request.contents").Array() + roles := make([]string, 0, len(contents)) + for _, content := range contents { + roles = append(roles, content.Get("role").String()) + } + if got := strings.Join(roles, ","); got != tt.wantRoles { + t.Fatalf("countTokens roles = %q, want %q; body=%s", got, tt.wantRoles, upstreamBody) + } + if strings.HasPrefix(tt.wantRoles, "user,") { + text := contents[0].Get("parts.0.text") + if !text.Exists() || text.String() != "" { + t.Fatalf("synthetic countTokens user missing: %s", upstreamBody) + } + } + }) + } +} + func TestAntigravityExecutorCountTokensSanitizesGeminiToolHistory(t *testing.T) { inner := protowire.AppendTag(nil, 1, protowire.BytesType) inner = protowire.AppendBytes(inner, []byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34}) @@ -248,16 +624,16 @@ func TestAntigravityExecutorCountTokensSanitizesGeminiToolHistory(t *testing.T) if len(upstreamBody) == 0 { t.Fatal("countTokens upstream body was not captured") } - if got := gjson.GetBytes(upstreamBody, "request.contents.0.parts.0.thoughtSignature").String(); got != nativeSignature { + if got := gjson.GetBytes(upstreamBody, "request.contents.1.parts.0.thoughtSignature").String(); got != nativeSignature { t.Fatalf("first call signature = %q, want native signature; body=%s", got, upstreamBody) } - if signature := gjson.GetBytes(upstreamBody, "request.contents.0.parts.1.thoughtSignature"); signature.Exists() { + if signature := gjson.GetBytes(upstreamBody, "request.contents.1.parts.1.thoughtSignature"); signature.Exists() { t.Fatalf("second sibling bypass was not removed: %s", upstreamBody) } - if got := gjson.GetBytes(upstreamBody, "request.contents.1.role").String(); got != "model" { + if got := gjson.GetBytes(upstreamBody, "request.contents.2.role").String(); got != "model" { t.Fatalf("functionResponse role = %q, want model; body=%s", got, upstreamBody) } - if got := gjson.GetBytes(upstreamBody, "request.contents.1.parts.0.functionResponse.id").String(); got != "call-1" { + if got := gjson.GetBytes(upstreamBody, "request.contents.2.parts.0.functionResponse.id").String(); got != "call-1" { t.Fatalf("first functionResponse.id = %q, want call-1; body=%s", got, upstreamBody) } if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(upstreamBody); errPairing != nil { @@ -310,11 +686,15 @@ func TestAntigravityExecutorCountTokensReconstructsCompactedClaudeToolCall(t *te if len(upstreamBody) == 0 { t.Fatal("countTokens upstream body was not captured") } - call := gjson.GetBytes(upstreamBody, "request.contents.0.parts.0") + leadingText := gjson.GetBytes(upstreamBody, "request.contents.0.parts.0.text") + if gjson.GetBytes(upstreamBody, "request.contents.0.role").String() != "user" || !leadingText.Exists() || leadingText.String() != "" { + t.Fatalf("synthetic leading user missing after replay insert: %s", upstreamBody) + } + call := gjson.GetBytes(upstreamBody, "request.contents.1.parts.0") if call.Get("functionCall.id").String() != nativeID || call.Get("functionCall.name").String() != "Bash" || call.Get("thoughtSignature").String() != nativeSignature { t.Fatalf("native function call provenance was not reconstructed: %s", upstreamBody) } - response := gjson.GetBytes(upstreamBody, "request.contents.1.parts.0.functionResponse") + response := gjson.GetBytes(upstreamBody, "request.contents.2.parts.0.functionResponse") if response.Get("id").String() != nativeID || response.Get("name").String() != "Bash" { t.Fatalf("native function response provenance was not reconstructed: %s", upstreamBody) } diff --git a/internal/runtime/executor/antigravity_executor_stream.go b/internal/runtime/executor/antigravity_executor_stream.go index 30b6d4a6..9a3f9bb2 100644 --- a/internal/runtime/executor/antigravity_executor_stream.go +++ b/internal/runtime/executor/antigravity_executor_stream.go @@ -107,6 +107,7 @@ attemptLoop: return nil, err } } + requestPayload = ensureAntigravityGeminiLeadingUserContent(baseModel, requestPayload) httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata)) if errReq != nil { err = errReq diff --git a/internal/runtime/executor/antigravity_executor_tokens.go b/internal/runtime/executor/antigravity_executor_tokens.go index a7281cba..fb422131 100644 --- a/internal/runtime/executor/antigravity_executor_tokens.go +++ b/internal/runtime/executor/antigravity_executor_tokens.go @@ -61,7 +61,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut if errReplay != nil { return cliproxyexecutor.Response{}, errReplay } - payload = preparedPayload + payload = ensureAntigravityGeminiLeadingUserContent(baseModel, preparedPayload) payload = helps.DeleteJSONField(payload, "project") payload = helps.DeleteJSONField(payload, "model") diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 70e3dcc7..e2c7d621 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -166,6 +166,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r action = "countTokens" } } + body = helps.EnsureGeminiLeadingUserContent(body, "contents") baseURL := resolveGeminiBaseURL(auth) url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, action) if opts.Alt != "" && action != "countTokens" { @@ -275,6 +276,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = capGeminiMaxOutputTokens(body, baseModel) + body = helps.EnsureGeminiLeadingUserContent(body, "contents") baseURL := resolveGeminiBaseURL(auth) url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, "streamGenerateContent") @@ -640,6 +642,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig") translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings") translatedReq = helps.SetStringIfDifferent(translatedReq, "model", baseModel) + translatedReq = helps.EnsureGeminiLeadingUserContent(translatedReq, "contents") baseURL := resolveGeminiBaseURL(auth) url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, "countTokens") diff --git a/internal/runtime/executor/gemini_executor_test.go b/internal/runtime/executor/gemini_executor_test.go index 4b2a720f..37b796e1 100644 --- a/internal/runtime/executor/gemini_executor_test.go +++ b/internal/runtime/executor/gemini_executor_test.go @@ -91,6 +91,162 @@ func TestGeminiExecutorExecuteCapsMaxOutputTokensBeforeUpstream(t *testing.T) { } } +func TestGeminiExecutorExecutePrependsLeadingUser(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = append([]byte(nil), body...) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}`)) + })) + defer server.Close() + + executor := NewGeminiExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }} + request := cliproxyexecutor.Request{ + Model: "gemini-3.7-flash", + Payload: []byte(`{"contents":[` + + `{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"key":"value"}}}]},` + + `{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"result":"ok"}}}]}` + + `]}`), + } + + if _, errExecute := executor.Execute(context.Background(), auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + contents := gjson.GetBytes(upstreamBody, "contents").Array() + if len(contents) != 3 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" || contents[2].Get("role").String() != "user" { + t.Fatalf("upstream roles malformed: %s", upstreamBody) + } + if got := contents[0].Get("parts.0.text").String(); got != "" { + t.Fatalf("leading user prompt = %q, want empty string; body=%s", got, upstreamBody) + } +} + +func TestGeminiExecutorExecutePrependsLeadingUserForIssue4959ResponsesHistory(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = append([]byte(nil), body...) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}`)) + })) + defer server.Close() + + executor := NewGeminiExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }} + if _, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gemini-3.7-flash", + Payload: issue4959ResponsesModelFirstPayload(), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}); errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + assertIssue4959LeadingUserContents(t, gjson.GetBytes(upstreamBody, "contents").Array()) +} + +func TestGeminiExecutorCountTokensPrependsLeadingUser(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = append([]byte(nil), body...) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"totalTokens":7}`)) + })) + defer server.Close() + + executor := NewGeminiExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }} + request := cliproxyexecutor.Request{ + Model: "gemini-3.7-flash", + Payload: []byte(`{"contents":[{"role":"model","parts":[{"text":"prior output"}]}]}`), + } + + if _, errCount := executor.CountTokens(context.Background(), auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); errCount != nil { + t.Fatalf("CountTokens() error = %v", errCount) + } + contents := gjson.GetBytes(upstreamBody, "contents").Array() + if len(contents) != 2 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" { + t.Fatalf("countTokens roles malformed: %s", upstreamBody) + } + if text := contents[0].Get("parts.0.text"); !text.Exists() || text.String() != "" { + t.Fatalf("countTokens synthetic user missing: %s", upstreamBody) + } + if got := contents[1].Get("parts.0.text").String(); got != "prior output" { + t.Fatalf("countTokens model text = %q, want prior output; body=%s", got, upstreamBody) + } + + request.Metadata = map[string]any{"action": "countTokens"} + if _, errExecute := executor.Execute(context.Background(), auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); errExecute != nil { + t.Fatalf("Execute(countTokens) error = %v", errExecute) + } + contents = gjson.GetBytes(upstreamBody, "contents").Array() + if len(contents) != 2 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" { + t.Fatalf("Execute(countTokens) roles malformed: %s", upstreamBody) + } +} + +func TestGeminiExecutorAppliesPayloadRulesBeforeLeadingUserNormalization(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = append([]byte(nil), body...) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}]}`)) + })) + defer server.Close() + + executor := NewGeminiExecutor(&config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "gemini-3.7-flash", Protocol: "gemini"}}, + Params: map[string]any{"contents.0.parts.0.text": "payload override"}, + }}}}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }} + request := cliproxyexecutor.Request{ + Model: "gemini-3.7-flash", + Payload: []byte(`{"contents":[` + + `{"role":"model","parts":[{"text":"prior output"}]},` + + `{"role":"user","parts":[{"text":"continue"}]}` + + `]}`), + } + + if _, errExecute := executor.Execute(context.Background(), auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + contents := gjson.GetBytes(upstreamBody, "contents").Array() + if len(contents) != 3 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" { + t.Fatalf("upstream roles malformed: %s", upstreamBody) + } + if text := contents[0].Get("parts.0.text"); !text.Exists() || text.String() != "" { + t.Fatalf("synthetic leading user changed: %s", upstreamBody) + } + if got := contents[1].Get("parts.0.text").String(); got != "payload override" { + t.Fatalf("payload rule applied to %q, want original first model turn; body=%s", got, upstreamBody) + } +} + func TestGeminiExecutorInteractionsWithGeminiAPIKeyUsesGeminiEndpoint(t *testing.T) { var gotPath string var gotRevision string diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index 6d6eb8ff..eb8aa6b2 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -350,6 +350,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au action = "countTokens" } } + body = helps.EnsureGeminiLeadingUserContent(body, "contents") baseURL := vertexBaseURL(location) url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, action) if opts.Alt != "" && action != "countTokens" { @@ -477,6 +478,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip action = "countTokens" } } + body = helps.EnsureGeminiLeadingUserContent(body, "contents") // For API key auth, use simpler URL format without project/location if baseURL == "" { @@ -590,6 +592,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) action := getVertexAction(baseModel, true) + body = helps.EnsureGeminiLeadingUserContent(body, "contents") baseURL := vertexBaseURL(location) url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, action) // Imagen models don't support streaming, skip SSE params @@ -737,6 +740,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) action := getVertexAction(baseModel, true) + body = helps.EnsureGeminiLeadingUserContent(body, "contents") // For API key auth, use simpler URL format without project/location if baseURL == "" { baseURL = "https://aiplatform.googleapis.com" @@ -874,6 +878,7 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools") translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig") translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings") + translatedReq = helps.EnsureGeminiLeadingUserContent(translatedReq, "contents") baseURL := vertexBaseURL(location) url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, "countTokens") @@ -965,6 +970,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth * translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools") translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig") translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings") + translatedReq = helps.EnsureGeminiLeadingUserContent(translatedReq, "contents") // For API key auth, use simpler URL format without project/location if baseURL == "" { diff --git a/internal/runtime/executor/helps/gemini_content_turns.go b/internal/runtime/executor/helps/gemini_content_turns.go new file mode 100644 index 00000000..27ec346f --- /dev/null +++ b/internal/runtime/executor/helps/gemini_content_turns.go @@ -0,0 +1,39 @@ +package helps + +import ( + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +var emptyGeminiUserTurnJSON = []byte(`{"role":"user","parts":[{"text":""}]}`) + +// EnsureGeminiLeadingUserContent ensures that the contents array at the given path +// starts with a user turn when sending to Gemini/Antigravity upstreams. +func EnsureGeminiLeadingUserContent(payload []byte, path string) []byte { + firstRole := gjson.GetBytes(payload, path+".0.role") + if firstRole.String() != "model" { + return payload + } + contents := util.GetGJSONBytesNoCopy(payload, path) + if !contents.IsArray() { + return payload + } + contentArray := contents.Array() + if len(contentArray) == 0 { + return payload + } + + contentItems := make([][]byte, 0, len(contentArray)+1) + contentItems = append(contentItems, emptyGeminiUserTurnJSON) + for _, content := range contentArray { + contentItems = append(contentItems, []byte(content.Raw)) + } + + out, errSet := sjson.SetRawBytes(payload, path, translatorcommon.JoinRawArray(contentItems)) + if errSet != nil { + return payload + } + return out +} diff --git a/internal/runtime/executor/helps/gemini_content_turns_test.go b/internal/runtime/executor/helps/gemini_content_turns_test.go new file mode 100644 index 00000000..fadb23a2 --- /dev/null +++ b/internal/runtime/executor/helps/gemini_content_turns_test.go @@ -0,0 +1,99 @@ +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) + } + } + }) + } +} -- 2.51.2