// openrouter provider 测试 - 用 httptest.NewServer mock /api/v1/models 端点. // // 精妙之处(CLEVER): 测试覆盖 OpenRouter 聚合网关的关键特性: // 1. APIKey 在 GoString 中遮蔽,防 %#v 泄露 // 2. 自动注入 HTTP-Referer / X-Title 排行榜 header // 3. 解析 live 模型列表的定价(per-token 字符串 → per-1M float) // 4. supported_parameters 推断 thinking 能力 // // BaseURL 字段允许指向 mock server,避免依赖真实 openrouter.ai. package openrouter import ( "context" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) // === 构造与默认值 === func TestNew_DefaultBaseURL(t *testing.T) { p := New(Config{APIKey: "sk-test"}) if p.cfg.BaseURL != "https://openrouter.ai" { t.Errorf("默认 BaseURL = %q", p.cfg.BaseURL) } } func TestNew_CustomBaseURL(t *testing.T) { p := New(Config{APIKey: "sk-test", BaseURL: "https://my-proxy.example.com"}) if p.cfg.BaseURL != "https://my-proxy.example.com" { t.Errorf("BaseURL = %q", p.cfg.BaseURL) } } func TestNew_NoAPIKey(t *testing.T) { // 不提供 APIKey 应仍能构造(运行时校验由后续请求触发) p := New(Config{}) if p == nil || p.client == nil { t.Fatal("无 APIKey 也应能构造 Provider") } } func TestProvider_Name(t *testing.T) { if New(Config{}).Name() != "openrouter" { t.Errorf("Name() != openrouter") } } func TestProvider_ImplementsModelProvider(t *testing.T) { var _ flyto.ModelProvider = (*Provider)(nil) } // === GoString APIKey 遮蔽 === func TestConfig_GoString_MasksAPIKey(t *testing.T) { c := Config{ APIKey: "sk-or-v1-supersecretkey-12345", SiteURL: "https://example.com", } s := c.GoString() if strings.Contains(s, "supersecretkey") { t.Errorf("GoString 必须遮蔽 APIKey: %q", s) } } func TestConfig_GoString_EmptyKey(t *testing.T) { c := Config{APIKey: ""} s := c.GoString() // 空 key 不应崩溃,应返回非空字符串 if s == "" { t.Error("空 APIKey GoString 不应空") } } // === Models() - mock /api/v1/models === // orHandlerCapture 捕获 /api/v1/models 请求的 header,便于断言 type orHandlerCapture struct { mu sync.Mutex headers http.Header } func TestModels_Success(t *testing.T) { cap := &orHandlerCapture{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cap.mu.Lock() cap.headers = r.Header.Clone() cap.mu.Unlock() if r.URL.Path != "/api/v1/models" { t.Errorf("意外路径: %s", r.URL.Path) } fmt.Fprint(w, `{ "data": [ { "id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6", "context_length": 200000, "max_completion_tokens": 16384, "pricing": {"prompt": "0.000003", "completion": "0.000015"}, "supported_parameters": ["tools", "reasoning"] }, { "id": "openai/gpt-4o", "name": "GPT-4o", "context_length": 128000, "max_completion_tokens": 16384, "pricing": {"prompt": "0.0000025", "completion": "0.00001"}, "supported_parameters": ["tools"] } ] }`) })) defer server.Close() p := New(Config{ APIKey: "sk-test", BaseURL: server.URL, SiteURL: "https://myapp.example.com", AppName: "MyApp", }) models, err := p.Models(context.Background()) if err != nil { t.Fatalf("Models() 失败: %v", err) } if len(models) != 2 { t.Fatalf("应有 2 个模型,实际 %d", len(models)) } // 验证 sonnet 字段映射 sonnet := models[0] if sonnet.ID != "anthropic/claude-sonnet-4.6" { t.Errorf("ID = %q", sonnet.ID) } if sonnet.DisplayName != "Claude Sonnet 4.6" { t.Errorf("DisplayName = %q", sonnet.DisplayName) } if sonnet.ContextWindow != 200000 { t.Errorf("ContextWindow = %d", sonnet.ContextWindow) } if sonnet.MaxOutputTokens != 16384 { t.Errorf("MaxOutputTokens = %d", sonnet.MaxOutputTokens) } // per-token "0.000003" → per-1M 3.0 if sonnet.InputPricePer1M != 3.0 { t.Errorf("InputPricePer1M = %v, want 3.0", sonnet.InputPricePer1M) } if sonnet.OutputPricePer1M != 15.0 { t.Errorf("OutputPricePer1M = %v, want 15.0", sonnet.OutputPricePer1M) } // supported_parameters 含 reasoning 应推断 SupportsThinking=true if !sonnet.SupportsThinking { t.Error("含 reasoning 应推断 SupportsThinking=true") } // gpt-4o 不含 reasoning if models[1].SupportsThinking { t.Error("gpt-4o 不应推断 SupportsThinking") } if models[1].InputPricePer1M != 2.5 { t.Errorf("gpt-4o InputPricePer1M = %v, want 2.5", models[1].InputPricePer1M) } // 验证 header 注入:HTTP-Referer + X-Title + Authorization cap.mu.Lock() defer cap.mu.Unlock() if cap.headers.Get("Authorization") != "Bearer sk-test" { t.Errorf("Authorization = %q", cap.headers.Get("Authorization")) } if cap.headers.Get("HTTP-Referer") != "https://myapp.example.com" { t.Errorf("HTTP-Referer = %q", cap.headers.Get("HTTP-Referer")) } if cap.headers.Get("X-Title") != "MyApp" { t.Errorf("X-Title = %q", cap.headers.Get("X-Title")) } } 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{APIKey: "sk-test", 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{APIKey: "sk-test", BaseURL: server.URL}) _, err := p.Models(context.Background()) if err == nil { t.Fatal("坏 JSON 应报错") } if !strings.Contains(err.Error(), "openrouter") { t.Errorf("错误应含 openrouter 前缀: %v", err) } } func TestModels_Unreachable(t *testing.T) { p := New(Config{APIKey: "sk-test", BaseURL: "http://127.0.0.1:1"}) _, err := p.Models(context.Background()) if err == nil { t.Error("不可达 server 应报错") } } // === 配置项保留 === func TestNew_DefaultThinkingFlag(t *testing.T) { p := New(Config{ APIKey: "sk-test", DefaultThinking: true, DefaultThinkingTokens: 8000, }) if !p.cfg.DefaultThinking { t.Error("DefaultThinking 应保留") } if p.cfg.DefaultThinkingTokens != 8000 { t.Errorf("DefaultThinkingTokens = %d", p.cfg.DefaultThinkingTokens) } } func TestNew_EnableCachingFlag(t *testing.T) { p := New(Config{APIKey: "sk-test", EnableCaching: true}) if !p.cfg.EnableCaching { t.Error("EnableCaching 应保留") } } func TestNew_CustomHTTPClient(t *testing.T) { custom := &http.Client{} p := New(Config{APIKey: "sk-test", HTTPClient: custom}) if p == nil || p.client == nil { t.Fatal("Provider 应正确创建") } } // === Stream() context 取消 === func TestStream_ContextCanceled(t *testing.T) { // 起一个永远不响应的 server,确保 stream 一定会卡在 ctx 上 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) defer server.Close() p := New(Config{APIKey: "sk-test", BaseURL: server.URL}) ctx, cancel := context.WithCancel(context.Background()) cancel() // Stream 返回 channel 后,channel 上要么很快关闭要么有错误事件 ch, err := p.Stream(ctx, &flyto.Request{ Model: "anthropic/claude-sonnet-4.6", Messages: []flyto.Message{flyto.UserText("hi")}, }) if err == nil && ch != nil { // 排空 channel 直到关闭 for range ch { } } } // === PR2.5 data-driven-capabilities: registry 优先 + 兜底 === func TestResolveMaxTools_NilCapabilities(t *testing.T) { req := &flyto.Request{Model: "anthropic/claude-sonnet-4.6"} 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: "anthropic/claude-sonnet-4.6", Capabilities: &flyto.ModelInfo{ MaxTools: 10, MaxToolsExhaustive: true, }, } mt, exh := resolveMaxTools(req) if mt != 10 || !exh { t.Errorf("registry 注入应返回 (10, true),实际 (%d, %v)", mt, exh) } } // openrouter 的语义反转:nil 兜底 **true**(与 ollama/gemini 的 false 相反). // 原因:openrouter 无静态表,nil 时必须假设支持以维持早期方案无脑注入行为的零回归. func TestResolveThinkingSupport_NilFallbackTrue(t *testing.T) { req := &flyto.Request{Model: "unknown/model"} if !resolveThinkingSupport(req) { t.Error("openrouter nil Capabilities 应兜底为 true(语义反转,零回归)") } } func TestResolveThinkingSupport_RegistryFalse(t *testing.T) { req := &flyto.Request{ Model: "some/non-thinking-model", Capabilities: &flyto.ModelInfo{SupportsThinking: false}, } if resolveThinkingSupport(req) { t.Error("registry SupportsThinking=false 应返回 false") } } func TestResolveCachingSupport_NilFallbackTrue(t *testing.T) { req := &flyto.Request{Model: "anthropic/claude-sonnet-4.6"} if !resolveCachingSupport(req) { t.Error("openrouter nil Capabilities 应兜底为 true") } } func TestResolveCachingSupport_RegistryFalse(t *testing.T) { req := &flyto.Request{ Model: "openai/gpt-4o", Capabilities: &flyto.ModelInfo{SupportsCaching: false}, } if resolveCachingSupport(req) { t.Error("registry SupportsCaching=false 应返回 false") } } // === 双开关协议(want × can)- 三路径组合 === func TestDetectWarnings_NoWants_NilCaps(t *testing.T) { p := New(Config{APIKey: "k"}) req := &flyto.Request{Model: "anthropic/claude-sonnet-4.6"} if w := p.detectFeatureWarnings(req); len(w) != 0 { t.Errorf("无 wants 应零 warning,实际 %d 个", len(w)) } } func TestDetectWarnings_ThinkingWantTrue_NilCaps_NoWarning(t *testing.T) { // 零回归验证:DefaultThinking=true + 无 registry → 兜底 can=true → 无 warning p := New(Config{APIKey: "k", DefaultThinking: true}) req := &flyto.Request{Model: "unknown/model"} if w := p.detectFeatureWarnings(req); len(w) != 0 { t.Errorf("nil Capabilities + DefaultThinking 应零 warning(兜底 true),实际 %d 个", len(w)) } } func TestDetectWarnings_ThinkingWantTrue_RegistryCanFalse(t *testing.T) { p := New(Config{APIKey: "k", DefaultThinking: true}) req := &flyto.Request{ Model: "openai/gpt-4o", Capabilities: &flyto.ModelInfo{SupportsThinking: false, SupportsCaching: true}, } warnings := p.detectFeatureWarnings(req) if len(warnings) != 1 { t.Fatalf("应发 1 个 thinking warning,实际 %d 个", len(warnings)) } if warnings[0].Code != "feature_unsupported" { t.Errorf("Code = %q, want feature_unsupported", warnings[0].Code) } if !strings.Contains(warnings[0].Detail, "feature=thinking") { t.Errorf("Detail 应含 feature=thinking: %q", warnings[0].Detail) } } func TestDetectWarnings_CachingWantTrue_NilCaps_NoWarning(t *testing.T) { p := New(Config{APIKey: "k", EnableCaching: true}) req := &flyto.Request{Model: "unknown/model"} if w := p.detectFeatureWarnings(req); len(w) != 0 { t.Errorf("nil Capabilities + EnableCaching 应零 warning(兜底 true),实际 %d 个", len(w)) } } func TestDetectWarnings_CachingWantTrue_RegistryCanFalse(t *testing.T) { p := New(Config{APIKey: "k", EnableCaching: true}) req := &flyto.Request{ Model: "openai/gpt-4o", Capabilities: &flyto.ModelInfo{SupportsThinking: true, SupportsCaching: false}, } warnings := p.detectFeatureWarnings(req) if len(warnings) != 1 { t.Fatalf("应发 1 个 caching warning,实际 %d 个", len(warnings)) } if !strings.Contains(warnings[0].Detail, "feature=caching") { t.Errorf("Detail 应含 feature=caching: %q", warnings[0].Detail) } } func TestDetectWarnings_BothWantsTrue_BothCanFalse(t *testing.T) { p := New(Config{APIKey: "k", DefaultThinking: true, EnableCaching: true}) req := &flyto.Request{ Model: "openai/gpt-4o", Capabilities: &flyto.ModelInfo{ SupportsThinking: false, SupportsCaching: false, }, } warnings := p.detectFeatureWarnings(req) if len(warnings) != 2 { t.Fatalf("应发 2 个 warning,实际 %d 个", len(warnings)) } features := map[string]bool{} for _, w := range warnings { if strings.Contains(w.Detail, "feature=thinking") { features["thinking"] = true } if strings.Contains(w.Detail, "feature=caching") { features["caching"] = true } } if !features["thinking"] || !features["caching"] { t.Errorf("应覆盖 thinking + caching 两个 feature,实际 %v", features) } } func TestDetectWarnings_BothWantsTrue_BothCanTrue(t *testing.T) { p := New(Config{APIKey: "k", DefaultThinking: true, EnableCaching: true}) req := &flyto.Request{ Model: "anthropic/claude-sonnet-4.6", Capabilities: &flyto.ModelInfo{ SupportsThinking: true, SupportsCaching: true, }, } if w := p.detectFeatureWarnings(req); len(w) != 0 { t.Errorf("双路径 want=true can=true 应零 warning,实际 %d 个", len(w)) } }