Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions internal/app/app_runtime_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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...),
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions internal/app/app_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type Config struct {
MemoryMaxChars int
MemoryFileOrder string
BudgetWarningUSD float64
Provider string
Model string
ModelExplicit bool
ReasoningEffort string
Expand All @@ -49,6 +50,7 @@ type Config struct {
RetryMaxDelay time.Duration
DeepSeekPrefixCompletion bool
DeepSeekMultimodal MultimodalProviderConfig
MiniMax MiniMaxProviderConfig
DeepSeekWebSearch deepseek.WebSearchMode
DeepSeekAPI deepseek.API
ShellForegroundWaitDefaultMS int
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions internal/app/config_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions internal/app/config_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -71,6 +72,7 @@ type FileAPIConfig struct {

type FileProvidersConfig struct {
DeepSeek FileDeepSeekProviderConfig `toml:"deepseek,omitempty"`
MiniMax FileMiniMaxProviderConfig `toml:"minimax,omitempty"`
}

type FileDeepSeekProviderConfig struct {
Expand All @@ -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"`
Expand Down
46 changes: 46 additions & 0 deletions internal/app/config_file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions internal/app/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions internal/app/model_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
58 changes: 58 additions & 0 deletions internal/app/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) != "" {
Expand Down
Loading
Loading