diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index d93fd23f..cac23c87 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -323,6 +323,7 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c to := sdktranslator.FromString("gemini") payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream) payload = ApplyThinkingMetadata(payload, req.Metadata, req.Model) + payload = util.ApplyGemini3ThinkingLevelFromMetadata(req.Model, req.Metadata, payload) payload = util.ApplyDefaultThinkingIfNeeded(req.Model, payload) payload = util.ConvertThinkingLevelToBudget(payload, req.Model) payload = util.NormalizeGeminiThinkingBudget(req.Model, payload) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 71d4841a..ad387c28 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -89,6 +89,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model) + translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated) translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated) translated = normalizeAntigravityThinking(req.Model, translated) @@ -182,6 +183,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model) + translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated) translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated) translated = normalizeAntigravityThinking(req.Model, translated) @@ -514,6 +516,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model) + translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated) translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated) translated = normalizeAntigravityThinking(req.Model, translated) diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index b3acb50f..b171041a 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -79,6 +79,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth to := sdktranslator.FromString("gemini-cli") basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) + basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload) basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, basePayload) basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload) basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload) @@ -217,6 +218,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut to := sdktranslator.FromString("gemini-cli") basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) + basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload) basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, basePayload) basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload) basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload) @@ -418,6 +420,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth. for _, attemptModel := range models { payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false) payload = applyThinkingMetadataCLI(payload, req.Metadata, req.Model) + payload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, payload) payload = deleteJSONField(payload, "project") payload = deleteJSONField(payload, "model") payload = deleteJSONField(payload, "request.safetySettings") diff --git a/internal/util/gemini_thinking.go b/internal/util/gemini_thinking.go index a1fa9451..d002a63d 100644 --- a/internal/util/gemini_thinking.go +++ b/internal/util/gemini_thinking.go @@ -274,6 +274,42 @@ func ApplyDefaultThinkingIfNeeded(model string, body []byte) []byte { return updated } +// ApplyGemini3ThinkingLevelFromMetadata applies thinkingLevel from metadata for Gemini 3 models. +// For standard Gemini API format (generationConfig.thinkingConfig path). +// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)). +func ApplyGemini3ThinkingLevelFromMetadata(model string, metadata map[string]any, body []byte) []byte { + if !IsGemini3Model(model) { + return body + } + effort, ok := ReasoningEffortFromMetadata(metadata) + if !ok || effort == "" { + return body + } + // Validate and apply the thinkingLevel + if level, valid := ValidateGemini3ThinkingLevel(model, effort); valid { + return ApplyGeminiThinkingLevel(body, level, nil) + } + return body +} + +// ApplyGemini3ThinkingLevelFromMetadataCLI applies thinkingLevel from metadata for Gemini 3 models. +// For Gemini CLI API format (request.generationConfig.thinkingConfig path). +// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)). +func ApplyGemini3ThinkingLevelFromMetadataCLI(model string, metadata map[string]any, body []byte) []byte { + if !IsGemini3Model(model) { + return body + } + effort, ok := ReasoningEffortFromMetadata(metadata) + if !ok || effort == "" { + return body + } + // Validate and apply the thinkingLevel + if level, valid := ValidateGemini3ThinkingLevel(model, effort); valid { + return ApplyGeminiCLIThinkingLevel(body, level, nil) + } + return body +} + // ApplyDefaultThinkingIfNeededCLI injects default thinkingConfig for models that require it. // For Gemini CLI API format (request.generationConfig.thinkingConfig path). // Returns the modified body if thinkingConfig was added, otherwise returns the original. diff --git a/test/gemini3_thinking_level_test.go b/test/gemini3_thinking_level_test.go new file mode 100644 index 00000000..b26bcff3 --- /dev/null +++ b/test/gemini3_thinking_level_test.go @@ -0,0 +1,423 @@ +package test + +import ( + "fmt" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/tidwall/gjson" +) + +// registerGemini3Models loads Gemini 3 models into the registry for testing. +func registerGemini3Models(t *testing.T) func() { + t.Helper() + reg := registry.GetGlobalRegistry() + uid := fmt.Sprintf("gemini3-test-%d", time.Now().UnixNano()) + reg.RegisterClient(uid+"-gemini", "gemini", registry.GetGeminiModels()) + reg.RegisterClient(uid+"-aistudio", "aistudio", registry.GetAIStudioModels()) + return func() { + reg.UnregisterClient(uid + "-gemini") + reg.UnregisterClient(uid + "-aistudio") + } +} + +func TestIsGemini3Model(t *testing.T) { + cases := []struct { + model string + expected bool + }{ + {"gemini-3-pro-preview", true}, + {"gemini-3-flash-preview", true}, + {"gemini_3_pro_preview", true}, + {"gemini-3-pro", true}, + {"gemini-3-flash", true}, + {"GEMINI-3-PRO-PREVIEW", true}, + {"gemini-2.5-pro", false}, + {"gemini-2.5-flash", false}, + {"gpt-5", false}, + {"claude-sonnet-4-5", false}, + {"", false}, + } + + for _, cs := range cases { + t.Run(cs.model, func(t *testing.T) { + got := util.IsGemini3Model(cs.model) + if got != cs.expected { + t.Fatalf("IsGemini3Model(%q) = %v, want %v", cs.model, got, cs.expected) + } + }) + } +} + +func TestIsGemini3ProModel(t *testing.T) { + cases := []struct { + model string + expected bool + }{ + {"gemini-3-pro-preview", true}, + {"gemini_3_pro_preview", true}, + {"gemini-3-pro", true}, + {"GEMINI-3-PRO-PREVIEW", true}, + {"gemini-3-flash-preview", false}, + {"gemini-3-flash", false}, + {"gemini-2.5-pro", false}, + {"", false}, + } + + for _, cs := range cases { + t.Run(cs.model, func(t *testing.T) { + got := util.IsGemini3ProModel(cs.model) + if got != cs.expected { + t.Fatalf("IsGemini3ProModel(%q) = %v, want %v", cs.model, got, cs.expected) + } + }) + } +} + +func TestIsGemini3FlashModel(t *testing.T) { + cases := []struct { + model string + expected bool + }{ + {"gemini-3-flash-preview", true}, + {"gemini_3_flash_preview", true}, + {"gemini-3-flash", true}, + {"GEMINI-3-FLASH-PREVIEW", true}, + {"gemini-3-pro-preview", false}, + {"gemini-3-pro", false}, + {"gemini-2.5-flash", false}, + {"", false}, + } + + for _, cs := range cases { + t.Run(cs.model, func(t *testing.T) { + got := util.IsGemini3FlashModel(cs.model) + if got != cs.expected { + t.Fatalf("IsGemini3FlashModel(%q) = %v, want %v", cs.model, got, cs.expected) + } + }) + } +} + +func TestValidateGemini3ThinkingLevel(t *testing.T) { + cases := []struct { + name string + model string + level string + wantOK bool + wantVal string + }{ + // Gemini 3 Pro: supports "low", "high" + {"pro-low", "gemini-3-pro-preview", "low", true, "low"}, + {"pro-high", "gemini-3-pro-preview", "high", true, "high"}, + {"pro-minimal-invalid", "gemini-3-pro-preview", "minimal", false, ""}, + {"pro-medium-invalid", "gemini-3-pro-preview", "medium", false, ""}, + + // Gemini 3 Flash: supports "minimal", "low", "medium", "high" + {"flash-minimal", "gemini-3-flash-preview", "minimal", true, "minimal"}, + {"flash-low", "gemini-3-flash-preview", "low", true, "low"}, + {"flash-medium", "gemini-3-flash-preview", "medium", true, "medium"}, + {"flash-high", "gemini-3-flash-preview", "high", true, "high"}, + + // Case insensitivity + {"flash-LOW-case", "gemini-3-flash-preview", "LOW", true, "low"}, + {"flash-High-case", "gemini-3-flash-preview", "High", true, "high"}, + {"pro-HIGH-case", "gemini-3-pro-preview", "HIGH", true, "high"}, + + // Invalid levels + {"flash-invalid", "gemini-3-flash-preview", "xhigh", false, ""}, + {"flash-invalid-auto", "gemini-3-flash-preview", "auto", false, ""}, + {"flash-empty", "gemini-3-flash-preview", "", false, ""}, + + // Non-Gemini 3 models + {"non-gemini3", "gemini-2.5-pro", "high", false, ""}, + {"gpt5", "gpt-5", "high", false, ""}, + } + + for _, cs := range cases { + t.Run(cs.name, func(t *testing.T) { + got, ok := util.ValidateGemini3ThinkingLevel(cs.model, cs.level) + if ok != cs.wantOK { + t.Fatalf("ValidateGemini3ThinkingLevel(%q, %q) ok = %v, want %v", cs.model, cs.level, ok, cs.wantOK) + } + if got != cs.wantVal { + t.Fatalf("ValidateGemini3ThinkingLevel(%q, %q) = %q, want %q", cs.model, cs.level, got, cs.wantVal) + } + }) + } +} + +func TestThinkingBudgetToGemini3Level(t *testing.T) { + cases := []struct { + name string + model string + budget int + wantOK bool + wantVal string + }{ + // Gemini 3 Pro: maps to "low" or "high" + {"pro-dynamic", "gemini-3-pro-preview", -1, true, "high"}, + {"pro-zero", "gemini-3-pro-preview", 0, true, "low"}, + {"pro-small", "gemini-3-pro-preview", 1000, true, "low"}, + {"pro-medium", "gemini-3-pro-preview", 8000, true, "low"}, + {"pro-large", "gemini-3-pro-preview", 20000, true, "high"}, + {"pro-huge", "gemini-3-pro-preview", 50000, true, "high"}, + + // Gemini 3 Flash: maps to "minimal", "low", "medium", "high" + {"flash-dynamic", "gemini-3-flash-preview", -1, true, "high"}, + {"flash-zero", "gemini-3-flash-preview", 0, true, "minimal"}, + {"flash-tiny", "gemini-3-flash-preview", 500, true, "minimal"}, + {"flash-small", "gemini-3-flash-preview", 1000, true, "low"}, + {"flash-medium-val", "gemini-3-flash-preview", 8000, true, "medium"}, + {"flash-large", "gemini-3-flash-preview", 20000, true, "high"}, + {"flash-huge", "gemini-3-flash-preview", 50000, true, "high"}, + + // Non-Gemini 3 models should return false + {"gemini25-budget", "gemini-2.5-pro", 8000, false, ""}, + {"gpt5-budget", "gpt-5", 8000, false, ""}, + } + + for _, cs := range cases { + t.Run(cs.name, func(t *testing.T) { + got, ok := util.ThinkingBudgetToGemini3Level(cs.model, cs.budget) + if ok != cs.wantOK { + t.Fatalf("ThinkingBudgetToGemini3Level(%q, %d) ok = %v, want %v", cs.model, cs.budget, ok, cs.wantOK) + } + if got != cs.wantVal { + t.Fatalf("ThinkingBudgetToGemini3Level(%q, %d) = %q, want %q", cs.model, cs.budget, got, cs.wantVal) + } + }) + } +} + +func TestApplyGemini3ThinkingLevelFromMetadata(t *testing.T) { + cleanup := registerGemini3Models(t) + defer cleanup() + + cases := []struct { + name string + model string + metadata map[string]any + inputBody string + wantLevel string + wantInclude bool + wantNoChange bool + }{ + { + name: "flash-minimal-from-suffix", + model: "gemini-3-flash-preview", + metadata: map[string]any{"reasoning_effort": "minimal"}, + inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`, + wantLevel: "minimal", + wantInclude: true, + }, + { + name: "flash-medium-from-suffix", + model: "gemini-3-flash-preview", + metadata: map[string]any{"reasoning_effort": "medium"}, + inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`, + wantLevel: "medium", + wantInclude: true, + }, + { + name: "pro-high-from-suffix", + model: "gemini-3-pro-preview", + metadata: map[string]any{"reasoning_effort": "high"}, + inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`, + wantLevel: "high", + wantInclude: true, + }, + { + name: "no-metadata-no-change", + model: "gemini-3-flash-preview", + metadata: nil, + inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`, + wantNoChange: true, + }, + { + name: "non-gemini3-no-change", + model: "gemini-2.5-pro", + metadata: map[string]any{"reasoning_effort": "high"}, + inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, + wantNoChange: true, + }, + { + name: "invalid-level-no-change", + model: "gemini-3-flash-preview", + metadata: map[string]any{"reasoning_effort": "xhigh"}, + inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`, + wantNoChange: true, + }, + } + + for _, cs := range cases { + t.Run(cs.name, func(t *testing.T) { + input := []byte(cs.inputBody) + result := util.ApplyGemini3ThinkingLevelFromMetadata(cs.model, cs.metadata, input) + + if cs.wantNoChange { + if string(result) != cs.inputBody { + t.Fatalf("expected no change, but got: %s", string(result)) + } + return + } + + level := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingLevel") + if !level.Exists() { + t.Fatalf("thinkingLevel not set in result: %s", string(result)) + } + if level.String() != cs.wantLevel { + t.Fatalf("thinkingLevel = %q, want %q", level.String(), cs.wantLevel) + } + + include := gjson.GetBytes(result, "generationConfig.thinkingConfig.includeThoughts") + if cs.wantInclude && (!include.Exists() || !include.Bool()) { + t.Fatalf("includeThoughts should be true, got: %s", string(result)) + } + }) + } +} + +func TestApplyGemini3ThinkingLevelFromMetadataCLI(t *testing.T) { + cleanup := registerGemini3Models(t) + defer cleanup() + + cases := []struct { + name string + model string + metadata map[string]any + inputBody string + wantLevel string + wantInclude bool + wantNoChange bool + }{ + { + name: "flash-minimal-from-suffix-cli", + model: "gemini-3-flash-preview", + metadata: map[string]any{"reasoning_effort": "minimal"}, + inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`, + wantLevel: "minimal", + wantInclude: true, + }, + { + name: "flash-low-from-suffix-cli", + model: "gemini-3-flash-preview", + metadata: map[string]any{"reasoning_effort": "low"}, + inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`, + wantLevel: "low", + wantInclude: true, + }, + { + name: "pro-low-from-suffix-cli", + model: "gemini-3-pro-preview", + metadata: map[string]any{"reasoning_effort": "low"}, + inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`, + wantLevel: "low", + wantInclude: true, + }, + { + name: "no-metadata-no-change-cli", + model: "gemini-3-flash-preview", + metadata: nil, + inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`, + wantNoChange: true, + }, + { + name: "non-gemini3-no-change-cli", + model: "gemini-2.5-pro", + metadata: map[string]any{"reasoning_effort": "high"}, + inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}}`, + wantNoChange: true, + }, + } + + for _, cs := range cases { + t.Run(cs.name, func(t *testing.T) { + input := []byte(cs.inputBody) + result := util.ApplyGemini3ThinkingLevelFromMetadataCLI(cs.model, cs.metadata, input) + + if cs.wantNoChange { + if string(result) != cs.inputBody { + t.Fatalf("expected no change, but got: %s", string(result)) + } + return + } + + level := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel") + if !level.Exists() { + t.Fatalf("thinkingLevel not set in result: %s", string(result)) + } + if level.String() != cs.wantLevel { + t.Fatalf("thinkingLevel = %q, want %q", level.String(), cs.wantLevel) + } + + include := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts") + if cs.wantInclude && (!include.Exists() || !include.Bool()) { + t.Fatalf("includeThoughts should be true, got: %s", string(result)) + } + }) + } +} + +func TestNormalizeGeminiThinkingBudget_Gemini3Conversion(t *testing.T) { + cleanup := registerGemini3Models(t) + defer cleanup() + + cases := []struct { + name string + model string + inputBody string + wantLevel string + wantBudget bool // if true, expect thinkingBudget instead of thinkingLevel + }{ + { + name: "gemini3-flash-budget-to-level", + model: "gemini-3-flash-preview", + inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":8000}}}`, + wantLevel: "medium", + }, + { + name: "gemini3-pro-budget-to-level", + model: "gemini-3-pro-preview", + inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":20000}}}`, + wantLevel: "high", + }, + { + name: "gemini25-keeps-budget", + model: "gemini-2.5-pro", + inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":8000}}}`, + wantBudget: true, + }, + } + + for _, cs := range cases { + t.Run(cs.name, func(t *testing.T) { + result := util.NormalizeGeminiThinkingBudget(cs.model, []byte(cs.inputBody)) + + if cs.wantBudget { + budget := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingBudget") + if !budget.Exists() { + t.Fatalf("thinkingBudget should exist for non-Gemini3 model: %s", string(result)) + } + level := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingLevel") + if level.Exists() { + t.Fatalf("thinkingLevel should not exist for non-Gemini3 model: %s", string(result)) + } + } else { + level := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingLevel") + if !level.Exists() { + t.Fatalf("thinkingLevel should exist for Gemini3 model: %s", string(result)) + } + if level.String() != cs.wantLevel { + t.Fatalf("thinkingLevel = %q, want %q", level.String(), cs.wantLevel) + } + budget := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingBudget") + if budget.Exists() { + t.Fatalf("thinkingBudget should be removed for Gemini3 model: %s", string(result)) + } + } + }) + } +}