diff --git a/config/config.go b/config/config.go index a39687f..8085772 100644 --- a/config/config.go +++ b/config/config.go @@ -57,17 +57,18 @@ const ( ) type modelT struct { - URL string - FlashModel string - ProModel string - MaxTokens int // 该供应商默认的单次 completion 上限 - ContextWindow int // 该供应商默认的上下文窗口大小 + URL string + FlashModel string + ProModel string + MaxTokens int // Default completion limit for this provider. + FlashContextWindow int + ProContextWindow int } // ProviderOptions 是配置时可选的模型供应商,顺序即 UI 展示顺序(第一个为默认)。 // "custom" 为「其它」自定义:flash/pro 各自填 base_url/model/api_key/max_tokens/context_window, // 全部兼容 OpenAI 接口。预设供应商(deepseek/mimo)只需填 api_key,套用 modelConfig 默认。 -var ProviderOptions = []string{"deepseek", "mimo", "kimi", "qwen", "custom"} +var ProviderOptions = []string{"deepseek", "mimo", "kimi", "qwen", "minimax", "custom"} // ProviderCustom 是「其它」自定义供应商的 id。 const ProviderCustom = "custom" @@ -85,32 +86,46 @@ const ( // ".../chat/completions/chat/completions" 而请求失败。 var modelConfig = map[string]modelT{ "deepseek": { - URL: defaultBaseURL, // https://api.deepseek.com - FlashModel: defaultFlashModel, - ProModel: defaultProModel, - MaxTokens: 393216, - ContextWindow: 1_048_576, // 1M + URL: defaultBaseURL, + FlashModel: defaultFlashModel, + ProModel: defaultProModel, + MaxTokens: 393216, + FlashContextWindow: 1_048_576, // 1M + ProContextWindow: 1_048_576, // 1M }, "mimo": { - URL: "https://api.xiaomimimo.com/v1", - FlashModel: "mimo-v2.5", - ProModel: "mimo-v2.5-pro", - MaxTokens: 131072, // mimo 单次 completion 上限 - ContextWindow: 1_048_576, // 1M + URL: "https://api.xiaomimimo.com/v1", + FlashModel: "mimo-v2.5", + ProModel: "mimo-v2.5-pro", + MaxTokens: 131072, + FlashContextWindow: 1_048_576, // 1M + ProContextWindow: 1_048_576, // 1M }, "kimi": { - URL: "https://api.moonshot.cn/v1", // 必须带 /v1,端点为 /v1/chat/completions - FlashModel: "kimi-k2.5", - ProModel: "kimi-k2.6", - MaxTokens: 0, // 0 = 不发 max_tokens,走模型默认输出上限(见 agent.chatRequest omitempty) - ContextWindow: 262144, // 256K + URL: "https://api.moonshot.cn/v1", + FlashModel: "kimi-k2.5", + ProModel: "kimi-k2.6", + MaxTokens: 0, + FlashContextWindow: 262144, // 256K + ProContextWindow: 262144, // 256K }, "qwen": { - URL: "https://dashscope.aliyuncs.com/compatible-mode/v1", // 阿里云北京;端点 /v1/chat/completions - FlashModel: "qwen3.7-plus", - ProModel: "qwen3.7-max", - MaxTokens: 0, // 0 = 不发 max_tokens,走模型默认输出上限 - ContextWindow: 1_048_576, // 1M + URL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + FlashModel: "qwen3.7-plus", + ProModel: "qwen3.7-max", + MaxTokens: 0, + FlashContextWindow: 1_048_576, // 1M + ProContextWindow: 1_048_576, // 1M + }, + "minimax": { + // The global endpoint is the native default. The China endpoint is + // https://api.minimaxi.com/v1 and remains available through custom overrides. + URL: "https://api.minimax.io/v1", + FlashModel: "MiniMax-M2.7", + ProModel: "MiniMax-M3", + MaxTokens: 0, + FlashContextWindow: 204_800, + ProContextWindow: 1_000_000, }, } @@ -146,14 +161,14 @@ func DefaultFor(provider, apiKey string) *Config { BaseURL: mc.URL, Model: mc.FlashModel, APIKey: apiKey, - ContextWindow: mc.ContextWindow, + ContextWindow: mc.FlashContextWindow, MaxTokens: mc.MaxTokens, }, Pro: ModelEntry{ BaseURL: mc.URL, Model: mc.ProModel, APIKey: apiKey, - ContextWindow: mc.ContextWindow, + ContextWindow: mc.ProContextWindow, MaxTokens: mc.MaxTokens, }, } @@ -164,21 +179,29 @@ func Default(apiKey string) *Config { return DefaultFor(defaultProvider, apiKey) } -// defaultContextWindow 根据模型名推断上下文窗口,给旧 yaml(没写 context_window)兜底用。 -// 已知供应商(deepseek / mimo)默认 1M tokens,其它未知模型保守取 64K。 +// defaultContextWindow infers a context window for legacy YAML without one. func defaultContextWindow(model string) int { m := strings.ToLower(model) + if strings.Contains(m, "minimax-m3") { + return 1_000_000 + } + if strings.Contains(m, "minimax-m2.7") { + return 204_800 + } if strings.Contains(m, "deepseek") || strings.Contains(m, "mimo") { return 1_048_576 } return 65_536 } -// defaultMaxTokens 根据模型名推断单次 completion 上限。含 deepseek 的模型沿用既有 384K; -// 其它模型保守取 131072(mimo 等的上限),避免超过模型实际允许值被拒。 -// 给旧 model.yaml(没写 max_tokens)兜底用。 +// defaultMaxTokens infers a completion limit for legacy YAML. MiniMax keeps +// zero so requests use the model's own output limit. func defaultMaxTokens(model string) int { - if strings.Contains(strings.ToLower(model), "deepseek") { + m := strings.ToLower(model) + if strings.Contains(m, "minimax") { + return 0 + } + if strings.Contains(m, "deepseek") { return 393216 } return 131072 diff --git a/config/config_test.go b/config/config_test.go index aa1daa6..e48c3fd 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -6,16 +6,18 @@ import "testing" // 关键回归:base_url 只到域名(agent 再追加 /chat/completions),mimo 的 max_tokens=131072。 func TestDefaultForProviders(t *testing.T) { cases := []struct { - provider string - wantBaseURL string - wantFlash string - wantPro string - wantMaxTok int - wantCtxWin int + provider string + wantBaseURL string + wantFlash string + wantPro string + wantMaxTok int + wantFlashCtx int + wantProCtx int }{ - {"deepseek", "https://api.deepseek.com", "deepseek-v4-flash", "deepseek-v4-pro", 393216, 1_048_576}, - {"mimo", "https://api.xiaomimimo.com/v1", "mimo-v2.5", "mimo-v2.5-pro", 131072, 1_048_576}, - {"unknown-provider", "https://api.deepseek.com", "deepseek-v4-flash", "deepseek-v4-pro", 393216, 1_048_576}, // 回退 deepseek + {"deepseek", "https://api.deepseek.com", "deepseek-v4-flash", "deepseek-v4-pro", 393216, 1_048_576, 1_048_576}, + {"mimo", "https://api.xiaomimimo.com/v1", "mimo-v2.5", "mimo-v2.5-pro", 131072, 1_048_576, 1_048_576}, + {"minimax", "https://api.minimax.io/v1", "MiniMax-M2.7", "MiniMax-M3", 0, 204_800, 1_000_000}, + {"unknown-provider", "https://api.deepseek.com", "deepseek-v4-flash", "deepseek-v4-pro", 393216, 1_048_576, 1_048_576}, // Falls back to deepseek. } for _, c := range cases { cfg := DefaultFor(c.provider, "sk-test") @@ -28,8 +30,8 @@ func TestDefaultForProviders(t *testing.T) { if cfg.Flash.MaxTokens != c.wantMaxTok || cfg.Pro.MaxTokens != c.wantMaxTok { t.Errorf("%s max_tokens = %d/%d, want %d", c.provider, cfg.Flash.MaxTokens, cfg.Pro.MaxTokens, c.wantMaxTok) } - if cfg.Flash.ContextWindow != c.wantCtxWin || cfg.Pro.ContextWindow != c.wantCtxWin { - t.Errorf("%s context_window = %d/%d, want %d", c.provider, cfg.Flash.ContextWindow, cfg.Pro.ContextWindow, c.wantCtxWin) + if cfg.Flash.ContextWindow != c.wantFlashCtx || cfg.Pro.ContextWindow != c.wantProCtx { + t.Errorf("%s context_window = %d/%d, want %d/%d", c.provider, cfg.Flash.ContextWindow, cfg.Pro.ContextWindow, c.wantFlashCtx, c.wantProCtx) } } } @@ -42,4 +44,46 @@ func TestDefaultMaxTokens(t *testing.T) { if got := defaultMaxTokens("mimo-v2.5"); got != 131072 { t.Errorf("mimo max_tokens = %d, want 131072", got) } + if got := defaultMaxTokens("MiniMax-M3"); got != 0 { + t.Errorf("MiniMax max_tokens = %d, want 0", got) + } +} + +func TestMiniMaxDefaultsSurviveRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + if err := Save(DefaultFor("minimax", "sk-test")); err != nil { + t.Fatal(err) + } + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Flash.ContextWindow != 204_800 || cfg.Pro.ContextWindow != 1_000_000 { + t.Fatalf("MiniMax context windows = %d/%d, want 204800/1000000", cfg.Flash.ContextWindow, cfg.Pro.ContextWindow) + } + if cfg.Flash.MaxTokens != 0 || cfg.Pro.MaxTokens != 0 { + t.Fatalf("MiniMax max_tokens = %d/%d, want 0/0", cfg.Flash.MaxTokens, cfg.Pro.MaxTokens) + } +} + +func TestProviderOptionsIncludesMiniMax(t *testing.T) { + miniMaxIndex := -1 + customIndex := -1 + for i, provider := range ProviderOptions { + switch provider { + case "minimax": + miniMaxIndex = i + case ProviderCustom: + customIndex = i + } + } + if miniMaxIndex < 0 { + t.Fatal("ProviderOptions does not include minimax") + } + if customIndex < 0 || miniMaxIndex > customIndex { + t.Fatalf("minimax must appear before custom, got %v", ProviderOptions) + } }