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) + } + } + }) + } +}