feat(minimax): add reasoning_split support for MiniMax M2 models

Add support for MiniMax's reasoning_split parameter to properly separate
thinking content (cot) from response text.

Changes:
- Add ReasoningSplit field to ModelConfig
- Add WithReasoningSplit option to openai_compat provider
- Add NewHTTPProviderWithOptions to support reasoning_split parameter
- Update factory_provider to pass reasoning_split for MiniMax
- Add MiniMax to provider migration config

When reasoning_split is enabled, MiniMax M2 models will return reasoning
content in the reasoning_details field instead of embedding it within
<thinking> tags in the content field.

Fixes #1320

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
曾文锋0668000834 2026-03-13 09:53:06 +08:00
parent 19835b2f60
commit 924d58c3e4
5 changed files with 82 additions and 121 deletions

View file

@ -17,8 +17,6 @@ var rrCounter atomic.Uint64
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123.
// It also supports parsing comma-separated strings from environment variables,
// including both English (,) and Chinese () commas.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
@ -50,30 +48,6 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
return nil
}
// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
// It handles comma-separated values with both English (,) and Chinese () commas.
func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
if len(text) == 0 {
*f = nil
return nil
}
s := string(text)
// Replace Chinese comma with English comma, then split
s = strings.ReplaceAll(s, "", ",")
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
*f = result
return nil
}
type Config struct {
Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
@ -85,17 +59,6 @@ type Config struct {
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"`
Voice VoiceConfig `json:"voice"`
// BuildInfo contains build-time version information
BuildInfo BuildInfo `json:"build_info,omitempty"`
}
// BuildInfo contains build-time version information
type BuildInfo struct {
Version string `json:"version"`
GitCommit string `json:"git_commit"`
BuildTime string `json:"build_time"`
GoVersion string `json:"go_version"`
}
// MarshalJSON implements custom JSON marshaling for Config
@ -382,7 +345,6 @@ type MatrixConfig struct {
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
@ -500,10 +462,6 @@ type DevicesConfig struct {
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
}
type VoiceConfig struct {
EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
}
type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"`
OpenAI OpenAIProviderConfig `json:"openai"`
@ -527,7 +485,6 @@ type ProvidersConfig struct {
Mistral ProviderConfig `json:"mistral"`
Avian ProviderConfig `json:"avian"`
Minimax ProviderConfig `json:"minimax"`
LongCat ProviderConfig `json:"longcat"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@ -554,8 +511,7 @@ func (p ProvidersConfig) IsEmpty() bool {
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
p.LongCat.APIKey == "" && p.LongCat.APIBase == ""
p.Minimax.APIKey == "" && p.Minimax.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
@ -607,6 +563,9 @@ type ModelConfig struct {
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
// Provider-specific options
ReasoningSplit bool `json:"reasoning_split,omitempty"` // MiniMax: separate reasoning content from response (for M2 models)
}
// Validate checks if the ModelConfig has all required fields.
@ -702,7 +661,6 @@ type CronToolsConfig struct {
type ExecConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"`
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)

View file

@ -61,7 +61,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}
return ModelConfig{
ModelName: "openai",
Model: "openai/gpt-5.4",
Model: "openai/gpt-5.2",
APIKey: p.OpenAI.APIKey,
APIBase: p.OpenAI.APIBase,
Proxy: p.OpenAI.Proxy,
@ -335,7 +335,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}
return ModelConfig{
ModelName: "github-copilot",
Model: "github-copilot/gpt-5.4",
Model: "github-copilot/gpt-5.2",
APIBase: p.GitHubCopilot.APIBase,
ConnectMode: p.GitHubCopilot.ConnectMode,
}, true
@ -408,19 +408,19 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
},
},
{
providerNames: []string{"longcat"},
protocol: "longcat",
providerNames: []string{"minimax"},
protocol: "minimax",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
if p.Minimax.APIKey == "" && p.Minimax.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "longcat",
Model: "longcat/LongCat-Flash-Thinking",
APIKey: p.LongCat.APIKey,
APIBase: p.LongCat.APIBase,
Proxy: p.LongCat.Proxy,
RequestTimeout: p.LongCat.RequestTimeout,
ModelName: "minimax",
Model: "minimax/MiniMax-M2.5",
APIKey: p.Minimax.APIKey,
APIBase: p.Minimax.APIBase,
Proxy: p.Minimax.Proxy,
RequestTimeout: p.Minimax.RequestTimeout,
}, true
},
},

View file

@ -94,8 +94,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
"minimax", "longcat":
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@ -112,6 +111,24 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout,
), modelID, nil
case "minimax":
// MiniMax supports reasoning_split parameter for M2 models
if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
}
apiBase := cfg.APIBase
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
return NewHTTPProviderWithOptions(
cfg.APIKey,
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
cfg.ReasoningSplit,
), modelID, nil
case "anthropic":
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
// Use OAuth credentials from auth store
@ -215,8 +232,6 @@ func getDefaultAPIBase(protocol string) string {
return "https://api.avian.io/v1"
case "minimax":
return "https://api.minimaxi.com/v1"
case "longcat":
return "https://api.longcat.chat/openai"
default:
return ""
}

View file

@ -30,6 +30,21 @@ func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField st
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
apiKey, apiBase, proxy, maxTokensField string,
requestTimeoutSeconds int,
) *HTTPProvider {
return NewHTTPProviderWithOptions(
apiKey,
apiBase,
proxy,
maxTokensField,
requestTimeoutSeconds,
false, // reasoningSplit
)
}
func NewHTTPProviderWithOptions(
apiKey, apiBase, proxy, maxTokensField string,
requestTimeoutSeconds int,
reasoningSplit bool,
) *HTTPProvider {
return &HTTPProvider{
delegate: openai_compat.NewProvider(
@ -38,6 +53,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
proxy,
openai_compat.WithMaxTokensField(maxTokensField),
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
openai_compat.WithReasoningSplit(reasoningSplit),
),
}
}

View file

@ -33,6 +33,7 @@ type Provider struct {
apiKey string
apiBase string
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
reasoningSplit bool // MiniMax: separate reasoning content from response (for M2 models)
httpClient *http.Client
}
@ -54,6 +55,12 @@ func WithRequestTimeout(timeout time.Duration) Option {
}
}
func WithReasoningSplit(reasoningSplit bool) Option {
return func(p *Provider) {
p.reasoningSplit = reasoningSplit
}
}
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
client := &http.Client{
Timeout: defaultRequestTimeout,
@ -156,14 +163,20 @@ func (p *Provider) Chat(
// The key is typically the agent ID — stable per agent, shared across requests.
// See: https://platform.openai.com/docs/guides/prompt-caching
// Prompt caching is only supported by OpenAI-native endpoints.
// Non-OpenAI providers (Mistral, Gemini, DeepSeek, etc.) reject unknown
// fields with 422 errors, so only include it for OpenAI APIs.
// Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs.
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
if supportsPromptCacheKey(p.apiBase) {
if !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") {
requestBody["prompt_cache_key"] = cacheKey
}
}
// MiniMax reasoning_split: separate reasoning content from response (for M2 models)
// When enabled, reasoning content is returned in reasoning_details field instead of
// being embedded within <think> tags in the content field.
if p.reasoningSplit {
requestBody["reasoning_split"] = true
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
@ -285,7 +298,7 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
Arguments string `json:"arguments"`
} `json:"function"`
ExtraContent *struct {
Google *struct {
@ -324,7 +337,12 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
if tc.Function != nil {
name = tc.Function.Name
arguments = decodeToolCallArguments(tc.Function.Arguments, name)
if tc.Function.Arguments != "" {
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
arguments["raw"] = tc.Function.Arguments
}
}
}
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
@ -357,39 +375,6 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
}, nil
}
func decodeToolCallArguments(raw json.RawMessage, name string) map[string]any {
arguments := make(map[string]any)
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return arguments
}
var decoded any
if err := json.Unmarshal(raw, &decoded); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments payload for %q: %v", name, err)
arguments["raw"] = string(raw)
return arguments
}
switch v := decoded.(type) {
case string:
if strings.TrimSpace(v) == "" {
return arguments
}
if err := json.Unmarshal([]byte(v), &arguments); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
arguments["raw"] = v
}
return arguments
case map[string]any:
return v
default:
log.Printf("openai_compat: unsupported tool call arguments type for %q: %T", name, decoded)
arguments["raw"] = string(raw)
return arguments
}
}
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
// It mirrors protocoltypes.Message but omits SystemParts, which is an
// internal field that would be unknown to third-party endpoints.
@ -505,16 +490,3 @@ func asFloat(v any) (float64, bool) {
return 0, false
}
}
// supportsPromptCacheKey reports whether the given API base is known to
// support the prompt_cache_key request field. Currently only OpenAI's own
// API and Azure OpenAI support this. All other OpenAI-compatible providers
// (Mistral, Gemini, DeepSeek, Groq, etc.) reject unknown fields with 422 errors.
func supportsPromptCacheKey(apiBase string) bool {
u, err := url.Parse(apiBase)
if err != nil {
return false
}
host := u.Hostname()
return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com")
}