Files
CLIProxyAPI/internal/runtime/executor/gemini_executor_test.go
hkfires 6a489fa84d fix(auth): prefer errors from upstream attempts
Track when executor calls cross an upstream transport boundary and use that
signal to keep model/provider errors from being replaced by later local
preparation, selection, or internal failures.

Mark HTTP, websocket, relay, and usage-tracked transports as upstream
attempts, while avoiding marks for local validation, logging, missing
sessions, and successful websocket handshakes before request send.

Parse relative auth expiry metadata and adjust Antigravity refresh timing.
2026-08-29 12:50:46 +08:00

1186 lines
48 KiB
Go

package executor
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)
func TestCapGeminiMaxOutputTokensUsesOutputTokenLimit(t *testing.T) {
body := []byte(`{"generationConfig":{"maxOutputTokens":500000,"temperature":0.2},"contents":[]}`)
out := capGeminiMaxOutputTokens(body, "gemini-3.1-pro-preview")
if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 65536 {
t.Fatalf("maxOutputTokens = %d, want 65536", got)
}
if got := gjson.GetBytes(out, "generationConfig.temperature").Float(); got != 0.2 {
t.Fatalf("temperature = %v, want 0.2", got)
}
}
func TestCapGeminiMaxOutputTokensLeavesAllowedOrUnknown(t *testing.T) {
tests := []struct {
name string
model string
body []byte
want int64
}{
{
name: "allowed value",
model: "gemini-3.1-pro-preview",
body: []byte(`{"generationConfig":{"maxOutputTokens":64000}}`),
want: 64000,
},
{
name: "unknown model",
model: "custom-gemini-model",
body: []byte(`{"generationConfig":{"maxOutputTokens":500000}}`),
want: 500000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := capGeminiMaxOutputTokens(tt.body, tt.model)
if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != tt.want {
t.Fatalf("maxOutputTokens = %d, want %d", got, tt.want)
}
})
}
}
func TestGeminiExecutorExecuteCapsMaxOutputTokensBeforeUpstream(t *testing.T) {
var upstreamMaxOutputTokens int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
upstreamMaxOutputTokens = gjson.GetBytes(body, "generationConfig.maxOutputTokens").Int()
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()
exec := NewGeminiExecutor(&config.Config{})
auth := &cliproxyauth.Auth{Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
}}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-pro-preview",
Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"maxOutputTokens":500000}}`),
}
if _, err := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if upstreamMaxOutputTokens != 65536 {
t.Fatalf("upstream maxOutputTokens = %d, want 65536", upstreamMaxOutputTokens)
}
}
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"}]}]}`),
}
ctx := cliproxyexecutor.WithUpstreamAttemptTracker(context.Background())
if _, errCount := executor.CountTokens(ctx, auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); errCount != nil {
t.Fatalf("CountTokens() error = %v", errCount)
}
if !cliproxyexecutor.UpstreamAttempted(ctx) {
t.Fatal("CountTokens() did not mark the HTTP request as an upstream attempt")
}
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
var upstreamBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotRevision = r.Header.Get("Api-Revision")
body, errRead := io.ReadAll(r.Body)
if errRead != nil {
t.Fatalf("read request body: %v", errRead)
}
upstreamBody = 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()
exec := NewGeminiExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.5-flash",
Payload: []byte(`{"model":"gemini-3.5-flash","input":"hi"}`),
}
_, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatInteractions,
ResponseFormat: sdktranslator.FormatInteractions,
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if gotPath != "/v1beta/models/gemini-3.5-flash:generateContent" {
t.Fatalf("path = %q, want Gemini generateContent endpoint", gotPath)
}
if gotRevision != "" {
t.Fatalf("Api-Revision = %q, want empty for Gemini protocol request", gotRevision)
}
if !gjson.GetBytes(upstreamBody, "contents.0.parts.0.text").Exists() {
t.Fatalf("contents text missing from translated Gemini body: %s", string(upstreamBody))
}
if gjson.GetBytes(upstreamBody, "input").Exists() {
t.Fatalf("raw interactions input exists in translated Gemini body: %s", string(upstreamBody))
}
}
func TestGeminiExecutorNativeInteractionsUsesInteractionsEndpoint(t *testing.T) {
var gotPath string
var gotRevision string
var gotModelExists bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotRevision = r.Header.Get("Api-Revision")
body, errRead := io.ReadAll(r.Body)
if errRead != nil {
t.Fatalf("read request body: %v", errRead)
}
gotModelExists = gjson.GetBytes(body, "model").Exists()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "agents/test-agent",
Payload: []byte(`{"agent":"agents/test-agent","input":"hi"}`),
}
resp, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatInteractions,
ResponseFormat: sdktranslator.FormatInteractions,
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if gotPath != "/v1beta/interactions" {
t.Fatalf("path = %q, want /v1beta/interactions", gotPath)
}
if gotRevision != "2026-05-20" {
t.Fatalf("Api-Revision = %q, want 2026-05-20", gotRevision)
}
if gotModelExists {
t.Fatal("model field exists for agent-only request, want absent")
}
if got := gjson.GetBytes(resp.Payload, "id").String(); got != "interaction_1" {
t.Fatalf("response id = %q, want interaction_1", got)
}
}
func TestGeminiExecutorNativeInteractionsTranslatesOpenAIResponsesRequest(t *testing.T) {
var gotPath string
var upstreamBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
body, errRead := io.ReadAll(r.Body)
if errRead != nil {
t.Fatalf("read request body: %v", errRead)
}
upstreamBody = body
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-flash-lite",
Payload: []byte(`{
"model":"gemini-3.1-flash-lite",
"instructions":"be brief",
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}],
"reasoning":{"effort":"high","summary":"auto"}
}`),
}
resp, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatOpenAIResponse,
ResponseFormat: sdktranslator.FormatOpenAIResponse,
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if gotPath != "/v1beta/interactions" {
t.Fatalf("path = %q, want /v1beta/interactions", gotPath)
}
if got := gjson.GetBytes(upstreamBody, "input.0.type").String(); got != "user_input" {
t.Fatalf("input.0.type = %q, want user_input. Body: %s", got, string(upstreamBody))
}
if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" {
t.Fatalf("thinking_level = %q, want high. Body: %s", got, string(upstreamBody))
}
if got := gjson.GetBytes(resp.Payload, "output.0.content.0.text").String(); got != "ok" {
t.Fatalf("response text = %q, want ok. Payload: %s", got, string(resp.Payload))
}
}
func TestGeminiExecutorNativeInteractionsPayloadRulesUseResponsesFromProtocol(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 = body
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}]}`))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{
Payload: config.PayloadConfig{
Override: []config.PayloadRule{
{
Models: []config.PayloadModelRule{
{Name: "gemini-3.1-flash-lite", Protocol: "interactions", FromProtocol: "openai"},
},
Params: map[string]any{
"generation_config.thinking_summaries": "wrong",
},
},
{
Models: []config.PayloadModelRule{
{Name: "gemini-3.1-flash-lite", Protocol: "interactions", FromProtocol: "responses"},
},
Params: map[string]any{
"generation_config.thinking_summaries": "detailed",
},
},
},
},
})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-flash-lite",
Payload: []byte(`{
"model":"gemini-3.1-flash-lite",
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]
}`),
}
_, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatOpenAIResponse,
ResponseFormat: sdktranslator.FormatOpenAIResponse,
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "detailed" {
t.Fatalf("thinking_summaries = %q, want detailed. Body: %s", got, string(upstreamBody))
}
}
func TestGeminiExecutorNativeInteractionsTranslatesOpenAIChatRequest(t *testing.T) {
var gotPath string
var upstreamBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
body, errRead := io.ReadAll(r.Body)
if errRead != nil {
t.Fatalf("read request body: %v", errRead)
}
upstreamBody = body
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"i1\",\"model\":\"gemini-3.1-flash-lite\"}}\n\n"))
_, _ = w.Write([]byte("event: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"function_call\",\"id\":\"call_1\",\"name\":\"get_weather\",\"arguments\":{}}}\n\n"))
_, _ = w.Write([]byte("event: step.delta\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"arguments_delta\",\"arguments\":\"{\\\"location\\\":\\\"北京\\\"}\"}}\n\n"))
_, _ = w.Write([]byte("event: step.stop\ndata: {\"event_type\":\"step.stop\",\"index\":0}\n\n"))
_, _ = w.Write([]byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"i1\",\"status\":\"requires_action\",\"usage\":{\"total_input_tokens\":2,\"total_output_tokens\":3,\"total_tokens\":5}}}\n\n"))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-flash-lite",
Payload: []byte(`{
"model":"gemini-3.1-flash-lite",
"stream":true,
"messages":[{"role":"user","content":"今天北京的天气怎么样?"}],
"tools":[{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"location":{"type":"string"}}}}}],
"tool_choice":"auto"
}`),
}
result, errExecute := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatOpenAI,
ResponseFormat: sdktranslator.FormatOpenAI,
})
if errExecute != nil {
t.Fatalf("ExecuteStream() error = %v", errExecute)
}
var toolStart []byte
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatalf("stream chunk error: %v", chunk.Err)
}
if gjson.GetBytes(chunk.Payload, "choices.0.delta.tool_calls.0.function.name").String() == "get_weather" {
toolStart = chunk.Payload
}
}
if gotPath != "/v1beta/interactions" {
t.Fatalf("path = %q, want /v1beta/interactions", gotPath)
}
if got := gjson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
t.Fatalf("translated request text = %q. Body: %s", got, string(upstreamBody))
}
if gjson.GetBytes(upstreamBody, "messages").Exists() {
t.Fatalf("raw OpenAI messages should not be sent upstream: %s", string(upstreamBody))
}
if got := gjson.GetBytes(upstreamBody, "tools.0.type").String(); got != "function" {
t.Fatalf("translated tool type = %q, want function. Body: %s", got, string(upstreamBody))
}
if got := gjson.GetBytes(upstreamBody, "generation_config.tool_choice").String(); got != "auto" {
t.Fatalf("translated tool choice = %q, want auto. Body: %s", got, string(upstreamBody))
}
if toolStart == nil {
t.Fatal("OpenAI tool call chunk not found")
}
if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.id").String(); got != "call_1" {
t.Fatalf("tool call id = %q, want call_1. Payload: %s", got, string(toolStart))
}
}
func TestGeminiExecutorNativeInteractionsPayloadDefaultsUseTranslatedOpenAIChatSource(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 = body
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}]}`))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{
Payload: config.PayloadConfig{
Default: []config.PayloadRule{
{
Models: []config.PayloadModelRule{
{Name: "gemini-3.1-flash-lite", Protocol: "interactions", FromProtocol: "openai"},
},
Params: map[string]any{
"generation_config.temperature": 0.9,
"generation_config.top_p": 0.8,
},
},
},
},
})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-flash-lite",
Payload: []byte(`{
"model":"gemini-3.1-flash-lite",
"messages":[{"role":"user","content":"hi"}],
"temperature":0.2
}`),
}
_, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatOpenAI,
ResponseFormat: sdktranslator.FormatOpenAI,
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if got := gjson.GetBytes(upstreamBody, "generation_config.temperature").Float(); got != 0.2 {
t.Fatalf("temperature = %v, want 0.2. Body: %s", got, string(upstreamBody))
}
if got := gjson.GetBytes(upstreamBody, "generation_config.top_p").Float(); got != 0.8 {
t.Fatalf("top_p = %v, want default 0.8. Body: %s", got, string(upstreamBody))
}
}
func TestGeminiExecutorNativeInteractionsTranslatesGeminiStreamResponse(t *testing.T) {
var gotPath string
var upstreamBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
body, errRead := io.ReadAll(r.Body)
if errRead != nil {
t.Fatalf("read request body: %v", errRead)
}
upstreamBody = body
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"i1\",\"model\":\"gemini-3.1-flash-lite\"}}\n\n"))
_, _ = w.Write([]byte("event: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"function_call\",\"id\":\"call_1\",\"signature\":\"sig_1\",\"name\":\"get_weather\",\"arguments\":{}}}\n\n"))
_, _ = w.Write([]byte("event: step.delta\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"arguments_delta\",\"arguments\":\"{\\\"location\\\":\\\"北京\\\"}\"}}\n\n"))
_, _ = w.Write([]byte("event: step.stop\ndata: {\"event_type\":\"step.stop\",\"index\":0}\n\n"))
_, _ = w.Write([]byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"i1\",\"status\":\"requires_action\",\"usage\":{\"total_input_tokens\":2,\"total_output_tokens\":3,\"total_tokens\":5,\"total_cached_tokens\":1},\"service_tier\":\"standard\",\"model\":\"gemini-3.1-flash-lite\"}}\n\n"))
_, _ = w.Write([]byte("event: done\ndata: [DONE]\n\n"))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-flash-lite",
Payload: []byte(`{
"contents":[{"role":"user","parts":[{"text":"今天北京的天气怎么样?"}]}],
"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"OBJECT","properties":{"location":{"type":"STRING"}},"required":["location"]}}]}]
}`),
}
result, errExecute := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatGemini,
ResponseFormat: sdktranslator.FormatGemini,
})
if errExecute != nil {
t.Fatalf("ExecuteStream() error = %v", errExecute)
}
var callChunk []byte
var finishChunk []byte
chunkCount := 0
for chunk := range result.Chunks {
chunkCount++
if chunk.Err != nil {
t.Fatalf("stream chunk error: %v", chunk.Err)
}
if gjson.GetBytes(chunk.Payload, "event_type").Exists() {
t.Fatalf("interactions payload leaked to Gemini response: %s", string(chunk.Payload))
}
if gjson.GetBytes(chunk.Payload, "candidates.0.content.parts.0.functionCall").Exists() {
callChunk = chunk.Payload
}
if gjson.GetBytes(chunk.Payload, "candidates.0.finishReason").Exists() {
finishChunk = chunk.Payload
}
}
if gotPath != "/v1beta/interactions" {
t.Fatalf("path = %q, want /v1beta/interactions", gotPath)
}
if gjson.GetBytes(upstreamBody, "contents").Exists() {
t.Fatalf("raw Gemini contents should not be sent upstream: %s", string(upstreamBody))
}
if got := gjson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
t.Fatalf("translated request text = %q. Body: %s", got, string(upstreamBody))
}
if chunkCount != 2 {
t.Fatalf("stream chunk count = %d, want 2", chunkCount)
}
if callChunk == nil {
t.Fatal("Gemini functionCall chunk not found")
}
if got := gjson.GetBytes(callChunk, "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" {
t.Fatalf("functionCall.name = %q, want get_weather. Payload: %s", got, string(callChunk))
}
if got := gjson.GetBytes(callChunk, "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" {
t.Fatalf("functionCall.args.location = %q, want 北京. Payload: %s", got, string(callChunk))
}
if got := gjson.GetBytes(callChunk, "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" {
t.Fatalf("thoughtSignature = %q, want sig_1. Payload: %s", got, string(callChunk))
}
if finishChunk == nil {
t.Fatal("Gemini finish chunk not found")
}
if got := gjson.GetBytes(finishChunk, "candidates.0.finishReason").String(); got != "STOP" {
t.Fatalf("finishReason = %q, want STOP. Payload: %s", got, string(finishChunk))
}
if got := gjson.GetBytes(finishChunk, "usageMetadata.promptTokenCount").Int(); got != 2 {
t.Fatalf("promptTokenCount = %d, want 2. Payload: %s", got, string(finishChunk))
}
if got := gjson.GetBytes(finishChunk, "usageMetadata.candidatesTokenCount").Int(); got != 3 {
t.Fatalf("candidatesTokenCount = %d, want 3. Payload: %s", got, string(finishChunk))
}
if got := gjson.GetBytes(finishChunk, "usageMetadata.totalTokenCount").Int(); got != 5 {
t.Fatalf("totalTokenCount = %d, want 5. Payload: %s", got, string(finishChunk))
}
}
func TestNativeInteractionsSourceFormatAllowsSupportedEntryProtocols(t *testing.T) {
supported := []sdktranslator.Format{
sdktranslator.FormatInteractions,
sdktranslator.FormatOpenAI,
sdktranslator.FormatOpenAIResponse,
sdktranslator.FormatClaude,
sdktranslator.FormatGemini,
}
for _, format := range supported {
if !nativeInteractionsSourceFormat(format) {
t.Fatalf("nativeInteractionsSourceFormat(%q) = false, want true", format)
}
}
for _, format := range []sdktranslator.Format{sdktranslator.FormatCodex, sdktranslator.FormatAntigravity} {
if nativeInteractionsSourceFormat(format) {
t.Fatalf("nativeInteractionsSourceFormat(%q) = true, want false", format)
}
}
}
func TestGeminiExecutorNativeInteractionsTranslatesClaudeRequest(t *testing.T) {
var gotPath string
var upstreamBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
body, errRead := io.ReadAll(r.Body)
if errRead != nil {
t.Fatalf("read request body: %v", errRead)
}
upstreamBody = body
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","model":"gemini-3.1-flash-lite","steps":[{"type":"model_output","content":[{"type":"text","text":"ok"}]}],"usage":{"total_input_tokens":1,"total_output_tokens":1}}`))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-flash-lite",
Payload: []byte(`{
"model":"gemini-3.1-flash-lite",
"max_tokens":1024,
"tools":[{"name":"get_weather","description":"weather","input_schema":{"type":"object","properties":{"location":{"type":"string"}}}}],
"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]
}`),
}
resp, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatClaude,
ResponseFormat: sdktranslator.FormatClaude,
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if gotPath != "/v1beta/interactions" {
t.Fatalf("path = %q, want /v1beta/interactions", gotPath)
}
if got := gjson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("translated request text = %q, want hi. Body: %s", got, string(upstreamBody))
}
if gjson.GetBytes(upstreamBody, "messages").Exists() {
t.Fatalf("raw Claude messages should not be sent upstream: %s", string(upstreamBody))
}
if got := gjson.GetBytes(upstreamBody, "tools.0.type").String(); got != "function" {
t.Fatalf("translated tool type = %q, want function. Body: %s", got, string(upstreamBody))
}
if got := gjson.GetBytes(resp.Payload, "content.0.text").String(); got != "ok" {
t.Fatalf("response text = %q, want ok. Payload: %s", got, string(resp.Payload))
}
if got := gjson.GetBytes(resp.Payload, "usage.output_tokens").Int(); got != 1 {
t.Fatalf("response output tokens = %d, want 1. Payload: %s", got, string(resp.Payload))
}
}
func TestGeminiExecutorNativeInteractionsAppliesThinkingSuffix(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 = body
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","status":"completed","steps":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-flash-lite(high)",
Payload: []byte(`{"model":"gemini-3.1-flash-lite(high)","generation_config":{"max_output_tokens":32},"input":"hi"}`),
}
_, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatInteractions,
ResponseFormat: sdktranslator.FormatInteractions,
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if got := gjson.GetBytes(upstreamBody, "model").String(); got != "gemini-3.1-flash-lite" {
t.Fatalf("model = %q, want gemini-3.1-flash-lite. Body: %s", got, string(upstreamBody))
}
if gjson.GetBytes(upstreamBody, "generationConfig").Exists() {
t.Fatalf("generationConfig exists, want Interactions snake_case only. Body: %s", string(upstreamBody))
}
if gjson.GetBytes(upstreamBody, "generation_config.thinking_config").Exists() {
t.Fatalf("thinking_config exists, want native Interactions fields. Body: %s", string(upstreamBody))
}
if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" {
t.Fatalf("thinking_level = %q, want high. Body: %s", got, string(upstreamBody))
}
if gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").Exists() {
t.Fatalf("thinking_summaries should be absent without explicit summary intent. Body: %s", string(upstreamBody))
}
}
func TestGeminiExecutorNativeInteractionsPreservesThinkingProtocolFields(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 = body
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","status":"completed","steps":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-flash-lite",
Payload: []byte(`{"model":"gemini-3.1-flash-lite","generation_config":{"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`),
}
_, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatInteractions,
ResponseFormat: sdktranslator.FormatInteractions,
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if gjson.GetBytes(upstreamBody, "generationConfig").Exists() {
t.Fatalf("generationConfig exists, want Interactions snake_case only. Body: %s", string(upstreamBody))
}
if gjson.GetBytes(upstreamBody, "generation_config.thinking_config").Exists() {
t.Fatalf("thinking_config exists, want native Interactions fields. Body: %s", string(upstreamBody))
}
if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" {
t.Fatalf("thinking_level = %q, want high. Body: %s", got, string(upstreamBody))
}
if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "auto" {
t.Fatalf("thinking_summaries = %q, want auto. Body: %s", got, string(upstreamBody))
}
}
func TestGeminiExecutorNativeInteractionsPreservesApiRevision(t *testing.T) {
var gotRevision string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRevision = r.Header.Get("Api-Revision")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","status":"completed","steps":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
auth.Attributes["header:Api-Revision"] = "2026-06-01"
req := cliproxyexecutor.Request{
Model: "agents/test-agent",
Payload: []byte(`{"agent":"agents/test-agent","input":"hi"}`),
}
_, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatInteractions,
ResponseFormat: sdktranslator.FormatInteractions,
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if gotRevision != "2026-06-01" {
t.Fatalf("Api-Revision = %q, want 2026-06-01", gotRevision)
}
}
func TestGeminiExecutorNativeInteractionsUsesRequestApiRevision(t *testing.T) {
var gotRevision string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRevision = r.Header.Get("Api-Revision")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","status":"completed","steps":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "agents/test-agent",
Payload: []byte(`{"agent":"agents/test-agent","input":"hi"}`),
}
_, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatInteractions,
ResponseFormat: sdktranslator.FormatInteractions,
Headers: http.Header{"Api-Revision": []string{"2026-06-01"}},
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if gotRevision != "2026-06-01" {
t.Fatalf("Api-Revision = %q, want 2026-06-01", gotRevision)
}
}
func TestGeminiExecutorNativeInteractionsRequestApiRevisionDoesNotOverrideAuthHeader(t *testing.T) {
var gotRevision string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRevision = r.Header.Get("Api-Revision")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","status":"completed","steps":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
"header:Api-Revision": "2026-06-01",
}, Provider: "gemini-interactions"}
req := cliproxyexecutor.Request{
Model: "agents/test-agent",
Payload: []byte(`{"agent":"agents/test-agent","input":"hi"}`),
}
_, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatInteractions,
ResponseFormat: sdktranslator.FormatInteractions,
Headers: http.Header{"Api-Revision": []string{"2026-07-01"}},
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if gotRevision != "2026-06-01" {
t.Fatalf("Api-Revision = %q, want 2026-06-01", gotRevision)
}
}
func TestGeminiExecutorNativeInteractionsStreamParsesUsage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"i1\"}}\n\n"))
_, _ = w.Write([]byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"i1\",\"status\":\"completed\",\"usage\":{\"total_input_tokens\":2,\"total_output_tokens\":3,\"total_tokens\":5}}}\n\n"))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.5-flash",
Payload: []byte(`{"model":"gemini-3.5-flash","input":"hi","stream":true}`),
}
result, errExecute := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatInteractions,
ResponseFormat: sdktranslator.FormatInteractions,
})
if errExecute != nil {
t.Fatalf("ExecuteStream() error = %v", errExecute)
}
count := 0
var completed []byte
for chunk := range result.Chunks {
count++
if chunk.Err != nil {
t.Fatalf("stream chunk error: %v", chunk.Err)
}
if !bytes.Contains(chunk.Payload, []byte("event:")) || !bytes.Contains(chunk.Payload, []byte("data:")) {
t.Fatalf("chunk = %q, want complete SSE frame", string(chunk.Payload))
}
payload := geminiInteractionsSSEPayload(chunk.Payload)
if gjson.GetBytes(payload, "event_type").String() == "interaction.completed" {
completed = payload
}
}
if count == 0 {
t.Fatal("no stream chunks received")
}
if completed == nil {
t.Fatal("interaction.completed chunk not found")
}
if got := gjson.GetBytes(completed, "interaction.usage.total_input_tokens").Int(); got != 2 {
t.Fatalf("total_input_tokens = %d, want 2", got)
}
if got := gjson.GetBytes(completed, "interaction.usage.total_output_tokens").Int(); got != 3 {
t.Fatalf("total_output_tokens = %d, want 3", got)
}
if got := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); got != 5 {
t.Fatalf("total_tokens = %d, want 5", got)
}
}
func TestGeminiExecutorNativeInteractionsClaudeStreamPreservesToolSignature(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"i1\",\"model\":\"gemini-3.1-flash-lite\"}}\n\n"))
_, _ = w.Write([]byte("event: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"function_call\",\"id\":\"toolu_1\",\"signature\":\"sig_1\",\"name\":\"get_weather\",\"arguments\":{}}}\n\n"))
_, _ = w.Write([]byte("event: step.delta\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"arguments_delta\",\"arguments\":\"{\\\"location\\\":\\\"北京\\\"}\"}}\n\n"))
_, _ = w.Write([]byte("event: step.stop\ndata: {\"event_type\":\"step.stop\",\"index\":0}\n\n"))
_, _ = w.Write([]byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"i1\",\"status\":\"requires_action\",\"usage\":{\"total_input_tokens\":1,\"total_output_tokens\":2}}}\n\n"))
_, _ = w.Write([]byte("event: done\ndata: [DONE]\n\n"))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-flash-lite",
Payload: []byte(`{"model":"gemini-3.1-flash-lite","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`),
}
result, errExecute := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatClaude,
ResponseFormat: sdktranslator.FormatClaude,
})
if errExecute != nil {
t.Fatalf("ExecuteStream() error = %v", errExecute)
}
var toolStart []byte
var toolDelta []byte
var messageStop []byte
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatalf("stream chunk error: %v", chunk.Err)
}
payload := geminiInteractionsSSEPayload(chunk.Payload)
switch gjson.GetBytes(payload, "type").String() {
case "content_block_start":
if gjson.GetBytes(payload, "content_block.type").String() == "tool_use" {
toolStart = payload
}
case "content_block_delta":
if gjson.GetBytes(payload, "delta.type").String() == "input_json_delta" {
toolDelta = payload
}
case "message_stop":
messageStop = payload
}
}
if toolStart == nil {
t.Fatal("tool content_block_start chunk not found")
}
if got := gjson.GetBytes(toolStart, "content_block.signature").String(); got != "sig_1" {
t.Fatalf("tool signature = %q, want sig_1. Payload: %s", got, string(toolStart))
}
if got := gjson.GetBytes(toolDelta, "delta.partial_json").String(); got != `{"location":"北京"}` {
t.Fatalf("tool partial_json = %q, want location payload. Payload: %s", got, string(toolDelta))
}
if messageStop == nil {
t.Fatal("message_stop chunk not found")
}
}
func TestGeminiExecutorNativeInteractionsResponsesStreamEmitsDone(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"i1\",\"model\":\"gemini-3.1-flash-lite\"}}\n\n"))
_, _ = w.Write([]byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"i1\",\"status\":\"completed\",\"usage\":{\"total_input_tokens\":1,\"total_output_tokens\":2}}}\n\n"))
_, _ = w.Write([]byte("event: done\ndata: [DONE]\n\n"))
}))
defer server.Close()
exec := NewGeminiInteractionsExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "gemini-interactions",
Attributes: map[string]string{
"api_key": "test-key",
"base_url": server.URL,
},
}
req := cliproxyexecutor.Request{
Model: "gemini-3.1-flash-lite",
Payload: []byte(`{"model":"gemini-3.1-flash-lite","stream":true,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`),
}
result, errExecute := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatOpenAIResponse,
ResponseFormat: sdktranslator.FormatOpenAIResponse,
})
if errExecute != nil {
t.Fatalf("ExecuteStream() error = %v", errExecute)
}
done := false
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatalf("stream chunk error: %v", chunk.Err)
}
if bytes.Equal(bytes.TrimSpace(chunk.Payload), []byte("data: [DONE]")) {
done = true
}
}
if !done {
t.Fatal("Responses [DONE] chunk not found")
}
}
func TestGeminiExecutor_PrepareRequest_EmptyAPIKey_OmitsAuthHeaders(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "https://custom-gemini.example.com/v1beta/models", nil)
if err != nil {
t.Fatalf("NewRequest() error = %v", err)
}
req.Header.Set("Authorization", "Bearer preexisting-bearer")
req.Header.Set("x-goog-api-key", "preexisting-key")
auth := &cliproxyauth.Auth{
Provider: "gemini",
Attributes: map[string]string{
"auth_kind": "apikey",
"base_url": "https://custom-gemini.example.com",
"header:Custom-Token": "gemini-secret",
},
}
exec := &GeminiExecutor{}
if errPrep := exec.PrepareRequest(req, auth); errPrep != nil {
t.Fatalf("PrepareRequest() error = %v", errPrep)
}
if got := req.Header.Get("Authorization"); got != "" {
t.Fatalf("Authorization = %q, want empty", got)
}
if got := req.Header.Get("x-goog-api-key"); got != "" {
t.Fatalf("x-goog-api-key = %q, want empty", got)
}
if got := req.Header.Get("Custom-Token"); got != "gemini-secret" {
t.Fatalf("Custom-Token = %q, want gemini-secret", got)
}
}