diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index d44efc06..20f9b0a0 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -160,7 +160,7 @@ func GetGeminiModels() []*ModelInfo { InputTokenLimit: 1048576, OutputTokenLimit: 65536, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, - Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, + Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, }, { ID: "gemini-3-pro-image-preview", @@ -175,7 +175,7 @@ func GetGeminiModels() []*ModelInfo { InputTokenLimit: 1048576, OutputTokenLimit: 65536, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, - Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, + Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, }, } } @@ -240,7 +240,7 @@ func GetGeminiVertexModels() []*ModelInfo { InputTokenLimit: 1048576, OutputTokenLimit: 65536, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, - Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, + Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, }, { ID: "gemini-3-flash-preview", @@ -255,7 +255,7 @@ func GetGeminiVertexModels() []*ModelInfo { InputTokenLimit: 1048576, OutputTokenLimit: 65536, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, - Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, + Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}, }, { ID: "gemini-3-pro-image-preview", @@ -270,7 +270,7 @@ func GetGeminiVertexModels() []*ModelInfo { InputTokenLimit: 1048576, OutputTokenLimit: 65536, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, - Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, + Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, }, } } @@ -336,7 +336,7 @@ func GetGeminiCLIModels() []*ModelInfo { InputTokenLimit: 1048576, OutputTokenLimit: 65536, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, - Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, + Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, }, { ID: "gemini-3-flash-preview", @@ -351,7 +351,7 @@ func GetGeminiCLIModels() []*ModelInfo { InputTokenLimit: 1048576, OutputTokenLimit: 65536, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, - Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, + Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}, }, } } @@ -417,7 +417,7 @@ func GetAIStudioModels() []*ModelInfo { InputTokenLimit: 1048576, OutputTokenLimit: 65536, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, - Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, + Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, }, { ID: "gemini-3-flash-preview", @@ -432,7 +432,7 @@ func GetAIStudioModels() []*ModelInfo { InputTokenLimit: 1048576, OutputTokenLimit: 65536, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, - Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, + Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}, }, { ID: "gemini-pro-latest", @@ -743,8 +743,9 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig { "gemini-2.5-flash": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash"}, "gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash-lite"}, "gemini-2.5-computer-use-preview-10-2025": {Name: "models/gemini-2.5-computer-use-preview-10-2025"}, - "gemini-3-pro-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, Name: "models/gemini-3-pro-preview"}, - "gemini-3-pro-image-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, Name: "models/gemini-3-pro-image-preview"}, + "gemini-3-pro-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-preview"}, + "gemini-3-pro-image-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-image-preview"}, + "gemini-3-flash-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}, Name: "models/gemini-3-flash-preview"}, "gemini-claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 200000, ZeroAllowed: false, DynamicAllowed: true}, MaxCompletionTokens: 64000}, "gemini-claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 200000, ZeroAllowed: false, DynamicAllowed: true}, MaxCompletionTokens: 64000}, } diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index ada0af39..d93fd23f 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -324,7 +324,7 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream) payload = ApplyThinkingMetadata(payload, req.Metadata, req.Model) payload = util.ApplyDefaultThinkingIfNeeded(req.Model, payload) - payload = util.ConvertThinkingLevelToBudget(payload) + payload = util.ConvertThinkingLevelToBudget(payload, req.Model) payload = util.NormalizeGeminiThinkingBudget(req.Model, payload) payload = util.StripThinkingConfigIfUnsupported(req.Model, payload) payload = fixGeminiImageAspectRatio(req.Model, payload) diff --git a/internal/util/gemini_thinking.go b/internal/util/gemini_thinking.go index 9e349d99..a1fa9451 100644 --- a/internal/util/gemini_thinking.go +++ b/internal/util/gemini_thinking.go @@ -1,6 +1,7 @@ package util import ( + "regexp" "strings" "github.com/tidwall/gjson" @@ -13,6 +14,44 @@ const ( GeminiOriginalModelMetadataKey = "gemini_original_model" ) +// Gemini model family detection patterns +var ( + gemini3Pattern = regexp.MustCompile(`(?i)^gemini[_-]?3[_-]`) + gemini3ProPattern = regexp.MustCompile(`(?i)^gemini[_-]?3[_-]pro`) + gemini3FlashPattern = regexp.MustCompile(`(?i)^gemini[_-]?3[_-]flash`) + gemini25Pattern = regexp.MustCompile(`(?i)^gemini[_-]?2\.?5[_-]`) +) + +// IsGemini3Model returns true if the model is a Gemini 3 family model. +// Gemini 3 models should use thinkingLevel (string) instead of thinkingBudget (number). +func IsGemini3Model(model string) bool { + return gemini3Pattern.MatchString(model) +} + +// IsGemini3ProModel returns true if the model is a Gemini 3 Pro variant. +// Gemini 3 Pro supports thinkingLevel: "low", "high" (default: "high") +func IsGemini3ProModel(model string) bool { + return gemini3ProPattern.MatchString(model) +} + +// IsGemini3FlashModel returns true if the model is a Gemini 3 Flash variant. +// Gemini 3 Flash supports thinkingLevel: "minimal", "low", "medium", "high" (default: "high") +func IsGemini3FlashModel(model string) bool { + return gemini3FlashPattern.MatchString(model) +} + +// IsGemini25Model returns true if the model is a Gemini 2.5 family model. +// Gemini 2.5 models should use thinkingBudget (number). +func IsGemini25Model(model string) bool { + return gemini25Pattern.MatchString(model) +} + +// Gemini3ProThinkingLevels are the valid thinkingLevel values for Gemini 3 Pro models. +var Gemini3ProThinkingLevels = []string{"low", "high"} + +// Gemini3FlashThinkingLevels are the valid thinkingLevel values for Gemini 3 Flash models. +var Gemini3FlashThinkingLevels = []string{"minimal", "low", "medium", "high"} + func ApplyGeminiThinkingConfig(body []byte, budget *int, includeThoughts *bool) []byte { if budget == nil && includeThoughts == nil { return body @@ -69,10 +108,141 @@ func ApplyGeminiCLIThinkingConfig(body []byte, budget *int, includeThoughts *boo return updated } +// ApplyGeminiThinkingLevel applies thinkingLevel config for Gemini 3 models. +// For standard Gemini API format (generationConfig.thinkingConfig path). +// Per Google's documentation, Gemini 3 models should use thinkingLevel instead of thinkingBudget. +func ApplyGeminiThinkingLevel(body []byte, level string, includeThoughts *bool) []byte { + if level == "" && includeThoughts == nil { + return body + } + updated := body + if level != "" { + valuePath := "generationConfig.thinkingConfig.thinkingLevel" + rewritten, err := sjson.SetBytes(updated, valuePath, level) + if err == nil { + updated = rewritten + } + } + // Default to including thoughts when a level is set but no explicit include flag is provided. + incl := includeThoughts + if incl == nil && level != "" { + defaultInclude := true + incl = &defaultInclude + } + if incl != nil { + valuePath := "generationConfig.thinkingConfig.includeThoughts" + rewritten, err := sjson.SetBytes(updated, valuePath, *incl) + if err == nil { + updated = rewritten + } + } + return updated +} + +// ApplyGeminiCLIThinkingLevel applies thinkingLevel config for Gemini 3 models. +// For Gemini CLI API format (request.generationConfig.thinkingConfig path). +// Per Google's documentation, Gemini 3 models should use thinkingLevel instead of thinkingBudget. +func ApplyGeminiCLIThinkingLevel(body []byte, level string, includeThoughts *bool) []byte { + if level == "" && includeThoughts == nil { + return body + } + updated := body + if level != "" { + valuePath := "request.generationConfig.thinkingConfig.thinkingLevel" + rewritten, err := sjson.SetBytes(updated, valuePath, level) + if err == nil { + updated = rewritten + } + } + // Default to including thoughts when a level is set but no explicit include flag is provided. + incl := includeThoughts + if incl == nil && level != "" { + defaultInclude := true + incl = &defaultInclude + } + if incl != nil { + valuePath := "request.generationConfig.thinkingConfig.includeThoughts" + rewritten, err := sjson.SetBytes(updated, valuePath, *incl) + if err == nil { + updated = rewritten + } + } + return updated +} + +// ValidateGemini3ThinkingLevel validates that the thinkingLevel is valid for the Gemini 3 model variant. +// Returns the validated level (normalized to lowercase) and true if valid, or empty string and false if invalid. +func ValidateGemini3ThinkingLevel(model, level string) (string, bool) { + if level == "" { + return "", false + } + normalized := strings.ToLower(strings.TrimSpace(level)) + + var validLevels []string + if IsGemini3ProModel(model) { + validLevels = Gemini3ProThinkingLevels + } else if IsGemini3FlashModel(model) { + validLevels = Gemini3FlashThinkingLevels + } else if IsGemini3Model(model) { + // Unknown Gemini 3 variant - allow all levels as fallback + validLevels = Gemini3FlashThinkingLevels + } else { + return "", false + } + + for _, valid := range validLevels { + if normalized == valid { + return normalized, true + } + } + return "", false +} + +// ThinkingBudgetToGemini3Level converts a thinkingBudget to a thinkingLevel for Gemini 3 models. +// This provides backward compatibility when thinkingBudget is provided for Gemini 3 models. +// Returns the appropriate thinkingLevel and true if conversion is possible. +func ThinkingBudgetToGemini3Level(model string, budget int) (string, bool) { + if !IsGemini3Model(model) { + return "", false + } + + // Map budget to level based on Google's documentation + // Gemini 3 Pro: "low", "high" (default: "high") + // Gemini 3 Flash: "minimal", "low", "medium", "high" (default: "high") + switch { + case budget == -1: + // Dynamic budget maps to "high" (API default) + return "high", true + case budget == 0: + // Zero budget - Gemini 3 doesn't support disabling thinking + // Map to lowest available level + if IsGemini3FlashModel(model) { + return "minimal", true + } + return "low", true + case budget > 0 && budget <= 512: + if IsGemini3FlashModel(model) { + return "minimal", true + } + return "low", true + case budget <= 1024: + return "low", true + case budget <= 8192: + if IsGemini3FlashModel(model) { + return "medium", true + } + return "low", true // Pro doesn't have medium, use low + default: + return "high", true + } +} + // modelsWithDefaultThinking lists models that should have thinking enabled by default // when no explicit thinkingConfig is provided. var modelsWithDefaultThinking = map[string]bool{ - "gemini-3-pro-preview": true, + "gemini-3-pro-preview": true, + "gemini-3-pro-image-preview": true, + "gemini-3-flash-preview": true, } // ModelHasDefaultThinking returns true if the model should have thinking enabled by default. @@ -83,6 +253,7 @@ func ModelHasDefaultThinking(model string) bool { // ApplyDefaultThinkingIfNeeded injects default thinkingConfig for models that require it. // For standard Gemini API format (generationConfig.thinkingConfig path). // Returns the modified body if thinkingConfig was added, otherwise returns the original. +// For Gemini 3 models, uses thinkingLevel instead of thinkingBudget per Google's documentation. func ApplyDefaultThinkingIfNeeded(model string, body []byte) []byte { if !ModelHasDefaultThinking(model) { return body @@ -90,6 +261,14 @@ func ApplyDefaultThinkingIfNeeded(model string, body []byte) []byte { if gjson.GetBytes(body, "generationConfig.thinkingConfig").Exists() { return body } + // Gemini 3 models use thinkingLevel instead of thinkingBudget + if IsGemini3Model(model) { + // Don't set a default - let the API use its dynamic default ("high") + // Only set includeThoughts + updated, _ := sjson.SetBytes(body, "generationConfig.thinkingConfig.includeThoughts", true) + return updated + } + // Gemini 2.5 and other models use thinkingBudget updated, _ := sjson.SetBytes(body, "generationConfig.thinkingConfig.thinkingBudget", -1) updated, _ = sjson.SetBytes(updated, "generationConfig.thinkingConfig.include_thoughts", true) return updated @@ -98,6 +277,7 @@ func ApplyDefaultThinkingIfNeeded(model string, body []byte) []byte { // 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. +// For Gemini 3 models, uses thinkingLevel instead of thinkingBudget per Google's documentation. func ApplyDefaultThinkingIfNeededCLI(model string, body []byte) []byte { if !ModelHasDefaultThinking(model) { return body @@ -105,6 +285,14 @@ func ApplyDefaultThinkingIfNeededCLI(model string, body []byte) []byte { if gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists() { return body } + // Gemini 3 models use thinkingLevel instead of thinkingBudget + if IsGemini3Model(model) { + // Don't set a default - let the API use its dynamic default ("high") + // Only set includeThoughts + updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", true) + return updated + } + // Gemini 2.5 and other models use thinkingBudget updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget", -1) updated, _ = sjson.SetBytes(updated, "request.generationConfig.thinkingConfig.include_thoughts", true) return updated @@ -128,12 +316,29 @@ func StripThinkingConfigIfUnsupported(model string, body []byte) []byte { // NormalizeGeminiThinkingBudget normalizes the thinkingBudget value in a standard Gemini // request body (generationConfig.thinkingConfig.thinkingBudget path). +// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation. func NormalizeGeminiThinkingBudget(model string, body []byte) []byte { const budgetPath = "generationConfig.thinkingConfig.thinkingBudget" + const levelPath = "generationConfig.thinkingConfig.thinkingLevel" + budget := gjson.GetBytes(body, budgetPath) if !budget.Exists() { return body } + + // For Gemini 3 models, convert thinkingBudget to thinkingLevel + if IsGemini3Model(model) { + if level, ok := ThinkingBudgetToGemini3Level(model, int(budget.Int())); ok { + updated, _ := sjson.SetBytes(body, levelPath, level) + updated, _ = sjson.DeleteBytes(updated, budgetPath) + return updated + } + // If conversion fails, just remove the budget (let API use default) + updated, _ := sjson.DeleteBytes(body, budgetPath) + return updated + } + + // For Gemini 2.5 and other models, normalize the budget value normalized := NormalizeThinkingBudget(model, int(budget.Int())) updated, _ := sjson.SetBytes(body, budgetPath, normalized) return updated @@ -141,12 +346,29 @@ func NormalizeGeminiThinkingBudget(model string, body []byte) []byte { // NormalizeGeminiCLIThinkingBudget normalizes the thinkingBudget value in a Gemini CLI // request body (request.generationConfig.thinkingConfig.thinkingBudget path). +// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation. func NormalizeGeminiCLIThinkingBudget(model string, body []byte) []byte { const budgetPath = "request.generationConfig.thinkingConfig.thinkingBudget" + const levelPath = "request.generationConfig.thinkingConfig.thinkingLevel" + budget := gjson.GetBytes(body, budgetPath) if !budget.Exists() { return body } + + // For Gemini 3 models, convert thinkingBudget to thinkingLevel + if IsGemini3Model(model) { + if level, ok := ThinkingBudgetToGemini3Level(model, int(budget.Int())); ok { + updated, _ := sjson.SetBytes(body, levelPath, level) + updated, _ = sjson.DeleteBytes(updated, budgetPath) + return updated + } + // If conversion fails, just remove the budget (let API use default) + updated, _ := sjson.DeleteBytes(body, budgetPath) + return updated + } + + // For Gemini 2.5 and other models, normalize the budget value normalized := NormalizeThinkingBudget(model, int(budget.Int())) updated, _ := sjson.SetBytes(body, budgetPath, normalized) return updated @@ -218,34 +440,42 @@ func ApplyReasoningEffortToGeminiCLI(body []byte, effort string) []byte { } // ConvertThinkingLevelToBudget checks for "generationConfig.thinkingConfig.thinkingLevel" -// and converts it to "thinkingBudget". -// "high" -> 32768 -// "low" -> 128 -// It removes "thinkingLevel" after conversion. -func ConvertThinkingLevelToBudget(body []byte) []byte { +// and converts it to "thinkingBudget" for Gemini 2.5 models. +// For Gemini 3 models, preserves thinkingLevel as-is (does not convert). +// Mappings for Gemini 2.5: +// - "high" -> 32768 +// - "medium" -> 8192 +// - "low" -> 1024 +// - "minimal" -> 512 +// +// It removes "thinkingLevel" after conversion (for Gemini 2.5 only). +func ConvertThinkingLevelToBudget(body []byte, model string) []byte { levelPath := "generationConfig.thinkingConfig.thinkingLevel" res := gjson.GetBytes(body, levelPath) if !res.Exists() { return body } + // For Gemini 3 models, preserve thinkingLevel - don't convert to budget + if IsGemini3Model(model) { + return body + } + level := strings.ToLower(res.String()) var budget int switch level { case "high": budget = 32768 + case "medium": + budget = 8192 case "low": - budget = 128 + budget = 1024 + case "minimal": + budget = 512 default: - // If unknown level, we might just leave it or default. - // User only specified high and low. We'll assume we shouldn't touch it if it's something else, - // or maybe we should just remove the invalid level? - // For safety adhering to strict instructions: "If high... if low...". - // If it's something else, the upstream might fail anyway if we leave it, - // but let's just delete the level if we processed it. - // Actually, let's check if we need to do anything for other values. - // For now, only handle high/low. - return body + // Unknown level - remove it and let the API use defaults + updated, _ := sjson.DeleteBytes(body, levelPath) + return updated } // Set budget @@ -262,3 +492,50 @@ func ConvertThinkingLevelToBudget(body []byte) []byte { } return updated } + +// ConvertThinkingLevelToBudgetCLI checks for "request.generationConfig.thinkingConfig.thinkingLevel" +// and converts it to "thinkingBudget" for Gemini 2.5 models. +// For Gemini 3 models, preserves thinkingLevel as-is (does not convert). +func ConvertThinkingLevelToBudgetCLI(body []byte, model string) []byte { + levelPath := "request.generationConfig.thinkingConfig.thinkingLevel" + res := gjson.GetBytes(body, levelPath) + if !res.Exists() { + return body + } + + // For Gemini 3 models, preserve thinkingLevel - don't convert to budget + if IsGemini3Model(model) { + return body + } + + level := strings.ToLower(res.String()) + var budget int + switch level { + case "high": + budget = 32768 + case "medium": + budget = 8192 + case "low": + budget = 1024 + case "minimal": + budget = 512 + default: + // Unknown level - remove it and let the API use defaults + updated, _ := sjson.DeleteBytes(body, levelPath) + return updated + } + + // Set budget + budgetPath := "request.generationConfig.thinkingConfig.thinkingBudget" + updated, err := sjson.SetBytes(body, budgetPath, budget) + if err != nil { + return body + } + + // Remove level + updated, err = sjson.DeleteBytes(updated, levelPath) + if err != nil { + return body + } + return updated +}