Files
Cyrene_v2/plugins/tools/io/web_search_provider_test.go
AskaEth 69a3b73777 feat(search): 原生 provider 层(searxng/智谱/阿里IQS/博查/火山/百度)+ 降级链(契约无改动)
主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。
- 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次**
- 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义)
- 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」)
- 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉)
- searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变)
- 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
2026-10-05 22:38:20 +08:00

367 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package io
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// 本文件是切片 WS1-B 的 io 侧契约:**后端由配置选定**(provider 注册表 + 降级链),
// 「未注册 / 未配置」的选定必须**启动期报错**(绝不静默回落),缺 key 的家不进可用清单,
// 降级链只在「不可用」时前进且**如实标注实际应答的 provider**,密钥不泄漏,
// 空结果 ≠ 成功。
// leakKey 是夹具用的假密钥:它只允许出现在请求头里。
const leakKey = "sk-LEAK-CANARY-0123456789"
// jsonServer 造一个固定应答的测试服务器(记录命中次数)。
func jsonServer(t *testing.T, status int, body string) (*httptest.Server, *int) {
t.Helper()
hits := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv, &hits
}
// searxngOK 是 searxng 形状的一条结果应答。
const searxngOK = `{"results":[{"title":"兜底结果","url":"https://fallback.example/1","content":"兜底正文"}]}`
// bochaOK 是博查形状的一条结果应答。
const bochaOK = `{"webPages":{"value":[{"name":"博查结果","url":"https://bocha.example/1","summary":"博查正文"}]}}`
// searchNew 走 New 这条**组合根同款**的路构造工具集(可拿到启动期错误)。
func searchNew(t *testing.T, opts ...Option) ([]sdk.Tool, error) {
t.Helper()
return New(&config.Config{}, append([]Option{WithLogger(silentLogger())}, opts...)...)
}
// searchTool 构造一把 web-search 工具(构造失败即 Fatal)。
func searchTool(t *testing.T, specs []SearchProviderSpec, primary string, fallback []string, opts ...Option) *webSearchTool {
t.Helper()
all := append([]Option{
WithSearchProviders(specs...),
WithSearchSelection(primary, fallback),
}, opts...)
set, err := searchNew(t, all...)
if err != nil {
t.Fatalf("New: %v", err)
}
return findTool(t, set, "web-search").(*webSearchTool)
}
// cloudSpec 造一条云 provider 的接线(Configured 的判定口径与组合根一致)。
func cloudSpec(name, key, base string) SearchProviderSpec {
return SearchProviderSpec{
Name: name, APIKey: key, BaseURL: base,
Configured: search.Configured(name, key, base),
}
}
// TestSearch_UnknownProviderFailsStartup 是验收 ③ 第一条:`WEB_SEARCH_PROVIDER` 指向
// **未登记**的名字(如本期未做的 tavily)⇒ 启动期显式报错,绝不静默回落到兜底。
func TestSearch_UnknownProviderFailsStartup(t *testing.T) {
t.Parallel()
searxng := jsonServerMust(t, searxngOK)
specs := []SearchProviderSpec{
{Name: search.NameSearXNG, BaseURL: searxng.URL, Configured: true},
}
for _, name := range []string{"tavily", "exa", "firecrawl", "serper", "serpapi", "typo-name"} {
_, err := searchNew(t,
WithSearchProviders(specs...),
WithSearchSelection(name, []string{search.NameSearXNG}),
)
if err == nil {
t.Fatalf("选定未登记的 provider %q 必须启动报错(不许静默回落到 searxng)", name)
}
if !errors.Is(err, search.ErrUnknownProvider) {
t.Fatalf("provider=%q 的错误 = %v, want errors.Is(search.ErrUnknownProvider)", name, err)
}
}
}
// TestSearch_UnconfiguredPrimaryFailsStartup 是验收 ③ 第二条:名字认识但**缺 key**
// ⇒ 启动报错;即使降级链里有一家配好的兜底,也**不许**悄悄换过去。
func TestSearch_UnconfiguredPrimaryFailsStartup(t *testing.T) {
t.Parallel()
searxng := jsonServerMust(t, searxngOK)
specs := []SearchProviderSpec{
{Name: search.NameSearXNG, BaseURL: searxng.URL, Configured: true},
cloudSpec(search.NameZhipu, "", "https://open.bigmodel.cn"), // 缺 key
}
_, err := searchNew(t,
WithSearchProviders(specs...),
WithSearchSelection(search.NameZhipu, []string{search.NameSearXNG}),
)
if err == nil {
t.Fatal("选定未配置(缺 key)的 provider 必须启动报错")
}
if !errors.Is(err, search.ErrNotConfigured) {
t.Fatalf("err = %v, want errors.Is(search.ErrNotConfigured)", err)
}
if !strings.Contains(err.Error(), search.NameZhipu) {
t.Fatalf("错误必须点出是哪一家: %v", err)
}
}
// TestSearch_MissingKeyFamilyNotInAvailableList 是验收 ③ 第三条:没配 key 的家
// **不出现在可用清单里**(fail-closed:不挂着一把必报错的锤子)。
func TestSearch_MissingKeyFamilyNotInAvailableList(t *testing.T) {
t.Parallel()
searxng := jsonServerMust(t, searxngOK)
bocha := jsonServerMust(t, bochaOK)
specs := []SearchProviderSpec{
{Name: search.NameSearXNG, BaseURL: searxng.URL, Configured: true},
cloudSpec(search.NameZhipu, "", "https://open.bigmodel.cn"), // 缺 key ⇒ 不可用
cloudSpec(search.NameBocha, leakKey, bocha.URL), // 有 key ⇒ 可用
}
ws := searchTool(t, specs, search.NameBocha, nil)
names := ws.registry.Names()
if !containsName(names, search.NameBocha) || !containsName(names, search.NameSearXNG) {
t.Fatalf("可用清单 = %v, 应含 bocha 与 searxng", names)
}
if containsName(names, search.NameZhipu) {
t.Fatalf("缺 key 的 zhipu 不得出现在可用清单里: %v", names)
}
if _, ok := ws.registry.Lookup(search.NameZhipu); ok {
t.Fatal("缺 key 的 zhipu 不得出现在注册表里")
}
}
// TestSearch_FallbackChainAdvancesAndLabelsProvider 是验收 ③ 第四条(含变异 ② 的判据):
// 主家被限流 ⇒ 前进一格到备家;结果里**如实标注备家**,绝不拿兜底冒充主力。
func TestSearch_FallbackChainAdvancesAndLabelsProvider(t *testing.T) {
t.Parallel()
zhipuSrv, zhipuHits := jsonServer(t, http.StatusTooManyRequests, `{"error":"rate limited"}`)
bochaSrv, bochaHits := jsonServer(t, http.StatusOK, bochaOK)
ws := searchTool(t,
[]SearchProviderSpec{
cloudSpec(search.NameZhipu, leakKey, zhipuSrv.URL),
cloudSpec(search.NameBocha, leakKey, bochaSrv.URL),
},
search.NameZhipu, []string{search.NameBocha},
WithHTTPClient(zhipuSrv.Client()),
)
got, err := ws.Execute(context.Background(), map[string]any{"query": "昔涟"})
if err != nil {
t.Fatalf("主家限流后备家应接住,却报错: %v", err)
}
m := got.(map[string]any)
if m["provider"] != search.NameBocha {
t.Fatalf("provider = %v, want %q —— 必须如实标注**实际应答**的家,不许冒充主力",
m["provider"], search.NameBocha)
}
if *zhipuHits != 1 || *bochaHits != 1 {
t.Fatalf("命中次数 zhipu=%d bocha=%d, want 1/1(先试主家、失败后前进一格)", *zhipuHits, *bochaHits)
}
results := m["results"].([]searchResult)
if len(results) != 1 || results[0].URL != "https://bocha.example/1" {
t.Fatalf("结果应来自备家: %+v", results)
}
}
// TestSearch_FallbackSkipsUnknownAndUnconfigured 钉住降级链上的「跳过」不是「报错」:
// 未登记(tavily)与未配置(缺 key 的 bocha)只是被跳过,链继续走到能用的 searxng。
func TestSearch_FallbackSkipsUnknownAndUnconfigured(t *testing.T) {
t.Parallel()
zhipuSrv, _ := jsonServer(t, http.StatusUnauthorized, `{"error":"bad key"}`)
searxngSrv, searxngHits := jsonServer(t, http.StatusOK, searxngOK)
ws := searchTool(t,
[]SearchProviderSpec{
{Name: search.NameSearXNG, BaseURL: searxngSrv.URL, Configured: true},
cloudSpec(search.NameZhipu, leakKey, zhipuSrv.URL),
cloudSpec(search.NameBocha, "", "https://api.bocha.cn"), // 缺 key
},
search.NameZhipu, []string{"tavily", search.NameBocha, search.NameSearXNG},
WithHTTPClient(zhipuSrv.Client()),
)
got, err := ws.Execute(context.Background(), map[string]any{"query": "昔涟"})
if err != nil {
t.Fatalf("链上跳过后应打到 searxng,却报错: %v", err)
}
if m := got.(map[string]any); m["provider"] != search.NameSearXNG {
t.Fatalf("provider = %v, want %q", m["provider"], search.NameSearXNG)
}
if *searxngHits != 1 {
t.Fatalf("searxng 命中 %d 次, want 1", *searxngHits)
}
}
// TestSearch_EmptyResultIsNotSuccessAndDoesNotAdvance 是验收 ⑥:
// 上游 200 + 空结果 ⇒ **不是**成功(报 ErrNoResults),且**不**触发降级链前进
// (空是空,不是「该家不可用」)。
func TestSearch_EmptyResultIsNotSuccessAndDoesNotAdvance(t *testing.T) {
t.Parallel()
zhipuSrv, _ := jsonServer(t, http.StatusOK, `{"search_result":[]}`)
bochaSrv, bochaHits := jsonServer(t, http.StatusOK, bochaOK)
ws := searchTool(t,
[]SearchProviderSpec{
cloudSpec(search.NameZhipu, leakKey, zhipuSrv.URL),
cloudSpec(search.NameBocha, leakKey, bochaSrv.URL),
},
search.NameZhipu, []string{search.NameBocha},
WithHTTPClient(zhipuSrv.Client()),
)
_, err := ws.Execute(context.Background(), map[string]any{"query": "查不到的词"})
if err == nil {
t.Fatal("空结果绝不是成功:必须返回错误而不是空列表冒充成功")
}
if !errors.Is(err, search.ErrNoResults) {
t.Fatalf("err = %v, want errors.Is(search.ErrNoResults)", err)
}
if !strings.Contains(err.Error(), search.NameZhipu) {
t.Fatalf("空结果要如实说清是谁答的: %v", err)
}
if *bochaHits != 0 {
t.Fatalf("空结果不该触发降级链前进(bocha 被打了 %d 次)", *bochaHits)
}
}
// TestSearch_KeyNeverLeaks 是验收 ④:密钥既不进日志,也不进响应/错误。
func TestSearch_KeyNeverLeaks(t *testing.T) {
t.Parallel()
t.Run("失败路径(鉴权失败)", func(t *testing.T) {
t.Parallel()
srv, _ := jsonServer(t, http.StatusUnauthorized, `{"error":"invalid api key"}`)
var buf bytes.Buffer
ws := searchTool(t,
[]SearchProviderSpec{cloudSpec(search.NameZhipu, leakKey, srv.URL)},
search.NameZhipu, nil,
WithHTTPClient(srv.Client()),
WithLogger(logger.New(logger.Options{Out: &buf, Level: "debug"})),
)
_, err := ws.Execute(context.Background(), map[string]any{"query": "昔涟"})
if err == nil {
t.Fatal("401 必须如实报错")
}
if strings.Contains(err.Error(), leakKey) {
t.Fatalf("密钥进了错误文案: %v", err)
}
if strings.Contains(buf.String(), leakKey) {
t.Fatalf("密钥进了日志: %s", buf.String())
}
})
t.Run("成功路径", func(t *testing.T) {
t.Parallel()
srv, _ := jsonServer(t, http.StatusOK, bochaOK)
var buf bytes.Buffer
ws := searchTool(t,
[]SearchProviderSpec{cloudSpec(search.NameBocha, leakKey, srv.URL)},
search.NameBocha, nil,
WithHTTPClient(srv.Client()),
WithLogger(logger.New(logger.Options{Out: &buf, Level: "debug"})),
)
got, err := ws.Execute(context.Background(), map[string]any{"query": "昔涟"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
encoded, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
if strings.Contains(string(encoded), leakKey) {
t.Fatalf("密钥进了工具响应: %s", encoded)
}
if strings.Contains(buf.String(), leakKey) {
t.Fatalf("密钥进了日志: %s", buf.String())
}
})
}
// TestSearch_MaxResultsOption 钉住 WEB_SEARCH_MAX_RESULTS(WithSearchMaxResults)真的
// 只约束 web-search:条数被截到配置值。
func TestSearch_MaxResultsOption(t *testing.T) {
t.Parallel()
srv, _ := jsonServer(t, http.StatusOK, `{"results":[
{"title":"A","url":"https://a.example/1"},
{"title":"B","url":"https://b.example/2"},
{"title":"C","url":"https://c.example/3"}]}`)
ws := searchTool(t,
[]SearchProviderSpec{{Name: search.NameSearXNG, BaseURL: srv.URL, Configured: true}},
search.NameSearXNG, nil,
WithHTTPClient(srv.Client()),
WithSearchMaxResults(2),
)
got, err := ws.Execute(context.Background(), map[string]any{"query": "昔涟"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if m := got.(map[string]any); m["count"].(int) != 2 {
t.Fatalf("count = %v, want 2(WEB_SEARCH_MAX_RESULTS 生效)", m["count"])
}
}
// TestSearch_ProviderOffNotRegistered 钉住「选定 provider 显式关闭」这条语义在 io 层
// 的样子:WithoutSearch 之后 web-search 不进注册面(组合根由 ResolveProvider 决定)。
func TestSearch_ProviderOffNotRegistered(t *testing.T) {
t.Parallel()
set, err := searchNew(t,
WithSearchProviders(SearchProviderSpec{Name: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471", Configured: true}),
WithSearchSelection(search.NameSearXNG, nil),
WithoutSearch(),
)
if err != nil {
t.Fatalf("关闭态不该报错: %v", err)
}
if hasTool(set, "web-search") {
t.Fatal("关闭态下 web-search 不得进注册面")
}
}
// jsonServerMust 造一个固定应答的服务器,返回它本身(只要 URL 时用)。
func jsonServerMust(t *testing.T, body string) *httptest.Server {
t.Helper()
srv, _ := jsonServer(t, http.StatusOK, body)
return srv
}
// containsName 报告名字清单里有没有某一项。
func containsName(names []string, want string) bool {
for _, n := range names {
if n == want {
return true
}
}
return false
}
// TestSearch_GuardFollowsItsOwnAllowlist 钉住 SSRF 守卫仍在起作用、且只看**配置里那几个
// 主机**:白名单内的回环端点(本机兜底)放行,白名单外的私网地址与非 http(s) 一律拦下。
//
// 「配置里的 provider 主机可信」是 WS1-A 定下的口径(端点是运维配置、不是模型输入);
// 模型只能改查询串,改不了主机 —— 共享白名单(http / web-fetch)不受影响,由
// TestWebSearch_DoesNotWidenSharedAllowlist 钉住。
func TestSearch_GuardFollowsItsOwnAllowlist(t *testing.T) {
t.Parallel()
guard := (&tools{}).searchGuard([]string{"127.0.0.1"})
if err := guard(context.Background(), "http://127.0.0.1:38471/search?q=x&format=json"); err != nil {
t.Fatalf("白名单内的本机兜底端点必须放行: %v", err)
}
for _, rawURL := range []string{"http://10.255.255.1/search", "http://169.254.1.1/x"} {
if err := guard(context.Background(), rawURL); !errors.Is(err, ErrSSRF) {
t.Fatalf("%s 必须被 SSRF 守卫拦下, err = %v", rawURL, err)
}
}
if err := guard(context.Background(), "ftp://files.example/x"); err == nil {
t.Fatal("非 http(s) 目标必须被拦下")
}
}