// lmstudio provider 测试 - 用 httptest.NewServer mock /v1/models 端点. package lmstudio 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:1234" { t.Errorf("默认 BaseURL = %q", p.cfg.BaseURL) } } func TestNew_CustomBaseURL(t *testing.T) { p := New(Config{BaseURL: "http://192.168.1.50:1234"}) if p.cfg.BaseURL != "http://192.168.1.50:1234" { t.Errorf("BaseURL = %q", p.cfg.BaseURL) } } func TestNew_CustomHTTPClient(t *testing.T) { custom := &http.Client{} p := New(Config{HTTPClient: custom}) if p == nil || p.client == nil { t.Fatal("Provider 应正确初始化") } } func TestProvider_Name(t *testing.T) { if New(Config{}).Name() != "lmstudio" { t.Errorf("Name() != lmstudio") } } func TestConfig_GoString(t *testing.T) { c := Config{BaseURL: "http://x:1234", HTTPClient: &http.Client{}} s := c.GoString() if !strings.Contains(s, "http://x:1234") { t.Errorf("GoString 应含 BaseURL: %q", s) } if strings.Contains(s, "http.Client") { t.Errorf("GoString 不应暴露 HTTPClient: %q", s) } } func TestProvider_ImplementsModelProvider(t *testing.T) { var _ flyto.ModelProvider = (*Provider)(nil) } // === Models() === func TestModels_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/models" { t.Errorf("意外路径: %s", r.URL.Path) } fmt.Fprint(w, `{ "object": "list", "data": [ {"id": "qwen2.5-7b-instruct", "owned_by": "organization-owner"}, {"id": "deepseek-coder-v2", "owned_by": ""} ] }`) })) defer server.Close() p := New(Config{BaseURL: server.URL}) models, err := p.Models(context.Background()) if err != nil { t.Fatalf("Models() 失败: %v", err) } if len(models) != 2 { t.Fatalf("应返回 2 个模型,实际: %d", len(models)) } // 验证 lmstudio 强制覆盖 Provider 字段 for i, m := range models { if m.Provider != "lmstudio" { t.Errorf("models[%d].Provider = %q, 应被强制为 lmstudio", i, m.Provider) } if m.ID != m.DisplayName { t.Errorf("models[%d] ID/DisplayName 应相同", i) } } if models[0].ID != "qwen2.5-7b-instruct" { t.Errorf("ID[0] = %q", models[0].ID) } } func TestModels_EmptyList(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"data": []}`) })) 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 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(), "lmstudio") { t.Errorf("错误应含 lmstudio 前缀: %v", err) } } func TestModels_Unreachable(t *testing.T) { p := New(Config{BaseURL: "http://127.0.0.1:1"}) _, err := p.Models(context.Background()) if err == nil { t.Error("不可达 server 应报错") } } // === PR2.2 data-driven-capabilities: registry 优先 + 包内兜底 === func TestResolveMaxTools_NilCapabilities(t *testing.T) { req := &flyto.Request{Model: "qwen2.5-7b-instruct"} 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: "qwen2.5-7b-instruct", Capabilities: &flyto.ModelInfo{ MaxTools: 8, MaxToolsExhaustive: true, }, } mt, exh := resolveMaxTools(req) if mt != 8 || !exh { t.Errorf("registry 注入应返回 (8, true),实际 (%d, %v)", mt, exh) } } func TestResolveMaxTools_RegistrySoft(t *testing.T) { req := &flyto.Request{ Model: "qwen2.5-7b-instruct", Capabilities: &flyto.ModelInfo{ MaxTools: 16, MaxToolsExhaustive: false, }, } mt, exh := resolveMaxTools(req) if mt != 16 || exh { t.Errorf("Exhaustive=false 应返回 (16, false),实际 (%d, %v)", mt, exh) } } func TestResolveMaxTools_RegistryZeroFallsBack(t *testing.T) { req := &flyto.Request{ Model: "qwen2.5-7b-instruct", 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: "qwen2.5-7b-instruct"} if resolveThinkingSupport(req) { t.Error("nil Capabilities 应兜底为 false") } } func TestResolveThinkingSupport_RegistryTrue(t *testing.T) { req := &flyto.Request{ Model: "qwq-32b-preview", Capabilities: &flyto.ModelInfo{SupportsThinking: true}, } if !resolveThinkingSupport(req) { t.Error("registry SupportsThinking=true 应返回 true") } } func TestResolveThinkingSupport_RegistryFalse(t *testing.T) { req := &flyto.Request{ Model: "qwen2.5-7b-instruct", 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: "qwen2.5-7b-instruct", 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{}) req := &flyto.Request{ Model: "qwq-32b-preview", NeedsThinking: false, Capabilities: &flyto.ModelInfo{SupportsThinking: true}, } if w := p.detectFeatureWarnings(req); len(w) != 0 { t.Errorf("want=false can=true 应零 warning(尊重 opt-out),实际 %d 个", len(w)) } } func TestDetectWarnings_WantTrue_CanFalse(t *testing.T) { p := New(Config{}) req := &flyto.Request{ Model: "qwen2.5-7b-instruct", NeedsThinking: true, } 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, "qwen2.5-7b-instruct") { 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) { p := New(Config{}) req := &flyto.Request{ Model: "qwq-32b-preview", 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_ContextCancel(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 慢响应,给客户端取消的机会 <-r.Context().Done() })) defer server.Close() p := New(Config{BaseURL: server.URL}) ctx, cancel := context.WithCancel(context.Background()) cancel() // 立即取消 _, err := p.Models(ctx) if err == nil { t.Error("已取消的 ctx 应导致错误") } }