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:
parent
19835b2f60
commit
924d58c3e4
5 changed files with 82 additions and 121 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue