Files
CLIProxyAPI/internal/runtime/executor/gemini_executor_test.go
sususu f51d3ae93b fix(interactions): strip invalid id from function_result and redundant call_id from function_call
- Remove unexpected id parameter from function_result in Claude and OpenAI Chat Interactions request translators to comply with Google Interactions API schema and fix HTTP 400 (Unknown parameter 'id' at 'input[N]').
- Remove redundant call_id parameter from function_call in Claude Interactions request translator to match schema requirements.
- Propagate is_error flag between Claude tool_result and Interactions function_result.
- Align Devin executor to prioritize call_id for function_result steps.
- Add regression tests covering parameter schemas and end-to-end executor request generation.

Closes: #5828
2026-09-16 00:31:31 +08:00

1396 lines
56 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 TestGeminiExecutorExecuteAppendsTrailingUserForTrailingModelTurn(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":"user","parts":[{"text":"hello"}]},` +
`{"role":"model","parts":[{"text":"answer"}]}` +
`]}`),
}
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[2].Get("parts.0.text").String(); got != "" {
t.Fatalf("trailing user prompt = %q, want empty string; body=%s", got, upstreamBody)
}
}
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 TestGeminiExecutorNativeInteractionsStripsClaudeToolIDsBeforeUpstream(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(`{"id":"interaction_1","object":"interaction","status":"completed","model":"gemini-3.6-flash","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.6-flash",
Payload: []byte(`{
"model":"gemini-3.6-flash",
"messages":[
{"role":"user","content":[{"type":"text","text":"weather?","id":"txt_user"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"location":"北京"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"晴"}]}
]
}`),
}
if _, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatClaude,
ResponseFormat: sdktranslator.FormatClaude,
}); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
inputs := gjson.GetBytes(upstreamBody, "input").Array()
if len(inputs) == 0 {
t.Fatalf("upstream input missing: %s", upstreamBody)
}
foundCall := false
foundResult := false
for i, item := range inputs {
for j, part := range item.Get("content").Array() {
if part.Get("id").Exists() {
t.Fatalf("input[%d].content[%d].id = %q, want omitted. Body: %s", i, j, part.Get("id").String(), upstreamBody)
}
}
switch item.Get("type").String() {
case "function_call":
foundCall = true
if got := item.Get("id").String(); got != "toolu_1" {
t.Fatalf("function_call id = %q, want toolu_1. Body: %s", got, upstreamBody)
}
if item.Get("call_id").Exists() {
t.Fatalf("function_call call_id must be omitted. Body: %s", upstreamBody)
}
case "function_result":
foundResult = true
if got := item.Get("call_id").String(); got != "toolu_1" {
t.Fatalf("function_result call_id = %q, want toolu_1. Body: %s", got, upstreamBody)
}
if item.Get("id").Exists() {
t.Fatalf("function_result id must be omitted. Body: %s", upstreamBody)
}
default:
if item.Get("id").Exists() {
t.Fatalf("input[%d].id = %q, want omitted. Body: %s", i, item.Get("id").String(), upstreamBody)
}
}
}
if !foundCall || !foundResult {
t.Fatalf("missing function_call/function_result in upstream input: %s", upstreamBody)
}
}
func TestGeminiExecutorNativeInteractionsStripsPassthroughInputIDsBeforeUpstream(t *testing.T) {
payload := []byte(`{
"model":"gemini-3.6-flash",
"input":[
{"type":"user_input","id":"msg_1","content":[{"type":"text","id":"txt_1","text":"hi"}]},
{"type":"function_call","id":"toolu_1","call_id":"toolu_1","name":"lookup","arguments":{"q":"x"}},
{"type":"function_result","id":"toolu_1","call_id":"toolu_1","result":"ok"}
]
}`)
for _, stream := range []bool{false, true} {
name := "execute"
if stream {
name = "stream"
}
t.Run(name, func(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...)
if stream {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"i1\",\"status\":\"completed\"}}\n\n"))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","model":"gemini-3.6-flash","steps":[],"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.6-flash", Payload: payload}
opts := cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatInteractions,
ResponseFormat: sdktranslator.FormatInteractions,
}
if stream {
result, errExecute := exec.ExecuteStream(context.Background(), auth, req, opts)
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)
}
}
} else if _, errExecute := exec.Execute(context.Background(), auth, req, opts); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
inputs := gjson.GetBytes(upstreamBody, "input").Array()
if len(inputs) != 3 {
t.Fatalf("upstream input count = %d, want 3. Body: %s", len(inputs), upstreamBody)
}
for i, item := range inputs {
if item.Get("type").String() != "function_call" && item.Get("id").Exists() {
t.Fatalf("input[%d].id = %q, want omitted. Body: %s", i, item.Get("id").String(), upstreamBody)
}
for j, part := range item.Get("content").Array() {
if part.Get("id").Exists() {
t.Fatalf("input[%d].content[%d].id = %q, want omitted. Body: %s", i, j, part.Get("id").String(), upstreamBody)
}
}
}
if got := gjson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("user text = %q, want hi. Body: %s", got, upstreamBody)
}
if got := gjson.GetBytes(upstreamBody, "input.1.id").String(); got != "toolu_1" {
t.Fatalf("function_call id = %q, want toolu_1. Body: %s", got, upstreamBody)
}
if gjson.GetBytes(upstreamBody, "input.1.call_id").Exists() {
t.Fatalf("function_call call_id must be omitted. Body: %s", upstreamBody)
}
if got := gjson.GetBytes(upstreamBody, "input.2.call_id").String(); got != "toolu_1" {
t.Fatalf("function_result call_id = %q, want toolu_1. Body: %s", got, upstreamBody)
}
if gjson.GetBytes(upstreamBody, "input.2.id").Exists() {
t.Fatalf("function_result id must be omitted. Body: %s", upstreamBody)
}
})
}
}
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)
}
}