Files
CLIProxyAPI/internal/thinking/provider/gemini/apply.go

177 lines
6.1 KiB
Go

// 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
}