diff --git a/internal/thinking/provider/openai/apply_test.go b/internal/thinking/provider/openai/apply_test.go new file mode 100644 index 00000000000..437f9656ca3 --- /dev/null +++ b/internal/thinking/provider/openai/apply_test.go @@ -0,0 +1,33 @@ +package openai + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/tidwall/gjson" +) + +func TestApplyLevelFallbackAfterDisabledThinking(t *testing.T) { + model := ®istry.ModelInfo{ + ID: "openrouter-3o", + Thinking: ®istry.ThinkingSupport{ + Levels: []string{"max", "xhigh", "high", "medium", "low"}, + }, + } + config, err := thinking.ValidateConfig( + thinking.ThinkingConfig{Mode: thinking.ModeNone}, model, "claude", "openai", false, + ) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + body, err := NewApplier().Apply( + []byte(`{"messages":[{"role":"user","content":"hi"}]}`), *config, model, + ) + if err != nil { + t.Fatalf("Apply() error = %v", err) + } + if got := gjson.GetBytes(body, "reasoning_effort").String(); got != "low" { + t.Fatalf("reasoning_effort = %q, want low; body=%s", got, body) + } +} diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index 7e92a7710ce..32de67aec45 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -172,18 +172,30 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo config.Budget = 0 config.Level = "" } else { - switch config.Mode { - case ModeBudget, ModeAuto, ModeNone: - config.Budget = clampBudget(config.Budget, modelInfo, toFormat) - } - // ModeNone for a model that cannot be disabled falls back to the lowest // supported level. Budget-capable models reach this path with Budget > 0; // level-only models need the capability flags checked explicitly because // their Min/Max range is zero. cannotDisableLevelModel := !support.ZeroAllowed && !isLevelSupported(string(LevelNone), support.Levels) - if config.Mode == ModeNone && len(support.Levels) > 0 && (config.Budget > 0 || cannotDisableLevelModel) { - config.Level = ThinkingLevel(support.Levels[0]) + pureLevelOnly := capability == CapabilityLevelOnly && support.Min == 0 && support.Max == 0 + if config.Mode == ModeNone && pureLevelOnly { + // Level-only models have no numeric budget range, so do not pass their + // disabled sentinel through the numeric budget clamp. If the target + // cannot disable thinking, resolve its lowest supported level from the + // canonical order; provider metadata is not guaranteed to be sorted. + config.Budget = 0 + if cannotDisableLevelModel { + config.Mode = ModeLevel + config.Level = clampLevel(LevelMinimal, modelInfo, toFormat) + } + } else { + switch config.Mode { + case ModeBudget, ModeAuto, ModeNone: + config.Budget = clampBudget(config.Budget, modelInfo, toFormat) + } + if config.Mode == ModeNone && len(support.Levels) > 0 && (config.Budget > 0 || cannotDisableLevelModel) { + config.Level = ThinkingLevel(support.Levels[0]) + } } } diff --git a/internal/thinking/validate_none_test.go b/internal/thinking/validate_none_test.go new file mode 100644 index 00000000000..2873fb537df --- /dev/null +++ b/internal/thinking/validate_none_test.go @@ -0,0 +1,84 @@ +package thinking + +import ( + "bytes" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + log "github.com/sirupsen/logrus" +) + +func TestValidateConfigDisabledLevelOnlyUsesLowestWithoutBudgetWarning(t *testing.T) { + previousOutput := log.StandardLogger().Out + previousLevel := log.GetLevel() + var output bytes.Buffer + log.SetOutput(&output) + log.SetLevel(log.WarnLevel) + t.Cleanup(func() { + log.SetOutput(previousOutput) + log.SetLevel(previousLevel) + }) + + model := ®istry.ModelInfo{ + ID: "openrouter-3o", + Thinking: ®istry.ThinkingSupport{ + Levels: []string{"max", "xhigh", "high", "medium", "low"}, + }, + } + got, err := ValidateConfig(ThinkingConfig{Mode: ModeNone}, model, "claude", "openai", false) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if got.Mode != ModeLevel || got.Budget != 0 || got.Level != LevelLow { + t.Fatalf("ValidateConfig() = %+v, want ModeLevel low with zero budget", got) + } + if strings.Contains(output.String(), "budget zero not allowed") { + t.Fatalf("disabled level-only thinking emitted a misleading warning: %s", output.String()) + } +} + +func TestValidateConfigDisabledHybridKeepsBudgetFallback(t *testing.T) { + model := ®istry.ModelInfo{ + ID: "hybrid-model", + Thinking: ®istry.ThinkingSupport{ + Min: 1024, Max: 32768, Levels: []string{"low", "high"}, + }, + } + got, err := ValidateConfig(ThinkingConfig{Mode: ModeNone}, model, "claude", "gemini", false) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if got.Mode != ModeNone || got.Budget != 1024 || got.Level != LevelLow { + t.Fatalf("ValidateConfig() = %+v, want prior hybrid fallback budget 1024 and level low", got) + } +} + +func TestValidateConfigDisabledLevelOnlyKeepsSupportedNoneWithoutBudgetWarning(t *testing.T) { + previousOutput := log.StandardLogger().Out + previousLevel := log.GetLevel() + var output bytes.Buffer + log.SetOutput(&output) + log.SetLevel(log.WarnLevel) + t.Cleanup(func() { + log.SetOutput(previousOutput) + log.SetLevel(previousLevel) + }) + + model := ®istry.ModelInfo{ + ID: "level-model-with-none", + Thinking: ®istry.ThinkingSupport{ + Levels: []string{"high", "none", "low"}, + }, + } + got, err := ValidateConfig(ThinkingConfig{Mode: ModeNone}, model, "claude", "openai", false) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if got.Mode != ModeNone || got.Budget != 0 || got.Level != "" { + t.Fatalf("ValidateConfig() = %+v, want unchanged disabled mode", got) + } + if strings.Contains(output.String(), "budget zero not allowed") { + t.Fatalf("supported disabled thinking emitted a misleading warning: %s", output.String()) + } +}