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 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 got := gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "auto" { t.Fatalf("thinking_summaries = %q, want auto. Body: %s", got, 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") } }