Something went wrong. Try again.
sloproxy
Something went wrong. Try again.
40 kB · 994 lines
Go
at commit e8e39526
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995package 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") }}