// minimax provider 测试 - 覆盖三种模式(Native/OpenAI/Anthropic)的构造路径, // 两个区域(China/Global)的 baseURL 选择,静态模型表和 ModelOverrides. // // 历史包袱(LEGACY): Stream() 涉及 wire.OpenAICompatClient 和 internal/transport // 的 SSE 解析,需要复杂的 mock 才能测.当前只测可独立验证的路径, // SSE 解析端到端验证留给 internal/wire 和 internal/transport 包. package minimax import ( "context" "net/http" "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) // === 构造与默认值 === func TestNew_DefaultRegionAndMode(t *testing.T) { p := New(Config{APIKey: "k"}) if p.cfg.Region != RegionChina { t.Errorf("默认 Region = %q, want china", p.cfg.Region) } if p.cfg.Mode != ModeNative { t.Errorf("默认 Mode = %q, want native", p.cfg.Mode) } if p.baseURL != "https://api.minimaxi.com" { t.Errorf("默认 baseURL = %q", p.baseURL) } } func TestNew_GlobalRegion(t *testing.T) { p := New(Config{APIKey: "k", Region: RegionGlobal}) if p.baseURL != "https://api.minimax.io" { t.Errorf("global baseURL = %q", p.baseURL) } } func TestNew_ModeNative_UsesWireClient(t *testing.T) { p := New(Config{APIKey: "k", Mode: ModeNative}) if p.wireClient == nil { t.Error("Native 模式应初始化 wireClient") } if p.anthroClient != nil { t.Error("Native 模式不应初始化 anthroClient") } } func TestNew_ModeOpenAI_UsesWireClient(t *testing.T) { p := New(Config{APIKey: "k", Mode: ModeOpenAI}) if p.wireClient == nil { t.Error("OpenAI 模式应初始化 wireClient") } if p.anthroClient != nil { t.Error("OpenAI 模式不应初始化 anthroClient") } } func TestNew_ModeAnthropic_UsesAnthroClient(t *testing.T) { p := New(Config{APIKey: "k", Mode: ModeAnthropic}) if p.anthroClient == nil { t.Error("Anthropic 模式应初始化 anthroClient") } if p.wireClient != nil { t.Error("Anthropic 模式不应初始化 wireClient") } } func TestNew_CustomHTTPClient_NativeMode(t *testing.T) { custom := &http.Client{} p := New(Config{APIKey: "k", HTTPClient: custom}) if p == nil || p.wireClient == nil { t.Fatal("自定义 HTTPClient 应正常初始化") } } func TestNew_CustomHTTPClient_AnthropicMode(t *testing.T) { custom := &http.Client{} p := New(Config{APIKey: "k", Mode: ModeAnthropic, HTTPClient: custom}) if p == nil || p.anthroClient == nil { t.Fatal("Anthropic 模式自定义 HTTPClient 应正常初始化") } } func TestProvider_Name(t *testing.T) { if New(Config{APIKey: "k"}).Name() != "minimax" { t.Errorf("Name() != minimax") } } func TestProvider_ImplementsModelProvider(t *testing.T) { var _ flyto.ModelProvider = (*Provider)(nil) } // === GoString APIKey 遮蔽 === func TestConfig_GoString_MasksAPIKey(t *testing.T) { c := Config{APIKey: "mm-supersecretapikey-12345"} s := c.GoString() if strings.Contains(s, "supersecretapikey") { t.Errorf("GoString 必须遮蔽 APIKey: %q", s) } } func TestConfig_GoString_EmptyKey(t *testing.T) { s := Config{APIKey: ""}.GoString() if s == "" { t.Error("空 APIKey GoString 不应空") } } // === ThinkingBudget 配置 === func TestNew_ThinkingBudgetPreserved(t *testing.T) { p := New(Config{APIKey: "k", ThinkingBudget: 8000}) if p.cfg.ThinkingBudget != 8000 { t.Errorf("ThinkingBudget = %d", p.cfg.ThinkingBudget) } } // === Mode 常量 === func TestModeConstants(t *testing.T) { tests := []struct { mode Mode want string }{ {ModeNative, "native"}, {ModeOpenAI, "openai"}, {ModeAnthropic, "anthropic"}, } for _, tt := range tests { if string(tt.mode) != tt.want { t.Errorf("Mode %v string = %q, want %q", tt.mode, tt.mode, tt.want) } } } func TestRegionConstants(t *testing.T) { if string(RegionChina) != "china" { t.Errorf("RegionChina = %q", RegionChina) } if string(RegionGlobal) != "global" { t.Errorf("RegionGlobal = %q", RegionGlobal) } } // === Models() === func TestModels_StaticList(t *testing.T) { p := New(Config{APIKey: "k"}) models, err := p.Models(context.Background()) if err != nil { t.Fatalf("Models() 失败: %v", err) } if len(models) != 6 { t.Errorf("应有 6 个静态模型,实际: %d", len(models)) } // 验证关键模型存在 expected := []string{ "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2", "MiniMax-M1", } got := make(map[string]flyto.ModelInfo) for _, m := range models { got[m.ID] = m } for _, id := range expected { if _, ok := got[id]; !ok { t.Errorf("应有模型 %q", id) } } // M2.7 是最新的,验证关键字段 m27 := got["MiniMax-M2.7"] if m27.Provider != "minimax" { t.Errorf("M2.7 Provider = %q", m27.Provider) } if !m27.SupportsThinking { t.Error("M2.7 应支持 thinking") } if !m27.SupportsCaching { t.Error("M2.7 应支持 caching") } if m27.ContextWindow != 205000 { t.Errorf("M2.7 ContextWindow = %d", m27.ContextWindow) } // M1 是 1M 上下文 m1 := got["MiniMax-M1"] if m1.ContextWindow != 1000000 { t.Errorf("M1 ContextWindow = %d, want 1000000", m1.ContextWindow) } if m1.SupportsCaching { t.Error("M1 不应支持 caching") } } func TestModels_AllHavePricing(t *testing.T) { models, _ := New(Config{APIKey: "k"}).Models(context.Background()) for _, m := range models { if m.InputPricePer1M <= 0 { t.Errorf("%s InputPricePer1M = %v 应为正", m.ID, m.InputPricePer1M) } if m.OutputPricePer1M <= 0 { t.Errorf("%s OutputPricePer1M = %v 应为正", m.ID, m.OutputPricePer1M) } } } func TestModels_OutputTokensSet(t *testing.T) { models, _ := New(Config{APIKey: "k"}).Models(context.Background()) for _, m := range models { if m.MaxOutputTokens == 0 { t.Errorf("%s MaxOutputTokens 不应为 0", m.ID) } } } func TestModels_OverrideReplacesStatic(t *testing.T) { overrides := []flyto.ModelInfo{ { ID: "custom-model-1", Provider: "minimax", ContextWindow: 50000, InputPricePer1M: 0.5, OutputPricePer1M: 1.5, }, } p := New(Config{APIKey: "k", ModelOverrides: overrides}) models, err := p.Models(context.Background()) if err != nil { t.Fatalf("Models() 失败: %v", err) } if len(models) != 1 { t.Fatalf("ModelOverrides 应替换静态表,应有 1 个,实际: %d", len(models)) } if models[0].ID != "custom-model-1" { t.Errorf("ID = %q", models[0].ID) } } func TestModels_EmptyOverridesUsesStatic(t *testing.T) { // 空 slice 也应回退到静态表(防御性测试) p := New(Config{APIKey: "k", ModelOverrides: []flyto.ModelInfo{}}) models, _ := p.Models(context.Background()) if len(models) != 6 { t.Errorf("空 overrides 应使用静态表,实际: %d", len(models)) } } // === PR2.6 data-driven-capabilities: registry 优先 + 静态表兜底 === func TestResolveMaxTools_NilCapabilities(t *testing.T) { req := &flyto.Request{Model: "MiniMax-M2.7"} mt, exh := resolveMaxTools(req) if mt != 0 || exh { t.Errorf("nil Capabilities 应返回兜底 (0, false),实际 (%d, %v)", mt, exh) } } func TestResolveMaxTools_RegistryExhaustive(t *testing.T) { req := &flyto.Request{ Model: "MiniMax-M2.7", Capabilities: &flyto.ModelInfo{ MaxTools: 50, MaxToolsExhaustive: true, }, } mt, exh := resolveMaxTools(req) if mt != 50 || !exh { t.Errorf("registry 注入应返回 (50, true),实际 (%d, %v)", mt, exh) } } func TestResolveMaxTools_RegistrySoft(t *testing.T) { req := &flyto.Request{ Model: "MiniMax-M2.7", Capabilities: &flyto.ModelInfo{ MaxTools: 256, MaxToolsExhaustive: false, }, } mt, exh := resolveMaxTools(req) if mt != 256 || exh { t.Errorf("Exhaustive=false 应返回 (256, false),实际 (%d, %v)", mt, exh) } } func TestModelSupportsThinkingFallback(t *testing.T) { // 静态表 SupportsThinking 字段验证 cases := map[string]bool{ "MiniMax-M2.7": true, "MiniMax-M2.7-highspeed": true, "MiniMax-M2.5": true, "MiniMax-M2.1": true, "MiniMax-M2": false, // M2 不支持 thinking "MiniMax-M1": false, // M1 不支持 thinking "unknown": false, } for model, want := range cases { if got := modelSupportsThinkingFallback(model); got != want { t.Errorf("modelSupportsThinkingFallback(%q) = %v, want %v", model, got, want) } } } func TestResolveThinkingSupport_NilFallsBackToStaticTable(t *testing.T) { // nil Capabilities → 扫静态表 if !resolveThinkingSupport(&flyto.Request{Model: "MiniMax-M2.7"}) { t.Error("nil + M2.7 静态表应返回 true") } if resolveThinkingSupport(&flyto.Request{Model: "MiniMax-M2"}) { t.Error("nil + M2 静态表应返回 false") } } func TestResolveThinkingSupport_RegistryOverridesStaticTable(t *testing.T) { // registry 数据优先于静态表 req := &flyto.Request{ Model: "MiniMax-M2", // 静态表 false Capabilities: &flyto.ModelInfo{SupportsThinking: true}, } if !resolveThinkingSupport(req) { t.Error("registry SupportsThinking=true 应优先于静态表 false") } } // === 双开关协议(want × can)4 组合 === func TestDetectWarnings_WantFalse_CanFalse(t *testing.T) { p := New(Config{APIKey: "k"}) req := &flyto.Request{Model: "MiniMax-M2", NeedsThinking: false} if w := p.detectFeatureWarnings(req); len(w) != 0 { t.Errorf("want=false can=false 应零 warning,实际 %d 个", len(w)) } } func TestDetectWarnings_WantFalse_CanTrue(t *testing.T) { p := New(Config{APIKey: "k"}) req := &flyto.Request{Model: "MiniMax-M2.7", NeedsThinking: false} if w := p.detectFeatureWarnings(req); len(w) != 0 { t.Errorf("want=false can=true 应零 warning(尊重 opt-out),实际 %d 个", len(w)) } } func TestDetectWarnings_WantTrue_CanFalse_ViaConfigBudget(t *testing.T) { // ThinkingBudget 设了但模型不支持 → silent disable p := New(Config{APIKey: "k", ThinkingBudget: 8000}) req := &flyto.Request{Model: "MiniMax-M2"} warnings := p.detectFeatureWarnings(req) if len(warnings) != 1 { t.Fatalf("ThinkingBudget>0 + 不支持模型应发 1 warning,实际 %d", len(warnings)) } w := warnings[0] if w.Code != "feature_unsupported" { t.Errorf("Code = %q, want feature_unsupported", w.Code) } if !strings.Contains(w.Message, "MiniMax-M2") { t.Errorf("Message 应含模型名: %q", w.Message) } if !strings.Contains(w.Detail, "feature=thinking") { t.Errorf("Detail 应含 feature=thinking: %q", w.Detail) } } func TestDetectWarnings_WantTrue_CanFalse_ViaReqNeedsThinking(t *testing.T) { p := New(Config{APIKey: "k"}) req := &flyto.Request{Model: "MiniMax-M1", NeedsThinking: true} warnings := p.detectFeatureWarnings(req) if len(warnings) != 1 { t.Fatalf("NeedsThinking=true + M1(不支持) 应发 1 warning,实际 %d", len(warnings)) } } func TestDetectWarnings_WantTrue_CanTrue(t *testing.T) { p := New(Config{APIKey: "k", ThinkingBudget: 8000}) req := &flyto.Request{Model: "MiniMax-M2.7"} if w := p.detectFeatureWarnings(req); len(w) != 0 { t.Errorf("want=true can=true 应零 warning,实际 %d 个", len(w)) } }