refactor(thinking): improve budget clamping and logging with provider/model context

This commit is contained in:
hkfires
2026-01-15 11:29:53 +08:00
parent 1fbbba6f59
commit 5a77b7728e
4 changed files with 95 additions and 94 deletions

View File

@@ -1467,7 +1467,7 @@ func normalizeAntigravityThinking(model string, payload []byte, isClaude bool) [
return payload return payload
} }
raw := int(budget.Int()) raw := int(budget.Int())
normalized := thinking.ClampBudget(raw, modelInfo.Thinking.Min, modelInfo.Thinking.Max) normalized := thinking.ClampBudget(raw, modelInfo, "antigravity")
if isClaude { if isClaude {
effectiveMax, setDefaultMax := antigravityEffectiveMaxTokens(model, payload) effectiveMax, setDefaultMax := antigravityEffectiveMaxTokens(model, payload)

View File

@@ -84,7 +84,10 @@ func ApplyThinking(body []byte, model string, provider string) ([]byte, error) {
// 1. Route check: Get provider applier // 1. Route check: Get provider applier
applier := GetProviderApplier(provider) applier := GetProviderApplier(provider)
if applier == nil { if applier == nil {
log.WithField("provider", provider).Debug("thinking: unknown provider, passthrough") log.WithFields(log.Fields{
"provider": provider,
"model": model,
}).Debug("thinking: unknown provider, passthrough")
return body, nil return body, nil
} }
@@ -108,14 +111,17 @@ func ApplyThinking(body []byte, model string, provider string) ([]byte, error) {
}).Debug("thinking: model does not support thinking, stripping config") }).Debug("thinking: model does not support thinking, stripping config")
return StripThinkingConfig(body, provider), nil return StripThinkingConfig(body, provider), nil
} }
log.WithField("model", baseModel).Debug("thinking: model does not support thinking, passthrough") log.WithFields(log.Fields{
"provider": provider,
"model": baseModel,
}).Debug("thinking: model does not support thinking, passthrough")
return body, nil return body, nil
} }
// 4. Get config: suffix priority over body // 4. Get config: suffix priority over body
var config ThinkingConfig var config ThinkingConfig
if suffixResult.HasSuffix { if suffixResult.HasSuffix {
config = parseSuffixToConfig(suffixResult.RawSuffix) config = parseSuffixToConfig(suffixResult.RawSuffix, provider, model)
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": provider,
"model": model, "model": model,
@@ -125,13 +131,15 @@ func ApplyThinking(body []byte, model string, provider string) ([]byte, error) {
}).Debug("thinking: config from model suffix") }).Debug("thinking: config from model suffix")
} else { } else {
config = extractThinkingConfig(body, provider) config = extractThinkingConfig(body, provider)
log.WithFields(log.Fields{ if hasThinkingConfig(config) {
"provider": provider, log.WithFields(log.Fields{
"model": modelInfo.ID, "provider": provider,
"mode": config.Mode, "model": modelInfo.ID,
"budget": config.Budget, "mode": config.Mode,
"level": config.Level, "budget": config.Budget,
}).Debug("thinking: original config from request") "level": config.Level,
}).Debug("thinking: original config from request")
}
} }
if !hasThinkingConfig(config) { if !hasThinkingConfig(config) {
@@ -143,7 +151,7 @@ func ApplyThinking(body []byte, model string, provider string) ([]byte, error) {
} }
// 5. Validate and normalize configuration // 5. Validate and normalize configuration
validated, err := ValidateConfig(config, modelInfo.Thinking) validated, err := ValidateConfig(config, modelInfo, provider)
if err != nil { if err != nil {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": provider,
@@ -185,7 +193,7 @@ func ApplyThinking(body []byte, model string, provider string) ([]byte, error) {
// 3. Numeric values: positive integers → ModeBudget, 0 → ModeNone // 3. Numeric values: positive integers → ModeBudget, 0 → ModeNone
// //
// If none of the above match, returns empty ThinkingConfig (treated as no config). // If none of the above match, returns empty ThinkingConfig (treated as no config).
func parseSuffixToConfig(rawSuffix string) ThinkingConfig { func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig {
// 1. Try special values first (none, auto, -1) // 1. Try special values first (none, auto, -1)
if mode, ok := ParseSpecialSuffix(rawSuffix); ok { if mode, ok := ParseSpecialSuffix(rawSuffix); ok {
switch mode { switch mode {
@@ -210,7 +218,11 @@ func parseSuffixToConfig(rawSuffix string) ThinkingConfig {
} }
// Unknown suffix format - return empty config // Unknown suffix format - return empty config
log.WithField("raw_suffix", rawSuffix).Debug("thinking: unknown suffix format, treating as no config") log.WithFields(log.Fields{
"provider": provider,
"model": model,
"raw_suffix": rawSuffix,
}).Debug("thinking: unknown suffix format, treating as no config")
return ThinkingConfig{} return ThinkingConfig{}
} }
@@ -228,7 +240,7 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, provider
// Get config: suffix priority over body // Get config: suffix priority over body
var config ThinkingConfig var config ThinkingConfig
if suffixResult.HasSuffix { if suffixResult.HasSuffix {
config = parseSuffixToConfig(suffixResult.RawSuffix) config = parseSuffixToConfig(suffixResult.RawSuffix, provider, modelID)
} else { } else {
config = extractThinkingConfig(body, provider) config = extractThinkingConfig(body, provider)
} }

View File

@@ -9,81 +9,59 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// ClampBudget clamps a budget value to the specified range [min, max]. // ClampBudget clamps a budget value to the model's supported range.
//
// This function ensures budget values stay within model-supported bounds.
// When clamping occurs, a Debug-level log is recorded.
//
// Special handling:
// - Auto value (-1) passes through without clamping
// - Values below min are clamped to min
// - Values above max are clamped to max
//
// Parameters:
// - value: The budget value to clamp
// - min: Minimum allowed budget (inclusive)
// - max: Maximum allowed budget (inclusive)
//
// Returns:
// - The clamped budget value (min ≤ result ≤ max, or -1 for auto)
// //
// Logging: // Logging:
// - Debug level when value is clamped (either to min or max) // - Warn when value=0 but ZeroAllowed=false
// - Fields: original_value, clamped_to, min, max // - Debug when value is clamped to min/max
func ClampBudget(value, min, max int) int { //
// Auto value (-1) passes through without clamping // Fields: provider, model, original_value, clamped_to, min, max
func ClampBudget(value int, modelInfo *registry.ModelInfo, provider string) int {
model := "unknown"
support := (*registry.ThinkingSupport)(nil)
if modelInfo != nil {
if modelInfo.ID != "" {
model = modelInfo.ID
}
support = modelInfo.Thinking
}
if support == nil {
return value
}
// Auto value (-1) passes through without clamping.
if value == -1 { if value == -1 {
return value return value
} }
// Clamp to min if below min := support.Min
if value < min { max := support.Max
logClamp(value, min, min, max) if value == 0 && !support.ZeroAllowed {
return min
}
// Clamp to max if above
if value > max {
logClamp(value, max, min, max)
return max
}
// Within range, return original
return value
}
// ClampBudgetWithZeroCheck clamps a budget value to the specified range [min, max]
// while honoring the ZeroAllowed constraint.
//
// This function extends ClampBudget with ZeroAllowed boundary handling.
// When zeroAllowed is false and value is 0, the value is clamped to min and logged.
//
// Parameters:
// - value: The budget value to clamp
// - min: Minimum allowed budget (inclusive)
// - max: Maximum allowed budget (inclusive)
// - zeroAllowed: Whether 0 (thinking disabled) is allowed
//
// Returns:
// - The clamped budget value (min ≤ result ≤ max, or -1 for auto)
//
// Logging:
// - Warn level when zeroAllowed=false and value=0 (zero not allowed for model)
// - Fields: original_value, clamped_to, reason
func ClampBudgetWithZeroCheck(value, min, max int, zeroAllowed bool) int {
if value == 0 {
if zeroAllowed {
return 0
}
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"clamped_to": min, "provider": provider,
"min": min, "model": model,
"max": max, "original_value": value,
"clamped_to": min,
"min": min,
"max": max,
}).Warn("thinking: budget zero not allowed") }).Warn("thinking: budget zero not allowed")
return min return min
} }
return ClampBudget(value, min, max) // Some models are level-only and do not define numeric budget ranges.
if min == 0 && max == 0 {
return value
}
if value < min {
logClamp(provider, model, value, min, min, max)
return min
}
if value > max {
logClamp(provider, model, value, max, min, max)
return max
}
return value
} }
// ValidateConfig validates a thinking configuration against model capabilities. // ValidateConfig validates a thinking configuration against model capabilities.
@@ -106,16 +84,26 @@ func ClampBudgetWithZeroCheck(value, min, max int, zeroAllowed bool) int {
// - Budget-only model + Level config → Level converted to Budget // - Budget-only model + Level config → Level converted to Budget
// - Level-only model + Budget config → Budget converted to Level // - Level-only model + Budget config → Budget converted to Level
// - Hybrid model → preserve original format // - Hybrid model → preserve original format
func ValidateConfig(config ThinkingConfig, support *registry.ThinkingSupport) (*ThinkingConfig, error) { func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, provider string) (*ThinkingConfig, error) {
normalized := config normalized := config
model := "unknown"
support := (*registry.ThinkingSupport)(nil)
if modelInfo != nil {
if modelInfo.ID != "" {
model = modelInfo.ID
}
support = modelInfo.Thinking
}
if support == nil { if support == nil {
if config.Mode != ModeNone { if config.Mode != ModeNone {
return nil, NewThinkingErrorWithModel(ErrThinkingNotSupported, "thinking not supported for this model", "unknown") return nil, NewThinkingErrorWithModel(ErrThinkingNotSupported, "thinking not supported for this model", model)
} }
return &normalized, nil return &normalized, nil
} }
capability := detectModelCapability(&registry.ModelInfo{Thinking: support}) capability := detectModelCapability(modelInfo)
switch capability { switch capability {
case CapabilityBudgetOnly: case CapabilityBudgetOnly:
if normalized.Mode == ModeLevel { if normalized.Mode == ModeLevel {
@@ -168,13 +156,12 @@ func ValidateConfig(config ThinkingConfig, support *registry.ThinkingSupport) (*
// Convert ModeAuto to mid-range if dynamic not allowed // Convert ModeAuto to mid-range if dynamic not allowed
if normalized.Mode == ModeAuto && !support.DynamicAllowed { if normalized.Mode == ModeAuto && !support.DynamicAllowed {
normalized = convertAutoToMidRange(normalized, support) normalized = convertAutoToMidRange(normalized, support, provider, model)
} }
switch normalized.Mode { switch normalized.Mode {
case ModeBudget, ModeAuto, ModeNone: case ModeBudget, ModeAuto, ModeNone:
clamped := ClampBudgetWithZeroCheck(normalized.Budget, support.Min, support.Max, support.ZeroAllowed) normalized.Budget = ClampBudget(normalized.Budget, modelInfo, provider)
normalized.Budget = clamped
} }
// ModeNone with clamped Budget > 0: set Level to lowest for Level-only/Hybrid models // ModeNone with clamped Budget > 0: set Level to lowest for Level-only/Hybrid models
@@ -213,17 +200,18 @@ func normalizeLevels(levels []string) []string {
// Logging: // Logging:
// - Debug level when conversion occurs // - Debug level when conversion occurs
// - Fields: original_mode, clamped_to, reason // - Fields: original_mode, clamped_to, reason
func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupport) ThinkingConfig { func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupport, provider, model string) ThinkingConfig {
// For level-only models (has Levels but no Min/Max range), use ModeLevel with medium // For level-only models (has Levels but no Min/Max range), use ModeLevel with medium
if len(support.Levels) > 0 && support.Min == 0 && support.Max == 0 { if len(support.Levels) > 0 && support.Min == 0 && support.Max == 0 {
config.Mode = ModeLevel config.Mode = ModeLevel
config.Level = LevelMedium config.Level = LevelMedium
config.Budget = 0 config.Budget = 0
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider,
"model": model,
"original_mode": "auto", "original_mode": "auto",
"clamped_to": string(LevelMedium), "clamped_to": string(LevelMedium),
"reason": "dynamic_not_allowed_level_only", }).Debug("thinking: mode converted: dynamic not allowed, using medium level")
}).Debug("thinking mode converted: dynamic not allowed, using medium level")
return config return config
} }
@@ -240,16 +228,19 @@ func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupp
config.Budget = mid config.Budget = mid
} }
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider,
"model": model,
"original_mode": "auto", "original_mode": "auto",
"clamped_to": config.Budget, "clamped_to": config.Budget,
"reason": "dynamic_not_allowed", }).Debug("thinking: mode converted: dynamic not allowed")
}).Debug("thinking mode converted: dynamic not allowed")
return config return config
} }
// logClamp logs a debug message when budget clamping occurs. // logClamp logs a debug message when budget clamping occurs.
func logClamp(original, clampedTo, min, max int) { func logClamp(provider, model string, original, clampedTo, min, max int) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider,
"model": model,
"original_value": original, "original_value": original,
"min": min, "min": min,
"max": max, "max": max,

View File

@@ -16,7 +16,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
@@ -123,8 +122,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
// Check for thinkingBudget first - if present, enable thinking with budget // Check for thinkingBudget first - if present, enable thinking with budget
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() && thinkingBudget.Int() > 0 { if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() && thinkingBudget.Int() > 0 {
out, _ = sjson.Set(out, "thinking.type", "enabled") out, _ = sjson.Set(out, "thinking.type", "enabled")
normalizedBudget := thinking.ClampBudget(int(thinkingBudget.Int()), modelInfo.Thinking.Min, modelInfo.Thinking.Max) out, _ = sjson.Set(out, "thinking.budget_tokens", thinkingBudget.Int())
out, _ = sjson.Set(out, "thinking.budget_tokens", normalizedBudget)
} else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { } else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
// Fallback to include_thoughts if no budget specified // Fallback to include_thoughts if no budget specified
out, _ = sjson.Set(out, "thinking.type", "enabled") out, _ = sjson.Set(out, "thinking.type", "enabled")