fix: apply thinkingLevel from model suffix metadata for Gemini 3

The previous commit added thinkingLevel support but didn't apply it
when the reasoning effort came from model name suffix (e.g., model(minimal)).

This was because ResolveThinkingConfigFromMetadata returns nil for
level-based models, bypassing the metadata application.

Changes:
- Add ApplyGemini3ThinkingLevelFromMetadata for standard Gemini API
- Add ApplyGemini3ThinkingLevelFromMetadataCLI for CLI API format
- Update gemini_cli_executor to apply Gemini 3 thinkingLevel from metadata
- Update antigravity_executor to apply Gemini 3 thinkingLevel from metadata
- Update aistudio_executor to apply Gemini 3 thinkingLevel from metadata
- Add comprehensive test coverage for Gemini 3 thinkingLevel functions
This commit is contained in:
Ben Vargas
2025-12-17 16:08:38 -07:00
parent a33f5d31fc
commit 598f0af19b
5 changed files with 466 additions and 0 deletions

View File

@@ -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))
}
}
})
}
}