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