From a005e5bb7082b6e61fa6a2baeb6e1cac9f1e5ece Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Sun, 22 Mar 2026 15:49:25 +0800 Subject: [PATCH 1/6] feat(providers): add extra_body config to inject custom fields into request body Allow configuring provider-specific fields like reasoning_split for minimax via the model config's extra_body map. These fields are merged into the request body last, giving them precedence over default values. Co-Authored-By: Claude Opus 4.6 --- pkg/config/config.go | 9 ++- pkg/config/config_test.go | 56 +++++++++++++ pkg/config/defaults.go | 1 + pkg/providers/factory_provider.go | 3 + pkg/providers/http_provider.go | 4 +- pkg/providers/openai_compat/provider.go | 13 +++ pkg/providers/openai_compat/provider_test.go | 84 ++++++++++++++++++++ 7 files changed, 165 insertions(+), 5 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index eab770991..c4f1e751f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -674,10 +674,11 @@ type ModelConfig struct { Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers // Optional optimizations - RPM int `json:"rpm,omitempty"` // Requests per minute limit - 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 + RPM int `json:"rpm,omitempty"` // Requests per minute limit + 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 + ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body } // Validate checks if the ModelConfig has all required fields. diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 45906ee70..678f02000 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1099,3 +1099,59 @@ func TestConfigLogLevelEmpty(t *testing.T) { t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Agents.Defaults.LogLevel) } } + +func TestDefaultConfig_MinimaxExtraBody(t *testing.T) { + cfg := DefaultConfig() + + var minimaxCfg *ModelConfig + for i := range cfg.ModelList { + if cfg.ModelList[i].Model == "minimax/MiniMax-M2.5" { + minimaxCfg = &cfg.ModelList[i] + break + } + } + if minimaxCfg == nil { + t.Fatal("Minimax model not found in ModelList") + } + if minimaxCfg.ExtraBody == nil { + t.Fatal("Minimax ExtraBody should not be nil") + } + if got, ok := minimaxCfg.ExtraBody["reasoning_split"]; !ok || got != true { + t.Fatalf("Minimax ExtraBody[reasoning_split] = %v, want true", got) + } +} + +func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + cfg := &Config{ + ModelList: []ModelConfig{ + { + ModelName: "test-model", + Model: "openai/test", + APIKey: "sk-test", + ExtraBody: map[string]any{"custom_field": "value", "num_field": 42}, + }, + }, + } + + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + loaded, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + + if loaded.ModelList[0].ExtraBody == nil { + t.Fatal("ExtraBody should not be nil after round-trip") + } + if got := loaded.ModelList[0].ExtraBody["custom_field"]; got != "value" { + t.Errorf("ExtraBody[custom_field] = %v, want value", got) + } + if got := loaded.ModelList[0].ExtraBody["num_field"]; got != float64(42) { + t.Errorf("ExtraBody[num_field] = %v, want 42", got) + } +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index f4056eca6..d96b139d1 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -376,6 +376,7 @@ func DefaultConfig() *Config { Model: "minimax/MiniMax-M2.5", APIBase: "https://api.minimaxi.com/v1", APIKey: "", + ExtraBody: map[string]any{"reasoning_split": true}, }, // LongCat - https://longcat.chat/platform diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index a7fef8f5b..98e781da3 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -93,6 +93,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, + cfg.ExtraBody, ), modelID, nil case "azure", "azure-openai": @@ -132,6 +133,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, + cfg.ExtraBody, ), modelID, nil case "anthropic": @@ -157,6 +159,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, + cfg.ExtraBody, ), modelID, nil case "anthropic-messages": diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 803165edb..f2ff52f1d 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -24,12 +24,13 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { } func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0) + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil) } func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( apiKey, apiBase, proxy, maxTokensField string, requestTimeoutSeconds int, + extraBody map[string]any, ) *HTTPProvider { return &HTTPProvider{ delegate: openai_compat.NewProvider( @@ -38,6 +39,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( proxy, openai_compat.WithMaxTokensField(maxTokensField), openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), + openai_compat.WithExtraBody(extraBody), ), } } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 938e4ea8b..90bc683b8 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -35,6 +35,7 @@ type Provider struct { apiBase string maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) httpClient *http.Client + extraBody map[string]any // Additional fields to inject into request body } type Option func(*Provider) @@ -55,6 +56,12 @@ func WithRequestTimeout(timeout time.Duration) Option { } } +func WithExtraBody(extraBody map[string]any) Option { + return func(p *Provider) { + p.extraBody = extraBody + } +} + func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { p := &Provider{ apiKey: apiKey, @@ -140,6 +147,12 @@ func (p *Provider) buildRequestBody( } } + // Merge extra body fields configured per-provider/model. + // These are injected last so they take precedence over defaults. + for k, v := range p.extraBody { + requestBody[k] = v + } + return requestBody } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index efb03ccb8..ab632ccf3 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -610,6 +610,90 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) { } } +func TestProviderChat_ExtraBodyInjected(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + extraBody := map[string]any{"reasoning_split": true, "custom_field": "test"} + p := NewProvider("key", server.URL, "", WithExtraBody(extraBody)) + + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "minimax/abab7", + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } + if got, ok := requestBody["custom_field"]; !ok || got != "test" { + t.Fatalf("custom_field = %v, want test", got) + } +} + +func TestProviderChat_ExtraBodyOverridesOptions(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + extraBody := map[string]any{"temperature": 0.9} + p := NewProvider("key", server.URL, "", WithExtraBody(extraBody)) + + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "gpt-4o", + map[string]any{"temperature": 0.5}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // ExtraBody takes precedence over options since it is merged last. + if got := requestBody["temperature"]; got != float64(0.9) { + t.Fatalf("temperature = %v, want 0.9 (from extraBody, overriding options)", got) + } +} + type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { From de0364c8ec7bd829be81d27db9a1acda97324484 Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Sun, 22 Mar 2026 20:37:06 +0800 Subject: [PATCH 2/6] Move minimax reasoning_split injection to provider factory Inject reasoning_split at provider creation time to allow user ExtraBody settings to be preserved --- pkg/config/config_test.go | 21 ------ pkg/config/defaults.go | 1 - pkg/providers/factory_provider.go | 27 +++++++- pkg/providers/factory_provider_test.go | 96 ++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 23 deletions(-) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 678f02000..0c7e0c002 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1100,27 +1100,6 @@ func TestConfigLogLevelEmpty(t *testing.T) { } } -func TestDefaultConfig_MinimaxExtraBody(t *testing.T) { - cfg := DefaultConfig() - - var minimaxCfg *ModelConfig - for i := range cfg.ModelList { - if cfg.ModelList[i].Model == "minimax/MiniMax-M2.5" { - minimaxCfg = &cfg.ModelList[i] - break - } - } - if minimaxCfg == nil { - t.Fatal("Minimax model not found in ModelList") - } - if minimaxCfg.ExtraBody == nil { - t.Fatal("Minimax ExtraBody should not be nil") - } - if got, ok := minimaxCfg.ExtraBody["reasoning_split"]; !ok || got != true { - t.Fatalf("Minimax ExtraBody[reasoning_split] = %v, want true", got) - } -} - func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index d96b139d1..f4056eca6 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -376,7 +376,6 @@ func DefaultConfig() *Config { Model: "minimax/MiniMax-M2.5", APIBase: "https://api.minimaxi.com/v1", APIKey: "", - ExtraBody: map[string]any{"reasoning_split": true}, }, // LongCat - https://longcat.chat/platform diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 98e781da3..7e33f4d17 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -117,7 +117,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", "qwen-intl", "qwen-international", "dashscope-intl", - "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "coding-plan", "alibaba-coding", "qwen-coding": // All other OpenAI-compatible HTTP providers if cfg.APIKey == "" && cfg.APIBase == "" { @@ -136,6 +136,31 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.ExtraBody, ), modelID, nil + case "minimax": + // Minimax requires reasoning_split: true in the request body + 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) + } + extraBody := cfg.ExtraBody + if extraBody == nil { + extraBody = make(map[string]any) + } + if _, ok := extraBody["reasoning_split"]; !ok { + extraBody["reasoning_split"] = true + } + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey, + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, + extraBody, + ), modelID, nil + case "anthropic": if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { // Use OAuth credentials from auth store diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 8b9ddeecd..cdc2cea8f 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -6,6 +6,7 @@ package providers import ( + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -603,3 +604,98 @@ func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { } } } + +func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-minimax", + Model: "minimax/MiniMax-M2.5", + APIKey: "test-key", + APIBase: server.URL, + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "MiniMax-M2.5" { + t.Errorf("modelID = %q, want %q", modelID, "MiniMax-M2.5") + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Verify reasoning_split is automatically injected + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } +} + +func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-minimax-custom", + Model: "minimax/MiniMax-M2.5", + APIKey: "test-key", + APIBase: server.URL, + ExtraBody: map[string]any{"custom_field": "test"}, + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Verify reasoning_split is automatically injected + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } + // Verify user's custom field is preserved + if got, ok := requestBody["custom_field"]; !ok || got != "test" { + t.Fatalf("custom_field = %v, want test", got) + } +} From 8a046e951a4ae0e2b1373b03de6bdca64414fa2f Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Sun, 22 Mar 2026 15:49:25 +0800 Subject: [PATCH 3/6] feat(providers): add extra_body config to inject custom fields into request body Allow configuring provider-specific fields like reasoning_split for minimax via the model config's extra_body map. These fields are merged into the request body last, giving them precedence over default values. Co-Authored-By: Claude Opus 4.6 --- pkg/config/config.go | 11 ++- pkg/config/config_test.go | 56 +++++++++++++ pkg/config/defaults.go | 1 + pkg/providers/factory_provider.go | 3 + pkg/providers/http_provider.go | 4 +- pkg/providers/openai_compat/provider.go | 13 +++ pkg/providers/openai_compat/provider_test.go | 84 ++++++++++++++++++++ web/backend/api/models.go | 17 ++-- web/frontend/src/api/models.ts | 1 + 9 files changed, 179 insertions(+), 11 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index c56c2645e..4dd1f9609 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -936,10 +936,11 @@ type ModelConfig struct { Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers // Optional optimizations - RPM int `json:"rpm,omitempty"` // Requests per minute limit - 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 + RPM int `json:"rpm,omitempty"` // Requests per minute limit + 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 + ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body // from security secModelName string @@ -2079,6 +2080,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, + ExtraBody: m.ExtraBody, } expanded = append(expanded, additionalEntry) fallbackNames = append(fallbackNames, expandedName) @@ -2097,6 +2099,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, + ExtraBody: m.ExtraBody, apiKeys: []string{keys[0]}, } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index a4c207470..429930eda 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1193,3 +1193,59 @@ func TestConfigLogLevelEmpty(t *testing.T) { t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Gateway.LogLevel) } } + +func TestDefaultConfig_MinimaxExtraBody(t *testing.T) { + cfg := DefaultConfig() + + var minimaxCfg *ModelConfig + for i := range cfg.ModelList { + if cfg.ModelList[i].Model == "minimax/MiniMax-M2.5" { + minimaxCfg = &cfg.ModelList[i] + break + } + } + if minimaxCfg == nil { + t.Fatal("Minimax model not found in ModelList") + } + if minimaxCfg.ExtraBody == nil { + t.Fatal("Minimax ExtraBody should not be nil") + } + if got, ok := minimaxCfg.ExtraBody["reasoning_split"]; !ok || got != true { + t.Fatalf("Minimax ExtraBody[reasoning_split] = %v, want true", got) + } +} + +func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + cfg := &Config{ + ModelList: []ModelConfig{ + { + ModelName: "test-model", + Model: "openai/test", + APIKey: "sk-test", + ExtraBody: map[string]any{"custom_field": "value", "num_field": 42}, + }, + }, + } + + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + loaded, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + + if loaded.ModelList[0].ExtraBody == nil { + t.Fatal("ExtraBody should not be nil after round-trip") + } + if got := loaded.ModelList[0].ExtraBody["custom_field"]; got != "value" { + t.Errorf("ExtraBody[custom_field] = %v, want value", got) + } + if got := loaded.ModelList[0].ExtraBody["num_field"]; got != float64(42) { + t.Errorf("ExtraBody[num_field] = %v, want 42", got) + } +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 18e0bbfd4..2a086821a 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -339,6 +339,7 @@ func DefaultConfig() *Config { ModelName: "MiniMax-M2.5", Model: "minimax/MiniMax-M2.5", APIBase: "https://api.minimaxi.com/v1", + ExtraBody: map[string]any{"reasoning_split": true}, }, // LongCat - https://longcat.chat/platform diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 8a18f8fe7..55d5fd10e 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -93,6 +93,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, + cfg.ExtraBody, ), modelID, nil case "azure", "azure-openai": @@ -132,6 +133,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, + cfg.ExtraBody, ), modelID, nil case "anthropic": @@ -157,6 +159,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, + cfg.ExtraBody, ), modelID, nil case "anthropic-messages": diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 803165edb..f2ff52f1d 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -24,12 +24,13 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { } func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0) + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil) } func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( apiKey, apiBase, proxy, maxTokensField string, requestTimeoutSeconds int, + extraBody map[string]any, ) *HTTPProvider { return &HTTPProvider{ delegate: openai_compat.NewProvider( @@ -38,6 +39,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( proxy, openai_compat.WithMaxTokensField(maxTokensField), openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), + openai_compat.WithExtraBody(extraBody), ), } } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 938e4ea8b..90bc683b8 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -35,6 +35,7 @@ type Provider struct { apiBase string maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) httpClient *http.Client + extraBody map[string]any // Additional fields to inject into request body } type Option func(*Provider) @@ -55,6 +56,12 @@ func WithRequestTimeout(timeout time.Duration) Option { } } +func WithExtraBody(extraBody map[string]any) Option { + return func(p *Provider) { + p.extraBody = extraBody + } +} + func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { p := &Provider{ apiKey: apiKey, @@ -140,6 +147,12 @@ func (p *Provider) buildRequestBody( } } + // Merge extra body fields configured per-provider/model. + // These are injected last so they take precedence over defaults. + for k, v := range p.extraBody { + requestBody[k] = v + } + return requestBody } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index efb03ccb8..ab632ccf3 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -610,6 +610,90 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) { } } +func TestProviderChat_ExtraBodyInjected(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + extraBody := map[string]any{"reasoning_split": true, "custom_field": "test"} + p := NewProvider("key", server.URL, "", WithExtraBody(extraBody)) + + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "minimax/abab7", + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } + if got, ok := requestBody["custom_field"]; !ok || got != "test" { + t.Fatalf("custom_field = %v, want test", got) + } +} + +func TestProviderChat_ExtraBodyOverridesOptions(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + extraBody := map[string]any{"temperature": 0.9} + p := NewProvider("key", server.URL, "", WithExtraBody(extraBody)) + + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "gpt-4o", + map[string]any{"temperature": 0.5}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // ExtraBody takes precedence over options since it is merged last. + if got := requestBody["temperature"]; got != float64(0.9) { + t.Fatalf("temperature = %v, want 0.9 (from extraBody, overriding options)", got) + } +} + type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { diff --git a/web/backend/api/models.go b/web/backend/api/models.go index dd71ad25a..802b28526 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -31,12 +31,13 @@ type modelResponse struct { Proxy string `json:"proxy,omitempty"` AuthMethod string `json:"auth_method,omitempty"` // Advanced fields - ConnectMode string `json:"connect_mode,omitempty"` - Workspace string `json:"workspace,omitempty"` - RPM int `json:"rpm,omitempty"` - MaxTokensField string `json:"max_tokens_field,omitempty"` - RequestTimeout int `json:"request_timeout,omitempty"` - ThinkingLevel string `json:"thinking_level,omitempty"` + ConnectMode string `json:"connect_mode,omitempty"` + Workspace string `json:"workspace,omitempty"` + RPM int `json:"rpm,omitempty"` + MaxTokensField string `json:"max_tokens_field,omitempty"` + RequestTimeout int `json:"request_timeout,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` + ExtraBody map[string]any `json:"extra_body,omitempty"` // Meta Configured bool `json:"configured"` IsDefault bool `json:"is_default"` @@ -81,6 +82,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, + ExtraBody: m.ExtraBody, Configured: configured[i], IsDefault: m.ModelName == defaultModel, }) @@ -183,6 +185,9 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { if mc.APIKey() == "" { mc.SetAPIKey(cfg.ModelList[idx].APIKey()) } + if mc.ExtraBody == nil { + mc.ExtraBody = cfg.ModelList[idx].ExtraBody + } cfg.ModelList[idx] = &mc diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 8e49b48b4..ff8c2e049 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -17,6 +17,7 @@ export interface ModelInfo { max_tokens_field?: string request_timeout?: number thinking_level?: string + extra_body?: Record // Meta configured: boolean is_default: boolean From 53c6dd3812408444f4561463611495665e5bc514 Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Sun, 22 Mar 2026 20:37:06 +0800 Subject: [PATCH 4/6] Move minimax reasoning_split injection to provider factory Inject reasoning_split at provider creation time to allow user ExtraBody settings to be preserved --- pkg/config/config_test.go | 21 ------ pkg/providers/factory_provider.go | 27 +++++++- pkg/providers/factory_provider_test.go | 96 ++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 22 deletions(-) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 429930eda..5fc0fe8fc 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1194,27 +1194,6 @@ func TestConfigLogLevelEmpty(t *testing.T) { } } -func TestDefaultConfig_MinimaxExtraBody(t *testing.T) { - cfg := DefaultConfig() - - var minimaxCfg *ModelConfig - for i := range cfg.ModelList { - if cfg.ModelList[i].Model == "minimax/MiniMax-M2.5" { - minimaxCfg = &cfg.ModelList[i] - break - } - } - if minimaxCfg == nil { - t.Fatal("Minimax model not found in ModelList") - } - if minimaxCfg.ExtraBody == nil { - t.Fatal("Minimax ExtraBody should not be nil") - } - if got, ok := minimaxCfg.ExtraBody["reasoning_split"]; !ok || got != true { - t.Fatalf("Minimax ExtraBody[reasoning_split] = %v, want true", got) - } -} - func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 55d5fd10e..68335a108 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -117,7 +117,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", "qwen-intl", "qwen-international", "dashscope-intl", - "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "coding-plan", "alibaba-coding", "qwen-coding": // All other OpenAI-compatible HTTP providers if cfg.APIKey() == "" && cfg.APIBase == "" { @@ -136,6 +136,31 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.ExtraBody, ), modelID, nil + case "minimax": + // Minimax requires reasoning_split: true in the request body + 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) + } + extraBody := cfg.ExtraBody + if extraBody == nil { + extraBody = make(map[string]any) + } + if _, ok := extraBody["reasoning_split"]; !ok { + extraBody["reasoning_split"] = true + } + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey, + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, + extraBody, + ), modelID, nil + case "anthropic": if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { // Use OAuth credentials from auth store diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index fb980f32f..06025fba2 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -6,6 +6,7 @@ package providers import ( + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -604,3 +605,98 @@ func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { } } } + +func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-minimax", + Model: "minimax/MiniMax-M2.5", + APIKey: "test-key", + APIBase: server.URL, + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "MiniMax-M2.5" { + t.Errorf("modelID = %q, want %q", modelID, "MiniMax-M2.5") + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Verify reasoning_split is automatically injected + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } +} + +func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-minimax-custom", + Model: "minimax/MiniMax-M2.5", + APIKey: "test-key", + APIBase: server.URL, + ExtraBody: map[string]any{"custom_field": "test"}, + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Verify reasoning_split is automatically injected + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } + // Verify user's custom field is preserved + if got, ok := requestBody["custom_field"]; !ok || got != "test" { + t.Fatalf("custom_field = %v, want test", got) + } +} From 2d9517c6550297428044286c1a6f954e16edb01a Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Mon, 23 Mar 2026 15:51:13 +0800 Subject: [PATCH 5/6] Use getter/setter methods for API key access in ModelConfig --- pkg/config/config.go | 4 ++-- pkg/config/config_test.go | 4 ++-- pkg/config/defaults.go | 2 +- pkg/providers/factory_provider.go | 4 ++-- pkg/providers/factory_provider_test.go | 4 ++-- web/frontend/src/api/models.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 4dd1f9609..33919d9d7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -939,8 +939,8 @@ type ModelConfig struct { RPM int `json:"rpm,omitempty"` // Requests per minute limit 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 - ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body + ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive + ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body // from security secModelName string diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 5fc0fe8fc..9bd27e5eb 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1199,11 +1199,11 @@ func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { cfgPath := filepath.Join(dir, "config.json") cfg := &Config{ - ModelList: []ModelConfig{ + ModelList: []*ModelConfig{ { ModelName: "test-model", Model: "openai/test", - APIKey: "sk-test", + apiKeys: []string{"sk-test"}, ExtraBody: map[string]any{"custom_field": "value", "num_field": 42}, }, }, diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 2a086821a..ccfd5732a 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -339,7 +339,7 @@ func DefaultConfig() *Config { ModelName: "MiniMax-M2.5", Model: "minimax/MiniMax-M2.5", APIBase: "https://api.minimaxi.com/v1", - ExtraBody: map[string]any{"reasoning_split": true}, + ExtraBody: map[string]any{"reasoning_split": true}, }, // LongCat - https://longcat.chat/platform diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 68335a108..bc7c2ff70 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -138,7 +138,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "minimax": // Minimax requires reasoning_split: true in the request body - if cfg.APIKey == "" && cfg.APIBase == "" { + 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 @@ -153,7 +153,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err extraBody["reasoning_split"] = true } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, + cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 06025fba2..1bff0419d 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -622,9 +622,9 @@ func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-minimax", Model: "minimax/MiniMax-M2.5", - APIKey: "test-key", APIBase: server.URL, } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -670,10 +670,10 @@ func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-minimax-custom", Model: "minimax/MiniMax-M2.5", - APIKey: "test-key", APIBase: server.URL, ExtraBody: map[string]any{"custom_field": "test"}, } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index ff8c2e049..2fd042593 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -17,7 +17,7 @@ export interface ModelInfo { max_tokens_field?: string request_timeout?: number thinking_level?: string - extra_body?: Record + extra_body?: Record // Meta configured: boolean is_default: boolean From b24c577e38e96624a936eaceca9d648785236e15 Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Mon, 23 Mar 2026 16:29:25 +0800 Subject: [PATCH 6/6] Add security config to ExtraBody round-trip test --- pkg/config/config_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 9bd27e5eb..0af14588b 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1207,6 +1207,9 @@ func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { ExtraBody: map[string]any{"custom_field": "value", "num_field": 42}, }, }, + security: &SecurityConfig{ + ModelList: map[string]ModelSecurityEntry{"test-model:0": {APIKeys: []string{"sk-test"}}}, + }, } if err := SaveConfig(cfgPath, cfg); err != nil {