mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
350 lines
14 KiB
Go
350 lines
14 KiB
Go
// Package thinking provides unified thinking configuration processing logic.
|
|
package thinking
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
|
log "github.com/sirupsen/logrus"
|
|
logtest "github.com/sirupsen/logrus/hooks/test"
|
|
)
|
|
|
|
// TestClampBudget tests the ClampBudget function.
|
|
//
|
|
// ClampBudget applies range constraints to a budget value:
|
|
// - budget < Min → clamp to Min (with Debug log)
|
|
// - budget > Max → clamp to Max (with Debug log)
|
|
// - Auto value (-1) passes through unchanged
|
|
func TestClampBudget(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value int
|
|
min int
|
|
max int
|
|
want int
|
|
}{
|
|
// Within range - no clamping
|
|
{"within range", 8192, 128, 32768, 8192},
|
|
{"at min", 128, 128, 32768, 128},
|
|
{"at max", 32768, 128, 32768, 32768},
|
|
|
|
// Below min - clamp to min
|
|
{"below min", 100, 128, 32768, 128},
|
|
|
|
// Above max - clamp to max
|
|
{"above max", 50000, 128, 32768, 32768},
|
|
|
|
// Edge cases
|
|
{"min equals max", 5000, 5000, 5000, 5000},
|
|
{"zero min zero value", 0, 0, 100, 0},
|
|
|
|
// Auto value (-1) - passes through
|
|
{"auto value", -1, 128, 32768, -1},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ClampBudget(tt.value, tt.min, tt.max)
|
|
if got != tt.want {
|
|
t.Errorf("ClampBudget(%d, %d, %d) = %d, want %d",
|
|
tt.value, tt.min, tt.max, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestZeroAllowedBoundaryHandling tests ZeroAllowed=false edge cases.
|
|
//
|
|
// When ZeroAllowed=false and user requests 0, clamp to Min + log Warn.
|
|
func TestZeroAllowedBoundaryHandling(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value int
|
|
min int
|
|
max int
|
|
zeroAllowed bool
|
|
want int
|
|
}{
|
|
// ZeroAllowed=true: 0 stays 0
|
|
{"zero allowed - keep zero", 0, 128, 32768, true, 0},
|
|
|
|
// ZeroAllowed=false: 0 clamps to min
|
|
{"zero not allowed - clamp to min", 0, 128, 32768, false, 128},
|
|
|
|
// ZeroAllowed=false but non-zero value: normal clamping
|
|
{"zero not allowed - positive value", 8192, 1024, 100000, false, 8192},
|
|
|
|
// Auto value (-1) always passes through
|
|
{"auto value", -1, 128, 32768, false, -1},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ClampBudgetWithZeroCheck(tt.value, tt.min, tt.max, tt.zeroAllowed)
|
|
if got != tt.want {
|
|
t.Errorf("ClampBudgetWithZeroCheck(%d, %d, %d, %v) = %d, want %d",
|
|
tt.value, tt.min, tt.max, tt.zeroAllowed, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateConfigFramework verifies the ValidateConfig function framework.
|
|
// This test is merged into TestValidateConfig for consolidation.
|
|
|
|
// TestValidateConfigNotSupported verifies nil support handling.
|
|
// This test is merged into TestValidateConfig for consolidation.
|
|
|
|
// TestValidateConfigConversion verifies mode conversion based on capability.
|
|
// This test is merged into TestValidateConfig for consolidation.
|
|
|
|
// TestValidateConfigLevelSupport verifies level list validation.
|
|
// This test is merged into TestValidateConfig for consolidation.
|
|
|
|
// TestValidateConfigClamping verifies budget clamping behavior.
|
|
// This test is merged into TestValidateConfig for consolidation.
|
|
|
|
// TestValidateConfig is the comprehensive test for ValidateConfig function.
|
|
//
|
|
// ValidateConfig checks if a ThinkingConfig is valid for a given model.
|
|
// This test covers all validation scenarios including:
|
|
// - Framework basics (nil support with ModeNone)
|
|
// - Error cases (thinking not supported, level not supported, dynamic not allowed)
|
|
// - Mode conversion (budget-only, level-only, hybrid)
|
|
// - Budget clamping (to max, to min)
|
|
// - ZeroAllowed boundary handling (ModeNone with ZeroAllowed=false)
|
|
// - DynamicAllowed validation
|
|
//
|
|
// Depends on: Epic 5 Story 5-3 (config validity validation)
|
|
func TestValidateConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config ThinkingConfig
|
|
support *registry.ThinkingSupport
|
|
wantMode ThinkingMode
|
|
wantBudget int
|
|
wantLevel ThinkingLevel
|
|
wantErr bool
|
|
wantCode ErrorCode
|
|
}{
|
|
// Framework basics
|
|
{"nil support mode none", ThinkingConfig{Mode: ModeNone, Budget: 0}, nil, ModeNone, 0, "", false, ""},
|
|
|
|
// Valid configs - no conversion needed
|
|
{"budget-only keeps budget", ThinkingConfig{Mode: ModeBudget, Budget: 8192}, ®istry.ThinkingSupport{Min: 1024, Max: 100000}, ModeBudget, 8192, "", false, ""},
|
|
|
|
// Auto-conversion: Level → Budget
|
|
{"budget-only converts level", ThinkingConfig{Mode: ModeLevel, Level: LevelHigh}, ®istry.ThinkingSupport{Min: 1024, Max: 100000}, ModeBudget, 24576, "", false, ""},
|
|
|
|
// Auto-conversion: Budget → Level
|
|
{"level-only converts budget", ThinkingConfig{Mode: ModeBudget, Budget: 5000}, ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}, ModeLevel, 0, LevelMedium, false, ""},
|
|
|
|
// Hybrid preserves original format
|
|
{"hybrid preserves level", ThinkingConfig{Mode: ModeLevel, Level: LevelLow}, ®istry.ThinkingSupport{Min: 128, Max: 32768, Levels: []string{"low", "high"}}, ModeLevel, 0, LevelLow, false, ""},
|
|
|
|
// Budget clamping
|
|
{"budget clamped to max", ThinkingConfig{Mode: ModeBudget, Budget: 200000}, ®istry.ThinkingSupport{Min: 1024, Max: 100000}, ModeBudget, 100000, "", false, ""},
|
|
{"budget clamped to min", ThinkingConfig{Mode: ModeBudget, Budget: 100}, ®istry.ThinkingSupport{Min: 1024, Max: 100000}, ModeBudget, 1024, "", false, ""},
|
|
|
|
// Error: thinking not supported
|
|
{"thinking not supported", ThinkingConfig{Mode: ModeBudget, Budget: 8192}, nil, 0, 0, "", true, ErrThinkingNotSupported},
|
|
|
|
// Error: level not in list
|
|
{"level not supported", ThinkingConfig{Mode: ModeLevel, Level: LevelXHigh}, ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}, 0, 0, "", true, ErrLevelNotSupported},
|
|
|
|
// Level case-insensitive
|
|
{"level supported case-insensitive", ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel("HIGH")}, ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}, ModeLevel, 0, ThinkingLevel("HIGH"), false, ""},
|
|
|
|
// ModeAuto with DynamicAllowed
|
|
{"auto with dynamic allowed", ThinkingConfig{Mode: ModeAuto, Budget: -1}, ®istry.ThinkingSupport{Min: 128, Max: 32768, DynamicAllowed: true}, ModeAuto, -1, "", false, ""},
|
|
|
|
// ModeAuto with DynamicAllowed=false - converts to mid-range (M3)
|
|
{"auto with dynamic not allowed", ThinkingConfig{Mode: ModeAuto, Budget: -1}, ®istry.ThinkingSupport{Min: 128, Max: 32768, DynamicAllowed: false}, ModeBudget, 16448, "", false, ""},
|
|
|
|
// ModeNone with ZeroAllowed=true - stays as ModeNone
|
|
{"mode none with zero allowed", ThinkingConfig{Mode: ModeNone, Budget: 0}, ®istry.ThinkingSupport{Min: 1024, Max: 100000, ZeroAllowed: true}, ModeNone, 0, "", false, ""},
|
|
|
|
// Budget=0 converts to ModeNone before clamping (M1)
|
|
{"budget zero converts to none", ThinkingConfig{Mode: ModeBudget, Budget: 0}, ®istry.ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false}, ModeNone, 128, "", false, ""},
|
|
|
|
// Level=none converts to ModeNone before clamping, then Level set to lowest
|
|
{"level none converts to none", ThinkingConfig{Mode: ModeLevel, Level: LevelNone}, ®istry.ThinkingSupport{Min: 128, Max: 32768, Levels: []string{"low", "high"}, ZeroAllowed: false}, ModeNone, 128, ThinkingLevel("low"), false, ""},
|
|
{"level auto converts to auto", ThinkingConfig{Mode: ModeLevel, Level: LevelAuto}, ®istry.ThinkingSupport{Min: 128, Max: 32768, Levels: []string{"low", "high"}, DynamicAllowed: true}, ModeAuto, -1, "", false, ""},
|
|
// M1: Level=auto with DynamicAllowed=false - converts to mid-range budget
|
|
{"level auto with dynamic not allowed", ThinkingConfig{Mode: ModeLevel, Level: LevelAuto}, ®istry.ThinkingSupport{Min: 128, Max: 32768, Levels: []string{"low", "high"}, DynamicAllowed: false}, ModeBudget, 16448, "", false, ""},
|
|
// M2: Level=auto on Budget-only model (no Levels)
|
|
{"level auto on budget-only model", ThinkingConfig{Mode: ModeLevel, Level: LevelAuto}, ®istry.ThinkingSupport{Min: 128, Max: 32768, DynamicAllowed: true}, ModeAuto, -1, "", false, ""},
|
|
|
|
// ModeNone with ZeroAllowed=false - clamps to min but preserves ModeNone (M1)
|
|
{"mode none with zero not allowed - preserve mode", ThinkingConfig{Mode: ModeNone, Budget: 0}, ®istry.ThinkingSupport{Min: 1024, Max: 100000, ZeroAllowed: false}, ModeNone, 1024, "", false, ""},
|
|
|
|
// ModeNone with clamped Budget > 0 and Levels: sets Level to lowest
|
|
{"mode none clamped with levels", ThinkingConfig{Mode: ModeNone, Budget: 0}, ®istry.ThinkingSupport{Min: 128, Max: 32768, Levels: []string{"low", "high"}, ZeroAllowed: false}, ModeNone, 128, ThinkingLevel("low"), false, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := ValidateConfig(tt.config, tt.support)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("ValidateConfig(%+v, support) error = nil, want %v", tt.config, tt.wantCode)
|
|
}
|
|
thinkingErr, ok := err.(*ThinkingError)
|
|
if !ok {
|
|
t.Fatalf("ValidateConfig(%+v, support) error type = %T, want *ThinkingError", tt.config, err)
|
|
}
|
|
if thinkingErr.Code != tt.wantCode {
|
|
t.Errorf("ValidateConfig(%+v, support) code = %v, want %v", tt.config, thinkingErr.Code, tt.wantCode)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("ValidateConfig(%+v, support) returned error: %v", tt.config, err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("ValidateConfig(%+v, support) returned nil config", tt.config)
|
|
}
|
|
if got.Mode != tt.wantMode {
|
|
t.Errorf("ValidateConfig(%+v, support) Mode = %v, want %v", tt.config, got.Mode, tt.wantMode)
|
|
}
|
|
if got.Budget != tt.wantBudget {
|
|
t.Errorf("ValidateConfig(%+v, support) Budget = %d, want %d", tt.config, got.Budget, tt.wantBudget)
|
|
}
|
|
if got.Level != tt.wantLevel {
|
|
t.Errorf("ValidateConfig(%+v, support) Level = %q, want %q", tt.config, got.Level, tt.wantLevel)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidationErrorMessages tests error message formatting.
|
|
//
|
|
// Error messages should:
|
|
// - Be lowercase
|
|
// - Have no trailing period
|
|
// - Include context with %s/%d
|
|
//
|
|
// Depends on: Epic 5 Story 5-4 (validation error messages)
|
|
func TestValidationErrorMessages(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
getErr func() error
|
|
wantCode ErrorCode
|
|
wantContains string
|
|
}{
|
|
{"invalid suffix", func() error {
|
|
_, err := ParseSuffixWithError("model(abc")
|
|
return err
|
|
}, ErrInvalidSuffix, "model(abc"},
|
|
{"level not supported", func() error {
|
|
_, err := ValidateConfig(ThinkingConfig{Mode: ModeLevel, Level: LevelXHigh}, ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}})
|
|
return err
|
|
}, ErrLevelNotSupported, "valid levels: low, medium, high"},
|
|
{"thinking not supported", func() error {
|
|
_, err := ValidateConfig(ThinkingConfig{Mode: ModeBudget, Budget: 1024}, nil)
|
|
return err
|
|
}, ErrThinkingNotSupported, "thinking not supported for this model"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := tt.getErr()
|
|
if err == nil {
|
|
t.Fatalf("error = nil, want ThinkingError")
|
|
}
|
|
thinkingErr, ok := err.(*ThinkingError)
|
|
if !ok {
|
|
t.Fatalf("error type = %T, want *ThinkingError", err)
|
|
}
|
|
if thinkingErr.Code != tt.wantCode {
|
|
t.Errorf("code = %v, want %v", thinkingErr.Code, tt.wantCode)
|
|
}
|
|
if thinkingErr.Message == "" {
|
|
t.Fatalf("message is empty")
|
|
}
|
|
first, _ := utf8.DecodeRuneInString(thinkingErr.Message)
|
|
if unicode.IsLetter(first) && !unicode.IsLower(first) {
|
|
t.Errorf("message does not start with lowercase: %q", thinkingErr.Message)
|
|
}
|
|
if strings.HasSuffix(thinkingErr.Message, ".") {
|
|
t.Errorf("message has trailing period: %q", thinkingErr.Message)
|
|
}
|
|
if !strings.Contains(thinkingErr.Message, tt.wantContains) {
|
|
t.Errorf("message %q does not contain %q", thinkingErr.Message, tt.wantContains)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestClampingLogging tests that clamping produces correct log entries.
|
|
//
|
|
// Clamping behavior:
|
|
// - Normal clamp (budget outside range) → Debug log
|
|
// - ZeroAllowed=false + zero request → Warn log
|
|
//
|
|
// Depends on: Epic 5 Story 5-1, 5-2
|
|
func TestClampingLogging(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
useZeroCheck bool
|
|
budget int
|
|
min int
|
|
max int
|
|
zeroAllowed bool
|
|
wantLevel log.Level
|
|
wantReason string
|
|
wantClamped int
|
|
}{
|
|
{"above max - debug", false, 50000, 128, 32768, false, log.DebugLevel, "", 32768},
|
|
{"below min - debug", false, 50, 128, 32768, false, log.DebugLevel, "", 128},
|
|
{"zero not allowed - warn", true, 0, 128, 32768, false, log.WarnLevel, "zero_not_allowed", 128},
|
|
}
|
|
|
|
logger := log.StandardLogger()
|
|
originalLevel := logger.GetLevel()
|
|
logger.SetLevel(log.DebugLevel)
|
|
hook := logtest.NewLocal(logger)
|
|
t.Cleanup(func() {
|
|
logger.SetLevel(originalLevel)
|
|
hook.Reset()
|
|
})
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
hook.Reset()
|
|
var got int
|
|
if tt.useZeroCheck {
|
|
got = ClampBudgetWithZeroCheck(tt.budget, tt.min, tt.max, tt.zeroAllowed)
|
|
} else {
|
|
got = ClampBudget(tt.budget, tt.min, tt.max)
|
|
}
|
|
if got != tt.wantClamped {
|
|
t.Fatalf("clamped budget = %d, want %d", got, tt.wantClamped)
|
|
}
|
|
|
|
entry := hook.LastEntry()
|
|
if entry == nil {
|
|
t.Fatalf("no log entry captured")
|
|
}
|
|
if entry.Level != tt.wantLevel {
|
|
t.Errorf("log level = %v, want %v", entry.Level, tt.wantLevel)
|
|
}
|
|
|
|
fields := []string{"original_value", "clamped_to", "min", "max"}
|
|
for _, key := range fields {
|
|
if _, ok := entry.Data[key]; !ok {
|
|
t.Errorf("missing field %q", key)
|
|
}
|
|
}
|
|
if tt.wantReason != "" {
|
|
if value, ok := entry.Data["reason"]; !ok || value != tt.wantReason {
|
|
t.Errorf("reason = %v, want %v", value, tt.wantReason)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|