diff --git a/internal/app/app_runtime_init.go b/internal/app/app_runtime_init.go index 5533acd9..3b15c73a 100644 --- a/internal/app/app_runtime_init.go +++ b/internal/app/app_runtime_init.go @@ -16,7 +16,12 @@ import ( ) func initAppRuntime(cfg Config, sessionInit appSessionInit, toolInit appToolInit, workspaceRoot, worktreeRoot string, parentSessionIDFunc func() string, approvalFunc policy.ApprovalFunc) (appRuntimeInit, error) { - model := core.FirstNonEmpty(strings.TrimSpace(cfg.Model), defaults.DefaultModel) + modelDefault := core.FirstNonEmpty(defaultModelForProvider(cfg.Provider), defaults.DefaultModel) + model := core.FirstNonEmpty(strings.TrimSpace(cfg.Model), modelDefault) + if strings.EqualFold(strings.TrimSpace(cfg.Provider), "minimax") && !cfg.ModelExplicit && model == defaults.DefaultModel { + model = modelDefault + cfg.Model = model + } effort := normalizeEffort(core.FirstNonEmpty(strings.TrimSpace(cfg.ReasoningEffort), defaults.DefaultReasoningEffort)) viewMode, err := NormalizeViewMode(cfg.ViewMode) if err != nil { @@ -29,6 +34,13 @@ func initAppRuntime(cfg Config, sessionInit appSessionInit, toolInit appToolInit if err != nil { return appRuntimeInit{}, fmt.Errorf("load api key failed: %w", err) } + miniMaxAPIKey, err := LoadMiniMaxAPIKey(cfg.DataDir) + if err != nil { + return appRuntimeInit{}, fmt.Errorf("load minimax api key failed: %w", err) + } + if strings.TrimSpace(cfg.MiniMax.APIKey) == "" { + cfg.MiniMax.APIKey = miniMaxAPIKey + } toolInit.toolset.SetExecBoundaryPolicy(policy.RulePolicy{ Default: cfg.PermissionDefault, Rules: append([]policy.PermissionRule(nil), cfg.PermissionRules...), @@ -44,10 +56,13 @@ func initAppRuntime(cfg Config, sessionInit appSessionInit, toolInit appToolInit })) providerFactory := func(model string, maxTokens int) (llm.Provider, error) { if strings.TrimSpace(model) == "" { - model = defaults.DefaultModel + model = modelDefault } - return newDeepSeekProvider(providerOptions{ + return newProvider(providerOptions{ + Provider: cfg.Provider, APIKey: apiKey, + MiniMaxAPIKey: miniMaxAPIKey, + MiniMax: cfg.MiniMax, BaseURL: cfg.APIBaseURL, Model: model, ReasoningEffort: effort, @@ -65,11 +80,14 @@ func initAppRuntime(cfg Config, sessionInit appSessionInit, toolInit appToolInit providerFactoryWithOptions := func(req tasks.ProviderRequest) (llm.Provider, error) { model := strings.TrimSpace(req.Model) if model == "" { - model = defaults.DefaultModel + model = modelDefault } reqEffort := normalizeEffort(core.FirstNonEmpty(strings.TrimSpace(req.Effort), effort)) - return newDeepSeekProvider(providerOptions{ + return newProvider(providerOptions{ + Provider: cfg.Provider, APIKey: apiKey, + MiniMaxAPIKey: miniMaxAPIKey, + MiniMax: cfg.MiniMax, BaseURL: cfg.APIBaseURL, Model: model, ReasoningEffort: reqEffort, @@ -133,7 +151,7 @@ func initAppRuntime(cfg Config, sessionInit appSessionInit, toolInit appToolInit ExtraSkills: extraSkills, AutoCompact: cfg.AutoCompact, AutoCompactThreshold: cfg.AutoCompactThreshold, - DefaultModel: defaults.DefaultModel, + DefaultModel: modelDefault, DefaultMaxTokens: tasks.DefaultMaxTokens, DefaultMaxToolIters: tasks.DefaultMaxToolIters, SummaryMaxChars: tasks.DefaultSummaryMaxChar, diff --git a/internal/app/app_types.go b/internal/app/app_types.go index f4b96091..d1631e5e 100644 --- a/internal/app/app_types.go +++ b/internal/app/app_types.go @@ -35,6 +35,7 @@ type Config struct { MemoryMaxChars int MemoryFileOrder string BudgetWarningUSD float64 + Provider string Model string ModelExplicit bool ReasoningEffort string @@ -49,6 +50,7 @@ type Config struct { RetryMaxDelay time.Duration DeepSeekPrefixCompletion bool DeepSeekMultimodal MultimodalProviderConfig + MiniMax MiniMaxProviderConfig DeepSeekWebSearch deepseek.WebSearchMode DeepSeekAPI deepseek.API ShellForegroundWaitDefaultMS int @@ -83,6 +85,13 @@ type MultimodalProviderConfig struct { Model string } +type MiniMaxProviderConfig struct { + Region string + BaseURL string + APIKey string + APIKeyEnv string +} + type StartOptions struct { SessionID string ModeOverride string diff --git a/internal/app/config_apply.go b/internal/app/config_apply.go index 1fa39965..32595067 100644 --- a/internal/app/config_apply.go +++ b/internal/app/config_apply.go @@ -29,6 +29,15 @@ func ApplyLoadedConfig(cfg *Config, loaded LoadedConfig) error { } func ApplyFileConfig(cfg *Config, file FileConfig) error { + if strings.TrimSpace(file.Provider) != "" { + provider := strings.ToLower(strings.TrimSpace(file.Provider)) + switch provider { + case "deepseek", "minimax": + cfg.Provider = provider + default: + return fmt.Errorf("invalid provider: %s", file.Provider) + } + } if strings.TrimSpace(file.Model) != "" { cfg.Model = strings.TrimSpace(file.Model) } @@ -66,6 +75,7 @@ func ApplyFileConfig(cfg *Config, file FileConfig) error { if err := applyMultimodalProviderConfig(cfg, file.Providers.DeepSeek.Multimodal); err != nil { return err } + applyMiniMaxProviderConfig(cfg, file.Providers.MiniMax) if strings.TrimSpace(file.Providers.DeepSeek.WebSearch) != "" { mode, err := deepseek.NormalizeWebSearchMode(file.Providers.DeepSeek.WebSearch) if err != nil { @@ -235,6 +245,9 @@ func overlayExplicitConfig(dst *Config, src Config) { dst.Model = src.Model dst.ModelExplicit = src.ModelExplicit } + if strings.TrimSpace(src.Provider) != "" && src.Provider != def.Provider { + dst.Provider = strings.ToLower(strings.TrimSpace(src.Provider)) + } if src.PermissionDefault != "" && src.PermissionDefault != def.PermissionDefault { dst.PermissionDefault = src.PermissionDefault } @@ -296,6 +309,9 @@ func overlayExplicitConfig(dst *Config, src Config) { if src.DeepSeekMultimodal != def.DeepSeekMultimodal { dst.DeepSeekMultimodal = normalizeMultimodalProviderConfig(src.DeepSeekMultimodal) } + if src.MiniMax != def.MiniMax { + dst.MiniMax = normalizeMiniMaxProviderConfig(src.MiniMax) + } if src.DeepSeekWebSearch != def.DeepSeekWebSearch { dst.DeepSeekWebSearch = src.DeepSeekWebSearch } @@ -384,6 +400,31 @@ func normalizeMultimodalProviderConfig(in MultimodalProviderConfig) MultimodalPr return in } +func applyMiniMaxProviderConfig(cfg *Config, file FileMiniMaxProviderConfig) { + next := cfg.MiniMax + if strings.TrimSpace(file.Region) != "" { + next.Region = strings.ToLower(strings.TrimSpace(file.Region)) + } + if strings.TrimSpace(file.BaseURL) != "" { + next.BaseURL = strings.TrimRight(strings.TrimSpace(file.BaseURL), "/") + } + if strings.TrimSpace(file.APIKey) != "" { + next.APIKey = strings.TrimSpace(file.APIKey) + } + if strings.TrimSpace(file.APIKeyEnv) != "" { + next.APIKeyEnv = strings.TrimSpace(file.APIKeyEnv) + } + cfg.MiniMax = normalizeMiniMaxProviderConfig(next) +} + +func normalizeMiniMaxProviderConfig(in MiniMaxProviderConfig) MiniMaxProviderConfig { + in.Region = strings.ToLower(strings.TrimSpace(in.Region)) + in.BaseURL = strings.TrimRight(strings.TrimSpace(in.BaseURL), "/") + in.APIKey = strings.TrimSpace(in.APIKey) + in.APIKeyEnv = strings.TrimSpace(in.APIKeyEnv) + return in +} + func applyShellConfig(cfg *Config, file FileShellConfig) error { hasDefault := file.ForegroundWaitDefaultMS != nil hasMax := file.ForegroundWaitMaxMS != nil diff --git a/internal/app/config_file.go b/internal/app/config_file.go index 45bc16b3..a1724e07 100644 --- a/internal/app/config_file.go +++ b/internal/app/config_file.go @@ -19,6 +19,7 @@ const ( ) type FileConfig struct { + Provider string `toml:"provider,omitempty"` Model string `toml:"model,omitempty"` ReasoningEffort string `toml:"reasoning_effort,omitempty"` ThinkingEnabled *bool `toml:"thinking_enabled,omitempty"` @@ -71,6 +72,7 @@ type FileAPIConfig struct { type FileProvidersConfig struct { DeepSeek FileDeepSeekProviderConfig `toml:"deepseek,omitempty"` + MiniMax FileMiniMaxProviderConfig `toml:"minimax,omitempty"` } type FileDeepSeekProviderConfig struct { @@ -79,6 +81,13 @@ type FileDeepSeekProviderConfig struct { API string `toml:"api,omitempty"` } +type FileMiniMaxProviderConfig struct { + Region string `toml:"region,omitempty"` + BaseURL string `toml:"base_url,omitempty"` + APIKey string `toml:"api_key,omitempty"` + APIKeyEnv string `toml:"api_key_env,omitempty"` +} + type FileMultimodalProviderConfig struct { Enabled *bool `toml:"enabled,omitempty"` Compat string `toml:"compat,omitempty"` diff --git a/internal/app/config_file_test.go b/internal/app/config_file_test.go index 51bd9514..a28f9fb7 100644 --- a/internal/app/config_file_test.go +++ b/internal/app/config_file_test.go @@ -158,6 +158,52 @@ func TestApplyFileConfigSupportsDeepSeekMultimodalProvider(t *testing.T) { } } +func TestApplyFileConfigSupportsMiniMaxProvider(t *testing.T) { + cfg := DefaultConfig() + if err := ApplyFileConfig(&cfg, FileConfig{ + Provider: " MiniMax ", + Model: "MiniMax-M3", + Providers: FileProvidersConfig{ + MiniMax: FileMiniMaxProviderConfig{ + Region: "CN_ZH", + BaseURL: "https://api.minimaxi.com/v1/", + APIKey: "test-minimax-key", + APIKeyEnv: "MINIMAX_TEST_KEY", + }, + }, + }); err != nil { + t.Fatalf("ApplyFileConfig: %v", err) + } + if cfg.Provider != "minimax" || + cfg.Model != "MiniMax-M3" || + cfg.MiniMax.Region != "cn_zh" || + cfg.MiniMax.BaseURL != "https://api.minimaxi.com/v1" || + cfg.MiniMax.APIKey != "test-minimax-key" || + cfg.MiniMax.APIKeyEnv != "MINIMAX_TEST_KEY" { + t.Fatalf("minimax config: %+v", cfg) + } +} + +func TestMiniMaxProviderUsesProviderDefaultModel(t *testing.T) { + cfg := DefaultConfig() + cfg.Provider = "minimax" + loaded, err := LoadAndApplyConfig(cfg, t.TempDir()) + if err != nil { + t.Fatalf("LoadAndApplyConfig: %v", err) + } + if defaultModelForProvider(loaded.Provider) != "MiniMax-M3" { + t.Fatalf("provider default model = %s", defaultModelForProvider(loaded.Provider)) + } +} + +func TestApplyFileConfigRejectsInvalidProvider(t *testing.T) { + cfg := DefaultConfig() + err := ApplyFileConfig(&cfg, FileConfig{Provider: "missing"}) + if err == nil || !strings.Contains(err.Error(), "invalid provider") { + t.Fatalf("error = %v, want invalid provider", err) + } +} + func TestApplyFileConfigSupportsDeepSeekWebSearchMode(t *testing.T) { for _, tc := range []struct { value string diff --git a/internal/app/credentials.go b/internal/app/credentials.go index 4d47ccf2..e4e85210 100644 --- a/internal/app/credentials.go +++ b/internal/app/credentials.go @@ -15,6 +15,7 @@ var deepSeekAPIKeyPattern = regexp.MustCompile(`^sk-[A-Za-z0-9_-]{16,}$`) type Credentials struct { DeepSeekAPIKey string `json:"deepseek_api_key,omitempty"` + MiniMaxAPIKey string `json:"minimax_api_key,omitempty"` } func credentialsPath(dataDir string) string { @@ -70,3 +71,14 @@ func LoadDeepSeekAPIKey(dataDir string) (string, error) { } return strings.TrimSpace(creds.DeepSeekAPIKey), nil } + +func LoadMiniMaxAPIKey(dataDir string) (string, error) { + if v := strings.TrimSpace(os.Getenv("MINIMAX_API_KEY")); v != "" { + return v, nil + } + creds, err := LoadCredentials(dataDir) + if err != nil { + return "", err + } + return strings.TrimSpace(creds.MiniMaxAPIKey), nil +} diff --git a/internal/app/model_context_test.go b/internal/app/model_context_test.go index 61274e5a..21647fab 100644 --- a/internal/app/model_context_test.go +++ b/internal/app/model_context_test.go @@ -9,6 +9,8 @@ func TestContextWindowForModel(t *testing.T) { }{ {model: "deepseek-v4-flash", want: 1_000_000}, {model: "deepseek-v4-pro", want: 1_000_000}, + {model: "MiniMax-M3", want: 1_000_000}, + {model: "MiniMax-M2.7", want: 204_800}, {model: "deepseek-chat", want: 128_000}, {model: "unknown-model", want: 128_000}, {model: "", want: 128_000}, diff --git a/internal/app/provider.go b/internal/app/provider.go index 58f1e8c4..e069aec4 100644 --- a/internal/app/provider.go +++ b/internal/app/provider.go @@ -7,11 +7,15 @@ import ( "github.com/usewhale/whale/internal/llm" "github.com/usewhale/whale/internal/llm/deepseek" + "github.com/usewhale/whale/internal/llm/minimax" llmretry "github.com/usewhale/whale/internal/llm/retry" ) type providerOptions struct { + Provider string APIKey string + MiniMaxAPIKey string + MiniMax MiniMaxProviderConfig BaseURL string Model string ReasoningEffort string @@ -26,6 +30,60 @@ type providerOptions struct { DeepSeekAPI deepseek.API } +func newProvider(opts providerOptions) (llm.Provider, error) { + if strings.TrimSpace(opts.Model) == "" { + opts.Model = defaultModelForProvider(opts.Provider) + } + switch strings.ToLower(strings.TrimSpace(opts.Provider)) { + case "minimax": + return newMiniMaxProvider(opts) + default: + return newDeepSeekProvider(opts) + } +} + +func defaultModelForProvider(provider string) string { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "minimax": + return minimax.DefaultModel + default: + return "" + } +} + +func newMiniMaxProvider(opts providerOptions) (llm.Provider, error) { + baseURL := firstProviderValue(opts.MiniMax.BaseURL, opts.BaseURL, minimaxBaseURLForRegion(opts.MiniMax.Region)) + return minimax.New(minimax.Options{ + APIKey: firstProviderValue(opts.MiniMax.APIKey, opts.MiniMaxAPIKey), + APIKeyEnv: opts.MiniMax.APIKeyEnv, + BaseURL: baseURL, + Model: opts.Model, + ThinkingEnabled: opts.ThinkingEnabled, + MaxTokens: opts.MaxTokens, + RetryPolicy: opts.RetryPolicy, + StreamMaxAttempts: opts.StreamMaxAttempts, + StreamIdleTimeout: opts.StreamIdleTimeout, + }) +} + +func minimaxBaseURLForRegion(region string) string { + switch strings.ToLower(strings.TrimSpace(region)) { + case "cn", "cn_zh", "china": + return minimax.CNBaseURL + default: + return minimax.DefaultBaseURL + } +} + +func firstProviderValue(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + func newDeepSeekProvider(opts providerOptions) (llm.Provider, error) { dsOpts := []deepseek.Option{} if strings.TrimSpace(opts.APIKey) != "" { diff --git a/internal/app/provider_test.go b/internal/app/provider_test.go index fb18453a..349fbadc 100644 --- a/internal/app/provider_test.go +++ b/internal/app/provider_test.go @@ -139,6 +139,212 @@ func TestNewDeepSeekProviderKeepsMissingMultimodalAPIKeyEnvError(t *testing.T) { } } +func TestNewMiniMaxProviderUsesConfiguredEndpoint(t *testing.T) { + var sawRequest bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawRequest = true + if r.URL.Path != "/chat/completions" { + t.Fatalf("unexpected request path: %s", r.URL.Path) + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request body: %v", err) + } + if payload["model"] != "MiniMax-M3" { + t.Fatalf("model = %v, want MiniMax-M3", payload["model"]) + } + thinking, ok := payload["thinking"].(map[string]any) + if !ok || thinking["type"] != "adaptive" { + t.Fatalf("thinking = %#v, want adaptive", payload["thinking"]) + } + if payload["reasoning_split"] != true { + t.Fatalf("reasoning_split = %v, want true", payload["reasoning_split"]) + } + if _, ok := payload["reasoning_effort"]; ok { + t.Fatalf("MiniMax request should not include reasoning_effort: %#v", payload) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n") + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer srv.Close() + + provider, err := newProvider(providerOptions{ + Provider: "minimax", + MiniMax: MiniMaxProviderConfig{ + APIKey: "test-minimax-key", + BaseURL: srv.URL, + }, + Model: "MiniMax-M3", + ThinkingEnabled: true, + }) + if err != nil { + t.Fatalf("newProvider: %v", err) + } + for ev := range provider.StreamResponse(context.Background(), []core.Message{{Role: core.RoleUser, Text: "hi"}}, nil) { + if ev.Type == llm.EventError { + t.Fatalf("provider error: %v", ev.Err) + } + } + if !sawRequest { + t.Fatal("expected request to configured base URL") + } +} + +func TestNewMiniMaxProviderSupportsDisabledThinking(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request body: %v", err) + } + thinking, ok := payload["thinking"].(map[string]any) + if !ok || thinking["type"] != "disabled" { + t.Fatalf("thinking = %#v, want disabled", payload["thinking"]) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n") + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer srv.Close() + + provider, err := newProvider(providerOptions{ + Provider: "minimax", + MiniMax: MiniMaxProviderConfig{ + APIKey: "test-minimax-key", + BaseURL: srv.URL, + }, + Model: "MiniMax-M3", + }) + if err != nil { + t.Fatalf("newProvider: %v", err) + } + for ev := range provider.StreamResponse(context.Background(), []core.Message{{Role: core.RoleUser, Text: "hi"}}, nil) { + if ev.Type == llm.EventError { + t.Fatalf("provider error: %v", ev.Err) + } + } +} + +func TestNewMiniMaxM27KeepsThinkingAlwaysOn(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request body: %v", err) + } + if _, ok := payload["thinking"]; ok { + t.Fatalf("MiniMax-M2.7 request should omit thinking control: %#v", payload["thinking"]) + } + if payload["reasoning_split"] != true { + t.Fatalf("reasoning_split = %v, want true", payload["reasoning_split"]) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think\",\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n") + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer srv.Close() + + provider, err := newProvider(providerOptions{ + Provider: "minimax", + MiniMax: MiniMaxProviderConfig{ + APIKey: "test-minimax-key", + BaseURL: srv.URL, + }, + Model: "MiniMax-M2.7", + }) + if err != nil { + t.Fatalf("newProvider: %v", err) + } + for ev := range provider.StreamResponse(context.Background(), []core.Message{{Role: core.RoleUser, Text: "hi"}}, nil) { + if ev.Type == llm.EventError { + t.Fatalf("provider error: %v", ev.Err) + } + } +} + +func TestNewMiniMaxM3SendsVideoWithAdaptiveThinking(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request body: %v", err) + } + thinking, ok := payload["thinking"].(map[string]any) + if !ok || thinking["type"] != "adaptive" { + t.Fatalf("thinking = %#v, want adaptive", payload["thinking"]) + } + if payload["reasoning_split"] != true { + t.Fatalf("reasoning_split = %v, want true", payload["reasoning_split"]) + } + messages, ok := payload["messages"].([]any) + if !ok || len(messages) != 1 { + t.Fatalf("messages = %#v, want one user message", payload["messages"]) + } + message, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("message = %#v", messages[0]) + } + content, ok := message["content"].([]any) + if !ok || len(content) != 2 { + t.Fatalf("content = %#v, want text and video parts", message["content"]) + } + video, ok := content[1].(map[string]any) + if !ok || video["type"] != "video_url" { + t.Fatalf("video part = %#v, want video_url", content[1]) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n") + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer srv.Close() + + videoPath := filepath.Join(t.TempDir(), "clip.mp4") + if err := os.WriteFile(videoPath, []byte("fake-video"), 0o644); err != nil { + t.Fatalf("write video: %v", err) + } + provider, err := newProvider(providerOptions{ + Provider: "minimax", + MiniMax: MiniMaxProviderConfig{ + APIKey: "test-minimax-key", + BaseURL: srv.URL, + }, + Model: "MiniMax-M3", + ThinkingEnabled: true, + }) + if err != nil { + t.Fatalf("newProvider: %v", err) + } + history := []core.Message{core.UserMessageFromParts("session", []core.MessagePart{ + {Type: core.MessagePartText, Text: "describe this clip"}, + {Type: core.MessagePartAttachment, Attachment: &core.AttachmentRef{ + Kind: core.AttachmentKindVideo, + Path: videoPath, + MIME: "video/mp4", + Filename: "clip.mp4", + }}, + }, false)} + for ev := range provider.StreamResponse(context.Background(), history, nil) { + if ev.Type == llm.EventError { + t.Fatalf("provider error: %v", ev.Err) + } + } +} + +func TestNewMiniMaxProviderUsesRegionalDefaultEndpoint(t *testing.T) { + _, err := newProvider(providerOptions{ + Provider: "minimax", + MiniMax: MiniMaxProviderConfig{ + APIKey: "test-minimax-key", + Region: "cn_zh", + }, + Model: "MiniMax-M2.7", + }) + if err != nil { + t.Fatalf("newProvider: %v", err) + } + if got := minimaxBaseURLForRegion("cn_zh"); got != "https://api.minimaxi.com/v1" { + t.Fatalf("regional endpoint = %s", got) + } +} + func TestTaskProviderUsesConfiguredRetryPolicy(t *testing.T) { t.Setenv("DEEPSEEK_API_KEY", "test-key") var parentRequests atomic.Int32 diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 4cf041b7..6abe9c51 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -80,8 +80,11 @@ func (a *App) ensureAgent() (*agent.Agent, error) { if a.pluginManager != nil { pluginBlocks = a.pluginManager.StartupBlocks(a.ctx) } - provider, err := newDeepSeekProvider(providerOptions{ + provider, err := newProvider(providerOptions{ + Provider: a.cfg.Provider, APIKey: a.apiKey, + MiniMaxAPIKey: a.cfg.MiniMax.APIKey, + MiniMax: a.cfg.MiniMax, BaseURL: a.cfg.APIBaseURL, Model: a.model, ReasoningEffort: a.reasoningEffort, diff --git a/internal/app/task_runtime.go b/internal/app/task_runtime.go index b569976c..e642e6dd 100644 --- a/internal/app/task_runtime.go +++ b/internal/app/task_runtime.go @@ -22,12 +22,16 @@ func (a *App) rebuildTaskRuntimeLocked() error { thinking := a.thinkingEnabled apiKey := a.apiKey cfg := a.cfg + modelDefault := core.FirstNonEmpty(defaultModelForProvider(cfg.Provider), defaults.DefaultModel) providerFactory := func(model string, maxTokens int) (llm.Provider, error) { if strings.TrimSpace(model) == "" { - model = defaults.DefaultModel + model = modelDefault } - return newDeepSeekProvider(providerOptions{ + return newProvider(providerOptions{ + Provider: cfg.Provider, APIKey: apiKey, + MiniMaxAPIKey: cfg.MiniMax.APIKey, + MiniMax: cfg.MiniMax, BaseURL: cfg.APIBaseURL, Model: model, ReasoningEffort: effort, @@ -45,11 +49,14 @@ func (a *App) rebuildTaskRuntimeLocked() error { providerFactoryWithOptions := func(req tasks.ProviderRequest) (llm.Provider, error) { model := strings.TrimSpace(req.Model) if model == "" { - model = defaults.DefaultModel + model = modelDefault } reqEffort := normalizeEffort(core.FirstNonEmpty(strings.TrimSpace(req.Effort), effort)) - return newDeepSeekProvider(providerOptions{ + return newProvider(providerOptions{ + Provider: cfg.Provider, APIKey: apiKey, + MiniMaxAPIKey: cfg.MiniMax.APIKey, + MiniMax: cfg.MiniMax, BaseURL: cfg.APIBaseURL, Model: model, ReasoningEffort: reqEffort, @@ -119,7 +126,7 @@ func (a *App) rebuildTaskRuntimeLocked() error { ExtraSkills: extraSkills, AutoCompact: cfg.AutoCompact, AutoCompactThreshold: cfg.AutoCompactThreshold, - DefaultModel: defaults.DefaultModel, + DefaultModel: modelDefault, DefaultMaxTokens: tasks.DefaultMaxTokens, DefaultMaxToolIters: tasks.DefaultMaxToolIters, SummaryMaxChars: tasks.DefaultSummaryMaxChar, diff --git a/internal/attachments/attachments.go b/internal/attachments/attachments.go index 60d2d398..c41225ab 100644 --- a/internal/attachments/attachments.go +++ b/internal/attachments/attachments.go @@ -23,6 +23,7 @@ const ( DefaultMaxImageBytes = 10 * 1024 * 1024 DefaultMaxPDFBytes = 20 * 1024 * 1024 DefaultMaxAudioBytes = 25 * 1024 * 1024 + DefaultMaxVideoBytes = 50 * 1024 * 1024 DefaultMaxFileBytes = 20 * 1024 * 1024 ) @@ -40,6 +41,7 @@ type Options struct { MaxImageBytes int64 MaxPDFBytes int64 MaxAudioBytes int64 + MaxVideoBytes int64 MaxFileBytes int64 } @@ -214,6 +216,9 @@ func normalizeOptions(opts Options) Options { if opts.MaxAudioBytes <= 0 { opts.MaxAudioBytes = DefaultMaxAudioBytes } + if opts.MaxVideoBytes <= 0 { + opts.MaxVideoBytes = DefaultMaxVideoBytes + } if opts.MaxFileBytes <= 0 { opts.MaxFileBytes = DefaultMaxFileBytes } @@ -231,6 +236,8 @@ func classifyAttachment(path string, header []byte) (core.AttachmentKind, string return core.AttachmentKindImage, mime case "audio/mpeg", "audio/wave", "audio/wav", "audio/x-wav", "audio/ogg", "audio/flac", "audio/webm": return core.AttachmentKindAudio, mime + case "video/mp4", "video/quicktime", "video/webm", "video/x-msvideo": + return core.AttachmentKindVideo, mime } switch ext { case "png": @@ -252,7 +259,16 @@ func classifyAttachment(path string, header []byte) (core.AttachmentKind, string case "flac": return core.AttachmentKindAudio, "audio/flac" case "webm": + if strings.HasPrefix(mime, "video/") { + return core.AttachmentKindVideo, mime + } return core.AttachmentKindAudio, "audio/webm" + case "mp4": + return core.AttachmentKindVideo, "video/mp4" + case "mov": + return core.AttachmentKindVideo, "video/quicktime" + case "avi": + return core.AttachmentKindVideo, "video/x-msvideo" } if mime == "application/octet-stream" && ext != "" { return core.AttachmentKindFile, mimeFromExtension(ext) @@ -285,6 +301,8 @@ func maxBytesForKind(kind core.AttachmentKind, opts Options) int64 { return opts.MaxPDFBytes case core.AttachmentKindAudio: return opts.MaxAudioBytes + case core.AttachmentKindVideo: + return opts.MaxVideoBytes default: return opts.MaxFileBytes } diff --git a/internal/attachments/attachments_test.go b/internal/attachments/attachments_test.go index 27b0fe14..eed0d3eb 100644 --- a/internal/attachments/attachments_test.go +++ b/internal/attachments/attachments_test.go @@ -126,6 +126,25 @@ func TestPrepareAudioFormatsMatchOpenAICompatibleEncoder(t *testing.T) { } } +func TestPrepareClassifiesVideo(t *testing.T) { + tmp := t.TempDir() + src := filepath.Join(tmp, "clip.mp4") + if err := os.WriteFile(src, []byte("fake-video"), 0o644); err != nil { + t.Fatalf("write video: %v", err) + } + prepared, err := Prepare(context.Background(), []Source{{Path: src}}, Options{ + SessionsDir: filepath.Join(tmp, "sessions"), + SessionID: "video", + MaxVideoBytes: 1024, + }) + if err != nil { + t.Fatalf("Prepare video: %v", err) + } + if prepared[0].Ref.Kind != core.AttachmentKindVideo || prepared[0].Ref.MIME != "video/mp4" { + t.Fatalf("kind/mime = %s/%s", prepared[0].Ref.Kind, prepared[0].Ref.MIME) + } +} + func TestPrepareFollowsSymlinkToRegularFile(t *testing.T) { tmp := t.TempDir() target := filepath.Join(tmp, "target.png") diff --git a/internal/core/message.go b/internal/core/message.go index 70d4a785..c2d44f0e 100644 --- a/internal/core/message.go +++ b/internal/core/message.go @@ -67,6 +67,7 @@ const ( AttachmentKindImage AttachmentKind = "image" AttachmentKindPDF AttachmentKind = "pdf" AttachmentKindAudio AttachmentKind = "audio" + AttachmentKindVideo AttachmentKind = "video" AttachmentKindFile AttachmentKind = "file" ) diff --git a/internal/defaults/defaults.go b/internal/defaults/defaults.go index 57453533..70617172 100644 --- a/internal/defaults/defaults.go +++ b/internal/defaults/defaults.go @@ -9,6 +9,8 @@ const ( DefaultThinkingEnabled = true DefaultContextWindow = 128_000 DeepSeekV4ContextWindow = 1_000_000 + MiniMaxM3ContextWindow = 1_000_000 + MiniMaxM27ContextWindow = 204_800 DefaultAutoCompactThreshold = 0.85 DefaultAgentCompactThreshold = 0.90 // DefaultMaxToolIters is the ACP entrypoint's tool-iteration cap. It must @@ -24,6 +26,8 @@ const ( var supportedModels = []string{ DefaultModel, ProModel, + "MiniMax-M3", + "MiniMax-M2.7", } var defaultMemoryFileOrder = []string{ @@ -55,6 +59,14 @@ func IsDeepSeekV4Model(model string) bool { return strings.Contains(m, DefaultModel) || strings.Contains(m, ProModel) } +func IsMiniMaxM3Model(model string) bool { + return strings.EqualFold(strings.TrimSpace(model), "MiniMax-M3") +} + +func IsMiniMaxM27Model(model string) bool { + return strings.EqualFold(strings.TrimSpace(model), "MiniMax-M2.7") +} + // ContextWindowForModel returns the context window size in tokens for model. func ContextWindowForModel(model string) int { if strings.TrimSpace(model) == "" { @@ -63,5 +75,11 @@ func ContextWindowForModel(model string) int { if IsDeepSeekV4Model(model) { return DeepSeekV4ContextWindow } + if IsMiniMaxM3Model(model) { + return MiniMaxM3ContextWindow + } + if IsMiniMaxM27Model(model) { + return MiniMaxM27ContextWindow + } return DefaultContextWindow } diff --git a/internal/llm/deepseek/client.go b/internal/llm/deepseek/client.go index f295b91c..0dc5e50a 100644 --- a/internal/llm/deepseek/client.go +++ b/internal/llm/deepseek/client.go @@ -39,6 +39,7 @@ type Client struct { model string reasoningEffort string thinkingEnabled bool + chatCompletionsThinking *ChatCompletionsThinkingConfig maxTokens int retryPolicy llmretry.Policy retrySleeper llmretry.Sleeper @@ -62,6 +63,12 @@ type MultimodalConfig struct { Model string } +type ChatCompletionsThinkingConfig struct { + EnabledType string + Omit bool + ReasoningSplit bool +} + func WithBaseURL(v string) Option { return func(c *Client) { c.baseURL = strings.TrimRight(v, "/") } } @@ -86,6 +93,13 @@ func WithThinking(enabled bool) Option { return func(c *Client) { c.thinkingEnabled = enabled } } +func WithChatCompletionsThinking(cfg ChatCompletionsThinkingConfig) Option { + return func(c *Client) { + cfg.EnabledType = strings.ToLower(strings.TrimSpace(cfg.EnabledType)) + c.chatCompletionsThinking = &cfg + } +} + func WithMaxTokens(v int) Option { return func(c *Client) { c.maxTokens = v } } @@ -245,14 +259,8 @@ func (c *Client) stream(ctx context.Context, history []core.Message, tools []cor "stream": true, "stream_options": map[string]any{"include_usage": true}, "messages": msgs, - "thinking": map[string]any{"type": "disabled"}, - } - if c.thinkingEnabled { - payload["thinking"] = map[string]any{"type": "enabled"} - if strings.TrimSpace(c.reasoningEffort) != "" { - payload["reasoning_effort"] = c.reasoningEffort - } } + c.applyChatCompletionsThinking(payload, true) if len(tools) > 0 { payload["tools"] = toDeepSeekTools(tools) } @@ -298,14 +306,8 @@ func (c *Client) streamPrefix(ctx context.Context, history []core.Message, prefi "stream": true, "stream_options": map[string]any{"include_usage": true}, "messages": msgs, - "thinking": map[string]any{"type": "disabled"}, - } - if c.thinkingEnabled { - payload["thinking"] = map[string]any{"type": "enabled"} - if strings.TrimSpace(c.reasoningEffort) != "" { - payload["reasoning_effort"] = c.reasoningEffort - } } + c.applyChatCompletionsThinking(payload, true) if len(stop) > 0 { payload["stop"] = append([]string(nil), stop...) } @@ -357,6 +359,37 @@ func (c *Client) streamPrefix(ctx context.Context, history []core.Message, prefi return <-done } +func (c *Client) applyChatCompletionsThinking(payload map[string]any, includeDefault bool) { + cfg := c.chatCompletionsThinking + if cfg == nil { + if !includeDefault { + return + } + thinkingType := "disabled" + if c.thinkingEnabled { + thinkingType = "enabled" + if strings.TrimSpace(c.reasoningEffort) != "" { + payload["reasoning_effort"] = c.reasoningEffort + } + } + payload["thinking"] = map[string]any{"type": thinkingType} + return + } + if !cfg.Omit { + thinkingType := "disabled" + if c.thinkingEnabled { + thinkingType = cfg.EnabledType + if thinkingType == "" { + thinkingType = "enabled" + } + } + payload["thinking"] = map[string]any{"type": thinkingType} + } + if cfg.ReasoningSplit { + payload["reasoning_split"] = true + } +} + func (c *Client) prefixCompletionBaseURL() (string, bool) { base := strings.TrimRight(strings.TrimSpace(c.baseURL), "/") if base == "" { diff --git a/internal/llm/deepseek/multimodal.go b/internal/llm/deepseek/multimodal.go index 68f6aead..e8b1a8d1 100644 --- a/internal/llm/deepseek/multimodal.go +++ b/internal/llm/deepseek/multimodal.go @@ -58,6 +58,7 @@ func (c *Client) streamMultimodal(ctx context.Context, history []core.Message, t "stream_options": map[string]any{"include_usage": true}, "messages": msgs, } + c.applyChatCompletionsThinking(payload, false) if len(tools) > 0 { payload["tools"] = toDeepSeekTools(tools) } @@ -250,6 +251,13 @@ func encodeOpenAIAttachment(att *core.AttachmentRef) (map[string]any, error) { "format": format, }, }, nil + case core.AttachmentKindVideo: + return map[string]any{ + "type": "video_url", + "video_url": map[string]any{ + "url": dataURL(mime, encoded), + }, + }, nil default: return nil, fmt.Errorf("unsupported attachment kind %q", att.Kind) } diff --git a/internal/llm/minimax/client.go b/internal/llm/minimax/client.go new file mode 100644 index 00000000..cd2a9750 --- /dev/null +++ b/internal/llm/minimax/client.go @@ -0,0 +1,101 @@ +package minimax + +import ( + "errors" + "os" + "strings" + "time" + + "github.com/usewhale/whale/internal/defaults" + "github.com/usewhale/whale/internal/llm" + "github.com/usewhale/whale/internal/llm/deepseek" + llmretry "github.com/usewhale/whale/internal/llm/retry" +) + +const ( + DefaultBaseURL = "https://api.minimax.io/v1" + CNBaseURL = "https://api.minimaxi.com/v1" + DefaultAPIKeyEnv = "MINIMAX_API_KEY" + DefaultModel = "MiniMax-M3" +) + +type Options struct { + APIKey string + APIKeyEnv string + BaseURL string + Model string + ThinkingEnabled bool + MaxTokens int + RetryPolicy llmretry.Policy + StreamMaxAttempts int + StreamIdleTimeout time.Duration +} + +func New(opts Options) (llm.Provider, error) { + apiKey := strings.TrimSpace(opts.APIKey) + apiKeyEnv := strings.TrimSpace(opts.APIKeyEnv) + if apiKeyEnv == "" { + apiKeyEnv = DefaultAPIKeyEnv + } + if apiKey == "" { + apiKey = strings.TrimSpace(os.Getenv(apiKeyEnv)) + } + if apiKey == "" { + return nil, errors.New("MiniMax API key is not configured. Set MINIMAX_API_KEY in your environment or save one in Whale credentials") + } + model := strings.TrimSpace(opts.Model) + if model == "" { + model = DefaultModel + } + baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") + if baseURL == "" { + baseURL = DefaultBaseURL + } + thinkingEnabled := opts.ThinkingEnabled || defaults.IsMiniMaxM27Model(model) + thinkingConfig := deepseek.ChatCompletionsThinkingConfig{ + EnabledType: "adaptive", + ReasoningSplit: true, + } + if defaults.IsMiniMaxM27Model(model) { + thinkingConfig.Omit = true + } + dsOpts := []deepseek.Option{ + deepseek.WithAPIKey(apiKey), + deepseek.WithBaseURL(baseURL), + deepseek.WithModel(model), + deepseek.WithThinking(thinkingEnabled), + deepseek.WithChatCompletionsThinking(thinkingConfig), + } + if defaults.IsMiniMaxM3Model(model) { + dsOpts = append(dsOpts, deepseek.WithMultimodal(deepseek.MultimodalConfig{ + Enabled: true, + Compat: "openai", + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + })) + } + if hasRetryPolicy(opts.RetryPolicy) { + dsOpts = append(dsOpts, deepseek.WithRetryPolicy(opts.RetryPolicy)) + } + if opts.StreamMaxAttempts > 0 { + dsOpts = append(dsOpts, deepseek.WithStreamMaxAttempts(opts.StreamMaxAttempts)) + } + if opts.StreamIdleTimeout > 0 { + dsOpts = append(dsOpts, deepseek.WithStreamIdleTimeout(opts.StreamIdleTimeout)) + } + if opts.MaxTokens > 0 { + dsOpts = append(dsOpts, deepseek.WithMaxTokens(opts.MaxTokens)) + } + return deepseek.New(dsOpts...) +} + +func hasRetryPolicy(policy llmretry.Policy) bool { + return policy.MaxAttempts != 0 || + policy.BaseDelay != 0 || + policy.MaxDelay != 0 || + policy.Jitter != 0 || + policy.RespectRetryAfter || + policy.RetryNetwork || + policy.RetryStatusCodes != nil +}