mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-18 12:20:52 +08:00
refactor: improve thinking logic
This commit is contained in:
160
internal/thinking/provider/iflow/apply.go
Normal file
160
internal/thinking/provider/iflow/apply.go
Normal 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")
|
||||
}
|
||||
328
internal/thinking/provider/iflow/apply_test.go
Normal file
328
internal/thinking/provider/iflow/apply_test.go
Normal 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 := ®istry.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 := ®istry.ModelInfo{
|
||||
ID: tt.modelID,
|
||||
Thinking: ®istry.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 := ®istry.ModelInfo{
|
||||
ID: tt.modelID,
|
||||
Thinking: ®istry.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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user