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 TestAIStudioTranslateRequestAppendsTrailingUserForTrailingModelTurn(t *testing.T) { executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil) _, body, err := executor.translateRequest(context.Background(), cliproxyexecutor.Request{ Model: "gemini-3.7-flash", Payload: []byte(`{"contents":[` + `{"role":"user","parts":[{"text":"hello"}]},` + `{"role":"model","parts":[{"text":"answer"}]}` + `]}`), }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}, false) if err != nil { t.Fatalf("translateRequest() error = %v", err) } contents := gjson.GetBytes(body.payload, "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("contents roles malformed: %s", body.payload) } if got := contents[2].Get("parts.0.text").String(); got != "" { t.Fatalf("trailing user prompt = %q, want empty string; body=%s", got, body.payload) }} func TestAIStudioTranslateRequestCountTokensPreservesTrailingModelTurn(t *testing.T) { executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil) // When action is countTokens, trailing model turn must not be modified _, body, err := executor.translateRequest(context.Background(), cliproxyexecutor.Request{ Model: "gemini-3.7-flash", Payload: []byte(`{"contents":[` + `{"role":"user","parts":[{"text":"hello"}]},` + `{"role":"model","parts":[{"text":"answer"}]}` + `]}`), Metadata: map[string]any{"action": "countTokens"}, }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}, false) if err != nil { t.Fatalf("translateRequest() error = %v", err) } contents := gjson.GetBytes(body.payload, "contents").Array() if len(contents) != 2 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" { t.Fatalf("countTokens contents roles malformed: %s", body.payload) }} 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") } }}
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 TestAIStudioTranslateRequestAppendsTrailingUserForTrailingModelTurn(t *testing.T) {
executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil)
_, body, err := executor.translateRequest(context.Background(), cliproxyexecutor.Request{
Model: "gemini-3.7-flash",
Payload: []byte(`{"contents":[` +
`{"role":"user","parts":[{"text":"hello"}]},` +
`{"role":"model","parts":[{"text":"answer"}]}` +
`]}`),
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}, false)
if err != nil {
t.Fatalf("translateRequest() error = %v", err)
}
contents := gjson.GetBytes(body.payload, "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("contents roles malformed: %s", body.payload)
}
if got := contents[2].Get("parts.0.text").String(); got != "" {
t.Fatalf("trailing user prompt = %q, want empty string; body=%s", got, body.payload)
}
}
func TestAIStudioTranslateRequestCountTokensPreservesTrailingModelTurn(t *testing.T) {
executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil)
// When action is countTokens, trailing model turn must not be modified
_, body, err := executor.translateRequest(context.Background(), cliproxyexecutor.Request{
Model: "gemini-3.7-flash",
Payload: []byte(`{"contents":[` +
`{"role":"user","parts":[{"text":"hello"}]},` +
`{"role":"model","parts":[{"text":"answer"}]}` +
`]}`),
Metadata: map[string]any{"action": "countTokens"},
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}, false)
if err != nil {
t.Fatalf("translateRequest() error = %v", err)
}
contents := gjson.GetBytes(body.payload, "contents").Array()
if len(contents) != 2 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" {
t.Fatalf("countTokens contents roles malformed: %s", body.payload)
}
}
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")
}
}
}