// Package gemini implements thinking configuration for Gemini models. // // Gemini models have two formats: // - Gemini 2.5: Uses thinkingBudget (numeric) // - Gemini 3.x: Uses thinkingLevel (string: minimal/low/medium/high) // or thinkingBudget=-1 for auto/dynamic mode // // Output format is determined by ThinkingConfig.Mode and ThinkingSupport.Levels: // - ModeAuto: Always uses thinkingBudget=-1 (both Gemini 2.5 and 3.x) // - len(Levels) > 0: Uses thinkingLevel (Gemini 3.x discrete levels) // - len(Levels) == 0: Uses thinkingBudget (Gemini 2.5) package gemini import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) // Applier applies thinking configuration for Gemini models. // // Gemini-specific behavior: // - Gemini 2.5: thinkingBudget format, flash series supports ZeroAllowed // - Gemini 3.x: thinkingLevel format, cannot be disabled // - Use ThinkingSupport.Levels to decide output format type Applier struct{} // NewApplier creates a new Gemini thinking applier. func NewApplier() *Applier { return &Applier{} } func init() { thinking.RegisterProvider("gemini", NewApplier()) } // Apply applies thinking configuration to Gemini request body. // // Expected output format (Gemini 2.5): // // { // "generationConfig": { // "thinkingConfig": { // "thinkingBudget": 8192, // "includeThoughts": true // } // } // } // // Expected output format (Gemini 3.x): // // { // "generationConfig": { // "thinkingConfig": { // "thinkingLevel": "high", // "includeThoughts": true // } // } // } func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) { if modelInfo == nil { return body, nil } if modelInfo.Thinking == nil { if modelInfo.Type == "" { modelID := modelInfo.ID if modelID == "" { modelID = "unknown" } return nil, thinking.NewThinkingErrorWithModel(thinking.ErrThinkingNotSupported, "thinking not supported for this model", modelID) } return a.applyCompatible(body, config) } if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto { return body, nil } if len(body) == 0 || !gjson.ValidBytes(body) { body = []byte(`{}`) } // Choose format based on config.Mode and model capabilities: // - ModeLevel: use Level format (validation will reject unsupported levels) // - ModeNone: use Level format if model has Levels, else Budget format // - ModeBudget/ModeAuto: use Budget format switch config.Mode { case thinking.ModeLevel: return a.applyLevelFormat(body, config) case thinking.ModeNone: // ModeNone: route based on model capability (has Levels or not) if len(modelInfo.Thinking.Levels) > 0 { return a.applyLevelFormat(body, config) } return a.applyBudgetFormat(body, config) default: return a.applyBudgetFormat(body, config) } } func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) ([]byte, error) { if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto { return body, nil } if len(body) == 0 || !gjson.ValidBytes(body) { body = []byte(`{}`) } if config.Mode == thinking.ModeAuto { return a.applyBudgetFormat(body, config) } if config.Mode == thinking.ModeLevel || (config.Mode == thinking.ModeNone && config.Level != "") { return a.applyLevelFormat(body, config) } return a.applyBudgetFormat(body, config) } func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) { // ModeNone semantics: // - ModeNone + Budget=0: completely disable thinking (not possible for Level-only models) // - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false) // ValidateConfig sets config.Level to the lowest level when ModeNone + Budget > 0. // Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingBudget") // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts") if config.Mode == thinking.ModeNone { result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false) if config.Level != "" { result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", string(config.Level)) } return result, nil } // Only handle ModeLevel - budget conversion should be done by upper layer if config.Mode != thinking.ModeLevel { return body, nil } level := string(config.Level) result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", level) result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", true) return result, nil } func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) { // Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingLevel") // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts") budget := config.Budget // ModeNone semantics: // - ModeNone + Budget=0: completely disable thinking // - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false) // When ZeroAllowed=false, ValidateConfig clamps Budget to Min while preserving ModeNone. includeThoughts := false switch config.Mode { case thinking.ModeNone: includeThoughts = false case thinking.ModeAuto: includeThoughts = true default: includeThoughts = budget > 0 } result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget) result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", includeThoughts) return result, nil }