mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
* 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.
171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/wsrelay"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestAIStudioTranslateRequestPreservesSummaryFromOriginalRequest(t *testing.T) {
|
|
executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil)
|
|
req := cliproxyexecutor.Request{
|
|
Model: "gemini-3.6-flash",
|
|
Payload: []byte(`{"model":"gemini-3.6-flash","input":"hi"}`),
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
OriginalRequest: []byte(`{"model":"gemini-3.6-flash","reasoning":{"summary":"auto"},"input":"hi"}`),
|
|
}
|
|
payload, _, err := executor.translateRequest(context.Background(), req, opts, false)
|
|
if err != nil {
|
|
t.Fatalf("translateRequest() error = %v", err)
|
|
}
|
|
if !gjson.GetBytes(payload, "generationConfig.thinkingConfig.includeThoughts").Bool() {
|
|
t.Fatalf("original request summary intent was lost: %s", payload)
|
|
}
|
|
}
|
|
|
|
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
|
|
connected := make(chan struct{})
|
|
var connectedOnce sync.Once
|
|
relay := wsrelay.NewManager(wsrelay.Options{
|
|
ProviderFactory: func(*http.Request) (string, error) {
|
|
return authID, nil
|
|
},
|
|
OnConnected: func(provider string) {
|
|
if provider == authID {
|
|
connectedOnce.Do(func() {
|
|
close(connected)
|
|
})
|
|
}
|
|
},
|
|
})
|
|
server := httptest.NewServer(relay.Handler())
|
|
defer server.Close()
|
|
defer func() {
|
|
if errStop := relay.Stop(context.Background()); errStop != nil {
|
|
t.Errorf("relay stop error = %v", errStop)
|
|
}
|
|
}()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + relay.Path()
|
|
conn, _, errDial := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if errDial != nil {
|
|
t.Fatalf("dial websocket: %v", errDial)
|
|
}
|
|
defer func() {
|
|
if errClose := conn.Close(); errClose != nil {
|
|
t.Errorf("websocket close error = %v", errClose)
|
|
}
|
|
}()
|
|
select {
|
|
case <-connected:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for relay connection")
|
|
}
|
|
|
|
clientDone := make(chan error, 1)
|
|
go func() {
|
|
var msg wsrelay.Message
|
|
if errReadJSON := conn.ReadJSON(&msg); errReadJSON != nil {
|
|
clientDone <- fmt.Errorf("read relay request: %w", errReadJSON)
|
|
return
|
|
}
|
|
if msg.Type != wsrelay.MessageTypeHTTPReq {
|
|
clientDone <- fmt.Errorf("relay message type = %q, want %q", msg.Type, wsrelay.MessageTypeHTTPReq)
|
|
return
|
|
}
|
|
time.Sleep(delay)
|
|
response := wsrelay.Message{
|
|
ID: msg.ID,
|
|
Type: wsrelay.MessageTypeHTTPResp,
|
|
Payload: map[string]any{
|
|
"status": float64(http.StatusOK),
|
|
"headers": map[string]any{"Content-Type": "application/json"},
|
|
"body": `{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}`,
|
|
},
|
|
}
|
|
if errWriteJSON := conn.WriteJSON(response); errWriteJSON != nil {
|
|
clientDone <- fmt.Errorf("write relay response: %w", errWriteJSON)
|
|
return
|
|
}
|
|
clientDone <- nil
|
|
}()
|
|
|
|
plugin := &captureAIStudioUsagePlugin{records: make(chan usage.Record, 16)}
|
|
usage.RegisterPlugin(plugin)
|
|
exec := NewAIStudioExecutor(&config.Config{}, "aistudio", relay)
|
|
_, errExecute := exec.Execute(context.Background(), &cliproxyauth.Auth{ID: authID, Provider: "aistudio"}, cliproxyexecutor.Request{
|
|
Model: "gemini-3.1-pro-preview",
|
|
Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`),
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini})
|
|
if errExecute != nil {
|
|
t.Fatalf("Execute() error = %v", errExecute)
|
|
}
|
|
if errClient := <-clientDone; errClient != nil {
|
|
t.Fatal(errClient)
|
|
}
|
|
|
|
record := waitForAIStudioUsageRecord(t, plugin.records, "gemini-3.1-pro-preview")
|
|
if record.TTFT < delay {
|
|
t.Fatalf("ttft = %v, want >= %v", record.TTFT, delay)
|
|
}
|
|
}
|
|
|
|
type captureAIStudioUsagePlugin struct {
|
|
records chan usage.Record
|
|
}
|
|
|
|
func (p *captureAIStudioUsagePlugin) HandleUsage(_ context.Context, record usage.Record) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
select {
|
|
case p.records <- record:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func waitForAIStudioUsageRecord(t *testing.T, records <-chan usage.Record, model string) usage.Record {
|
|
t.Helper()
|
|
timeout := time.After(2 * time.Second)
|
|
for {
|
|
select {
|
|
case record := <-records:
|
|
if record.Provider == "aistudio" && record.Model == model {
|
|
return record
|
|
}
|
|
case <-timeout:
|
|
t.Fatalf("timed out waiting for AI Studio usage record")
|
|
}
|
|
}
|
|
}
|