From 924d58c3e4786f365ed893a18bd7f0420edc083c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E6=96=87=E9=94=8B0668000834?= Date: Fri, 13 Mar 2026 09:53:06 +0800 Subject: [PATCH] feat(minimax): add reasoning_split support for MiniMax M2 models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 tags in the content field. Fixes #1320 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pkg/config/config.go | 66 ++++----------------- pkg/config/migration.go | 22 +++---- pkg/providers/factory_provider.go | 23 ++++++-- pkg/providers/http_provider.go | 16 ++++++ pkg/providers/openai_compat/provider.go | 76 ++++++++----------------- 5 files changed, 82 insertions(+), 121 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 7a7edb489..90399c420 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 @@ -376,17 +339,16 @@ type SlackConfig struct { } type MatrixConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` - Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` - UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` - 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"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` + Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` + UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` + 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"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` } type LINEConfig struct { @@ -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) diff --git a/pkg/config/migration.go b/pkg/config/migration.go index af6391651..20438e7d9 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -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 }, }, diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 9749e7a15..e85eaaa40 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -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 "" } diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 5c328f418..03ad1685d 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -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), ), } } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index f97bf3acd..f8361e432 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -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 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) @@ -284,8 +297,8 @@ func parseResponse(body io.Reader) (*LLMResponse, error) { ID string `json:"id"` Type string `json:"type"` Function *struct { - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` + Name string `json:"name"` + 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") -}