refactor: improve thinking logic

This commit is contained in:
hkfires
2026-01-14 08:32:02 +08:00
parent 5a7e5bd870
commit 0b06d637e7
76 changed files with 8712 additions and 1815 deletions

View File

@@ -0,0 +1,160 @@
// Package iflow implements thinking configuration for iFlow models (GLM, MiniMax).
//
// iFlow models use boolean toggle semantics:
// - GLM models: chat_template_kwargs.enable_thinking (boolean)
// - MiniMax models: reasoning_split (boolean)
//
// Level values are converted to boolean: none=false, all others=true
// See: _bmad-output/planning-artifacts/architecture.md#Epic-9
package iflow
import (
"strings"
"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 implements thinking.ProviderApplier for iFlow models.
//
// iFlow-specific behavior:
// - GLM models: enable_thinking boolean + clear_thinking=false
// - MiniMax models: reasoning_split boolean
// - Level to boolean: none=false, others=true
// - No quantized support (only on/off)
type Applier struct{}
var _ thinking.ProviderApplier = (*Applier)(nil)
// NewApplier creates a new iFlow thinking applier.
func NewApplier() *Applier {
return &Applier{}
}
func init() {
thinking.RegisterProvider("iflow", NewApplier())
}
// Apply applies thinking configuration to iFlow request body.
//
// Expected output format (GLM):
//
// {
// "chat_template_kwargs": {
// "enable_thinking": true,
// "clear_thinking": false
// }
// }
//
// Expected output format (MiniMax):
//
// {
// "reasoning_split": 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 {
modelID := modelInfo.ID
if modelID == "" {
modelID = "unknown"
}
return nil, thinking.NewThinkingErrorWithModel(thinking.ErrThinkingNotSupported, "thinking not supported for this model", modelID)
}
if isGLMModel(modelInfo.ID) {
return applyGLM(body, config), nil
}
if isMiniMaxModel(modelInfo.ID) {
return applyMiniMax(body, config), nil
}
return body, nil
}
// configToBoolean converts ThinkingConfig to boolean for iFlow models.
//
// Conversion rules:
// - ModeNone: false
// - ModeAuto: true
// - ModeBudget + Budget=0: false
// - ModeBudget + Budget>0: true
// - ModeLevel + Level="none": false
// - ModeLevel + any other level: true
// - Default (unknown mode): true
func configToBoolean(config thinking.ThinkingConfig) bool {
switch config.Mode {
case thinking.ModeNone:
return false
case thinking.ModeAuto:
return true
case thinking.ModeBudget:
return config.Budget > 0
case thinking.ModeLevel:
return config.Level != thinking.LevelNone
default:
return true
}
}
// applyGLM applies thinking configuration for GLM models.
//
// Output format when enabled:
//
// {"chat_template_kwargs": {"enable_thinking": true, "clear_thinking": false}}
//
// Output format when disabled:
//
// {"chat_template_kwargs": {"enable_thinking": false}}
//
// Note: clear_thinking is only set when thinking is enabled, to preserve
// thinking output in the response.
func applyGLM(body []byte, config thinking.ThinkingConfig) []byte {
enableThinking := configToBoolean(config)
if len(body) == 0 || !gjson.ValidBytes(body) {
body = []byte(`{}`)
}
result, _ := sjson.SetBytes(body, "chat_template_kwargs.enable_thinking", enableThinking)
// clear_thinking only needed when thinking is enabled
if enableThinking {
result, _ = sjson.SetBytes(result, "chat_template_kwargs.clear_thinking", false)
}
return result
}
// applyMiniMax applies thinking configuration for MiniMax models.
//
// Output format:
//
// {"reasoning_split": true/false}
func applyMiniMax(body []byte, config thinking.ThinkingConfig) []byte {
reasoningSplit := configToBoolean(config)
if len(body) == 0 || !gjson.ValidBytes(body) {
body = []byte(`{}`)
}
result, _ := sjson.SetBytes(body, "reasoning_split", reasoningSplit)
return result
}
// isGLMModel determines if the model is a GLM series model.
// GLM models use chat_template_kwargs.enable_thinking format.
func isGLMModel(modelID string) bool {
return strings.HasPrefix(strings.ToLower(modelID), "glm")
}
// isMiniMaxModel determines if the model is a MiniMax series model.
// MiniMax models use reasoning_split format.
func isMiniMaxModel(modelID string) bool {
return strings.HasPrefix(strings.ToLower(modelID), "minimax")
}

View File

@@ -0,0 +1,328 @@
// Package iflow implements thinking configuration for iFlow models (GLM, MiniMax).
package iflow
import (
"bytes"
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/tidwall/gjson"
)
func TestNewApplier(t *testing.T) {
tests := []struct {
name string
}{
{"default"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
applier := NewApplier()
if applier == nil {
t.Fatalf("expected non-nil applier")
}
})
}
}
func TestApplierImplementsInterface(t *testing.T) {
tests := []struct {
name string
applier thinking.ProviderApplier
}{
{"default", NewApplier()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.applier == nil {
t.Fatalf("expected thinking.ProviderApplier implementation")
}
})
}
}
func TestApplyNilModelInfo(t *testing.T) {
applier := NewApplier()
tests := []struct {
name string
body []byte
}{
{"nil body", nil},
{"empty body", []byte{}},
{"json body", []byte(`{"model":"glm-4.6"}`)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := applier.Apply(tt.body, thinking.ThinkingConfig{}, nil)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if !bytes.Equal(got, tt.body) {
t.Fatalf("expected body unchanged, got %s", string(got))
}
})
}
}
func TestApplyMissingThinkingSupport(t *testing.T) {
applier := NewApplier()
tests := []struct {
name string
modelID string
wantModel string
}{
{"model id", "glm-4.6", "glm-4.6"},
{"empty model id", "", "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
modelInfo := &registry.ModelInfo{ID: tt.modelID}
got, err := applier.Apply([]byte(`{"model":"`+tt.modelID+`"}`), thinking.ThinkingConfig{}, modelInfo)
if err == nil {
t.Fatalf("expected error, got nil")
}
if got != nil {
t.Fatalf("expected nil body on error, got %s", string(got))
}
thinkingErr, ok := err.(*thinking.ThinkingError)
if !ok {
t.Fatalf("expected ThinkingError, got %T", err)
}
if thinkingErr.Code != thinking.ErrThinkingNotSupported {
t.Fatalf("expected code %s, got %s", thinking.ErrThinkingNotSupported, thinkingErr.Code)
}
if thinkingErr.Model != tt.wantModel {
t.Fatalf("expected model %s, got %s", tt.wantModel, thinkingErr.Model)
}
})
}
}
func TestConfigToBoolean(t *testing.T) {
tests := []struct {
name string
config thinking.ThinkingConfig
want bool
}{
{"mode none", thinking.ThinkingConfig{Mode: thinking.ModeNone}, false},
{"mode auto", thinking.ThinkingConfig{Mode: thinking.ModeAuto}, true},
{"budget zero", thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 0}, false},
{"budget positive", thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 1000}, true},
{"level none", thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelNone}, false},
{"level minimal", thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelMinimal}, true},
{"level low", thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelLow}, true},
{"level medium", thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelMedium}, true},
{"level high", thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelHigh}, true},
{"level xhigh", thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelXHigh}, true},
{"zero value config", thinking.ThinkingConfig{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := configToBoolean(tt.config); got != tt.want {
t.Fatalf("configToBoolean(%+v) = %v, want %v", tt.config, got, tt.want)
}
})
}
}
func TestApplyGLM(t *testing.T) {
applier := NewApplier()
tests := []struct {
name string
modelID string
body []byte
config thinking.ThinkingConfig
wantEnable bool
wantPreserve string
}{
{"mode none", "glm-4.6", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeNone}, false, ""},
{"level none", "glm-4.7", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelNone}, false, ""},
{"mode auto", "glm-4.6", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeAuto}, true, ""},
{"level minimal", "glm-4.6", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelMinimal}, true, ""},
{"level low", "glm-4.7", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelLow}, true, ""},
{"level medium", "glm-4.6", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelMedium}, true, ""},
{"level high", "GLM-4.6", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelHigh}, true, ""},
{"level xhigh", "glm-z1-preview", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelXHigh}, true, ""},
{"budget zero", "glm-4.6", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 0}, false, ""},
{"budget 1000", "glm-4.6", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 1000}, true, ""},
{"preserve fields", "glm-4.6", []byte(`{"model":"glm-4.6","extra":{"keep":true}}`), thinking.ThinkingConfig{Mode: thinking.ModeAuto}, true, "glm-4.6"},
{"empty body", "glm-4.6", nil, thinking.ThinkingConfig{Mode: thinking.ModeAuto}, true, ""},
{"malformed json", "glm-4.6", []byte(`{invalid`), thinking.ThinkingConfig{Mode: thinking.ModeAuto}, true, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
modelInfo := &registry.ModelInfo{
ID: tt.modelID,
Thinking: &registry.ThinkingSupport{},
}
got, err := applier.Apply(tt.body, tt.config, modelInfo)
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
if !gjson.ValidBytes(got) {
t.Fatalf("expected valid JSON, got %s", string(got))
}
enableResult := gjson.GetBytes(got, "chat_template_kwargs.enable_thinking")
if !enableResult.Exists() {
t.Fatalf("enable_thinking missing")
}
gotEnable := enableResult.Bool()
if gotEnable != tt.wantEnable {
t.Fatalf("enable_thinking = %v, want %v", gotEnable, tt.wantEnable)
}
// clear_thinking only set when enable_thinking=true
clearResult := gjson.GetBytes(got, "chat_template_kwargs.clear_thinking")
if tt.wantEnable {
if !clearResult.Exists() {
t.Fatalf("clear_thinking missing when enable_thinking=true")
}
if clearResult.Bool() {
t.Fatalf("clear_thinking = %v, want false", clearResult.Bool())
}
} else {
if clearResult.Exists() {
t.Fatalf("clear_thinking should not exist when enable_thinking=false")
}
}
if tt.wantPreserve != "" {
gotModel := gjson.GetBytes(got, "model").String()
if gotModel != tt.wantPreserve {
t.Fatalf("model = %q, want %q", gotModel, tt.wantPreserve)
}
if !gjson.GetBytes(got, "extra.keep").Bool() {
t.Fatalf("expected extra.keep preserved")
}
}
})
}
}
func TestApplyMiniMax(t *testing.T) {
applier := NewApplier()
tests := []struct {
name string
modelID string
body []byte
config thinking.ThinkingConfig
wantSplit bool
wantModel string
wantKeep bool
}{
{"mode none", "minimax-m2", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeNone}, false, "", false},
{"level none", "minimax-m2.1", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelNone}, false, "", false},
{"mode auto", "minimax-m2", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeAuto}, true, "", false},
{"level high", "MINIMAX-M2", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelHigh}, true, "", false},
{"level low", "minimax-m2.1", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelLow}, true, "", false},
{"level minimal", "minimax-m2", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelMinimal}, true, "", false},
{"level medium", "minimax-m2", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelMedium}, true, "", false},
{"level xhigh", "minimax-m2", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelXHigh}, true, "", false},
{"budget zero", "minimax-m2", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 0}, false, "", false},
{"budget 1000", "minimax-m2.1", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 1000}, true, "", false},
{"unknown level", "minimax-m2", []byte(`{}`), thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: "unknown"}, true, "", false},
{"preserve fields", "minimax-m2", []byte(`{"model":"minimax-m2","extra":{"keep":true}}`), thinking.ThinkingConfig{Mode: thinking.ModeAuto}, true, "minimax-m2", true},
{"empty body", "minimax-m2", nil, thinking.ThinkingConfig{Mode: thinking.ModeAuto}, true, "", false},
{"malformed json", "minimax-m2", []byte(`{invalid`), thinking.ThinkingConfig{Mode: thinking.ModeAuto}, true, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
modelInfo := &registry.ModelInfo{
ID: tt.modelID,
Thinking: &registry.ThinkingSupport{},
}
got, err := applier.Apply(tt.body, tt.config, modelInfo)
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
if !gjson.ValidBytes(got) {
t.Fatalf("expected valid JSON, got %s", string(got))
}
splitResult := gjson.GetBytes(got, "reasoning_split")
if !splitResult.Exists() {
t.Fatalf("reasoning_split missing")
}
// Verify JSON type is boolean, not string
if splitResult.Type != gjson.True && splitResult.Type != gjson.False {
t.Fatalf("reasoning_split should be boolean, got type %v", splitResult.Type)
}
gotSplit := splitResult.Bool()
if gotSplit != tt.wantSplit {
t.Fatalf("reasoning_split = %v, want %v", gotSplit, tt.wantSplit)
}
if tt.wantModel != "" {
gotModel := gjson.GetBytes(got, "model").String()
if gotModel != tt.wantModel {
t.Fatalf("model = %q, want %q", gotModel, tt.wantModel)
}
if tt.wantKeep && !gjson.GetBytes(got, "extra.keep").Bool() {
t.Fatalf("expected extra.keep preserved")
}
}
})
}
}
// TestIsGLMModel tests the GLM model detection.
//
// Depends on: Epic 9 Story 9-1
func TestIsGLMModel(t *testing.T) {
tests := []struct {
name string
model string
wantGLM bool
}{
{"glm-4.6", "glm-4.6", true},
{"glm-z1-preview", "glm-z1-preview", true},
{"glm uppercase", "GLM-4.7", true},
{"minimax-01", "minimax-01", false},
{"gpt-5.2", "gpt-5.2", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isGLMModel(tt.model); got != tt.wantGLM {
t.Fatalf("isGLMModel(%q) = %v, want %v", tt.model, got, tt.wantGLM)
}
})
}
}
// TestIsMiniMaxModel tests the MiniMax model detection.
//
// Depends on: Epic 9 Story 9-1
func TestIsMiniMaxModel(t *testing.T) {
tests := []struct {
name string
model string
wantMiniMax bool
}{
{"minimax-01", "minimax-01", true},
{"minimax uppercase", "MINIMAX-M2", true},
{"glm-4.6", "glm-4.6", false},
{"gpt-5.2", "gpt-5.2", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isMiniMaxModel(tt.model); got != tt.wantMiniMax {
t.Fatalf("isMiniMaxModel(%q) = %v, want %v", tt.model, got, tt.wantMiniMax)
}
})
}
}