mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 08:02:22 +08:00
* fix(executor): prepend empty user turn for model-first requests targeting Gemini/Antigravity (#4959) When forwarding sliced conversation histories or tool calls across OpenAI Responses, OpenAI Chat Completions, Claude Messages, and native Gemini, native Gemini and Antigravity Gemini endpoints require that conversation contents begin with a user turn. Normalize leading turns at the executor boundary rather than the translator layer: - Prepend an empty user turn ({"role":"user","parts":[{"text":""}]}) for Gemini, Gemini Vertex, AI Studio, and Antigravity Gemini generation and CountTokens requests if the first turn is 'model'. - Keep Antigravity Claude requests untouched to avoid adapter 400 errors. - Ensure normalization runs after payload rules so payload index overrides target the original turns. - Use no-copy GJSON inspection to keep overhead zero on valid user-first requests. * fix(executor): inject Antigravity leading user after reasoning replay (#4959) Replay can insert a model functionCall at contents[0] for sliced tool-result history. Run the empty-user prepend on the final requestPayload, after sanitize and prepareAntigravityGeminiReasoningReplayPayload.
1151 lines
46 KiB
Go
1151 lines
46 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"}]}]}`),
|
|
}
|
|
|
|
if _, errCount := executor.CountTokens(context.Background(), auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); errCount != nil {
|
|
t.Fatalf("CountTokens() error = %v", errCount)
|
|
}
|
|
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")
|
|
}
|
|
}
|