// gemini provider 测试 - 覆盖构造路径,双密钥模式,静态模型表, // modelSupportsThinking 查找逻辑. // // 历史包袱(LEGACY): Stream() 走 wire.GeminiClient,需要复杂的 Gemini SSE mock, // 留给 internal/wire 包测试.本包只测可独立验证的部分. package gemini import ( "context" "net/http" "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) // === 构造与基础 === func TestNew_DefaultConfig(t *testing.T) { p := New(Config{APIKey: "k"}) if p == nil || p.client == nil { t.Fatal("Provider 应正确创建") } if p.cfg.APIKey != "k" { t.Errorf("APIKey 应保留") } } func TestNew_VertexBearerToken(t *testing.T) { // BearerToken 非空时切换到 Vertex AI 模式 p := New(Config{BearerToken: "ya29.bearer-token"}) if p == nil || p.client == nil { t.Fatal("Vertex 模式应能创建") } if p.cfg.BearerToken != "ya29.bearer-token" { t.Errorf("BearerToken 应保留") } } func TestNew_CustomBaseURL(t *testing.T) { p := New(Config{ APIKey: "k", BaseURL: "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google", }) if p == nil { t.Fatal("自定义 BaseURL 应能创建") } } func TestNew_ThinkingBudget(t *testing.T) { p := New(Config{APIKey: "k", ThinkingBudget: 8000}) if p.cfg.ThinkingBudget != 8000 { t.Errorf("ThinkingBudget = %d", p.cfg.ThinkingBudget) } } func TestNew_CustomHTTPClient(t *testing.T) { custom := &http.Client{} p := New(Config{APIKey: "k", HTTPClient: custom}) if p == nil || p.client == nil { t.Fatal("自定义 HTTPClient 应能创建") } } func TestProvider_Name(t *testing.T) { if New(Config{APIKey: "k"}).Name() != "gemini" { t.Errorf("Name() != gemini") } } func TestProvider_ImplementsModelProvider(t *testing.T) { var _ flyto.ModelProvider = (*Provider)(nil) } // === GoString 遮蔽 === func TestConfig_GoString_MasksAPIKey(t *testing.T) { c := Config{APIKey: "AIza-supersecretkey-12345"} s := c.GoString() if strings.Contains(s, "supersecretkey") { t.Errorf("GoString 必须遮蔽 APIKey: %q", s) } } func TestConfig_GoString_EmptyKey(t *testing.T) { c := Config{APIKey: ""} if c.GoString() == "" { t.Error("空 APIKey GoString 不应空") } } // === 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) < 5 { t.Errorf("应至少有 5 个静态模型,实际: %d", len(models)) } // 关键模型存在性 expected := []string{ "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", } 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) } } // 全部 gemini 模型应支持 vision for _, m := range models { if !m.SupportsVision { t.Errorf("%s 应支持 vision(Gemini 全系列原生多模态)", m.ID) } if m.Provider != "gemini" { t.Errorf("%s Provider = %q", m.ID, m.Provider) } } // 2.5-pro 是最新的 thinking 旗舰 pro := got["gemini-2.5-pro"] if !pro.SupportsThinking { t.Error("gemini-2.5-pro 应支持 thinking") } if pro.ContextWindow != 1_000_000 { t.Errorf("gemini-2.5-pro ContextWindow = %d", pro.ContextWindow) } // 1.5-pro 有 2M 上下文 v15pro := got["gemini-1.5-pro"] if v15pro.ContextWindow != 2_000_000 { t.Errorf("gemini-1.5-pro ContextWindow = %d, want 2M", v15pro.ContextWindow) } } 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_OverrideReplacesStatic(t *testing.T) { overrides := []flyto.ModelInfo{ {ID: "gemini-3.0-experimental", Provider: "gemini"}, } p := New(Config{APIKey: "k", ModelOverrides: overrides}) models, _ := p.Models(context.Background()) if len(models) != 1 { t.Fatalf("应只有 1 个 override 模型") } if models[0].ID != "gemini-3.0-experimental" { t.Errorf("ID = %q", models[0].ID) } } func TestModels_EmptyOverridesUsesStatic(t *testing.T) { p := New(Config{APIKey: "k", ModelOverrides: []flyto.ModelInfo{}}) models, _ := p.Models(context.Background()) if len(models) < 5 { t.Errorf("空 overrides 应使用静态表") } } // === modelSupportsThinking 私有函数 === func TestModelSupportsThinking_Known(t *testing.T) { tests := []struct { model string want bool }{ {"gemini-2.5-pro", true}, {"gemini-2.5-flash", true}, {"gemini-2.5-flash-thinking-exp", true}, {"gemini-2.0-flash", false}, {"gemini-2.0-flash-thinking-exp", true}, {"gemini-1.5-pro", false}, {"gemini-1.5-flash", false}, {"gemini-1.5-flash-8b", false}, } for _, tt := range tests { t.Run(tt.model, func(t *testing.T) { got := modelSupportsThinking(tt.model) if got != tt.want { t.Errorf("modelSupportsThinking(%q) = %v, want %v", tt.model, got, tt.want) } }) } } func TestModelSupportsThinking_Unknown(t *testing.T) { // 未知模型保守返回 false(防止把 thinking budget 注入不支持的模型) _ = New(Config{APIKey: "k"}) // 保留构造验证 (早期方案意图) if modelSupportsThinking("gemini-99-imaginary") { t.Error("未知模型应保守返回 false") } if modelSupportsThinking("") { t.Error("空字符串应返回 false") } } // === PR2.4 data-driven-capabilities: registry 优先 + 包内兜底 === func TestResolveMaxTools_NilCapabilities(t *testing.T) { req := &flyto.Request{Model: "gemini-2.5-pro"} 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: "gemini-2.5-pro", Capabilities: &flyto.ModelInfo{ MaxTools: 20, MaxToolsExhaustive: true, }, } mt, exh := resolveMaxTools(req) if mt != 20 || !exh { t.Errorf("registry 注入应返回 (20, true),实际 (%d, %v)", mt, exh) } } func TestResolveMaxTools_RegistrySoft(t *testing.T) { req := &flyto.Request{ Model: "gemini-2.5-pro", Capabilities: &flyto.ModelInfo{ MaxTools: 50, MaxToolsExhaustive: false, }, } mt, exh := resolveMaxTools(req) if mt != 50 || exh { t.Errorf("Exhaustive=false 应返回 (50, false),实际 (%d, %v)", mt, exh) } } func TestResolveThinkingSupport_NilFallsBackToStaticTable(t *testing.T) { // nil Capabilities → 扫静态表 if !resolveThinkingSupport(&flyto.Request{Model: "gemini-2.5-pro"}) { t.Error("nil + gemini-2.5-pro 静态表应返回 true") } if resolveThinkingSupport(&flyto.Request{Model: "gemini-2.0-flash"}) { t.Error("nil + gemini-2.0-flash 静态表应返回 false") } } func TestResolveThinkingSupport_RegistryOverridesStaticTable(t *testing.T) { // registry 数据优先于静态表 req := &flyto.Request{ Model: "gemini-2.0-flash", // 静态表 false Capabilities: &flyto.ModelInfo{SupportsThinking: true}, } if !resolveThinkingSupport(req) { t.Error("registry SupportsThinking=true 应优先") } } // === 双开关协议(want × can)4 组合 === func TestDetectWarnings_WantFalse_CanFalse(t *testing.T) { p := New(Config{APIKey: "k"}) req := &flyto.Request{Model: "gemini-2.0-flash", 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: "gemini-2.5-pro", 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_ViaConfig(t *testing.T) { // 关键场景:Config.ThinkingBudget 设了但模型不支持 → silent disable (早期方案 Stream 会清零) p := New(Config{APIKey: "k", ThinkingBudget: 8000}) req := &flyto.Request{Model: "gemini-2.0-flash"} 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, "gemini-2.0-flash") { t.Errorf("Message 应含模型名: %q", w.Message) } } func TestDetectWarnings_WantTrue_CanFalse_ViaReq(t *testing.T) { // 关键场景 2:req.NeedsThinking=true + 不支持模型 p := New(Config{APIKey: "k"}) req := &flyto.Request{Model: "gemini-2.0-flash", NeedsThinking: true} warnings := p.detectFeatureWarnings(req) if len(warnings) != 1 { t.Fatalf("NeedsThinking=true + 不支持模型应发 1 warning,实际 %d", len(warnings)) } } func TestDetectWarnings_WantTrue_CanTrue(t *testing.T) { p := New(Config{APIKey: "k", ThinkingBudget: 8000}) req := &flyto.Request{Model: "gemini-2.5-pro"} if w := p.detectFeatureWarnings(req); len(w) != 0 { t.Errorf("want=true can=true 应零 warning,实际 %d 个", len(w)) } }