Files
CLIProxyAPI/internal/runtime/executor/aistudio_executor_test.go
Luis Pater 9dfddd613d fix(aistudio): normalize thinking level to uppercase
- Normalize `generationConfig.thinkingConfig.thinkingLevel` to canonical uppercase enum values (`MINIMAL`, `LOW`, `MEDIUM`, `HIGH`).
- Prevent upstream HTTP 400 invalid argument errors caused by case-sensitive validation.

Closes: #5481
2026-09-06 04:14:41 +08:00

315 lines
11 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 TestAIStudioTranslateRequestNormalizesThinkingLevel(t *testing.T) {
executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil)
req := cliproxyexecutor.Request{
Model: "gemini-3.7-flash",
Payload: []byte(`{
"contents":[{"role":"user","parts":[{"text":"Human: Hi"}]}],
"model":"gemini-3.7-flash",
"generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}}
}`),
}
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}
payload, _, err := executor.translateRequest(context.Background(), req, opts, false)
if err != nil {
t.Fatalf("translateRequest() error = %v", err)
}
if got := gjson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "HIGH" {
t.Fatalf("thinkingLevel = %q, want HIGH; payload=%s", got, payload)
}
if !gjson.GetBytes(payload, "generationConfig.thinkingConfig.includeThoughts").Bool() {
t.Fatalf("includeThoughts was lost: %s", payload)
}
}
func TestAIStudioTranslateRequestNormalizesThinkingLevelAfterPayloadOverride(t *testing.T) {
executor := NewAIStudioExecutor(&config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{
Models: []config.PayloadModelRule{{Name: "gemini-3.7-flash", Protocol: "gemini"}},
Params: map[string]any{"generationConfig.thinkingConfig.thinkingLevel": "medium"},
}}}}, "aistudio", nil)
req := cliproxyexecutor.Request{
Model: "gemini-3.7-flash",
Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"model":"gemini-3.7-flash"}`),
}
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}
payload, _, err := executor.translateRequest(context.Background(), req, opts, false)
if err != nil {
t.Fatalf("translateRequest() error = %v", err)
}
if got := gjson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "MEDIUM" {
t.Fatalf("thinkingLevel = %q, want MEDIUM; payload=%s", got, payload)
}
}
func TestNormalizeAIStudioThinkingLevel(t *testing.T) {
tests := []struct {
name string
levelJSON string
want string
unchanged bool
}{
{name: "minimal", levelJSON: `"minimal"`, want: "MINIMAL"},
{name: "low", levelJSON: `"low"`, want: "LOW"},
{name: "medium", levelJSON: `"medium"`, want: "MEDIUM"},
{name: "mixed case high", levelJSON: `"hIgH"`, want: "HIGH"},
{name: "already uppercase", levelJSON: `"HIGH"`, want: "HIGH", unchanged: true},
{name: "none", levelJSON: `"none"`, want: "none", unchanged: true},
{name: "xhigh", levelJSON: `"xhigh"`, want: "xhigh", unchanged: true},
{name: "max", levelJSON: `"max"`, want: "max", unchanged: true},
{name: "custom", levelJSON: `"custom"`, want: "custom", unchanged: true},
{name: "non-string", levelJSON: `1`, want: "1", unchanged: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
input := []byte(fmt.Sprintf(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":%s}},"marker":true}`, test.levelJSON))
output := normalizeAIStudioThinkingLevel(input)
level := gjson.GetBytes(output, "generationConfig.thinkingConfig.thinkingLevel")
if got := level.String(); got != test.want {
t.Fatalf("thinkingLevel = %q, want %q; payload=%s", got, test.want, output)
}
if test.unchanged && string(output) != string(input) {
t.Fatalf("payload changed from %s to %s", input, output)
}
})
}
}
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 TestAIStudioExecutorWithoutRelaySessionDoesNotMarkUpstreamAttempt(t *testing.T) {
const authID = "aistudio-not-connected"
relay := wsrelay.NewManager(wsrelay.Options{})
exec := NewAIStudioExecutor(&config.Config{}, "aistudio", relay)
auth := &cliproxyauth.Auth{ID: authID, Provider: "aistudio"}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-pro-preview",
Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`),
}
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}
tests := []struct {
name string
run func(context.Context) error
}{
{
name: "HTTP request",
run: func(ctx context.Context) error {
httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, "https://example.com/generate", strings.NewReader(`{"contents":[]}`))
if errRequest != nil {
return errRequest
}
_, errRequest = exec.HttpRequest(ctx, auth, httpReq)
return errRequest
},
},
{
name: "execute",
run: func(ctx context.Context) error {
_, errExecute := exec.Execute(ctx, auth, req, opts)
return errExecute
},
},
{
name: "stream",
run: func(ctx context.Context) error {
_, errStream := exec.ExecuteStream(ctx, auth, req, opts)
return errStream
},
},
{
name: "count tokens",
run: func(ctx context.Context) error {
_, errCount := exec.CountTokens(ctx, auth, req, opts)
return errCount
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := cliproxyexecutor.WithUpstreamAttemptTracker(context.Background())
errRun := test.run(ctx)
if errRun == nil || !strings.Contains(errRun.Error(), "not connected") {
t.Fatalf("request error = %v, want provider not connected", errRun)
}
if cliproxyexecutor.UpstreamAttempted(ctx) {
t.Fatal("missing relay session was marked as an upstream attempt")
}
})
}
}
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)
ctx := cliproxyexecutor.WithUpstreamAttemptTracker(context.Background())
_, errExecute := exec.Execute(ctx, &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 !cliproxyexecutor.UpstreamAttempted(ctx) {
t.Fatal("relay request did not mark an upstream attempt")
}
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")
}
}
}