From a005e5bb7082b6e61fa6a2baeb6e1cac9f1e5ece Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Sun, 22 Mar 2026 15:49:25 +0800 Subject: [PATCH 01/15] 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 02/15] 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 df17684dd4bedb89a54b01a0dcd430670207ea54 Mon Sep 17 00:00:00 2001 From: Cytown Date: Mon, 23 Mar 2026 15:40:17 +0800 Subject: [PATCH 03/15] implement panic log for gateway and launcher add file logger to gateway ref issue: #1734 Signed-off-by: Cytown --- cmd/picoclaw/internal/gateway/command.go | 2 +- pkg/gateway/gateway.go | 18 +++++++++++- pkg/logger/panic.go | 36 ++++++++++++++++++++++++ pkg/logger/panic_unix.go | 21 ++++++++++++++ pkg/logger/panic_win.go | 25 ++++++++++++++++ pkg/logger/syscall_amd64.go | 12 ++++++++ pkg/logger/syscall_arm64.go | 12 ++++++++ pkg/logger/syscall_darwin.go | 12 ++++++++ pkg/logger/syscall_loong64.go | 12 ++++++++ web/backend/main.go | 20 +++++++++---- 10 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 pkg/logger/panic.go create mode 100644 pkg/logger/panic_unix.go create mode 100644 pkg/logger/panic_win.go create mode 100644 pkg/logger/syscall_amd64.go create mode 100644 pkg/logger/syscall_arm64.go create mode 100644 pkg/logger/syscall_darwin.go create mode 100644 pkg/logger/syscall_loong64.go diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 4812f1bee..7fa588c5c 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -34,7 +34,7 @@ func NewGatewayCommand() *cobra.Command { return nil }, RunE: func(_ *cobra.Command, _ []string) error { - return gateway.Run(debug, internal.GetConfigPath(), allowEmpty) + return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty) }, } diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 454ee2c48..fc2465747 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -47,6 +47,10 @@ const ( serviceShutdownTimeout = 30 * time.Second providerReloadTimeout = 30 * time.Second gracefulShutdownTimeout = 15 * time.Second + + logPath = "logs" + panicFile = "gateway_panic.log" + logFile = "gateway.log" ) type services struct { @@ -79,7 +83,19 @@ func (p *startupBlockedProvider) GetDefaultModel() string { } // Run starts the gateway runtime using the configuration loaded from configPath. -func Run(debug bool, configPath string, allowEmptyStartup bool) error { +func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error { + panicPath := filepath.Join(homePath, logPath, panicFile) + panicFunc, err := logger.InitPanic(panicPath) + if err != nil { + return fmt.Errorf("error initializing panic log: %w", err) + } + defer panicFunc() + + if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil { + panic(fmt.Sprintf("error enabling file logging: %v", err)) + } + defer logger.DisableFileLogging() + cfg, err := config.LoadConfig(configPath) if err != nil { return fmt.Errorf("error loading config: %w", err) diff --git a/pkg/logger/panic.go b/pkg/logger/panic.go new file mode 100644 index 000000000..e53e4351a --- /dev/null +++ b/pkg/logger/panic.go @@ -0,0 +1,36 @@ +package logger + +import ( + "fmt" + "os" + "path/filepath" + "runtime/debug" + "time" +) + +func InitPanic(filePath string) (func(), error) { + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + writer := initPanicFile(filePath) + if writer == nil { + return nil, fmt.Errorf("failed to create log file: %s", filePath) + } + return func() { + defer writer.Close() + if err := recover(); err != nil { + now := time.Now().Format("2006-01-02 15:04:05") + stack := debug.Stack() + logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf( + "%v", + err, + ) + "\n" + string( + stack, + ) + + writer.Write([]byte(logMsg)) + + os.Exit(1) + } + }, nil +} diff --git a/pkg/logger/panic_unix.go b/pkg/logger/panic_unix.go new file mode 100644 index 000000000..1178f6a5a --- /dev/null +++ b/pkg/logger/panic_unix.go @@ -0,0 +1,21 @@ +//go:build !windows +// +build !windows + +package logger + +import ( + "fmt" + "io" + "os" +) + +func initPanicFile(panicFile string) io.WriteCloser { + file, err := os.OpenFile(panicFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND|os.O_SYNC, 0o600) + if err != nil { + panic(fmt.Sprintf("error in open panic: %v", err)) + } + if err = Dup2(int(file.Fd()), int(os.Stderr.Fd())); err != nil { + panic(fmt.Sprintf("error in syscall.Dup2: %v", err)) + } + return file +} diff --git a/pkg/logger/panic_win.go b/pkg/logger/panic_win.go new file mode 100644 index 000000000..29d3f21d8 --- /dev/null +++ b/pkg/logger/panic_win.go @@ -0,0 +1,25 @@ +//go:build windows +// +build windows + +package logger + +import ( + "fmt" + "io" + "os" + + "golang.org/x/sys/windows" +) + +func initPanicFile(panicFile string) io.WriteCloser { + file, err := os.OpenFile(panicFile, os.O_WRONLY|os.O_CREATE|os.O_SYNC|os.O_APPEND, 0600) + if err != nil { + panic(fmt.Sprintf("error in open panic: %v", err)) + } + err = windows.SetStdHandle(windows.STD_ERROR_HANDLE, windows.Handle(file.Fd())) + if err != nil { + panic(fmt.Sprintf("Failed to redirect stderr to file: %v", err)) + } + os.Stderr = file + return file +} diff --git a/pkg/logger/syscall_amd64.go b/pkg/logger/syscall_amd64.go new file mode 100644 index 000000000..e862b4578 --- /dev/null +++ b/pkg/logger/syscall_amd64.go @@ -0,0 +1,12 @@ +//go:build linux && amd64 +// +build linux,amd64 + +package logger + +import ( + "syscall" +) + +func Dup2(oldfd int, newfd int) error { + return syscall.Dup2(oldfd, newfd) +} diff --git a/pkg/logger/syscall_arm64.go b/pkg/logger/syscall_arm64.go new file mode 100644 index 000000000..9854bc85e --- /dev/null +++ b/pkg/logger/syscall_arm64.go @@ -0,0 +1,12 @@ +//go:build linux && arm64 +// +build linux,arm64 + +package logger + +import ( + "syscall" +) + +func Dup2(oldfd int, newfd int) error { + return syscall.Dup3(oldfd, newfd, 0) +} diff --git a/pkg/logger/syscall_darwin.go b/pkg/logger/syscall_darwin.go new file mode 100644 index 000000000..80306b521 --- /dev/null +++ b/pkg/logger/syscall_darwin.go @@ -0,0 +1,12 @@ +//go:build darwin +// +build darwin + +package logger + +import ( + "syscall" +) + +func Dup2(oldfd int, newfd int) error { + return syscall.Dup2(oldfd, newfd) +} diff --git a/pkg/logger/syscall_loong64.go b/pkg/logger/syscall_loong64.go new file mode 100644 index 000000000..6f36d26a4 --- /dev/null +++ b/pkg/logger/syscall_loong64.go @@ -0,0 +1,12 @@ +//go:build linux && loong64 +// +build linux,loong64 + +package logger + +import ( + "syscall" +) + +func Dup2(oldfd int, newfd int) error { + return syscall.Dup3(oldfd, newfd, 0) +} diff --git a/web/backend/main.go b/web/backend/main.go index b1db3c57a..8183731fe 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -33,6 +33,10 @@ import ( const ( appName = "PicoClaw" + + logPath = "logs" + panicFile = "launcher_panic.log" + logFile = "launcher.log" ) var ( @@ -72,6 +76,14 @@ func main() { // Initialize logger picoHome := utils.GetPicoclawHome() + + f := filepath.Join(picoHome, logPath, panicFile) + panicFunc, err := logger.InitPanic(f) + if err != nil { + panic(fmt.Sprintf("error initializing panic log: %v", err)) + } + defer panicFunc() + // By default, detect terminal to decide console log behavior // If -console-logs flag is explicitly set, it overrides the detection enableConsole := *console @@ -79,11 +91,9 @@ func main() { // Disable console logging by setting level to Fatal (no output) logger.SetConsoleLevel(logger.FATAL) - logPath := filepath.Join(picoHome, "logs", "web.log") - if err := logger.EnableFileLogging(logPath); err != nil { - // FIXME: https://github.com/sipeed/picoclaw/issues/1734 - fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) - os.Exit(1) + f := filepath.Join(picoHome, logPath, logFile) + if err = logger.EnableFileLogging(f); err != nil { + panic(fmt.Sprintf("error enabling file logging: %v", err)) } defer logger.DisableFileLogging() } From 8a046e951a4ae0e2b1373b03de6bdca64414fa2f Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Sun, 22 Mar 2026 15:49:25 +0800 Subject: [PATCH 04/15] 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 05/15] 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 06/15] 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 07/15] 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 { From f2985b8bee02e01034ee6a4670645323b7e507da Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Sun, 22 Mar 2026 15:49:25 +0800 Subject: [PATCH 08/15] 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 608ec6d329291f301b08a10b003f4cbc99d387c1 Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Sun, 22 Mar 2026 20:37:06 +0800 Subject: [PATCH 09/15] 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 79df9386964651e4a711aae318dee3abfa28b1f2 Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Mon, 23 Mar 2026 15:51:13 +0800 Subject: [PATCH 10/15] 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 c7544f7cb99cbd0e474c5a38c456de9b05deffe3 Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Sun, 22 Mar 2026 15:49:25 +0800 Subject: [PATCH 11/15] 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_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 9bd27e5eb..3f8ec6150 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 { @@ -1228,3 +1231,24 @@ func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { t.Errorf("ExtraBody[num_field] = %v, want 42", got) } } + +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) + } +} From d1d2155edbe63d8569f394966e01220d1db04b27 Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Mon, 23 Mar 2026 16:47:13 +0800 Subject: [PATCH 12/15] Use ModelName instead of Model in test config structs --- pkg/agent/loop_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 863623f4c..ad0022138 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -150,7 +150,7 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -196,7 +196,7 @@ func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -240,7 +240,7 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, From 1961aab85015d9f31a576ecd1b7556f59f0f7688 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:16:17 +0800 Subject: [PATCH 13/15] fix(agent): use ModelName in loop tests --- pkg/agent/loop_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 863623f4c..ad0022138 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -150,7 +150,7 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -196,7 +196,7 @@ func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -240,7 +240,7 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, From f81b44bf194927e28f2a87a0b2f091bdb8d78a4c Mon Sep 17 00:00:00 2001 From: Liqiang Liu Date: Mon, 23 Mar 2026 17:24:46 +0800 Subject: [PATCH 14/15] fix(provider): deduplicate tool results and merge consecutive tool_result blocks for Anthropic API (#1793) Anthropic API returns 400 when multiple tool_result blocks share the same tool_use_id, or when consecutive tool results are sent as separate user messages. This fix: 1. Adds ToolCallID deduplication in sanitizeHistoryForProvider (context.go) to drop duplicate tool results before sending to any provider. 2. Merges consecutive tool result messages into a single user message with multiple tool_result content blocks in Anthropic's buildRequestBody, for both "user" (with ToolCallID) and "tool" role messages. 3. Adds tests for both behaviors. Co-authored-by: Claude Opus 4.6 (1M context) --- pkg/agent/context.go | 13 +++ pkg/agent/context_test.go | 25 ++++++ pkg/providers/anthropic_messages/provider.go | 44 +++++---- .../anthropic_messages/provider_test.go | 90 +++++++++++++++++++ 4 files changed, 156 insertions(+), 16 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 4f5d75a50..12e3cdd4d 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -678,8 +678,21 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message // like DeepSeek that enforce: "An assistant message with 'tool_calls' must // be followed by tool messages responding to each 'tool_call_id'." final := make([]providers.Message, 0, len(sanitized)) + seenToolCallID := make(map[string]bool) for i := 0; i < len(sanitized); i++ { msg := sanitized[i] + + // Deduplicate tool results by ToolCallID + if msg.Role == "tool" && msg.ToolCallID != "" { + if seenToolCallID[msg.ToolCallID] { + logger.DebugCF("agent", "Dropping duplicate tool result", map[string]any{ + "tool_call_id": msg.ToolCallID, + }) + continue + } + seenToolCallID[msg.ToolCallID] = true + } + if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { // Collect expected tool_call IDs expected := make(map[string]bool, len(msg.ToolCalls)) diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 5756ed911..0d7948eef 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -188,6 +188,31 @@ func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) { assertRoles(t, result, "user", "assistant", "user", "assistant") } +func TestSanitizeHistoryForProvider_DuplicateToolResults(t *testing.T) { + history := []providers.Message{ + msg("user", "do something"), + assistantWithTools("A", "B"), + toolResult("A"), + toolResult("B"), + toolResult("A"), // duplicate + toolResult("B"), // duplicate + msg("assistant", "done"), + } + + result := sanitizeHistoryForProvider(history) + if len(result) != 5 { + t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant") + // Verify the kept tool results have the correct IDs + if result[2].ToolCallID != "A" { + t.Errorf("expected tool result A, got %q", result[2].ToolCallID) + } + if result[3].ToolCallID != "B" { + t.Errorf("expected tool result B, got %q", result[3].ToolCallID) + } +} + func roles(msgs []providers.Message) []string { r := make([]string, len(msgs)) for i, m := range msgs { diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go index 2b19e941a..6a1c473dd 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -188,17 +188,23 @@ func buildRequestBody( case "user": if msg.ToolCallID != "" { - // Tool result message - content := []map[string]any{ - { - "type": "tool_result", - "tool_use_id": msg.ToolCallID, - "content": msg.Content, - }, + // Tool result message — merge into previous user message if it contains tool_results + toolResultBlock := map[string]any{ + "type": "tool_result", + "tool_use_id": msg.ToolCallID, + "content": msg.Content, + } + if len(apiMessages) > 0 { + if prev, ok := apiMessages[len(apiMessages)-1].(map[string]any); ok && prev["role"] == "user" { + if content, ok := prev["content"].([]map[string]any); ok { + prev["content"] = append(content, toolResultBlock) + continue + } + } } apiMessages = append(apiMessages, map[string]any{ "role": "user", - "content": content, + "content": []map[string]any{toolResultBlock}, }) } else { // Regular user message @@ -246,17 +252,23 @@ func buildRequestBody( }) case "tool": - // Tool result (alternative format) - content := []map[string]any{ - { - "type": "tool_result", - "tool_use_id": msg.ToolCallID, - "content": msg.Content, - }, + // Tool result (alternative format) — merge into previous user message if it contains tool_results + toolResultBlock := map[string]any{ + "type": "tool_result", + "tool_use_id": msg.ToolCallID, + "content": msg.Content, + } + if len(apiMessages) > 0 { + if prev, ok := apiMessages[len(apiMessages)-1].(map[string]any); ok && prev["role"] == "user" { + if content, ok := prev["content"].([]map[string]any); ok { + prev["content"] = append(content, toolResultBlock) + continue + } + } } apiMessages = append(apiMessages, map[string]any{ "role": "user", - "content": content, + "content": []map[string]any{toolResultBlock}, }) } } diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go index 8eabc15fa..39bc48117 100644 --- a/pkg/providers/anthropic_messages/provider_test.go +++ b/pkg/providers/anthropic_messages/provider_test.go @@ -562,6 +562,96 @@ func TestBuildRequestBodyEdgeCases(t *testing.T) { } } +func TestBuildRequestBody_ConsecutiveToolResultsMerged(t *testing.T) { + // Consecutive tool results (role "tool") should be merged into a single "user" message + messages := []Message{ + {Role: "user", Content: "Use tools"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{ + {ID: "t1", Name: "tool_a", Arguments: map[string]any{"x": 1}}, + {ID: "t2", Name: "tool_b", Arguments: map[string]any{"y": 2}}, + }}, + {Role: "tool", ToolCallID: "t1", Content: "result1"}, + {Role: "tool", ToolCallID: "t2", Content: "result2"}, + } + + got, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 8192}) + if err != nil { + t.Fatalf("buildRequestBody() error: %v", err) + } + + apiMessages, ok := got["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any") + } + + // Expect: user, assistant, user (merged tool results) + if len(apiMessages) != 3 { + for i, m := range apiMessages { + t.Logf("message[%d]: %+v", i, m) + } + t.Fatalf("expected 3 API messages, got %d", len(apiMessages)) + } + + // The third message should be a user message with 2 tool_result blocks + toolResultMsg, ok := apiMessages[2].(map[string]any) + if !ok { + t.Fatalf("tool result message is not map[string]any") + } + if toolResultMsg["role"] != "user" { + t.Errorf("expected role 'user', got %v", toolResultMsg["role"]) + } + content, ok := toolResultMsg["content"].([]map[string]any) + if !ok { + t.Fatalf("content is not []map[string]any: %T", toolResultMsg["content"]) + } + if len(content) != 2 { + t.Fatalf("expected 2 tool_result blocks, got %d", len(content)) + } + if content[0]["tool_use_id"] != "t1" { + t.Errorf("first tool_result tool_use_id = %v, want t1", content[0]["tool_use_id"]) + } + if content[1]["tool_use_id"] != "t2" { + t.Errorf("second tool_result tool_use_id = %v, want t2", content[1]["tool_use_id"]) + } +} + +func TestBuildRequestBody_UserToolResultsMerged(t *testing.T) { + // Consecutive tool results using role "user" with ToolCallID should also be merged + messages := []Message{ + {Role: "user", Content: "Use tools"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{ + {ID: "t1", Name: "tool_a", Arguments: map[string]any{"x": 1}}, + {ID: "t2", Name: "tool_b", Arguments: map[string]any{"y": 2}}, + }}, + {Role: "user", ToolCallID: "t1", Content: "result1"}, + {Role: "user", ToolCallID: "t2", Content: "result2"}, + } + + got, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 8192}) + if err != nil { + t.Fatalf("buildRequestBody() error: %v", err) + } + + apiMessages, ok := got["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any") + } + + // Expect: user, assistant, user (merged tool results) + if len(apiMessages) != 3 { + t.Fatalf("expected 3 API messages, got %d", len(apiMessages)) + } + + toolResultMsg := apiMessages[2].(map[string]any) + content, ok := toolResultMsg["content"].([]map[string]any) + if !ok { + t.Fatalf("content is not []map[string]any: %T", toolResultMsg["content"]) + } + if len(content) != 2 { + t.Fatalf("expected 2 tool_result blocks, got %d", len(content)) + } +} + // TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody. func TestParseResponseBodyEdgeCases(t *testing.T) { tests := []struct { From 8e3e517135e8278c5128688189209899448bd0f4 Mon Sep 17 00:00:00 2001 From: LC <64722907+lc6464@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:25:27 +0800 Subject: [PATCH 15/15] feat: render mixed Markdown+HTML in assistant messages and skills (#1900) * feat(chat): render mixed Markdown+HTML in assistant messages using rehype-raw + rehype-sanitize (safe default) * build: remove irrelevant changes of pnpm-lock.yaml * feat(skills): enable rendering of Markdown with HTML in skill details using rehype-raw and rehype-sanitize * fix(agent): use ModelName in loop tests --- web/frontend/package.json | 2 + web/frontend/pnpm-lock.yaml | 127 ++++++++++++++++++ .../src/components/chat/assistant-message.tsx | 9 +- .../src/components/skills/skills-page.tsx | 7 +- 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index b1cc09b7b..8053d1f2a 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -31,6 +31,8 @@ "react-i18next": "^16.5.8", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "shadcn": "^4.1.0", "sonner": "^2.0.7", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index f893abda9..edaf49ccc 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -62,6 +62,12 @@ importers: react-textarea-autosize: specifier: ^8.5.9 version: 8.5.9(@types/react@19.2.14)(react@19.2.4) + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -2155,6 +2161,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2467,12 +2477,30 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} @@ -2492,6 +2520,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3141,6 +3172,9 @@ packages: parse-statements@1.0.11: resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3390,6 +3424,12 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -3812,6 +3852,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -3862,6 +3905,9 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -5945,6 +5991,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -6318,6 +6366,43 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -6338,10 +6423,28 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + headers-polyfill@4.0.3: {} hermes-estree@0.25.1: {} @@ -6358,6 +6461,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -7135,6 +7240,10 @@ snapshots: parse-statements@1.0.11: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -7369,6 +7478,17 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -7860,6 +7980,11 @@ snapshots: vary@1.1.2: {} + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -7887,6 +8012,8 @@ snapshots: void-elements@3.1.0: {} + web-namespaces@2.0.1: {} + web-streams-polyfill@3.3.3: {} webpack-virtual-modules@0.6.2: {} diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 150f2f87d..05da3ceb1 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -1,6 +1,8 @@ import { IconCheck, IconCopy } from "@tabler/icons-react" import { useState } from "react" import ReactMarkdown from "react-markdown" +import rehypeRaw from "rehype-raw" +import rehypeSanitize from "rehype-sanitize" import remarkGfm from "remark-gfm" import { Button } from "@/components/ui/button" @@ -42,7 +44,12 @@ export function AssistantMessage({
- {content} + + {content} +