// ollama provider 测试 - 用 httptest.NewServer mock /api/tags 端点. // // 精妙之处(CLEVER): 不依赖真实本地 Ollama 服务, // 通过 httptest.NewServer 起一个 HTTP server 拦截 GET /api/tags, // 同步返回构造好的 JSON 响应,验证 provider 解析 + 字段映射正确. package ollama import ( "context" "fmt" "net/http" "net/http/httptest" "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) // === 构造与基础元数据 === func TestNew_DefaultBaseURL(t *testing.T) { p := New(Config{}) if p.cfg.BaseURL != "http://localhost:11434" { t.Errorf("默认 BaseURL = %q", p.cfg.BaseURL) } } func TestNew_CustomBaseURL(t *testing.T) { p := New(Config{BaseURL: "http://192.168.1.100:11434"}) if p.cfg.BaseURL != "http://192.168.1.100:11434" { t.Errorf("BaseURL = %q", p.cfg.BaseURL) } } func TestNew_CustomHTTPClient(t *testing.T) { custom := &http.Client{} p := New(Config{HTTPClient: custom}) // client 已在 wire 层创建--这里只验证 New 接受 HTTPClient 不 panic if p == nil || p.client == nil { t.Fatal("Provider 应正确初始化") } } func TestProvider_Name(t *testing.T) { p := New(Config{}) if p.Name() != "ollama" { t.Errorf("Name() = %q, want ollama", p.Name()) } } func TestConfig_GoString_HidesDetails(t *testing.T) { // GoString 应只暴露 BaseURL,不暴露 HTTPClient 或其他字段 c := Config{BaseURL: "http://x:11434", HTTPClient: &http.Client{}} s := c.GoString() if !strings.Contains(s, "http://x:11434") { t.Errorf("GoString 应含 BaseURL: %q", s) } if strings.Contains(s, "http.Client") { t.Errorf("GoString 不应暴露 HTTPClient 实现细节: %q", s) } } // === ModelProvider 接口断言 === func TestProvider_ImplementsModelProvider(t *testing.T) { var _ flyto.ModelProvider = (*Provider)(nil) } // === Models() - mock /api/tags === func TestModels_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/tags" { t.Errorf("意外路径: %s", r.URL.Path) http.NotFound(w, r) return } if r.Method != "GET" { t.Errorf("应为 GET,实际: %s", r.Method) } w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{ "models": [ { "name": "llama3.2:3b", "details": {"parameter_size": "3B", "quantization_level": "Q4_0"} }, { "name": "qwen2.5:7b", "details": {"parameter_size": "7B", "quantization_level": "Q4_K_M"} }, { "name": "gemma2:latest", "details": {"parameter_size": "9B", "quantization_level": "Q4_0"} } ] }`) })) defer server.Close() p := New(Config{BaseURL: server.URL}) ctx := context.Background() models, err := p.Models(ctx) if err != nil { t.Fatalf("Models() 失败: %v", err) } if len(models) != 3 { t.Fatalf("应返回 3 个模型,实际: %d", len(models)) } // 验证字段映射 if models[0].ID != "llama3.2:3b" { t.Errorf("ID[0] = %q", models[0].ID) } if models[0].Provider != "ollama" { t.Errorf("Provider[0] = %q", models[0].Provider) } if !strings.Contains(models[0].DisplayName, "3B") { t.Errorf("DisplayName[0] 应含参数规模: %q", models[0].DisplayName) } if models[0].SupportsVision { t.Error("Ollama provider 默认 SupportsVision 应为 false(保守)") } } func TestModels_EmptyList(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"models": []}`) })) defer server.Close() p := New(Config{BaseURL: server.URL}) models, err := p.Models(context.Background()) if err != nil { t.Fatalf("空列表不应返回错误: %v", err) } if len(models) != 0 { t.Errorf("应返回 0 个模型,实际: %d", len(models)) } } func TestModels_BadJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{not valid json`) })) defer server.Close() p := New(Config{BaseURL: server.URL}) _, err := p.Models(context.Background()) if err == nil { t.Fatal("坏 JSON 应返回错误") } if !strings.Contains(err.Error(), "ollama") { t.Errorf("错误应有 'ollama' 前缀: %v", err) } } func TestModels_HTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal", http.StatusInternalServerError) })) defer server.Close() p := New(Config{BaseURL: server.URL}) // FetchOllamaModels 当前实现未检查 status code,500 + 非 JSON 会触发 JSON 解析错 _, err := p.Models(context.Background()) if err == nil { t.Error("500 应导致错误") } } func TestModels_ServerUnreachable(t *testing.T) { // 故意指向不存在的端口 p := New(Config{BaseURL: "http://127.0.0.1:1"}) ctx := context.Background() _, err := p.Models(ctx) if err == nil { t.Error("不可达 server 应返回错误") } } // === PR2.1 data-driven-capabilities: registry 优先 + 包内兜底 === // // 这些测试覆盖 resolveMaxTools / resolveThinkingSupport / detectFeatureWarnings 三个 helper, // 不走 Stream() 端到端(会需要 mock OpenAICompatClient,开销不成比例). // helper 是 PR2.1 引入的所有新决策逻辑,单元层级足以覆盖. func TestResolveMaxTools_NilCapabilities(t *testing.T) { req := &flyto.Request{Model: "llama3.2:3b"} 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: "llama3.2:3b", Capabilities: &flyto.ModelInfo{ MaxTools: 5, MaxToolsExhaustive: true, }, } mt, exh := resolveMaxTools(req) if mt != 5 || !exh { t.Errorf("registry 注入应返回 (5, true),实际 (%d, %v)", mt, exh) } } func TestResolveMaxTools_RegistrySoft(t *testing.T) { // MaxTools 有值但 Exhaustive=false → 软处理(下界,不硬拒) req := &flyto.Request{ Model: "llama3.2:3b", Capabilities: &flyto.ModelInfo{ MaxTools: 10, MaxToolsExhaustive: false, }, } mt, exh := resolveMaxTools(req) if mt != 10 || exh { t.Errorf("Exhaustive=false 应返回 (10, false),实际 (%d, %v)", mt, exh) } } func TestResolveMaxTools_RegistryZeroFallsBack(t *testing.T) { // MaxTools=0 表示 registry 无数据 → 走包内兜底 req := &flyto.Request{ Model: "llama3.2:3b", Capabilities: &flyto.ModelInfo{MaxTools: 0}, } mt, exh := resolveMaxTools(req) if mt != 0 || exh { t.Errorf("MaxTools=0 应降级到兜底 (0, false),实际 (%d, %v)", mt, exh) } } func TestResolveThinkingSupport_NilFallbackFalse(t *testing.T) { req := &flyto.Request{Model: "llama3.2:3b"} if resolveThinkingSupport(req) { t.Error("nil Capabilities 应兜底为 false(ollama 本地模型默认不支持 thinking)") } } func TestResolveThinkingSupport_RegistryTrue(t *testing.T) { // 未来某本地推理模型(如 DeepSeek-R1)进 registry 后应被识别为支持 req := &flyto.Request{ Model: "deepseek-r1:7b", Capabilities: &flyto.ModelInfo{SupportsThinking: true}, } if !resolveThinkingSupport(req) { t.Error("registry SupportsThinking=true 应返回 true") } } func TestResolveThinkingSupport_RegistryFalse(t *testing.T) { req := &flyto.Request{ Model: "llama3.2:3b", Capabilities: &flyto.ModelInfo{SupportsThinking: false}, } if resolveThinkingSupport(req) { t.Error("registry SupportsThinking=false 应返回 false") } } // === 双开关协议(want × can)4 组合 === func TestDetectWarnings_WantFalse_CanFalse(t *testing.T) { p := New(Config{}) req := &flyto.Request{Model: "llama3.2:3b", 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) { // 用户 opt-out(NeedsThinking=false),尊重不开,不发 warning p := New(Config{}) req := &flyto.Request{ Model: "deepseek-r1:7b", NeedsThinking: false, Capabilities: &flyto.ModelInfo{SupportsThinking: true}, } if w := p.detectFeatureWarnings(req); len(w) != 0 { t.Errorf("want=false can=true 应零 warning,实际 %d 个", len(w)) } } func TestDetectWarnings_WantTrue_CanFalse(t *testing.T) { // 关键场景:silent disable → 必须发 warning p := New(Config{}) req := &flyto.Request{ Model: "llama3.2:3b", NeedsThinking: true, // Capabilities=nil → resolveThinkingSupport 兜底 false } warnings := p.detectFeatureWarnings(req) if len(warnings) != 1 { t.Fatalf("want=true can=false 应发 1 个 warning,实际 %d 个", len(warnings)) } w := warnings[0] if w.Code != "feature_unsupported" { t.Errorf("warning Code = %q, want feature_unsupported", w.Code) } if !strings.Contains(w.Message, "llama3.2:3b") { t.Errorf("warning Message 应含模型名: %q", w.Message) } if !strings.Contains(w.Detail, "feature=thinking") { t.Errorf("warning Detail 应含 feature=thinking: %q", w.Detail) } } func TestDetectWarnings_WantTrue_CanTrue(t *testing.T) { // registry 说模型支持,用户想要 → 本应启用,不发 warning // (注:ollama Stream 实际不转发 thinking 的 LEGACY 债务不在本测试范围) p := New(Config{}) req := &flyto.Request{ Model: "deepseek-r1:7b", NeedsThinking: true, Capabilities: &flyto.ModelInfo{SupportsThinking: true}, } if w := p.detectFeatureWarnings(req); len(w) != 0 { t.Errorf("want=true can=true 应零 warning,实际 %d 个", len(w)) } } func TestModels_DisplayNameWithoutParameterSize(t *testing.T) { // 没有 details.parameter_size 时 DisplayName 应等于 model name server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"models": [{"name": "custom:latest", "details": {}}]}`) })) defer server.Close() p := New(Config{BaseURL: server.URL}) models, err := p.Models(context.Background()) if err != nil { t.Fatal(err) } if len(models) != 1 { t.Fatalf("应有 1 个模型") } if models[0].DisplayName != "custom:latest" { t.Errorf("DisplayName = %q, 应等于 model name 当无 parameter_size", models[0].DisplayName) } }