diff --git a/cmd/cyrene/wire_io_tools.go b/cmd/cyrene/wire_io_tools.go index 1ecb9a8..9078093 100644 --- a/cmd/cyrene/wire_io_tools.go +++ b/cmd/cyrene/wire_io_tools.go @@ -1,31 +1,42 @@ package main // 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及 -// **web-search 的端点与超时**(切片 WS1-A)。 +// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层)。 // // 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限, -// 而本片新增的裁决(端点关闭/非法 ⇒ 工具不进注册面)需要一个能单测的落点。 +// 而本片新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置 +// ⇒ 启动报错)需要一个能单测的落点。 // -// 三条口径: +// 四条口径: // -// 1. **端点默认开箱即用**:`WEB_SEARCH_ENDPOINT` 未配置时取 config 标签默认值 -// (本机兜底 http://127.0.0.1:38471,配套服务见 scripts/searxng/settings.yml)。 -// 2. **拿不到可用端点就不注册工具**(诚意要求,主人 2026-10-05 报障的核心诉求): -// 端点显式关闭(空串 / off / none)或不是 http(s) 绝对地址时,web-search -// **不进注册面** —— 模型根本看不见它,而不是看得见、一调就报错。 -// 3. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` 未配置(0)时沿用 io 包既有超时; +// 1. **后端由配置选定**:`WEB_SEARCH_PROVIDER` 选主 provider,`WEB_SEARCH_FALLBACK` +// 给降级链;各家的 key/base URL 走 `WEB_SEARCH__*`(design/27 §3)。 +// 2. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭 +// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** —— +// 模型根本看不见它,而不是看得见、一调就报错。 +// 3. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点), +// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在 +// **启动期报错**,绝不静默回落到兜底。 +// 4. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` 未配置(0)时沿用 io 包既有超时; // 负值是笔误,按未配置处理并记一条带 env 键名的 WARN(不静默)。 import ( + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) -// web-search 的两个 env 键名:必须与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目), +// web-search 的 env 键名:必须与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目), // 由 wire_web_search_test.go 逐字核对。 const ( - webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT" - webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT" + webSearchProviderEnv = "WEB_SEARCH_PROVIDER" + webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT" + webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT" + webSearchMaxResultsEnv = "WEB_SEARCH_MAX_RESULTS" + webSearchFallbackEnv = "WEB_SEARCH_FALLBACK" ) // ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。 @@ -41,26 +52,100 @@ func (a *app) ioToolOptions() []iotools.Option { return append(opts, a.webSearchOptions()...) } -// webSearchOptions 把 web-search 的配置翻译成 io 选项。端点裁决见 -// config.WebSearchConfig.ResolveEndpoint(关闭 / 非法 / 可用三种去处都在那里定死)。 +// webSearchOptions 把 web-search 的配置翻译成 io 选项。 +// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。 func (a *app) webSearchOptions() []iotools.Option { log := logger.WithSource(a.log, ioLogSource) - decision := a.cfg.WebSearch.ResolveEndpoint() - if !decision.Enabled { - // 端点的值**绝不写进日志**(URL 里可能带凭据),只记键名与原因。 + ws := a.cfg.WebSearch + + // ① 后端显式关闭(WEB_SEARCH_PROVIDER = 空 / off / none)⇒ 整件工具不注册。 + if decision := ws.ResolveProvider(); decision.Disabled { + // 配置值**绝不写进日志**(只记键名与原因)。 log.Warn("web-search tool not registered", - "env", webSearchEndpointEnv, "reason", decision.Reason, + "env", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "effect", "the model will not see a web-search tool at all") return []iotools.Option{iotools.WithoutSearch()} } - opts := []iotools.Option{iotools.WithSearchEndpoint(decision.URL)} + specs := a.searchProviderSpecs() + + // ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s) + // 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置 + // 错误,因此不 fatal,只留一条带键名的 WARN。 + if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && a.selectsSearXNG() { + log.Warn("web-search tool not registered", + "env", webSearchEndpointEnv, "reason", endpoint.Reason, + "effect", "the model will not see a web-search tool at all") + return []iotools.Option{iotools.WithoutSearch()} + } + + log.Info("web-search providers configured", + "env", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)), + "env_fallback", webSearchFallbackEnv, "fallback", ws.Fallback, + "available", availableProviderNames(specs)) + + opts := []iotools.Option{ + iotools.WithSearchProviders(specs...), + // 选定主 provider + 降级链:未登记(如本期未做的 tavily)或未配置(缺 key) + // ⇒ io 工具集在启动期报错(不许静默回落)。 + iotools.WithSearchSelection(ws.Provider, ws.Fallback), + iotools.WithSearchMaxResults(ws.MaxResults), + } switch { - case a.cfg.WebSearch.Timeout > 0: - opts = append(opts, iotools.WithSearchTimeout(a.cfg.WebSearch.Timeout)) - case a.cfg.WebSearch.Timeout < 0: + case ws.Timeout > 0: + opts = append(opts, iotools.WithSearchTimeout(ws.Timeout)) + case ws.Timeout < 0: log.Warn("web-search timeout is negative, treated as unset", "env", webSearchTimeoutEnv, "effect", "io package default timeout applies") } return opts } + +// selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。 +func (a *app) selectsSearXNG() bool { + selected := strings.TrimSpace(a.cfg.WebSearch.Provider) + return strings.EqualFold(selected, search.NameSearXNG) +} + +// searchProviderSpecs 把 `WEB_SEARCH_*` 配置翻译成各家接线(一家一行)。 +// +// Configured 的口径由 search.Configured 统一判定:searxng 看端点、云家看 API key; +// false ⇒ 该家不进注册表、不出现在可用清单里。**密钥只在这里流转,绝不写日志。** +func (a *app) searchProviderSpecs() []iotools.SearchProviderSpec { + ws := a.cfg.WebSearch + endpoint := ws.ResolveEndpoint() + + specs := []iotools.SearchProviderSpec{{ + Name: search.NameSearXNG, + BaseURL: endpoint.URL, + Configured: endpoint.Enabled, + }} + // 各家独立一组键(design/27 §3):新增一家 = 这里加一行 + search 包加一个文件。 + for _, p := range []struct{ name, key, base string }{ + {search.NameZhipu, ws.ZhipuAPIKey, ws.ZhipuBaseURL}, + {search.NameAliyunIQS, ws.AliyunIQSAPIKey, ws.AliyunIQSBaseURL}, + {search.NameBocha, ws.BochaAPIKey, ws.BochaBaseURL}, + {search.NameVolcengine, ws.VolcengineAPIKey, ws.VolcengineBaseURL}, + {search.NameBaiduQianfan, ws.BaiduQianfanAPIKey, ws.BaiduQianfanBaseURL}, + } { + specs = append(specs, iotools.SearchProviderSpec{ + Name: p.name, + APIKey: p.key, + BaseURL: p.base, + Configured: search.Configured(p.name, p.key, p.base), + }) + } + return specs +} + +// availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。 +// **不含任何密钥** —— 只回名字。 +func availableProviderNames(specs []iotools.SearchProviderSpec) []string { + out := make([]string, 0, len(specs)) + for _, s := range specs { + if s.Configured { + out = append(out, s.Name) + } + } + return out +} diff --git a/cmd/cyrene/wire_web_search_providers_test.go b/cmd/cyrene/wire_web_search_providers_test.go new file mode 100644 index 0000000..30bebe1 --- /dev/null +++ b/cmd/cyrene/wire_web_search_providers_test.go @@ -0,0 +1,248 @@ +package main + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// 本文件钉住切片 WS1-B 的组合根接线语义(design/27 §3): +// - 选定 provider 未登记 / 未配置 ⇒ **启动报错**(不许静默回落); +// - 后端显式关闭(off / 空)⇒ web-search **不进注册面**; +// - 缺 key 的家**不出现在可用清单**里; +// - 选定的云 provider 真的被用上(配置 → provider → 请求)且如实标注 provider。 + +// wsNewTools 走组合根的同一入口构造 io 工具集,返回它的错误(用于启动期断言)。 +func wsNewTools(t *testing.T, cfg *config.Config) error { + t.Helper() + a := &app{cfg: cfg, log: testLog()} + _, err := iotools.New(cfg, a.ioToolOptions()...) + return err +} + +// TestWireIOTools_ProviderOffOmitsWebSearch 钉住 WEB_SEARCH_PROVIDER 的关闭语义: +// 空 / off / none ⇒ 不注册工具(模型看不见),且**不报错**(是「少一把工具」)。 +func TestWireIOTools_ProviderOffOmitsWebSearch(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ name, line string }{ + {"显式留空", "WEB_SEARCH_PROVIDER="}, + {"off", "WEB_SEARCH_PROVIDER=off"}, + {"大写 NONE", "WEB_SEARCH_PROVIDER=NONE"}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + cfg := wsLoadConfig(t, tc.line) + a := wsSurfaceApp(t, cfg, &buf) + if wsHasTool(a, "web-search") { + t.Fatalf("后端关闭时 web-search 不得进注册面,实际 = %v", toolNames(a)) + } + for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} { + if !wsHasTool(a, name) { + t.Errorf("%q 不得因 web-search 关闭而消失", name) + } + } + out := buf.String() + if !strings.Contains(out, webSearchProviderEnv) { + t.Fatalf("关闭必须留下带键名的 WARN: %q", out) + } + if !strings.Contains(out, "web-search tool not registered") { + t.Fatalf("WARN 必须说清工具没注册: %q", out) + } + }) + } +} + +// TestWireIOTools_UnavailableSelectionFailsStartup 钉住「未登记 / 未配置」两条都 +// **启动报错**,且绝不静默回落到配好的 searxng 兜底。 +func TestWireIOTools_UnavailableSelectionFailsStartup(t *testing.T) { + t.Parallel() + cases := []struct { + name string + lines []string + wantErr error + }{ + {"未登记的名字(tavily)", []string{"WEB_SEARCH_PROVIDER=tavily"}, search.ErrUnknownProvider}, + {"未登记的名字(typo)", []string{"WEB_SEARCH_PROVIDER=searxgn"}, search.ErrUnknownProvider}, + {"已登记但缺 key(zhipu)", []string{"WEB_SEARCH_PROVIDER=zhipu"}, search.ErrNotConfigured}, + {"已登记但缺 key(bocha)", []string{"WEB_SEARCH_PROVIDER=bocha"}, search.ErrNotConfigured}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cfg := wsLoadConfig(t, tc.lines...) + err := wsNewTools(t, cfg) + if err == nil { + t.Fatal("选定不可用的 provider 必须启动报错(默认 searxng 兜底配好了也不许悄悄换过去)") + } + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want errors.Is(..., %v)", err, tc.wantErr) + } + }) + } +} + +// TestWireIOTools_AvailableListExcludesUnconfigured 钉住「未配置的家不出现在可用清单里」: +// 启动日志的 available 只列已配齐的家。 +func TestWireIOTools_AvailableListExcludesUnconfigured(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + cfg := wsLoadConfig(t, + "WEB_SEARCH_PROVIDER=bocha", + "WEB_SEARCH_BOCHA_API_KEY=test-bocha-key", + ) + a := wsSurfaceApp(t, cfg, &buf) + if !wsHasTool(a, "web-search") { + t.Fatalf("已配置的 provider 应让工具进注册面,实际 = %v", toolNames(a)) + } + out := buf.String() + if !strings.Contains(out, "web-search providers configured") { + t.Fatalf("启动应如实列出可用清单: %q", out) + } + if !strings.Contains(out, search.NameBocha) { + t.Fatalf("可用清单里应有 bocha: %q", out) + } + for _, absent := range []string{search.NameZhipu, search.NameAliyunIQS, search.NameVolcengine, search.NameBaiduQianfan} { + if strings.Contains(out, absent) { + t.Fatalf("缺 key 的 %s 不得出现在可用清单里: %q", absent, out) + } + } + // 密钥绝不进日志(只列名字)。 + if strings.Contains(out, "test-bocha-key") { + t.Fatalf("密钥进了日志: %q", out) + } +} + +// TestWireIOTools_SelectedCloudProviderIsUsed 是「配置 → provider → 请求」的真透传断言: +// 选定的 bocha 真的带 Bearer 打到配置的 base URL 上,结果里 provider 也是 bocha。 +func TestWireIOTools_SelectedCloudProviderIsUsed(t *testing.T) { + t.Parallel() + const key = "test-bocha-secret-key" + got := make(chan *http.Request, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- r + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`)) + })) + defer srv.Close() + + cfg := wsLoadConfig(t, + "WEB_SEARCH_PROVIDER=bocha", + "WEB_SEARCH_BOCHA_API_KEY="+key, + "WEB_SEARCH_BOCHA_BASE_URL="+srv.URL, + ) + a := wsSurfaceApp(t, cfg, nil) + tl, err := a.caps.Get("web-search") + if err != nil { + t.Fatalf("web-search 不在注册面里: %v", err) + } + exec := tl.Value.(interface { + Execute(context.Context, any) (any, error) + }) + res, err := exec.Execute(context.Background(), map[string]any{"query": "昔涟"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if m, ok := res.(map[string]any); !ok || m["provider"] != search.NameBocha { + t.Fatalf("provider 必须如实是 bocha: %v", res) + } + select { + case r := <-got: + if r.URL.Path != "/v1/web-search" { + t.Errorf("请求路径 = %q, want /v1/web-search(各家固定路径)", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer "+key { + t.Errorf("Authorization = %q, want Bearer (密钥必须真的被用上)", r.Header.Get("Authorization")) + } + default: + t.Fatal("选定的云 provider 一个请求都没发 —— 配置没接到 provider 上") + } +} + +// TestWireIOTools_ProviderVectorSummary 是一句「本期内置哪几家」的防回归: +// 六家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。 +func TestWireIOTools_ProviderVectorSummary(t *testing.T) { + t.Parallel() + want := map[string]bool{ + search.NameSearXNG: true, search.NameZhipu: true, search.NameAliyunIQS: true, + search.NameBocha: true, search.NameVolcengine: true, search.NameBaiduQianfan: true, + } + got := search.KnownNames() + if len(got) != len(want) { + t.Fatalf("KnownNames() = %v, want %d 家(本期内置:searxng/zhipu/aliyun_iqs/bocha/volcengine/baidu_qianfan)", + got, len(want)) + } + for _, n := range got { + if !want[n] { + t.Errorf("登记了计划外的 provider %q", n) + } + } + // 本期明确不实现的 B 档:必须**未登记**(选它 ⇒ 启动报错,而不是静默回落)。 + for _, n := range []string{"tavily", "exa", "firecrawl", "serper", "serpapi"} { + if search.Known(n) { + t.Errorf("%q 本期不实现,不该是已登记的名字(设计 §2 B 档)", n) + } + } +} + +// TestWireIOTools_RealSearXNG_ProviderEndToEnd 是 WS1-B 的**真机**端到端: +// 默认配置(一个 WEB_SEARCH_* 键都不写)⇒ 组合根接线 ⇒ 注册面里的 web-search +// ⇒ 走 provider 注册表打本机兜底 SearXNG,真的拿到结果,并**如实标注 provider=searxng**。 +// +// 它只读外部服务,不碰后端进程、不碰仓库根产物。本机兜底的上游只剩 `mwmbl` +// (英文爬取索引,无中文索引)⇒ 中文查询会「200 但 0 条」,那是外部覆盖面,不是代码红: +// 探针(裸 HTTP,与工具实现无关)用**同一个查询**先探测,0 条就 skip; +// 探针有结果而工具没有 = 真红。 +func TestWireIOTools_RealSearXNG_ProviderEndToEnd(t *testing.T) { + t.Parallel() + // mwmbl 是英文索引:中文查询在本机兜底上恒为 0 条(分册 08 实测), + // 因此真机 e2e 用英文查询,才能真的检验「配置 → provider → 结果」这条链。 + const query = "cyrene" + cfg := wsLoadConfig(t) + upstream := wsSearxngResultCount(cfg.WebSearch.Endpoint, query) + if upstream < 0 { + t.Skipf("本机兜底 SearXNG 不可达(%s),跳过真机 e2e", cfg.WebSearch.Endpoint) + } + if upstream == 0 { + t.Skipf("兜底 SearXNG 当前产不出结果(上游引擎被限流/CAPTCHA),跳过真机 e2e") + } + + a := wsSurfaceApp(t, cfg, nil) + if !wsHasTool(a, "web-search") { + t.Fatalf("默认配置下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a)) + } + tl, err := a.caps.Get("web-search") + if err != nil { + t.Fatalf("caps.Get(web-search): %v", err) + } + exec := tl.Value.(interface { + Execute(context.Context, any) (any, error) + }) + got, err := exec.Execute(context.Background(), map[string]any{"query": query}) + if err != nil { + t.Fatalf("真机查询失败(探针报了 %d 条,工具却报错): %v", upstream, err) + } + m, ok := got.(map[string]any) + if !ok { + t.Fatalf("返回值类型 = %T", got) + } + count, _ := m["count"].(int) + if count == 0 { + t.Fatalf("裸 HTTP 探针拿到 %d 条、工具却拿到 0 条 —— 这不再是外部降级: %v", upstream, m) + } + if m["provider"] != search.NameSearXNG { + t.Fatalf("provider = %v, want %q(如实标注实际应答的家)", m["provider"], search.NameSearXNG) + } + t.Logf("真机 e2e(WS1-B,端点 %s,query=%q):provider=%v 返回 %d 条结果", + cfg.WebSearch.Endpoint, query, m["provider"], count) +} diff --git a/cmd/cyrene/wire_web_search_test.go b/cmd/cyrene/wire_web_search_test.go index 9f5d953..f35d1d4 100644 --- a/cmd/cyrene/wire_web_search_test.go +++ b/cmd/cyrene/wire_web_search_test.go @@ -328,16 +328,19 @@ func TestWireIOTools_RealSearXNG_EndToEnd(t *testing.T) { } // TestWireIOTools_SeamsAndEnvTemplate 是防回归补充:组合根那两个接缝还在, -// 两个 env 键与 `.env.example` 逐字一致(AGENTS §10 的 E6 固定动作)。 +// env 键与 `.env.example` 逐字一致(AGENTS §10 的 E6 固定动作)。 func TestWireIOTools_SeamsAndEnvTemplate(t *testing.T) { t.Parallel() seams := map[string][]string{ "wire_runtime.go": { - // io 工具集必须经这一个入口构造,否则端点裁决会被绕过。 + // io 工具集必须经这一个入口构造,否则端点/后端裁决会被绕过。 "iotools.New(a.cfg, a.ioToolOptions()...)", }, "wire_io_tools.go": { - "iotools.WithSearchEndpoint(decision.URL)", + // WS1-B:各家接线 + 主 provider/降级链选定,是 web-search 后端唯一来源。 + "iotools.WithSearchProviders(specs...)", + "iotools.WithSearchSelection(ws.Provider, ws.Fallback)", + "iotools.WithSearchMaxResults(ws.MaxResults)", "iotools.WithoutSearch()", }, } @@ -359,10 +362,15 @@ func TestWireIOTools_SeamsAndEnvTemplate(t *testing.T) { } template := string(raw) for _, line := range []string{ + webSearchProviderEnv + "=", webSearchEndpointEnv + "=", webSearchTimeoutEnv + "=", - // 默认值必须在模板里如实写出(本机兜底),否则照模板部署的人看不出它接的是什么。 + webSearchMaxResultsEnv + "=", + webSearchFallbackEnv + "=", + // 默认值必须在模板里如实写出(本机兜底 + searxng 主后端),否则照模板部署的人 + // 看不出它接的是什么。 webSearchEndpointEnv + "=http://127.0.0.1:38471", + webSearchProviderEnv + "=searxng", } { if !strings.Contains(template, line) { t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", line) diff --git a/internal/config/websearch.go b/internal/config/websearch.go index a032dee..9769e1f 100644 --- a/internal/config/websearch.go +++ b/internal/config/websearch.go @@ -6,14 +6,30 @@ import ( "time" ) -// WebSearchConfig 是 web-search 工具的搜索端点配置(design/09-插件生态;切片 WS1-A)。 +// WebSearchConfig 是 web-search 工具的搜索配置(design/27-网络搜索与检索提供商;切片 WS1-A/WS1-B)。 // 写法照既有 VoiceConfig 惯例:一组 provider 键 + 未配置则如实降级,绝不硬编码。 type WebSearchConfig struct { - // Endpoint 是 SearXNG 兼容搜索端点(支持 `format=json` 的 GET 接口)。 + // Provider 选谁当后端(design/27 §3):`searxng`(默认)/ `zhipu` / `aliyun_iqs` / + // `bocha` / `volcengine` / `baidu_qianfan`。 + // + // **语义硬约束**(design/27 §3,由组合根的 io 工具集在启动期执行): + // - 指向**未登记**(如本期未做的 tavily)或**未配置**(缺 key/端点)的名字 + // ⇒ **启动期显式报错**,绝不静默回落到兜底; + // - 写空串 / `off` / `none`(大小写不敏感)⇒ **不注册 `web-search` 工具** + // (模型根本看不见它),见 ResolveProvider。 + Provider string `env:"WEB_SEARCH_PROVIDER" default:"searxng"` + // Fallback 是降级链(design/27 §3):从左到右试,空 = 只用手选的 provider。 + // 链上的家**只在「不可用」时**才被跳过(无 key / 网络失败 / 额度错误…); + // 结果里会如实标注**实际应答**的那一家,绝不拿兜底冒充主力。 + Fallback []string `env:"WEB_SEARCH_FALLBACK"` + // MaxResults 是单次检索返回的条数上限(design/27 §3 默认 5)。 + MaxResults int `env:"WEB_SEARCH_MAX_RESULTS" default:"5"` + + // Endpoint 是 **searxng provider** 的端点(SearXNG 兼容的 `format=json` GET 接口)。 // // 默认值指向**本机兜底**:`scripts/searxng/settings.yml` 配套的 SearXNG 实例 // (宿主机 http://127.0.0.1:38471)。默认指向本机而不是某个云搜索 API,是因为 - // 兜底实例不需要任何第三方 key、开箱即用;要换成云 provider 请在本键里写新地址。 + // 兜底实例不需要任何第三方 key、开箱即用。 // // **显式关闭**(值写成空串 / off / none,大小写与首尾空白不敏感)⇒ 组合根 // **不注册** web-search 工具:模型根本看不见它,而不是注册后每次调用都报错 @@ -24,6 +40,63 @@ type WebSearchConfig struct { // **未配置(零值)语义 = 「沿用 io 包既有超时」**:这里刻意不写第二个默认值, // 否则「文档承诺的超时」与「工具真正用的超时」会各有一份而悄悄漂移。 Timeout time.Duration `env:"WEB_SEARCH_TIMEOUT"` + + // ---- 各家独立一组键(design/27 §3)---- + // + // 口径:`WEB_SEARCH__API_KEY` 给凭据、`WEB_SEARCH__BASE_URL` 给基地址 + // (各自的固定路径由 provider 代码拼上)。**未配置的家不出现在可用清单里** + // (fail-closed:绝不挂着报错)。密钥只从配置读,**绝不进日志/响应/提示词**。 + + // Zhipu* 是智谱 BigModel(design/27 §2.1 国内首选)。 + ZhipuAPIKey string `env:"WEB_SEARCH_ZHIPU_API_KEY"` + ZhipuBaseURL string `env:"WEB_SEARCH_ZHIPU_BASE_URL" default:"https://open.bigmodel.cn"` + + // AliyunIQS* 是阿里云信息查询服务(IQS;HTTP + X-API-Key,免签名免 SDK)。 + AliyunIQSAPIKey string `env:"WEB_SEARCH_ALIYUN_IQS_API_KEY"` + AliyunIQSBaseURL string `env:"WEB_SEARCH_ALIYUN_IQS_BASE_URL" default:"https://cloud-iqs.aliyuncs.com"` + + // Bocha* 是博查(中文优先)。默认基地址取**官方文档现行口径** `api.bocha.cn` + // (分册 02:官网首页 curl 样例仍用 api.bochaai.com,两域关系官方未说明)。 + BochaAPIKey string `env:"WEB_SEARCH_BOCHA_API_KEY"` + BochaBaseURL string `env:"WEB_SEARCH_BOCHA_BASE_URL" default:"https://api.bocha.cn"` + + // Volcengine* 是火山引擎·豆包搜索(SearchInfinity;Bearer + open.feedcoopapi.com)。 + VolcengineAPIKey string `env:"WEB_SEARCH_VOLCENGINE_API_KEY"` + VolcengineBaseURL string `env:"WEB_SEARCH_VOLCENGINE_BASE_URL" default:"https://open.feedcoopapi.com"` + + // BaiduQianfan* 是百度·千帆 AI 搜索。 + BaiduQianfanAPIKey string `env:"WEB_SEARCH_BAIDU_QIANFAN_API_KEY"` + BaiduQianfanBaseURL string `env:"WEB_SEARCH_BAIDU_QIANFAN_BASE_URL" default:"https://qianfan.baidubce.com"` +} + +// ProviderDecision 是 WEB_SEARCH_PROVIDER 的解析结论。 +type ProviderDecision struct { + // Selected 是选定的 provider 名(已去空白;大小写不敏感,比较时请再归一化)。 + Selected string + // Disabled 为 true ⇒ 显式关闭(空串 / off / none)⇒ 不注册 web-search 工具。 + Disabled bool +} + +// ResolveProvider 把 WEB_SEARCH_PROVIDER 解析成选择结论:显式关闭(空串 / off / none, +// 大小写与首尾空白不敏感)⇒ Disabled=true(组合根据此**不注册** web-search 工具); +// 其余 ⇒ Selected=去空白后的名字(是否登记/是否配置齐由 io 工具集在启动期校验并报错)。 +func (w WebSearchConfig) ResolveProvider() ProviderDecision { + raw := strings.TrimSpace(w.Provider) + if isOffSentinel(raw) { + return ProviderDecision{Disabled: true} + } + return ProviderDecision{Selected: raw} +} + +// isOffSentinel 报告取值是否属于「显式关闭」的哨兵词(空串 / off / none,大小写与 +// 首尾空白不敏感)。它同时服务 WEB_SEARCH_PROVIDER 与 WEB_SEARCH_ENDPOINT 两个键。 +func isOffSentinel(raw string) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "off", "none": + return true + default: + return false + } } // EndpointDecision 是 WEB_SEARCH_ENDPOINT 的解析结论:要不要注册 web-search, @@ -47,7 +120,7 @@ const ( WebSearchReasonInvalid = "不是可用的 http(s) 地址" ) -// ResolveEndpoint 把配置值解析成端点裁决: +// ResolveEndpoint 把 WEB_SEARCH_ENDPOINT(= searxng provider 的基地址)解析成端点裁决: // // - 显式关闭(空串 / off / none,大小写与首尾空白不敏感)⇒ Enabled=false、 // Reason=WebSearchReasonOff —— 组合根据此**不注册**该工具; @@ -59,9 +132,7 @@ const ( // 注意「未配置」不落到这里:没写 WEB_SEARCH_ENDPOINT 时字段已被标签默认值填好。 func (w WebSearchConfig) ResolveEndpoint() EndpointDecision { raw := strings.TrimSpace(w.Endpoint) - // 显式关闭的三个哨兵词(大小写不敏感):空串、off、none。 - switch strings.ToLower(raw) { - case "", "off", "none": + if isOffSentinel(raw) { return EndpointDecision{Reason: WebSearchReasonOff} } u, err := url.Parse(raw) diff --git a/internal/config/websearch_providers_test.go b/internal/config/websearch_providers_test.go new file mode 100644 index 0000000..c611a8b --- /dev/null +++ b/internal/config/websearch_providers_test.go @@ -0,0 +1,128 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// 本文件钉住切片 WS1-B 的搜索配置键(design/27 §3): +// WEB_SEARCH_PROVIDER / _FALLBACK / _MAX_RESULTS 与各家独立一组键 +// (WEB_SEARCH__API_KEY / _BASE_URL),以及 ResolveProvider 的关闭语义。 + +// wsTestLoad 写一份只含必填键加 extra 行的 .env,加载出配置。 +func wsTestLoad(t *testing.T, extra ...string) (*Config, error) { + t.Helper() + lines := append([]string{"JWT_SECRET=" + strings.Repeat("s", 40)}, extra...) + path := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatalf("write .env: %v", err) + } + return Load(path) +} + +// TestWebSearchProvider_Defaults 钉住三个新键的默认值:provider=searxng、 +// maxResults=5、fallback 为空(= 只管手选那一家)。 +func TestWebSearchProvider_Defaults(t *testing.T) { + cfg, err := wsTestLoad(t) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.WebSearch.Provider != "searxng" { + t.Fatalf("WEB_SEARCH_PROVIDER 默认值 = %q, want searxng", cfg.WebSearch.Provider) + } + if cfg.WebSearch.MaxResults != 5 { + t.Fatalf("WEB_SEARCH_MAX_RESULTS 默认值 = %d, want 5", cfg.WebSearch.MaxResults) + } + if len(cfg.WebSearch.Fallback) != 0 { + t.Fatalf("WEB_SEARCH_FALLBACK 未配置时应为空, got %v", cfg.WebSearch.Fallback) + } + // 云家的 base URL 有默认值、key 一律为空(未配置 ⇒ 该家不出现在可用清单里)。 + if cfg.WebSearch.ZhipuBaseURL == "" || cfg.WebSearch.BochaBaseURL == "" || + cfg.WebSearch.AliyunIQSBaseURL == "" || cfg.WebSearch.VolcengineBaseURL == "" || + cfg.WebSearch.BaiduQianfanBaseURL == "" { + t.Fatalf("各家的 BASE_URL 必须有默认值: %+v", cfg.WebSearch) + } + if cfg.WebSearch.ZhipuAPIKey != "" || cfg.WebSearch.BochaAPIKey != "" || + cfg.WebSearch.AliyunIQSAPIKey != "" || cfg.WebSearch.VolcengineAPIKey != "" || + cfg.WebSearch.BaiduQianfanAPIKey != "" { + t.Fatalf("各家 API key 未配置时必须为空: %+v", cfg.WebSearch) + } +} + +// TestWebSearchProvider_EnvOverrides 钉住新键都能被 .env 覆盖(含逗号分隔的降级链)。 +func TestWebSearchProvider_EnvOverrides(t *testing.T) { + cfg, err := wsTestLoad(t, + "WEB_SEARCH_PROVIDER=Bocha", + "WEB_SEARCH_FALLBACK=zhipu,bocha,searxng", + "WEB_SEARCH_MAX_RESULTS=9", + "WEB_SEARCH_ZHIPU_API_KEY=zk", + "WEB_SEARCH_ZHIPU_BASE_URL=https://zhipu.example", + "WEB_SEARCH_ALIYUN_IQS_API_KEY=ak", + "WEB_SEARCH_ALIYUN_IQS_BASE_URL=https://iqs.example", + "WEB_SEARCH_BOCHA_API_KEY=bk", + "WEB_SEARCH_BOCHA_BASE_URL=https://bocha.example", + "WEB_SEARCH_VOLCENGINE_API_KEY=vk", + "WEB_SEARCH_VOLCENGINE_BASE_URL=https://volc.example", + "WEB_SEARCH_BAIDU_QIANFAN_API_KEY=qk", + "WEB_SEARCH_BAIDU_QIANFAN_BASE_URL=https://qianfan.example", + ) + if err != nil { + t.Fatalf("Load: %v", err) + } + ws := cfg.WebSearch + if ws.Provider != "Bocha" { + t.Fatalf("Provider = %q(大小写应原样保留,比较时再归一化)", ws.Provider) + } + if got := ws.Fallback; len(got) != 3 || got[0] != "zhipu" || got[2] != "searxng" { + t.Fatalf("Fallback = %v, want [zhipu bocha searxng]", got) + } + if ws.MaxResults != 9 { + t.Fatalf("MaxResults = %d, want 9", ws.MaxResults) + } + for name, got := range map[string]string{ + "Zhipu": ws.ZhipuAPIKey, "AliyunIQS": ws.AliyunIQSAPIKey, "Bocha": ws.BochaAPIKey, + "Volcengine": ws.VolcengineAPIKey, "BaiduQianfan": ws.BaiduQianfanAPIKey, + } { + if got == "" { + t.Errorf("%s 的 API key 没被读进来", name) + } + } + for name, got := range map[string]string{ + "Zhipu": ws.ZhipuBaseURL, "AliyunIQS": ws.AliyunIQSBaseURL, "Bocha": ws.BochaBaseURL, + "Volcengine": ws.VolcengineBaseURL, "BaiduQianfan": ws.BaiduQianfanBaseURL, + } { + if !strings.HasSuffix(got, ".example") { + t.Errorf("%s 的 BASE_URL 没被覆盖: %q", name, got) + } + } +} + +// TestWebSearchConfig_ResolveProvider 是关闭语义的表驱动:空 / off / none ⇒ 关闭 +// (组合根据此不注册工具);其余 ⇒ 选定名。 +func TestWebSearchConfig_ResolveProvider(t *testing.T) { + cases := []struct { + name string + value string + wantDisable bool + wantSel string + }{ + {"默认 searxng", "searxng", false, "searxng"}, + {"大写 Zhipu", " Zhipu ", false, "Zhipu"}, + {"空串 = 关闭", "", true, ""}, + {"off", "off", true, ""}, + {"none", "NONE", true, ""}, + {"带空白的 None", " none ", true, ""}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := WebSearchConfig{Provider: tc.value}.ResolveProvider() + if got.Disabled != tc.wantDisable || got.Selected != tc.wantSel { + t.Fatalf("ResolveProvider(%q) = %+v, want disabled=%v selected=%q", + tc.value, got, tc.wantDisable, tc.wantSel) + } + }) + } +} diff --git a/plugins/tools/io/doc.go b/plugins/tools/io/doc.go index 5705c50..2ad3e56 100644 --- a/plugins/tools/io/doc.go +++ b/plugins/tools/io/doc.go @@ -20,18 +20,24 @@ // - http: GET/POST only, timeouts, response truncation and SSRF protection // (loopback/private/link-local addresses and localhost are refused unless // the host is on an explicit allowlist). -// - web-search: queries the configured SearXNG-compatible endpoint. When no -// usable endpoint is configured the composition root passes WithoutSearch, so -// the tool is not registered at all — the model never sees a tool that could -// only error. The endpoint itself is operator configuration (the model only -// supplies the query), hence its host is trusted by this tool alone: a -// loopback fallback endpoint stays reachable without widening the shared -// http/web-fetch allowlist. +// - web-search: queries the configured search **provider** through the registry in +// plugins/tools/io/search (searxng / zhipu / aliyun_iqs / bocha / volcengine / +// baidu_qianfan; one provider per file, design/27 §4). The model sees exactly one +// tool whose schema never changes with the backend. A configured fallback chain is +// tried in order, and only an *unavailable* provider (missing key, auth / rate +// limit / quota / network failure) advances the chain — an empty result set does +// not, and it is reported as "no results" rather than as success. The result names +// the provider that actually answered. When no usable backend is configured the +// composition root passes WithoutSearch, so the tool is not registered at all — the +// model never sees a tool that could only error. Provider endpoints are operator +// configuration (the model only supplies the query), hence their hosts are trusted +// by this tool alone: a loopback fallback endpoint stays reachable without widening +// the shared http/web-fetch allowlist. API keys never reach logs, results or prompts. // - web-fetch: size- and content-type-limited readable text extraction. // - knowledge: RAG lookup over the runtime memory retriever; a clear message // when no knowledge base is configured. // -// File-level mutual exclusion +// # File-level mutual exclusion // // The two tools that touch files declare the file each call will touch // (tool.FileTargeter, implemented in file_targets.go): file declares read for diff --git a/plugins/tools/io/search/aliyun.go b/plugins/tools/io/search/aliyun.go new file mode 100644 index 0000000..a622e95 --- /dev/null +++ b/plugins/tools/io/search/aliyun.go @@ -0,0 +1,58 @@ +package search + +import ( + "context" + "net/http" + "net/url" +) + +// aliyunIQSProfile 是阿里云信息查询服务(AI 联网搜索,IQS;design/27 §2.1,分册 15)。 +// +// 接入形态对本仓最友好:**GET `/search/genericSearch` + `X-API-Key`(免签名、免 SDK)** +// ⇒ 标准库即可,恒不破「零新依赖」。 +// +// 分册 15 确证的两个硬约束: +// +// - 官方原文:**默认返回网页 10 条,暂不支持调整** ⇒ 本 provider 忽略 Query.Count +// (本地截断仍生效,见 io 层的 Finalize)。 +// - `mainText` 默认只给**前 500 字符**(要 3000 字需联系客户经理开通)⇒ 正文偏短是 +// 上游口径,不是本仓截断。 +type aliyunIQS struct{ cfg Config } + +// aliyunIQSPath 是联网搜索端点路径(分册 15 官方原文:GET /search/genericSearch?query=)。 +const aliyunIQSPath = "/search/genericSearch" + +// newAliyunIQS 造一家阿里云 IQS provider。 +func newAliyunIQS(cfg Config) Provider { return &aliyunIQS{cfg: cfg} } + +// Name 实现 Provider。 +func (p *aliyunIQS) Name() string { return NameAliyunIQS } + +// Search 实现 Provider。 +func (p *aliyunIQS) Search(ctx context.Context, q Query) ([]Result, error) { + target := baseURLJoin(p.cfg.BaseURL, aliyunIQSPath) + "?query=" + url.QueryEscape(q.Text) + data, err := do(ctx, p.cfg, request{ + Method: http.MethodGet, + URL: target, + Headers: map[string]string{"X-API-Key": p.cfg.APIKey}, + }) + if err != nil { + return nil, err + } + return extractResults(data, aliyunIQSSpec()) +} + +// aliyunIQSSpec 是 IQS 的响应形状(分册 15「响应字段」官方原文确证): +// `pageItems[].{title,link,snippet,mainText,markdownText,publishTime,score}`。 +// 正文优先取 `mainText`(纯文本,更适合进提示词),其次 `markdownText`。 +func aliyunIQSSpec() spec { + return spec{ + Arrays: []string{"pageItems"}, + Title: []string{"title"}, + Link: []string{"link"}, + Snippet: []string{"snippet"}, + Content: []string{"mainText", "markdownText"}, + Published: []string{"publishTime"}, + Score: []string{"score"}, + } +} diff --git a/plugins/tools/io/search/baidu.go b/plugins/tools/io/search/baidu.go new file mode 100644 index 0000000..57d0c3c --- /dev/null +++ b/plugins/tools/io/search/baidu.go @@ -0,0 +1,98 @@ +package search + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// baiduQianfanProfile 是百度·千帆 AI 搜索(design/27 §2.1;分册 16 官方原文确证)。 +// +// 接口形态:**POST `/v2/ai_search/web_search` + `Authorization: Bearer `** +// ⇒ 标准库即可。 +// +// 分册 16 确证的口径: +// +// - 请求必须走 `messages` 数组,且**百度搜索仅支持单轮输入**(多轮则以最后一条 +// content 为查询)⇒ 本 provider 只放一条 user 消息。 +// - `search_source` 固定值 `baidu_search_v2`。 +// - `resource_type_filter[].top_k` 上限:**网页 50**(默认 20)。 +// - 计费:标准版 0.036 元/次、增强版 0.072 元/次;每月免费 1500 次(约每天 50 次)。 +// +// ⚠️ 诚实边界(分册 16「未确证」):**响应结构未逐字取全** ⇒ 用共享提取器按多组候选键 +// 容错,不把猜测的字段当事实;拿到真实 key 后按官方文档收窄。 +type baiduQianfan struct{ cfg Config } + +// baiduQianfanPath 是百度搜索端点路径 +// (分册 16 官方原文:POST /v2/ai_search/web_search,HOST qianfan.baidubce.com)。 +const baiduQianfanPath = "/v2/ai_search/web_search" + +// baiduSearchSourceV2 是搜索引擎版本固定值(分册 16 官方原文:固定值 `baidu_search_v2`)。 +const baiduSearchSourceV2 = "baidu_search_v2" + +// baiduDefaultTopK / baiduMaxTopK 是网页模态的 top_k 口径 +// (分册 16 官方原文:默认 `[{"type":"web","top_k":20}]`,网页 top_k 上限 50)。 +const ( + baiduDefaultTopK = 20 + baiduMaxTopK = 50 +) + +// newBaiduQianfan 造一家百度千帆 provider。 +func newBaiduQianfan(cfg Config) Provider { return &baiduQianfan{cfg: cfg} } + +// Name 实现 Provider。 +func (p *baiduQianfan) Name() string { return NameBaiduQianfan } + +// Search 实现 Provider。 +func (p *baiduQianfan) Search(ctx context.Context, q Query) ([]Result, error) { + payload := map[string]any{ + "messages": []map[string]string{{"role": "user", "content": q.Text}}, + // 单轮输入:百度搜索只认最后一条 content,这里就只放一条。 + "search_source": baiduSearchSourceV2, + "resource_type_filter": []map[string]any{ + {"type": "web", "top_k": baiduTopK(q.Count)}, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("search: baidu_qianfan: encode request: %w", err) + } + data, err := do(ctx, p.cfg, request{ + Method: http.MethodPost, + URL: baseURLJoin(p.cfg.BaseURL, baiduQianfanPath), + Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey}, + Body: body, + }) + if err != nil { + return nil, err + } + return extractResults(data, baiduQianfanSpec()) +} + +// baiduTopK 把调用方要的条数收进官方允许区间(分册 16:网页 top_k 上限 50,默认 20)。 +func baiduTopK(n int) int { + if n <= 0 { + return baiduDefaultTopK + } + if n > baiduMaxTopK { + return baiduMaxTopK + } + return n +} + +// baiduQianfanSpec 是百度千帆响应的**候选**形状。 +// +// TODO(未确证):分册 16 只逐字取到了请求参数;响应结构**未确证**。 +// 这里的候选键是容错解析(尽量认得出来),拿到真实 key 后必须按官方文档复核并收窄。 +func baiduQianfanSpec() spec { + return spec{ + Arrays: []string{"references", "search_results", "data.references", "results"}, + Title: []string{"title", "Title"}, + Link: []string{"url", "Url", "link"}, + Snippet: []string{"snippet", "summary", "abstract"}, + Content: []string{"content", "Content"}, + Published: []string{"date", "publish_time", "publishTime"}, + Score: []string{"score", "rank"}, + } +} diff --git a/plugins/tools/io/search/bocha.go b/plugins/tools/io/search/bocha.go new file mode 100644 index 0000000..2b23092 --- /dev/null +++ b/plugins/tools/io/search/bocha.go @@ -0,0 +1,89 @@ +package search + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// bochaProfile 是博查 Bocha(design/27 §2 A 档「中文优先」;分册 02 官方原文确证)。 +// +// POST `/v1/web-search` + `Authorization: Bearer`;响应 `webPages.value[]` 的 +// `name/url/snippet/summary/datePublished` —— **与 SearXNG 字段近乎同名**,归一化最省事。 +// +// 两个诚实边界(分册 02): +// +// - 官方文档域名为 `api.bocha.cn`,而官网首页 curl 样例仍用 `api.bochaai.com`, +// 两者关系官方未说明 ⇒ 默认取**文档现行口径** `api.bocha.cn`(见 config 的默认值), +// 两域都可由 `WEB_SEARCH_BOCHA_BASE_URL` 覆盖。 +// - 中文检索质量**未实测**(无 key)。 +type bocha struct{ cfg Config } + +// bochaPath 是 Web Search 端点路径(分册 02:POST https://api.bocha.cn/v1/web-search)。 +const bochaPath = "/v1/web-search" + +// bochaDefaultCount / bochaMaxCount 是请求条数口径(分册 02 官方原文: +// 「返回条数,可填范围 1-50,单次最大 50,默认 10」)。 +const ( + bochaMaxCount = 50 + bochaDefaultCount = 10 +) + +// newBocha 造一家博查 provider。 +func newBocha(cfg Config) Provider { return &bocha{cfg: cfg} } + +// Name 实现 Provider。 +func (p *bocha) Name() string { return NameBocha } + +// Search 实现 Provider。 +func (p *bocha) Search(ctx context.Context, q Query) ([]Result, error) { + // summary=true 换取中文摘要(分册 02:默认 false 不给 summary); + // freshness 用官方推荐的 noLimit(官方原文:「推荐使用 noLimit。搜索算法会自动 + // 进行时间范围的改写,效果更佳」)。 + payload := map[string]any{ + "query": q.Text, + "summary": true, + "freshness": "noLimit", + "count": bochaClampCount(q.Count), + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("search: bocha: encode request: %w", err) + } + data, err := do(ctx, p.cfg, request{ + Method: http.MethodPost, + URL: baseURLJoin(p.cfg.BaseURL, bochaPath), + Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey}, + Body: body, + }) + if err != nil { + return nil, err + } + return extractResults(data, bochaSpec()) +} + +// bochaClampCount 把调用方要的条数收进官方允许区间(分册 02:1-50,默认 10)。 +func bochaClampCount(n int) int { + if n <= 0 { + return bochaDefaultCount + } + if n > bochaMaxCount { + return bochaMaxCount + } + return n +} + +// bochaSpec 是博查的响应形状(分册 02「响应字段」官方原文确证)。 +// `summary` 只在请求里 `summary=true` 时才有 —— 我们恒置 true。 +func bochaSpec() spec { + return spec{ + Arrays: []string{"webPages.value"}, + Title: []string{"name"}, + Link: []string{"url"}, + Snippet: []string{"snippet"}, + Content: []string{"summary"}, + Published: []string{"datePublished"}, + // 官方字段表未列评分(`id` 是排序 ID、不是分数)⇒ 不给 Score 候选。 + } +} diff --git a/plugins/tools/io/search/doc.go b/plugins/tools/io/search/doc.go new file mode 100644 index 0000000..1f3381a --- /dev/null +++ b/plugins/tools/io/search/doc.go @@ -0,0 +1,16 @@ +// Package search 实现 web-search 工具的「搜索后端(provider)」层: +// 一 provider 一文件、统一窄接口、统一归一化结果 +// (DevDocs/design/27-网络搜索与检索提供商.md §4,照 Open WebUI 的 provider 范式,不自创)。 +// +// 三条不变量(design/27 §1): +// +// - **能力 ≠ 单一后端**:能力面对模型只有一把工具(`web-search`),后端由配置选定; +// 换 provider 不改工具 schema。 +// - **provider 是一等公民、各自独立配置**:未配置的家**不出现**在注册表里(fail-closed), +// 绝不「挂着报错」。 +// - **诚实**:provider 报错与空结果必须如实区分(空结果是空结果,不是成功); +// 密钥只从配置读,**绝不进日志/响应/提示词**。 +// +// provider 只负责「发命令、取回 JSON、按 spec 归一化」:**不做域名过滤/去重/排序** +// —— 那些统一在归一化之后做一次(见 Finalize)。零新依赖:一律标准库 `net/http`。 +package search diff --git a/plugins/tools/io/search/errors.go b/plugins/tools/io/search/errors.go new file mode 100644 index 0000000..f0af17b --- /dev/null +++ b/plugins/tools/io/search/errors.go @@ -0,0 +1,125 @@ +package search + +import ( + "encoding/json" + "errors" + "net/http" + "strings" +) + +// 本包的哨兵错误:把「上游为什么不行」如实分成几类 +// (design/27 §1.4/§3:provider 报错与空结果必须如实区分)。 +// 调用方一律用 errors.Is 判定。 +var ( + // ErrUnknownProvider 表示名字不在本期接入表里(写错了,或本期没做,如 tavily)。 + ErrUnknownProvider = errors.New("search: unknown provider") + // ErrNotConfigured 表示这家 provider 没配齐(缺 key / 缺 base URL)。 + ErrNotConfigured = errors.New("search: provider not configured") + // ErrUnauthorized 表示上游拒了凭据(401/403)。 + ErrUnauthorized = errors.New("search: upstream rejected credentials") + // ErrRateLimited 表示被上游限流(429)。 + ErrRateLimited = errors.New("search: upstream rate limited") + // ErrQuotaExceeded 表示额度用尽/欠费/未开通(含 403 + 业务码)。 + ErrQuotaExceeded = errors.New("search: upstream quota exceeded") + // ErrNetwork 表示网络层失败(连不上、DNS、超时)——它**是**「该家不可用」的一种。 + ErrNetwork = errors.New("search: network error") + // ErrUpstream 表示上游 5xx 或其他无法进一步分类的失败状态。 + ErrUpstream = errors.New("search: upstream error") + // ErrResponseTooLarge 表示上游响应超过本仓的读取上限(不静默截断成半截 JSON)。 + ErrResponseTooLarge = errors.New("search: response exceeds size limit") + // ErrBadResponse 表示响应不是预期结构(JSON 解不开,或按 spec 压根找不到结果数组)。 + ErrBadResponse = errors.New("search: malformed upstream response") + // ErrNoResults 表示上游正常应答但**没有任何结果**。 + // + // 它是「空」不是「成功」,也**不是**「该家不可用」:降级链遇到它要停下并如实 + // 报「查到了 0 条」,而不是前进一格假装换一家就有了(design/27 §1.4/§3)。 + ErrNoResults = errors.New("search: no results") +) + +// Unavailable 报告一个错误是否属于「该家不可用」——只有这类错误才让降级链前进一格 +// (design/27 §3:无 key / 网络失败 / 额度错误…)。 +// +// 空结果(ErrNoResults)与上下文取消/超时**不在**其列:前者是「空」不是「不可用」, +// 后者是预算已经花光,换一家也不会有时间。 +func Unavailable(err error) bool { + switch { + case err == nil: + return false + case errors.Is(err, ErrNoResults): + return false + case errors.Is(err, ErrNotConfigured), + errors.Is(err, ErrUnauthorized), + errors.Is(err, ErrRateLimited), + errors.Is(err, ErrQuotaExceeded), + errors.Is(err, ErrNetwork), + errors.Is(err, ErrUpstream), + errors.Is(err, ErrResponseTooLarge), + errors.Is(err, ErrBadResponse): + return true + default: + return false + } +} + +// classifyStatus 把上游的失败状态码归成本仓的哨兵错误。 +// 先看业务错误码(各家官方文档确证的那几个,见 upstreamCodeClass),再看 HTTP 状态。 +func classifyStatus(status int, code string) error { + if e, ok := upstreamCodeClass(code); ok { + return e + } + switch { + case status == http.StatusUnauthorized || status == http.StatusForbidden: + // 403 既可能是鉴权也可能是额度,业务错误码(若上游给了)已在上面先行判定。 + return ErrUnauthorized + case status == http.StatusPaymentRequired: + return ErrQuotaExceeded + case status == http.StatusTooManyRequests: + return ErrRateLimited + default: + // 5xx 与其余 4xx 一律归 ErrUpstream(工具侧只关心「不可用 ⇒ 前进一格」)。 + return ErrUpstream + } +} + +// upstreamCodeClass 把各家**官方文档确证**的业务错误码归类到哨兵错误(大小写不敏感)。 +// +// 来源:阿里云 IQS 错误码表(help.aliyun.com/document_detail/2857020.html; +// DevDocs/reference/search-providers/15-阿里云-IQS.md 的「错误码」节)。 +// 只收录确证过的条目 —— 未确证的错误码一律不猜。 +func upstreamCodeClass(code string) (error, bool) { + switch strings.ToLower(strings.TrimSpace(code)) { + case "retrieval.throttling.user", "retrieval.testuserqueryperdayexceeded": + return ErrRateLimited, true + case "retrieval.arrears", "retrieval.notactivate", "retrieval.testuserperiodexpired": + return ErrQuotaExceeded, true + case "retrieval.notauthorised": + return ErrUnauthorized, true + default: + return nil, false + } +} + +// codePaths 是上游错误体里「业务错误码」的候选位置(点分路径,按序取第一个非空字符串)。 +// 各家形状不一,这里给的是容错候选;取不到就当没有错误码(按状态码分类)。 +func codePaths() []string { + return []string{"code", "error.code", "error_code", "ErrorCode", "ResponseMetadata.Error.Code"} +} + +// upstreamCode 尽力从错误响应体里取出业务错误码。取不到(不是 JSON、没有该字段)返回空串。 +func upstreamCode(data []byte) string { + if len(data) == 0 { + return "" + } + var root any + if err := json.Unmarshal(data, &root); err != nil { + return "" + } + for _, p := range codePaths() { + if v, ok := resolvePath(root, p); ok { + if s := scalarString(v); s != "" { + return s + } + } + } + return "" +} diff --git a/plugins/tools/io/search/extract.go b/plugins/tools/io/search/extract.go new file mode 100644 index 0000000..651bad3 --- /dev/null +++ b/plugins/tools/io/search/extract.go @@ -0,0 +1,237 @@ +package search + +import ( + "encoding/json" + "fmt" + "net/url" + "sort" + "strconv" + "strings" +) + +// spec 描述「怎么把一家 provider 的 JSON 归一到 []Result」:每个字段给一串**候选键名** +// (按序取第一个非空值),结果数组给一串**候选路径**(点分,按序取第一个是数组的)。 +// +// 六家 provider **共用这一个提取器** —— 不许各写一套 JSON 解析样板(AGENTS §10)。 +// 候选键的意义:分册里「完整响应结构未确证」的家(智谱/火山/百度)用多组候选键容错, +// 而不是把某个猜测的字段当事实写死(design/27 §7 的诚实边界)。 +type spec struct { + // Arrays 是结果数组的候选路径(点分)。例:{"webPages.value", "results"}。 + Arrays []string + // 以下每个字段是一组候选键名(同一层对象内),按序取第一个非空值。 + Title []string + Link []string + Snippet []string + Content []string + Published []string + Score []string +} + +// extractResults 把上游 JSON 归一化成 []Result。 +// +// 「空」与「解析不了」必须分开(design/27 §1.4):候选路径命中了数组(哪怕是空数组) +// ⇒ 返回(可能为空的)切片、无错误;一条候选路径都不是数组 ⇒ ErrBadResponse。 +func extractResults(data []byte, s spec) ([]Result, error) { + var root any + if err := json.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("%w: %w", ErrBadResponse, err) + } + items, ok := findArray(root, s.Arrays) + if !ok { + return nil, fmt.Errorf("%w: no results array", ErrBadResponse) + } + out := make([]Result, 0, len(items)) + for _, item := range items { + obj, ok := item.(map[string]any) + if !ok { + // 非对象条目跳过:容错,不让一条脏数据毁掉整次检索。 + continue + } + out = append(out, Result{ + Link: pickString(obj, s.Link), + Title: pickString(obj, s.Title), + Snippet: pickString(obj, s.Snippet), + Content: pickString(obj, s.Content), + Published: pickString(obj, s.Published), + Score: pickFloat(obj, s.Score), + }) + } + for i := range out { + // 既有 SearXNG 口径(WS1-A 之前就在):正文缺了就回落摘要,别让 content 空着。 + if out[i].Content == "" { + out[i].Content = out[i].Snippet + } + } + return out, nil +} + +// resolvePath 按点分路径取嵌套值;任一段不是对象或键不存在时返回 ok=false。 +func resolvePath(root any, path string) (any, bool) { + cur := root + for _, seg := range strings.Split(path, ".") { + obj, ok := cur.(map[string]any) + if !ok { + return nil, false + } + cur, ok = obj[seg] + if !ok { + return nil, false + } + } + return cur, true +} + +// findArray 依次尝试候选路径,返回第一个确实是数组的值。 +func findArray(root any, paths []string) ([]any, bool) { + for _, p := range paths { + v, ok := resolvePath(root, p) + if !ok { + continue + } + if arr, ok := v.([]any); ok { + return arr, true + } + } + return nil, false +} + +// pickString 按候选键顺序取第一个**非空**标量值(空串不算命中,好让后面的候选有机会)。 +func pickString(obj map[string]any, keys []string) string { + for _, k := range keys { + v, ok := obj[k] + if !ok || v == nil { + continue + } + if s := scalarString(v); s != "" { + return s + } + } + return "" +} + +// pickFloat 按候选键顺序取第一个能当数字用的值(数字或数字字符串)。 +func pickFloat(obj map[string]any, keys []string) float64 { + for _, k := range keys { + v, ok := obj[k] + if !ok || v == nil { + continue + } + switch t := v.(type) { + case float64: + return t + case string: + if f, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil { + return f + } + } + } + return 0 +} + +// scalarString 把 JSON 标量转成字符串(非标量返回空串)。时间戳(毫秒数字)按整数值原样输出。 +func scalarString(v any) string { + switch t := v.(type) { + case string: + return strings.TrimSpace(t) + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case bool: + return strconv.FormatBool(t) + default: + return "" + } +} + +// Options 是**归一化之后统一做一次**的后处理开关 +// (design/27 §4:域名过滤/去重/排序集中在这里,各 provider 一律不许自己过滤)。 +type Options struct { + // Limit 是返回条数上限(≤0 = 不截断)。 + Limit int + // AllowDomains 非空时只保留这些域名(含子域)。 + AllowDomains []string + // BlockDomains 是排除的域名(含子域);优先于 AllowDomains。 + BlockDomains []string +} + +// Finalize 对归一化后的结果做一次统一后处理: +// 丢弃空 link → 域名过滤 → 去重(按规范化 link)→ 按 Score 降序**稳定**排序 → 截断到 Limit。 +// +// 排序用 SliceStable:分数相同(多数上游不给分)时保持上游给出的原始顺序,不制造随机。 +func Finalize(results []Result, opt Options) []Result { + out := make([]Result, 0, len(results)) + seen := make(map[string]struct{}, len(results)) + for _, r := range results { + r.Link = strings.TrimSpace(r.Link) + if r.Link == "" { + // 没有链接的结果没法被模型引用,也不算一条检索结果。 + continue + } + if !domainAllowed(r.Link, opt.AllowDomains, opt.BlockDomains) { + continue + } + key := canonicalLink(r.Link) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, r) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score }) + if opt.Limit > 0 && len(out) > opt.Limit { + out = out[:opt.Limit] + } + return out +} + +// domainAllowed 按 allow/block 名单判定一条结果的域名是否保留。 +// 解析不出主机时**保留**(不是「过滤失败」——域名过滤不该把正常结果误杀)。 +func domainAllowed(link string, allow, block []string) bool { + u, err := url.Parse(link) + if err != nil { + return true + } + host := strings.ToLower(u.Hostname()) + if host == "" { + return true + } + if matchesDomain(host, block) { + return false + } + if len(allow) == 0 { + return true + } + return matchesDomain(host, allow) +} + +// matchesDomain 报告 host 是否命中名单里的某一项(同名或为其子域)。 +// 名单项容忍大小写、首尾空白与前导点(如 ".example.com")。 +func matchesDomain(host string, list []string) bool { + h := strings.ToLower(strings.TrimSpace(host)) + if h == "" { + return false + } + for _, raw := range list { + entry := strings.ToLower(strings.Trim(strings.TrimSpace(raw), ".")) + if entry == "" { + continue + } + if h == entry || strings.HasSuffix(h, "."+entry) { + return true + } + } + return false +} + +// canonicalLink 是去重键:去 fragment、scheme/host 小写、path 去尾斜杠。 +// 刻意**保留 query** —— 查询串不同的地址通常是不同页面,过度归并会吞掉真结果。 +func canonicalLink(link string) string { + u, err := url.Parse(strings.TrimSpace(link)) + if err != nil { + return strings.ToLower(strings.TrimSpace(link)) + } + u.Fragment = "" + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + u.Path = strings.TrimSuffix(u.Path, "/") + return u.String() +} diff --git a/plugins/tools/io/search/extract_test.go b/plugins/tools/io/search/extract_test.go new file mode 100644 index 0000000..4780b82 --- /dev/null +++ b/plugins/tools/io/search/extract_test.go @@ -0,0 +1,137 @@ +package search + +import "testing" + +// TestFinalize_Pipeline 是「归一化之后**统一做一次**」的判据(design/27 §4): +// 空 link 丢弃 → 域名过滤 → 去重 → 按分数稳定排序 → 截断。 +func TestFinalize_Pipeline(t *testing.T) { + t.Parallel() + in := []Result{ + {Link: "https://a.example/1#frag", Title: "A1", Score: 0.2}, + {Link: "https://a.example/1", Title: "A1 副本", Score: 0.9}, // 去重被丢(同一地址) + {Link: "https://b.example/2", Title: "B2", Score: 0.9}, + {Link: " ", Title: "没有链接"}, + {Link: "https://blocked.example/3", Title: "被排除的域名"}, + {Link: "https://c.example/4/", Title: "C4", Score: 1.5}, + } + got := Finalize(in, Options{BlockDomains: []string{"blocked.example"}}) + if len(got) != 3 { + t.Fatalf("条数 = %d, want 3: %+v", len(got), got) + } + // 去重保留**先出现的那一条**(A1,分 0.2);Score 降序;同分保持原序(稳定排序)。 + if got[0].Title != "C4" || got[1].Title != "B2" || got[2].Title != "A1" { + t.Fatalf("排序/去重结果 = %q,%q,%q; want C4,B2,A1(分数降序 + 同分稳定 + 重复地址保留首条)", + got[0].Title, got[1].Title, got[2].Title) + } + if got[2].Link != "https://a.example/1#frag" { + t.Fatalf("去重应保留先出现的那一条: %+v", got[2]) + } + if got[1].Link != "https://b.example/2" { + t.Fatalf("B2 的链接被改动: %q", got[1].Link) + } +} + +// TestFinalize_LimitAndAllowDomains 覆盖截断与域名白名单(域名过滤只在归一化后做一次)。 +func TestFinalize_LimitAndAllowDomains(t *testing.T) { + t.Parallel() + in := []Result{ + {Link: "https://a.example/1", Title: "A"}, + {Link: "https://sub.a.example/2", Title: "A 子域"}, + {Link: "https://other.example/3", Title: "O"}, + } + allow := Finalize(in, Options{AllowDomains: []string{".a.example"}}) + if len(allow) != 2 { + t.Fatalf("白名单结果 = %+v, want 2 条(含子域)", allow) + } + limited := Finalize(in, Options{Limit: 1}) + if len(limited) != 1 { + t.Fatalf("Limit=1 应截断成 1 条, got %d", len(limited)) + } + none := Finalize(in, Options{AllowDomains: []string{"nope.example"}}) + if len(none) != 0 { + t.Fatalf("白名单不命中时应为空, got %+v", none) + } +} + +// TestFinalize_ToleratesUnparsableLink 解析不出主机的链接**保留**(域名过滤不该误杀)。 +func TestFinalize_ToleratesUnparsableLink(t *testing.T) { + t.Parallel() + got := Finalize([]Result{{Link: "not a url", Title: "X"}}, Options{AllowDomains: []string{"a.example"}}) + if len(got) != 1 { + t.Fatalf("解析不出主机的链接应被保留(不是过滤失败): %+v", got) + } +} + +// TestCanonicalLink 覆盖去重键的口径:去 fragment、scheme/host 小写、path 去尾斜杠, +// **保留 query**(查询串不同通常是不同页面,过度归并会吞掉真结果)。 +func TestCanonicalLink(t *testing.T) { + t.Parallel() + same := [][2]string{ + {"https://A.example/p/", "https://a.example/p"}, + {"https://a.example/p#sec", "https://a.example/p"}, + } + for _, pair := range same { + if canonicalLink(pair[0]) != canonicalLink(pair[1]) { + t.Errorf("应视为同一条: %q vs %q", pair[0], pair[1]) + } + } + if canonicalLink("https://a.example/p?x=1") == canonicalLink("https://a.example/p?x=2") { + t.Error("query 不同不该被去重合并") + } +} + +// TestScalarStringAndPick 覆盖容错提取的取值口径(字符串/数字/布尔;空串不算命中)。 +func TestScalarStringAndPick(t *testing.T) { + t.Parallel() + obj := map[string]any{ + "empty": "", + "title": "标题", + "num": float64(1700000000000), + "flag": true, + "nested": map[string]any{"x": 1}, + "score": "0.75", + "badnum": "abc", + } + if got := pickString(obj, []string{"empty", "title"}); got != "标题" { + t.Errorf("空串不该算命中, got %q", got) + } + if got := pickString(obj, []string{"num"}); got != "1700000000000" { + t.Errorf("数字应原样字符串化, got %q", got) + } + if got := pickString(obj, []string{"flag"}); got != "true" { + t.Errorf("布尔应字符串化, got %q", got) + } + if got := pickString(obj, []string{"nested"}); got != "" { + t.Errorf("非标量应返回空串, got %q", got) + } + if got := pickFloat(obj, []string{"score"}); got != 0.75 { + t.Errorf("数字字符串应能当分数用, got %v", got) + } + if got := pickFloat(obj, []string{"badnum"}); got != 0 { + t.Errorf("非数字字符串应返回 0, got %v", got) + } + if got := pickFloat(obj, []string{"missing"}); got != 0 { + t.Errorf("缺键应返回 0, got %v", got) + } +} + +// TestResolvePath 覆盖点分路径解析(含中间不是对象的情况)。 +func TestResolvePath(t *testing.T) { + t.Parallel() + var root any + mustUnmarshal(t, `{"a":{"b":[1,2]}}`, &root) + if v, ok := resolvePath(root, "a.b"); !ok { + t.Fatal("a.b 应能解析") + } else if arr, ok := v.([]any); !ok || len(arr) != 2 { + t.Fatalf("a.b = %v", v) + } + if _, ok := resolvePath(root, "a.c"); ok { + t.Error("不存在的键不该命中") + } + if _, ok := resolvePath(root, "a.b.c"); ok { + t.Error("路径穿过数组时不该命中") + } + if arr, ok := findArray(root, []string{"nope", "a.b"}); !ok || len(arr) != 2 { + t.Errorf("findArray 应回退到后面的候选路径: %v %v", arr, ok) + } +} diff --git a/plugins/tools/io/search/http.go b/plugins/tools/io/search/http.go new file mode 100644 index 0000000..3424c63 --- /dev/null +++ b/plugins/tools/io/search/http.go @@ -0,0 +1,99 @@ +package search + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" +) + +// defaultMaxBytes 是单次响应体的兜底读取上限(1 MiB,与 io 包的 http 响应上限同口径)。 +// 组合根总会传入显式值;这里只保证 Config.MaxBytes 为零值时不是「无限读」。 +const defaultMaxBytes = 1 << 20 + +// request 描述一次上游调用。provider 只填这些,其余(读取上限、错误分类)走共享通路 +// —— 六家绝不能各写一套「发请求 + 判状态 + 读 body」的样板(AGENTS §10 三次复制铁律)。 +type request struct { + // Method 是 HTTP 方法(http.MethodGet / http.MethodPost)。 + Method string + // URL 是完整目标地址(provider 自己拼 base + path + query)。 + URL string + // Headers 是附加请求头(含认证头)。**绝不写进日志**。 + Headers map[string]string + // Body 是请求体(非空时默认 Content-Type: application/json)。 + Body []byte +} + +// do 发一次请求并返回读取上限内的响应体: +// +// - SSRF 守卫(组合根注入)先判最终地址 ⇒ 被拦时如实报错(不静默放行); +// - 网络层失败 ⇒ ErrNetwork(含 context 超时/取消,调用方可用 errors.Is 区分); +// - 非 2xx ⇒ 按业务错误码 + 状态码分类(classifyStatus); +// - 超过 MaxBytes ⇒ ErrResponseTooLarge(不静默截断成半截 JSON)。 +// +// 报文正文与认证头**都不进日志**(本函数不记日志,日志由调用方在工具层记)。 +func do(ctx context.Context, cfg Config, r request) ([]byte, error) { + if cfg.Client == nil { + return nil, fmt.Errorf("search: no http client: %w", ErrNotConfigured) + } + if cfg.Guard != nil { + if err := cfg.Guard(ctx, r.URL); err != nil { + return nil, fmt.Errorf("search: blocked upstream url: %w", err) + } + } + var body io.Reader + if len(r.Body) > 0 { + body = bytes.NewReader(r.Body) + } + req, err := http.NewRequestWithContext(ctx, r.Method, r.URL, body) + if err != nil { + return nil, fmt.Errorf("search: build request: %w", err) + } + for k, v := range r.Headers { + req.Header.Set(k, v) + } + if len(r.Body) > 0 && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + resp, err := cfg.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrNetwork, err) + } + defer resp.Body.Close() + + data, err := readCapped(resp.Body, cfg.MaxBytes) + if err != nil { + return nil, err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + // 错误体可能带业务错误码(如阿里云 Retrieval.Arrears);错误体本身不入错误文案 + // 以外的去处,也不会被当成结果。 + return nil, fmt.Errorf("search: upstream status %d: %w", resp.StatusCode, classifyStatus(resp.StatusCode, upstreamCode(data))) + } + return data, nil +} + +// readCapped 读回最多 max 字节;超限返回 ErrResponseTooLarge。 +func readCapped(r io.Reader, max int64) ([]byte, error) { + if max <= 0 { + max = defaultMaxBytes + } + data, err := io.ReadAll(io.LimitReader(r, max+1)) + if err != nil { + return nil, fmt.Errorf("search: read response: %w", err) + } + if int64(len(data)) > max { + return nil, fmt.Errorf("%w: exceeds the %d-byte limit", ErrResponseTooLarge, max) + } + return data, nil +} + +// baseURLJoin 把基地址与各家固定路径拼起来(容忍基地址尾部的 "/")。 +func baseURLJoin(baseURL, path string) string { + trimmed := baseURL + for len(trimmed) > 0 && trimmed[len(trimmed)-1] == '/' { + trimmed = trimmed[:len(trimmed)-1] + } + return trimmed + path +} diff --git a/plugins/tools/io/search/provider.go b/plugins/tools/io/search/provider.go new file mode 100644 index 0000000..644734c --- /dev/null +++ b/plugins/tools/io/search/provider.go @@ -0,0 +1,77 @@ +package search + +import ( + "context" + "net/http" +) + +// Provider 是一种搜索后端的**窄接口**(design/27 §4)。实现必须并发安全: +// 工具循环可能从多个 goroutine 调它。 +type Provider interface { + // Name 返回该 provider 的配置名(即 WEB_SEARCH_PROVIDER / WEB_SEARCH_FALLBACK 里的取值)。 + Name() string + // Search 执行一次检索,返回**已归一化**的结果(可能为空)。 + // + // 返回空切片(len==0)**不是错误**:它表示「上游正常应答但什么都没查到」, + // 由调用方按「空结果 ≠ 成功」处置(见 ErrNoResults 的注释)。 + Search(ctx context.Context, q Query) ([]Result, error) +} + +// Query 是一次检索的输入。工具 schema 仍是 `query`/`count`(design/27 §4:不破坏契约)。 +// +// 域名过滤**不在这里**:按 design/27 §4,域名过滤/去重/排序统一在归一化之后做一次 +// (见 Finalize 的 Options),各家 provider 一律不许自己过滤。 +type Query struct { + // Text 是检索词。 + Text string + // Count 是调用方期望的结果条数(>0 才有意义)。provider 支持就透传, + // 不支持(如阿里云 IQS 默认 10 条不可调)就忽略 —— 本地截断仍然生效。 + Count int +} + +// Result 是所有 provider **统一归一化**后的结果(design/27 §4)。 +type Result struct { + Link string + Title string + Snippet string + Content string + Published string + Score float64 +} + +// 本期内置的 provider 名(对外配置值 WEB_SEARCH_PROVIDER 的合法取值之一)。 +const ( + // NameSearXNG 是自建 SearXNG(A 档、零 key、本地兜底)。 + NameSearXNG = "searxng" + // NameZhipu 是智谱 BigModel 联网搜索(design/27 §2.1 国内首选)。 + NameZhipu = "zhipu" + // NameAliyunIQS 是阿里云信息查询服务 IQS(HTTP 免签名免 SDK)。 + NameAliyunIQS = "aliyun_iqs" + // NameBocha 是博查 Bocha(中文优先)。 + NameBocha = "bocha" + // NameVolcengine 是火山引擎·豆包搜索 SearchInfinity。 + NameVolcengine = "volcengine" + // NameBaiduQianfan 是百度千帆 AI 搜索。 + NameBaiduQianfan = "baidu_qianfan" +) + +// Config 是构造一家 provider 所需的一切接线(密钥/地址/客户端/响应上限/URL 守卫)。 +// +// 它由组合根(plugins/tools/io 的 searchWires)填好 —— provider 自己不读配置、 +// 不读环境变量、不开新的 HTTP 客户端(单二进制、零新依赖:design/27 §1.5)。 +type Config struct { + // APIKey 是上游凭据。**绝不进日志/响应/提示词**。 + APIKey string + // BaseURL 是上游基地址(不含各家自己的路径),可被各自的环境变量覆盖。 + BaseURL string + // Client 是这一套工具共用的 HTTP 客户端(已带 SSRF 重定向守卫与超时)。 + Client *http.Client + // MaxBytes 是单次响应体读取上限(字节);≤0 用包内默认。 + MaxBytes int64 + // Guard 是**组合根注入的 SSRF 守卫**:发请求前对最终地址做一次判定 + // (scheme / host / 解析结果)。它复用 plugins/tools/io 的既有守卫与本工具自己 + // 那份白名单,**不改** http / web-fetch 共用的白名单。 + // + // 为 nil 时不做判定(search 包的夹具测试直接用 httptest 客户端)。 + Guard func(ctx context.Context, rawURL string) error +} diff --git a/plugins/tools/io/search/providers_test.go b/plugins/tools/io/search/providers_test.go new file mode 100644 index 0000000..da1f5a7 --- /dev/null +++ b/plugins/tools/io/search/providers_test.go @@ -0,0 +1,380 @@ +package search + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// testKey 是夹具里用的假密钥。它只出现在**请求头**里:任何日志/错误/响应里出现它 +// 都是缺陷(密钥不泄漏,design/27 §1.5)。 +const testKey = "test-api-key-do-not-log" + +// mustUnmarshal 解一份 JSON 到 out,失败即 Fatal。 +func mustUnmarshal(t *testing.T, raw string, out any) { + t.Helper() + if err := json.Unmarshal([]byte(raw), out); err != nil { + t.Fatalf("unmarshal %q: %v", raw, err) + } +} + +// fixtureServer 造一个「记录请求 + 回固定 JSON」的测试服务器。 +func fixtureServer(t *testing.T, fixture string) (*httptest.Server, *captured) { + t.Helper() + c := &captured{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + c.set(r, raw) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(fixture)) + })) + t.Cleanup(srv.Close) + return srv, c +} + +// captured 记录最后一次收到的请求(测试同步:handler 先于 Search 返回执行完)。 +type captured struct { + req *http.Request + body string +} + +func (c *captured) set(r *http.Request, body []byte) { + // 只留断言需要的部分:URL/Method/Header(不含 Body 读句柄)。 + clone := r.Clone(r.Context()) + c.req = clone + c.body = string(body) +} + +// providerCase 是「一家 provider 的 httptest 夹具」:断言请求形态 + 响应归一化。 +type providerCase struct { + name string + newP func(Config) Provider + // method / path 是期望的请求形态。 + method string + path string + // authHeader / authValue 是期望的认证头(authHeader 为空 = 该家不用 key,如 searxng)。 + authHeader string + authValue string + // fixture 是上游返回的 JSON;want* 是归一化后应得到的第一条结果。 + fixture string + wantTitle string + wantLink string + wantContent string + // checkRequest 做该家特有的断言(请求体字段等)。 + checkRequest func(t *testing.T, c *captured) +} + +// searchProviderCaseTable 是六家的夹具表(**禁止真调外部 API** —— 全部打 httptest)。 +func searchProviderCaseTable() []providerCase { + return []providerCase{ + searxngCase(), zhipuCase(), aliyunCase(), bochaCase(), volcengineCase(), baiduCase(), + } +} + +// searxngCase 是 searxng 的夹具(GET + q/format=json,无认证头)。 +func searxngCase() providerCase { + return providerCase{ + name: "searxng", newP: newSearXNG, + // searxng 的基地址就是端点本身(无固定路径)⇒ 请求落在 "/" 上, + // 查询串才是它的语义(见 checkRequest)。带路径的端点由 io 包既有测试钉住。 + method: http.MethodGet, path: "/", + fixture: `{"results":[{"title":"SearX 结果","url":"https://s.example/1","content":"正文","score":0.5}]}`, + wantTitle: "SearX 结果", + wantLink: "https://s.example/1", + wantContent: "正文", + checkRequest: func(t *testing.T, c *captured) { + t.Helper() + if got := c.req.URL.Query().Get("q"); got != "昔涟" { + t.Errorf("searxng q = %q, want 昔涟", got) + } + if got := c.req.URL.Query().Get("format"); got != "json" { + t.Errorf("searxng format = %q, want json", got) + } + if c.req.Header.Get("Authorization") != "" { + t.Errorf("searxng 不该带认证头: %q", c.req.Header.Get("Authorization")) + } + }, + } +} + +// zhipuCase 是智谱的夹具(POST + Bearer;请求字段 search_engine/search_query)。 +func zhipuCase() providerCase { + return providerCase{ + name: "zhipu", newP: newZhipu, + method: http.MethodPost, path: zhipuPath, authHeader: "Authorization", authValue: "Bearer " + testKey, + fixture: `{"search_result":[{"title":"智谱结果","link":"https://z.example/1","content":"智谱正文"}]}`, + wantTitle: "智谱结果", + wantLink: "https://z.example/1", + wantContent: "智谱正文", + checkRequest: func(t *testing.T, c *captured) { + t.Helper() + // 只发官方文档确证过的字段(search_engine / search_query)。 + var body map[string]any + mustUnmarshal(t, c.body, &body) + if body["search_query"] != "昔涟" { + t.Errorf("zhipu body search_query = %v", body["search_query"]) + } + if body["search_engine"] != zhipuEngineStd { + t.Errorf("zhipu body search_engine = %v, want %s", body["search_engine"], zhipuEngineStd) + } + }, + } +} + +// aliyunCase 是阿里云 IQS 的夹具(GET + X-API-Key + query 查询串)。 +func aliyunCase() providerCase { + return providerCase{ + name: "aliyun_iqs", newP: newAliyunIQS, + method: http.MethodGet, path: aliyunIQSPath, authHeader: "X-API-Key", authValue: testKey, + fixture: `{"pageItems":[{"title":"IQS 结果","link":"https://a.example/1","snippet":"摘要",` + + `"mainText":"IQS 正文","publishTime":1700000000000,"score":0.9}]}`, + wantTitle: "IQS 结果", + wantLink: "https://a.example/1", + wantContent: "IQS 正文", + checkRequest: func(t *testing.T, c *captured) { + t.Helper() + if got := c.req.URL.Query().Get("query"); got != "昔涟" { + t.Errorf("aliyun query = %q, want 昔涟", got) + } + }, + } +} + +// bochaCase 是博查的夹具(POST + Bearer;summary=true / count 夹在官方区间)。 +func bochaCase() providerCase { + return providerCase{ + name: "bocha", newP: newBocha, + method: http.MethodPost, path: bochaPath, authHeader: "Authorization", authValue: "Bearer " + testKey, + fixture: `{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1",` + + `"snippet":"摘要","summary":"博查总结","datePublished":"2025-01-01"}]}}`, + wantTitle: "博查结果", + wantLink: "https://b.example/1", + wantContent: "博查总结", + checkRequest: func(t *testing.T, c *captured) { + t.Helper() + var body map[string]any + mustUnmarshal(t, c.body, &body) + if body["query"] != "昔涟" { + t.Errorf("bocha body query = %v", body["query"]) + } + if body["summary"] != true { + t.Errorf("bocha body summary = %v, want true(中文摘要)", body["summary"]) + } + if body["count"] != float64(bochaDefaultCount) { + t.Errorf("bocha body count = %v, want %d", body["count"], bochaDefaultCount) + } + }, + } +} + +// volcengineCase 是火山·豆包搜索的夹具(POST + Bearer;请求字段是大写驼峰)。 +func volcengineCase() providerCase { + return providerCase{ + name: "volcengine", newP: newVolcengine, + method: http.MethodPost, path: volcenginePath, authHeader: "Authorization", authValue: "Bearer " + testKey, + fixture: `{"Result":{"WebResults":[{"Title":"火山结果","Url":"https://v.example/1",` + + `"Snippet":"摘要","Content":"火山正文","PublishTime":1700000000000,"RankScore":0.8}]}}`, + wantTitle: "火山结果", + wantLink: "https://v.example/1", + wantContent: "火山正文", + checkRequest: func(t *testing.T, c *captured) { + t.Helper() + var body map[string]any + mustUnmarshal(t, c.body, &body) + if body["Query"] != "昔涟" { + t.Errorf("volcengine body Query = %v", body["Query"]) + } + if body["SearchType"] != volcengineSearchTypeWeb { + t.Errorf("volcengine body SearchType = %v", body["SearchType"]) + } + }, + } +} + +// baiduCase 是百度千帆的夹具(POST + Bearer;单轮 messages + search_source)。 +func baiduCase() providerCase { + return providerCase{ + name: "baidu_qianfan", newP: newBaiduQianfan, + method: http.MethodPost, path: baiduQianfanPath, authHeader: "Authorization", authValue: "Bearer " + testKey, + fixture: `{"references":[{"title":"百度结果","url":"https://q.example/1",` + + `"snippet":"摘要","content":"百度正文"}]}`, + wantTitle: "百度结果", + wantLink: "https://q.example/1", + wantContent: "百度正文", + checkRequest: func(t *testing.T, c *captured) { + t.Helper() + var body map[string]any + mustUnmarshal(t, c.body, &body) + if body["search_source"] != baiduSearchSourceV2 { + t.Errorf("baidu body search_source = %v, want %s", body["search_source"], baiduSearchSourceV2) + } + msgs, ok := body["messages"].([]any) + if !ok || len(msgs) != 1 { + t.Fatalf("百度搜索仅支持单轮输入,messages = %v", body["messages"]) + } + first, _ := msgs[0].(map[string]any) + if first["content"] != "昔涟" || first["role"] != "user" { + t.Errorf("baidu messages[0] = %v", first) + } + }, + } +} + +// TestProviders_FixturePerProvider 是验收 ①:六家各一条 httptest 夹具(不打真上游)。 +func TestProviders_FixturePerProvider(t *testing.T) { + t.Parallel() + for _, tc := range searchProviderCaseTable() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv, cap := fixtureServer(t, tc.fixture) + p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()}) + got, err := p.Search(context.Background(), Query{Text: "昔涟"}) + if err != nil { + t.Fatalf("%s Search: %v", tc.name, err) + } + if cap.req == nil { + t.Fatalf("%s 没发出任何请求", tc.name) + } + if cap.req.Method != tc.method { + t.Errorf("method = %q, want %q", cap.req.Method, tc.method) + } + if cap.req.URL.Path != tc.path { + t.Errorf("path = %q, want %q", cap.req.URL.Path, tc.path) + } + if tc.authHeader != "" { + if gotAuth := cap.req.Header.Get(tc.authHeader); gotAuth != tc.authValue { + t.Errorf("认证头 %s = %q, want %q", tc.authHeader, gotAuth, tc.authValue) + } + } + if len(got) != 1 { + t.Fatalf("%s 结果条数 = %d, want 1(归一化失败): %+v", tc.name, len(got), got) + } + r0 := got[0] + if r0.Title != tc.wantTitle || r0.Link != tc.wantLink || r0.Content != tc.wantContent { + t.Errorf("%s 归一化结果 = %+v, want title=%q link=%q content=%q", + tc.name, r0, tc.wantTitle, tc.wantLink, tc.wantContent) + } + if tc.checkRequest != nil { + tc.checkRequest(t, cap) + } + }) + } +} + +// TestProviders_AllNormalizeToSameResult 是验收 ②:六家输出**归一化成同一 Result** +// (同一结构体、同一语义),且共用同一套后处理(Finalize)。 +func TestProviders_AllNormalizeToSameResult(t *testing.T) { + t.Parallel() + for _, tc := range searchProviderCaseTable() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, tc.fixture) + p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()}) + got, err := p.Search(context.Background(), Query{Text: "q"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + for i, r := range got { + if r.Link == "" { + t.Errorf("[%d] Link 为空 —— 没链接的结果不算结果: %+v", i, r) + } + if r.Title == "" { + t.Errorf("[%d] Title 为空(夹具里给了标题): %+v", i, r) + } + } + if final := Finalize(got, Options{Limit: 1}); len(final) != 1 { + t.Fatalf("Finalize 后条数 = %d, want 1", len(final)) + } + }) + } +} + +// TestProviders_EmptyBodyIsEmptyNotError 钉住六家对「200 + 空数组」的处置一致: +// 不是错误,而是 0 条(由上层按「空结果 ≠ 成功」处置)。 +func TestProviders_EmptyBodyIsEmptyNotError(t *testing.T) { + t.Parallel() + for _, tc := range searchProviderCaseTable() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, tc.emptyFixture()) + p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()}) + got, err := p.Search(context.Background(), Query{Text: "q"}) + if err != nil { + t.Fatalf("空结果不是错误: %v", err) + } + if len(got) != 0 { + t.Fatalf("条数 = %d, want 0", len(got)) + } + }) + } +} + +// emptyFixture 返回该家「0 条结果」的合法应答(候选数组为空)。 +func (tc providerCase) emptyFixture() string { + switch tc.name { + case "searxng": + return `{"results":[]}` + case "zhipu": + return `{"search_result":[]}` + case "aliyun_iqs": + return `{"pageItems":[]}` + case "bocha": + return `{"webPages":{"value":[]}}` + case "volcengine": + return `{"Result":{"WebResults":[]}}` + default: + return `{"references":[]}` + } +} + +// TestExtractResults_MissingFieldsTolerated 钉住「缺字段的容错」: +// 缺 content 回落 snippet;全缺也不 panic、不报错;非对象条目跳过。 +func TestExtractResults_MissingFieldsTolerated(t *testing.T) { + t.Parallel() + data := []byte(`{"results":[ + {"title":"只有标题","url":"https://x.example/1"}, + {"url":"https://x.example/2","snippet":"只有摘要"}, + "not-an-object", + {"title":"只有正文","url":"https://x.example/3","content":"正文"} + ]}`) + got, err := extractResults(data, searxngSpec()) + if err != nil { + t.Fatalf("缺字段不该报错: %v", err) + } + if len(got) != 3 { + t.Fatalf("条数 = %d, want 3(非对象条目应被跳过)", len(got)) + } + if got[0].Content != "" || got[0].Title != "只有标题" { + t.Errorf("缺 content/正文时应原样留空: %+v", got[0]) + } + if got[1].Content != "只有摘要" { + t.Errorf("content 缺了必须回落 snippet: %+v", got[1]) + } + if got[2].Content != "正文" { + t.Errorf("正文应原样带出: %+v", got[2]) + } +} + +// TestExtractResults_EmptyVsMalformed 钉住「空」与「解析不了」必须分开(design/27 §1.4)。 +func TestExtractResults_EmptyVsMalformed(t *testing.T) { + t.Parallel() + empty, err := extractResults([]byte(`{"results":[]}`), searxngSpec()) + if err != nil { + t.Fatalf("空数组不是错误: %v", err) + } + if len(empty) != 0 { + t.Fatalf("空数组应归一化成 0 条, got %d", len(empty)) + } + for _, bad := range []string{`not json`, `{"data":{}}`, `[]`, `{}`} { + if _, err := extractResults([]byte(bad), searxngSpec()); !errors.Is(err, ErrBadResponse) { + t.Errorf("输入 %q 应报 ErrBadResponse, got %v", bad, err) + } + } +} diff --git a/plugins/tools/io/search/registry.go b/plugins/tools/io/search/registry.go new file mode 100644 index 0000000..723263d --- /dev/null +++ b/plugins/tools/io/search/registry.go @@ -0,0 +1,159 @@ +package search + +import ( + "context" + "net/http" + "sort" + "strings" +) + +// Wire 是组合根给**一家** provider 的接线值(来自 env 配置)。 +// +// Configured 由组合根**显式**判定后给出(判定口径见 Configured 函数):false ⇒ +// 这家不进注册表、不出现在可用清单里(design/27 §1.2 的 fail-closed)。刻意不用 +// 「APIKey 是否为空」在这里自动推断,是为了让 searxng 这种「零 key、看 base URL」 +// 的家也走同一条判定,且判定口径只有一处。 +type Wire struct { + // Name 是 provider 名(必须是本期内置的接入位之一,否则被忽略)。 + Name string + // APIKey 是上游凭据(searxng 不用)。 + APIKey string + // BaseURL 是上游基地址。 + BaseURL string + // Configured 报告这家在当前配置下是否已配置齐。 + Configured bool +} + +// Registry 是**已配置且已登记**的 provider 集合(值语义,由 Build 构造)。 +// +// 为什么用注入的 Registry 而不是包级 `Register(provider)` 全局注册表 +// (design/27 §4 提到的写法):AGENTS §10 禁包级全局变量,且包级可变注册表会让 +// 并行测试(t.Parallel)互相污染。这里把它做成一个由组合根构造、注入工具的值, +// 能力等价(同样的「按名字选一家」+「可用清单」),但没有全局可变状态。 +type Registry struct { + providers map[string]Provider + order []string +} + +// Lookup 按名字取一家已登记的 provider(名字大小写与首尾空白不敏感)。 +// 未配置/未登记的家返回 ok=false。 +func (r *Registry) Lookup(name string) (Provider, bool) { + if r == nil { + return nil, false + } + p, ok := r.providers[strings.ToLower(strings.TrimSpace(name))] + return p, ok +} + +// Names 返回**已配置且已登记**的 provider 名(稳定顺序 = 接线表给出的顺序)。 +// 它就是「可用清单」,未配置的家不会出现在这里。 +func (r *Registry) Names() []string { + if r == nil { + return nil + } + out := make([]string, len(r.order)) + copy(out, r.order) + return out +} + +// definition 是一家 provider 的**接入位**:名字 + 是否需要 key + 构造函数。 +// 新增一家 = 新建一个文件(design/27 §4 一 provider 一文件)并在此表加一行。 +type definition struct { + name string + needKey bool + new func(Config) Provider +} + +// definitions 返回本期内置的全部接入位(**含未配置的家**)。顺序即「可用清单」的展示顺序。 +// +// 它每次返回新建的切片(不是包级变量),保持包内无全局状态。 +func definitions() []definition { + return []definition{ + {name: NameSearXNG, needKey: false, new: newSearXNG}, + {name: NameZhipu, needKey: true, new: newZhipu}, + {name: NameAliyunIQS, needKey: true, new: newAliyunIQS}, + {name: NameBocha, needKey: true, new: newBocha}, + {name: NameVolcengine, needKey: true, new: newVolcengine}, + {name: NameBaiduQianfan, needKey: true, new: newBaiduQianfan}, + } +} + +// lookupDef 找一家接入位(名字去空白、大小写不敏感 —— 配置里写 `Zhipu` 也认)。 +func lookupDef(name string) (definition, bool) { + want := strings.ToLower(strings.TrimSpace(name)) + for _, d := range definitions() { + if d.name == want { + return d, true + } + } + return definition{}, false +} + +// Known 报告 name 是否是本期**已登记的接入位**(含未配置的家)。 +// +// 它的用处是把两种失败分开报错:名字不认识(写错了 / 本期没做,如 tavily)⇒ +// ErrUnknownProvider;名字认识但没配齐(缺 key)⇒ ErrNotConfigured。 +// 两者都不许静默回落到兜底(design/27 §3 的语义硬约束)。 +func Known(name string) bool { + _, ok := lookupDef(name) + return ok +} + +// KnownNames 返回本期全部接入位名(含未配置的家),稳定顺序。 +func KnownNames() []string { + defs := definitions() + out := make([]string, 0, len(defs)) + for _, d := range defs { + out = append(out, d.name) + } + sort.Strings(out) + return out +} + +// Configured 报告一家 provider 在给到的接线值下是否**配置齐**: +// searxng 无 key、看 base URL;云家看 API key(design/27 §3:未配置 ⇒ 该家不出现)。 +// 名字不是接入位时返回 false。 +func Configured(name, apiKey, baseURL string) bool { + def, ok := lookupDef(name) + if !ok { + return false + } + if def.needKey { + return strings.TrimSpace(apiKey) != "" + } + return strings.TrimSpace(baseURL) != "" +} + +// Build 按接线表构造注册表:**只登记 Configured 的家**,名字不是接入位的直接忽略。 +// +// guard 是组合根注入的 SSRF 守卫(可为 nil,夹具测试不判定);client / maxBytes 由 +// 各家共用(同一份连接池与读取上限)。 +// +// 调用方要区分「未注册」与「未配置」时用 Known / Configured 自行判定后报错 +// (注册表本身只承载「能用哪些」这一个事实)。 +func Build(wires []Wire, client *http.Client, maxBytes int64, guard Guard) *Registry { + reg := &Registry{providers: make(map[string]Provider, len(wires))} + for _, w := range wires { + if !w.Configured { + continue + } + def, ok := lookupDef(w.Name) + if !ok { + continue + } + cfg := Config{APIKey: w.APIKey, BaseURL: w.BaseURL, Client: client, MaxBytes: maxBytes, Guard: guard} + p := def.new(cfg) + if p == nil { + continue + } + if _, dup := reg.providers[def.name]; dup { + continue + } + reg.providers[def.name] = p + reg.order = append(reg.order, def.name) + } + return reg +} + +// Guard 是发请求前的地址判定(组合根注入的 SSRF 守卫;见 Config.Guard)。 +type Guard func(ctx context.Context, rawURL string) error diff --git a/plugins/tools/io/search/registry_test.go b/plugins/tools/io/search/registry_test.go new file mode 100644 index 0000000..364b43d --- /dev/null +++ b/plugins/tools/io/search/registry_test.go @@ -0,0 +1,256 @@ +package search + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +// TestRegistry_KnownAndKnownNames 覆盖「哪些名字是本期接入位」:认识的家(含未配置的) +// 与不认识的家必须分得开 —— 这正是启动期「未注册 vs 未配置」两种报错的判据。 +func TestRegistry_KnownAndKnownNames(t *testing.T) { + t.Parallel() + for _, name := range []string{ + NameSearXNG, NameZhipu, NameAliyunIQS, NameBocha, NameVolcengine, NameBaiduQianfan, + "Zhipu", " bocha ", + } { + if !Known(name) { + t.Errorf("Known(%q) = false, want true(大小写/空白应不敏感)", name) + } + } + for _, name := range []string{"", "tavily", "exa", "firecrawl", "serper", "serpapi", "nope"} { + if Known(name) { + t.Errorf("Known(%q) = true, want false(本期未登记)", name) + } + } + names := KnownNames() + if len(names) != 6 { + t.Fatalf("KnownNames() = %v, want 六家", names) + } +} + +// TestRegistry_Configured 钉住「配齐」的判定口径:searxng 无 key、看 base URL; +// 云家看 API key。它是「未配置的家不出现在可用清单里」的唯一判据。 +func TestRegistry_Configured(t *testing.T) { + t.Parallel() + cases := []struct { + name string + provider string + key, base string + wantConfigured bool + }{ + {"searxng 有端点无 key 也算配置齐", NameSearXNG, "", "http://127.0.0.1:38471", true}, + {"searxng 没端点 = 未配置", NameSearXNG, "whatever", " ", false}, + {"云家有 key = 配置齐", NameZhipu, "k", "", true}, + {"云家没 key = 未配置", NameZhipu, " ", "https://open.bigmodel.cn", false}, + {"未登记的名字一律未配置", "tavily", "k", "https://api.tavily.com", false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := Configured(tc.provider, tc.key, tc.base); got != tc.wantConfigured { + t.Fatalf("Configured(%q,%q,%q) = %v, want %v", tc.provider, tc.key, tc.base, got, tc.wantConfigured) + } + }) + } +} + +// TestRegistry_BuildOnlyRegistersConfigured 钉住 fail-closed:未配置的家根本不进注册表, +// 因而**不出现在可用清单里**(design/27 §1.2)。 +func TestRegistry_BuildOnlyRegistersConfigured(t *testing.T) { + t.Parallel() + wires := []Wire{ + {Name: NameBocha, APIKey: "k", BaseURL: "https://api.bocha.cn", Configured: true}, + {Name: NameZhipu, APIKey: "", BaseURL: "https://open.bigmodel.cn", Configured: false}, + {Name: "tavily", APIKey: "k", BaseURL: "https://api.tavily.com", Configured: true}, + {Name: NameBocha, APIKey: "k2", BaseURL: "https://api.bocha.cn", Configured: true}, // 重复 + } + reg := Build(wires, &http.Client{}, defaultMaxBytes, nil) + names := reg.Names() + if len(names) != 1 || names[0] != NameBocha { + t.Fatalf("可用清单 = %v, want [bocha](未配置的家与未登记的名字都不得出现)", names) + } + if _, ok := reg.Lookup(NameZhipu); ok { + t.Fatal("缺 key 的家不得出现在注册表里") + } + if _, ok := reg.Lookup("tavily"); ok { + t.Fatal("未登记的名字不得出现在注册表里") + } + if p, ok := reg.Lookup(" BOCHA "); !ok || p.Name() != NameBocha { + t.Fatalf("Lookup 应大小写/空白不敏感, got %v %v", p, ok) + } +} + +// TestRegistry_EmptyAndNilSafe 覆盖零值路径:空注册表 / nil 注册表不得 panic。 +func TestRegistry_EmptyAndNilSafe(t *testing.T) { + t.Parallel() + reg := Build(nil, &http.Client{}, 0, nil) + if len(reg.Names()) != 0 { + t.Fatalf("空接线表应得到空注册表: %v", reg.Names()) + } + if _, ok := reg.Lookup("zhipu"); ok { + t.Fatal("空注册表不该命中任何名字") + } + var nilReg *Registry + if _, ok := nilReg.Lookup("zhipu"); ok { + t.Fatal("nil 注册表应安全返回 false") + } + if len(nilReg.Names()) != 0 { + t.Fatal("nil 注册表应返回空清单") + } +} + +// TestProviders_ErrorClassification 是验收 ⑤:鉴权 / 限流 / 额度 / 上游 各一条。 +// 用同一家 provider + 不同应答,确认分类**如实区分**(错误分类决定降级链是否前进)。 +func TestProviders_ErrorClassification(t *testing.T) { + t.Parallel() + cases := []struct { + name string + status int + body string + wantErr error + }{ + {"鉴权失败 401", http.StatusUnauthorized, `{"error":"bad key"}`, ErrUnauthorized}, + {"限流 429", http.StatusTooManyRequests, `{"error":"too many"}`, ErrRateLimited}, + {"额度/欠费(403 + 官方错误码)", http.StatusForbidden, + `{"code":"Retrieval.Arrears","message":"账户欠费"}`, ErrQuotaExceeded}, + {"未开通(403 + 官方错误码)", http.StatusForbidden, + `{"code":"Retrieval.NotActivate"}`, ErrQuotaExceeded}, + {"上游 500", http.StatusInternalServerError, `{}`, ErrUpstream}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + p := newZhipu(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()}) + if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want errors.Is(..., %v)", err, tc.wantErr) + } + }) + } +} + +// TestProviders_TransportFailures 覆盖非 HTTP 状态类的失败:网络 / 读取上限 / +// 没有客户端 / 注入守卫被调用。它们同样要**如实分类**,不许混成一团。 +func TestProviders_TransportFailures(t *testing.T) { + t.Parallel() + t.Run("网络失败", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + srv.Close() // 关掉之后再打 ⇒ 连接被拒 + p := newZhipu(Config{APIKey: testKey, BaseURL: url, Client: &http.Client{}}) + if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, ErrNetwork) { + t.Fatalf("err = %v, want ErrNetwork", err) + } + }) + + t.Run("响应超上限", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"search_result":[{"title":"很长"}]}`)) + })) + defer srv.Close() + p := newZhipu(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 8}) + if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, ErrResponseTooLarge) { + t.Fatalf("err = %v, want ErrResponseTooLarge", err) + } + }) + + t.Run("没有 HTTP 客户端", func(t *testing.T) { + t.Parallel() + p := newZhipu(Config{APIKey: testKey, BaseURL: "https://open.bigmodel.cn"}) + if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("err = %v, want ErrNotConfigured", err) + } + }) + + t.Run("注入的 SSRF 守卫被调用", func(t *testing.T) { + t.Parallel() + called := false + blocked := errors.New("blocked by guard") + p := newZhipu(Config{ + APIKey: testKey, BaseURL: "https://open.bigmodel.cn", Client: &http.Client{}, + Guard: func(context.Context, string) error { called = true; return blocked }, + }) + if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, blocked) { + t.Fatalf("err = %v, want wrapped guard error", err) + } + if !called { + t.Fatal("注入的守卫没被调用 —— SSRF 判定成了死代码") + } + }) +} + +// TestClassifyStatus 是状态码分类的表驱动(含业务码优先于状态码)。 +func TestClassifyStatus(t *testing.T) { + t.Parallel() + cases := []struct { + status int + code string + wantErr error + }{ + {http.StatusUnauthorized, "", ErrUnauthorized}, + {http.StatusForbidden, "", ErrUnauthorized}, + {http.StatusPaymentRequired, "", ErrQuotaExceeded}, + {http.StatusTooManyRequests, "", ErrRateLimited}, + {http.StatusInternalServerError, "", ErrUpstream}, + {http.StatusBadRequest, "", ErrUpstream}, + // 业务码优先:403 也可能是额度(阿里云官方错误码表确证)。 + {http.StatusForbidden, "Retrieval.Arrears", ErrQuotaExceeded}, + {http.StatusForbidden, "retrieval.throttling.user", ErrRateLimited}, + {http.StatusForbidden, "Retrieval.NotAuthorised", ErrUnauthorized}, + } + for _, tc := range cases { + if got := classifyStatus(tc.status, tc.code); !errors.Is(got, tc.wantErr) { + t.Errorf("classifyStatus(%d,%q) = %v, want %v", tc.status, tc.code, got, tc.wantErr) + } + } +} + +// TestUpstreamCode 覆盖业务错误码的候选位置抽取。 +func TestUpstreamCode(t *testing.T) { + t.Parallel() + cases := []struct { + body string + want string + }{ + {`{"code":"Retrieval.Arrears"}`, "Retrieval.Arrears"}, + {`{"error":{"code":"abc"}}`, "abc"}, + {`{"ErrorCode":"xyz"}`, "xyz"}, + {`{"message":"no code here"}`, ""}, + {`not json`, ""}, + {``, ""}, + } + for _, tc := range cases { + if got := upstreamCode([]byte(tc.body)); got != tc.want { + t.Errorf("upstreamCode(%q) = %q, want %q", tc.body, got, tc.want) + } + } +} + +// TestUnavailable 钉住降级链的判据:**空结果不是「不可用」**(不该前进一格)。 +func TestUnavailable(t *testing.T) { + t.Parallel() + for _, err := range []error{ + ErrNotConfigured, ErrUnauthorized, ErrRateLimited, ErrQuotaExceeded, + ErrNetwork, ErrUpstream, ErrResponseTooLarge, ErrBadResponse, + } { + if !Unavailable(err) { + t.Errorf("Unavailable(%v) = false, want true(该家不可用 ⇒ 前进一格)", err) + } + } + for _, err := range []error{nil, ErrNoResults, context.Canceled, context.DeadlineExceeded} { + if Unavailable(err) { + t.Errorf("Unavailable(%v) = true, want false(不是「不可用」)", err) + } + } +} diff --git a/plugins/tools/io/search/searxng.go b/plugins/tools/io/search/searxng.go new file mode 100644 index 0000000..83585e4 --- /dev/null +++ b/plugins/tools/io/search/searxng.go @@ -0,0 +1,67 @@ +package search + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" +) + +// searxngProfile 是自建 SearXNG(A 档、零 key、**本地兜底**;design/27 §2 与分册 08)。 +// +// 协议(WS1-A 已落地,这里逐字保留其语义):GET,`{query}` 占位符优先,否则追加 +// `q=&format=json`;响应是 `{"results":[{title,url,content,snippet,score}]}`。 +// 它是「底线」不是「主力」:上游可达性决定覆盖面(分册 08 实测:mwmbl 稳定、 +// bing 静默返空、sogou 会 CAPTCHA)。 +type searxng struct{ cfg Config } + +// newSearXNG 造一家 SearXNG provider。BaseURL 为空也能造出来 —— 工具层会以 +// 「no search endpoint configured」如实报错(WS1-A 的既有行为,不在这里提前崩)。 +func newSearXNG(cfg Config) Provider { return &searxng{cfg: cfg} } + +// Name 实现 Provider。 +func (p *searxng) Name() string { return NameSearXNG } + +// Search 实现 Provider。 +func (p *searxng) Search(ctx context.Context, q Query) ([]Result, error) { + target, err := buildSearxngURL(p.cfg.BaseURL, q.Text) + if err != nil { + return nil, err + } + data, err := do(ctx, p.cfg, request{Method: http.MethodGet, URL: target}) + if err != nil { + return nil, err + } + return extractResults(data, searxngSpec()) +} + +// searxngSpec 是 SearXNG 的结果形状(分册 08:`results[].{url,title,content,score}`)。 +// 只列确证过的键;没确证的(publishedDate 之类)不写死。 +func searxngSpec() spec { + return spec{ + Arrays: []string{"results"}, + Title: []string{"title"}, + Link: []string{"url"}, + Snippet: []string{"snippet"}, + Content: []string{"content"}, + Score: []string{"score"}, + } +} + +// buildSearxngURL 用一个端点与查询词拼出请求地址(WS1-A 的原逻辑,逐字保留): +// 端点里含 `{query}` 占位符就替换,否则追加 `q=` 与 `format=json`。 +func buildSearxngURL(endpoint, query string) (string, error) { + if strings.TrimSpace(endpoint) == "" { + return "", fmt.Errorf("search: searxng: no search endpoint configured") + } + q := url.QueryEscape(query) + if strings.Contains(endpoint, "{query}") { + return strings.ReplaceAll(endpoint, "{query}", q), nil + } + sep := "?" + if strings.Contains(endpoint, "?") { + sep = "&" + } + return endpoint + sep + "q=" + q + "&format=json", nil +} diff --git a/plugins/tools/io/search/volcengine.go b/plugins/tools/io/search/volcengine.go new file mode 100644 index 0000000..02150c1 --- /dev/null +++ b/plugins/tools/io/search/volcengine.go @@ -0,0 +1,95 @@ +package search + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// volcengineProfile 是火山引擎·豆包搜索(SearchInfinity,design/27 §2.1;分册 13 官方原文确证)。 +// +// 接口形态:**POST `/search_api/web_search` + `Authorization: Bearer `** +// ⇒ 标准库即可,无需签名/SDK(不必走 IAM AK/SK 的 TOP 网关那条路)。 +// 请求字段是**大写驼峰**(Query/SearchType/Count/Filter),与其它家不同 —— 这是上游口径。 +// +// 分册 13 确证的口径:`Query` 1~100 字符且**不支持多词搜索**;账号维度默认 10 QPS; +// 每账号每月 500 次免费(自 2026-03-17 起对个人账号生效);按量 0.020 元/次。 +// +// ⚠️ 诚实边界(分册 13「未确证」):**响应字段未逐字取全**(含「如意」结构化卡) +// ⇒ 用共享提取器按多组候选键容错,不把猜测的字段当事实;拿到真实 key 后按官方文档收窄。 +type volcengine struct{ cfg Config } + +// volcenginePath 是 API Key 接入的端点路径 +// (分册 13 官方原文:POST https://open.feedcoopapi.com/search_api/web_search)。 +const volcenginePath = "/search_api/web_search" + +// volcengineSearchTypeWeb 是搜索类型枚举(分册 13 官方原文:目前支持 `web`,需开通 web搜索)。 +const volcengineSearchTypeWeb = "web" + +// volcengineDefaultCount / volcengineMaxCount 是条数口径 +// (分册 13 官方原文:Count 最多 50 条,默认 10 条)。 +const ( + volcengineDefaultCount = 10 + volcengineMaxCount = 50 +) + +// newVolcengine 造一家火山·豆包搜索 provider。 +func newVolcengine(cfg Config) Provider { return &volcengine{cfg: cfg} } + +// Name 实现 Provider。 +func (p *volcengine) Name() string { return NameVolcengine } + +// Search 实现 Provider。 +func (p *volcengine) Search(ctx context.Context, q Query) ([]Result, error) { + payload := map[string]any{ + "Query": q.Text, + "SearchType": volcengineSearchTypeWeb, + "Count": volcengineCount(q.Count), + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("search: volcengine: encode request: %w", err) + } + data, err := do(ctx, p.cfg, request{ + Method: http.MethodPost, + URL: baseURLJoin(p.cfg.BaseURL, volcenginePath), + Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey}, + Body: body, + }) + if err != nil { + return nil, err + } + return extractResults(data, volcengineSpec()) +} + +// volcengineCount 把调用方要的条数收进官方允许区间(分册 13:最多 50,默认 10)。 +func volcengineCount(n int) int { + if n <= 0 { + return volcengineDefaultCount + } + if n > volcengineMaxCount { + return volcengineMaxCount + } + return n +} + +// volcengineSpec 是火山·豆包搜索响应的**候选**形状。 +// +// TODO(未确证):分册 13 只逐字取到了请求字段;响应结构(外层 Result.WebResults 与 +// 每条结果的 Title/Url/Snippet/Summary/PublishTime/RankScore 等)**未确证**。 +// 这里按可信度从高到低列候选键做容错解析 —— 它是「尽量认得出来」,不是「已确证」; +// 拿到真实 key 后必须按官方文档复核并删掉不成立的候选。 +func volcengineSpec() spec { + return spec{ + Arrays: []string{"Result.WebResults", "WebResults", "results", "Result.Results"}, + Title: []string{"Title", "title"}, + Link: []string{"Url", "url", "Link"}, + Snippet: []string{ + "Snippet", "snippet", "Summary", "summary", + }, + Content: []string{"Content", "content", "Summary", "summary"}, + Published: []string{"PublishTime", "publishTime", "PublishTimeMs"}, + Score: []string{"RankScore", "rankScore", "Score", "score"}, + } +} diff --git a/plugins/tools/io/search/zhipu.go b/plugins/tools/io/search/zhipu.go new file mode 100644 index 0000000..de2b6c1 --- /dev/null +++ b/plugins/tools/io/search/zhipu.go @@ -0,0 +1,74 @@ +package search + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// zhipuProfile 是智谱 BigModel 联网搜索(design/27 §2.1 国内首选;分册 11)。 +// +// 认证:`Authorization: Bearer `;纯 REST(POST + JSON)⇒ 零新依赖。 +// 官方**定价已确证**(分册 11 的官方定价页逐字):Search-Std 0.01 元/次、 +// Search-Pro 0.03、Search-Pro-Sogou 0.05、Search-Pro-Quark 0.05。 +// +// ⚠️ 诚实边界(分册 11「未确证」):**完整请求/响应 JSON schema 未取到**,只确证了 +// 字段名(请求侧 search_engine/search_query/…,响应侧 title/link/content)。 +// 因此这里:① 请求只发确证过的两个字段;② 响应用共享提取器按**多组候选键**容错, +// 不把任何猜测的字段当事实。拿到真实 key 后应按官方 API Reference 复核并收窄候选键。 +type zhipu struct{ cfg Config } + +// zhipuPath 是联网搜索端点路径(分册 11 官方原文:POST https://open.bigmodel.cn/api/paas/v4/web_search)。 +const zhipuPath = "/api/paas/v4/web_search" + +// zhipuEngineStd 是默认搜索型号。取 `search_std`(自研基础搜索,官方定价页确证 +// 0.01 元/次,四档里最便宜)。⚠️ `search_engine` 是否可选官方未明说(分册 11), +// 显式写死一档以固定成本;要换档请改这里或后续开放为配置项。 +const zhipuEngineStd = "search_std" + +// newZhipu 造一家智谱 provider。 +func newZhipu(cfg Config) Provider { return &zhipu{cfg: cfg} } + +// Name 实现 Provider。 +func (p *zhipu) Name() string { return NameZhipu } + +// Search 实现 Provider。 +func (p *zhipu) Search(ctx context.Context, q Query) ([]Result, error) { + // TODO(未确证):响应可能很大(自带网页正文,分册 11 提示注意本仓 1 MiB 上限)。 + // 官方另有 `content_size` 可限正文长度,但其取值域未确证 ⇒ 不写死,留待拿到 + // 真实 key 后按官方文档补上(当前超限会如实返回 ErrResponseTooLarge)。 + payload := map[string]any{ + "search_engine": zhipuEngineStd, + "search_query": q.Text, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("search: zhipu: encode request: %w", err) + } + data, err := do(ctx, p.cfg, request{ + Method: http.MethodPost, + URL: baseURLJoin(p.cfg.BaseURL, zhipuPath), + Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey}, + Body: body, + }) + if err != nil { + return nil, err + } + return extractResults(data, zhipuSpec()) +} + +// zhipuSpec 是智谱响应的候选形状。官方示例以「工具块」给出 WebBrowserOutput{title,link,content} +// (分册 11),故 title/link/content 是确证键;外层数组键名未确证 ⇒ 给多组候选。 +func zhipuSpec() spec { + return spec{ + Arrays: []string{"search_result", "results", "data", "data.results", "web_search_result"}, + Title: []string{"title", "name"}, + Link: []string{"link", "url"}, + Snippet: []string{"snippet", "summary", "abstract"}, + Content: []string{"content", "text"}, + // 官方示例未见发布时间/评分字段 ⇒ 只给通用候选,取不到就是空。 + Published: []string{"publish_date", "published", "date"}, + Score: []string{"score", "rank"}, + } +} diff --git a/plugins/tools/io/tools.go b/plugins/tools/io/tools.go index 85878ab..dab5d67 100644 --- a/plugins/tools/io/tools.go +++ b/plugins/tools/io/tools.go @@ -10,6 +10,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/platform" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // source tags every log record emitted by this package (project rule §10). @@ -55,6 +56,29 @@ type tools struct { // searchTimeout 是 web-search 单次请求的超时覆盖(0 = 沿用本套工具的 // httpTimeout,也就是 io 包的既有超时 —— 这里刻意不另造默认值)。 searchTimeout time.Duration + // searchProviders 是组合根给的各家接线(WS1-B:provider 由配置选定); + // searchPrimary / searchFallback 是主 provider 与降级链。 + searchProviders []SearchProviderSpec + searchPrimary string + searchFallback []string + // searchSelected 报告组合根是否**显式**选过 provider:只有显式选择才做启动期 + // fail-fast 校验(io 包的默认构造路径保留 WS1-A 的历史行为:工具在、调用时报错)。 + searchSelected bool + // searchMaxResults 是 web-search 自己的条数上限(0 = 沿用 maxResults)。 + // 与 WithMaxResults 分开,是因为后者会一起改到 knowledge 工具的条数。 + searchMaxResults int +} + +// SearchProviderSpec 是组合根给 web-search 的**一家** provider 的接线(一家一行)。 +// +// Name 必须是 plugins/tools/io/search 的接入位(search.Known);Configured 由组合根 +// 用 search.Configured 判定后显式给出 —— 未配置的家不进注册表、不出现在可用清单里 +// (design/27 §1.2 的 fail-closed)。 +type SearchProviderSpec struct { + Name string + APIKey string + BaseURL string + Configured bool } // Option customises the tool set built by New. @@ -86,10 +110,14 @@ func WithAllowedHosts(hosts ...string) Option { return func(t *tools) { t.allowedHosts = append(t.allowedHosts, hosts...) } } -// WithSearchEndpoint sets the web-search endpoint (a SearXNG-compatible URL). -// WithSearchEndpoint("") clears it so web-search reports a clear error. +// WithSearchEndpoint sets the **searxng provider's** base endpoint (a SearXNG-compatible +// URL). WithSearchEndpoint("") clears it so web-search reports a clear error. // -// 语义与既往逐字不变。**注意**:置空只是「端点为空」,工具仍在注册面里; +// 语义与既往逐字不变(design/27 §4:现有 WithSearchEndpoint 保留为 searxng provider 的 +// 配置)。组合根走配置路径时用 WithSearchProviders 给出 searxng 的端点与可用性(单一 +// 事实来源);本选项保留给 io 包的既有调用方与测试。 +// +// **注意**:置空只是「端点为空」,工具仍在注册面里; // 要「模型根本看不见它」请用 WithoutSearch(组合根在端点显式关闭 / 非法时的用法)。 func WithSearchEndpoint(u string) Option { return func(t *tools) { t.searchURL = u } @@ -110,7 +138,8 @@ func WithoutSearch() Option { // define a second default for search (project rule §10: one default per knob). // // 它只约束 web-search 这一次请求:与 WithHTTPTimeout(整套工具共用的客户端 -// 超时,会同时改到 http / web-fetch)不是同一件事。 +// 超时,会同时改到 http / web-fetch)不是同一件事。WS1-B 起它按**每一次上游尝试** +// 生效(降级链里每家都各有自己的预算)。 func WithSearchTimeout(d time.Duration) Option { return func(t *tools) { if d > 0 { @@ -119,6 +148,35 @@ func WithSearchTimeout(d time.Duration) Option { } } +// WithSearchProviders 声明 web-search 可用的各家接线(WS1-B,design/27 §3)。 +// 未传时退回 WS1-A 的既有路径:只有 searxng 一家(端点来自 WithSearchEndpoint)。 +// +// 传进来的顺序即「可用清单」的展示顺序;Configured=false 的家不进注册表。 +func WithSearchProviders(specs ...SearchProviderSpec) Option { + return func(t *tools) { t.searchProviders = append(t.searchProviders, specs...) } +} + +// WithSearchSelection 选定主 provider 与降级链(primary + fallback,按序)。 +// 它同时打开**启动期 fail-fast 校验**:主 provider 未登记(如 tavily)或未配置(缺 key) +// 时 New 返回错误,绝不静默回落到兜底(design/27 §3)。 +func WithSearchSelection(primary string, fallback []string) Option { + return func(t *tools) { + t.searchPrimary = primary + t.searchFallback = append([]string(nil), fallback...) + t.searchSelected = true + } +} + +// WithSearchMaxResults overrides web-search's own result cap (WEB_SEARCH_MAX_RESULTS). +// Non-positive values are ignored (the set's shared cap applies). +func WithSearchMaxResults(n int) Option { + return func(t *tools) { + if n > 0 { + t.searchMaxResults = n + } + } +} + // WithKnowledgeRetriever wires the knowledge tool to a runtime memory retriever. // WithKnowledgeRetriever(nil) makes knowledge report "no knowledge base". func WithKnowledgeRetriever(r KnowledgeRetriever) Option { @@ -210,7 +268,7 @@ func New(cfg *config.Config, opts ...Option) ([]sdk.Tool, error) { if t.client == nil { t.client = newHTTPClient(t) } - return t.build(), nil + return t.build() } // Tools returns the tool set built from platform defaults. Production wiring @@ -238,24 +296,50 @@ func Register(reg sdk.Registrar) error { } // build assembles the tools sharing this set's client, limits and logger. -func (t *tools) build() []sdk.Tool { +func (t *tools) build() ([]sdk.Tool, error) { file := newFileTool(t) set := []sdk.Tool{ file, newCropTool(t, file), newHTTPTool(t), } - // web-search 只有在**确实有可用端点**时才进注册面:端点显式关闭或非法时由 - // 组合根传 WithoutSearch,模型便根本看不见它。顺序与既往一致(http 之后、 - // web-fetch 之前),启用态下工具表的形状逐字不变。 + // web-search 只有在**确实有可用后端**时才进注册面:端点显式关闭或选定 provider + // 未登记/未配置时由组合根传 WithoutSearch,模型便根本看不见它。顺序与既往一致 + // (http 之后、web-fetch 之前),启用态下工具表的形状逐字不变。 if !t.searchDisabled { - set = append(set, newWebSearchTool(t)) + ws, err := newWebSearchTool(t) + if err != nil { + // 启动期 fail-fast(design/27 §3):选定的 provider 有问题就不许静默回落。 + return nil, err + } + set = append(set, ws) } return append(set, newWebFetchTool(t), newKnowledgeTool(t), newDocumentReadTool(t), - ) + ), nil +} + +// searchWires 汇总要交给搜索注册表的接线(WS1-B): +// +// - 组合根给了接线表(WithSearchProviders)⇒ 用它,是否可用由 Configured 决定; +// - 没给 ⇒ 退回 WS1-A 的既有路径:只有 searxng 一家,且**始终登记**(哪怕端点是 +// 空串)—— 那是历史行为:工具在注册面里、调用时如实报「no search endpoint」。 +func (t *tools) searchWires() []search.Wire { + if len(t.searchProviders) == 0 { + return []search.Wire{{Name: search.NameSearXNG, BaseURL: t.searchURL, Configured: true}} + } + wires := make([]search.Wire, 0, len(t.searchProviders)) + for _, s := range t.searchProviders { + wires = append(wires, search.Wire{ + Name: s.Name, + APIKey: s.APIKey, + BaseURL: s.BaseURL, + Configured: s.Configured, + }) + } + return wires } // newHTTPClient builds a client with a unified timeout and an SSRF-guarded diff --git a/plugins/tools/io/web_search.go b/plugins/tools/io/web_search.go index 815401c..8e5d9c3 100644 --- a/plugins/tools/io/web_search.go +++ b/plugins/tools/io/web_search.go @@ -2,7 +2,7 @@ package io import ( "context" - "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -11,55 +11,134 @@ import ( "time" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // Compile-time assertion that webSearchTool implements sdk.Tool. var _ sdk.Tool = (*webSearchTool)(nil) -// webSearchTool queries a configured SearXNG-compatible (or custom) search -// endpoint and returns ranked results, capped in size and count. +// webSearchTool 是**一把**能力面对模型的工具(design/27 §1.1:能力 ≠ 单一后端, +// 换 provider 不改工具 schema)。它把「选谁当后端」交给注入的注册表, +// 并按 主 provider → 降级链 依次尝试(design/27 §3)。 type webSearchTool struct { - client *http.Client - endpoint string + client *http.Client + registry *search.Registry + // primary 是主 provider 名;fallback 是降级链(按序)。 + primary string + fallback []string + // allowedHosts 是**本工具自己**那份 SSRF 白名单(含配置里各 provider 的主机), + // 不是 http / web-fetch 共用的那份(见 newWebSearchTool 的说明)。 allowedHosts []string - maxBytes int64 - maxResults int - log *slog.Logger - // timeout 是本次搜索的超时覆盖(0 = 沿用共享客户端自己的超时)。它只约束 - // web-search 一次请求,不牵动 http / web-fetch 共用的那个客户端(WS1-A)。 + // endpoint 是 searxng provider 的端点(WS1-A 的 WEB_SEARCH_ENDPOINT 语义, + // 保留为字段以便既有测试与诊断继续可用)。 + endpoint string + maxBytes int64 + maxResults int + log *slog.Logger + // timeout 是**每次**上游尝试的超时预算(0 = 沿用共享客户端自己的超时)。 + // 按「每次尝试」而不是「整个调用」计,降级链才真的有意义:主家超时后, + // 备家仍有自己的预算可用。 timeout time.Duration } -func newWebSearchTool(t *tools) *webSearchTool { - // 端点来自**运维配置**,不是模型输入(模型的 query 只会进查询串,主机名恒为 - // 配置里那一个):因此把它并入本工具**自己**的 SSRF 白名单。否则本机兜底端点 - // http://127.0.0.1:38471 会被 SSRF 守卫按回环地址拒掉——那正是「端点配了却 - // 仍然用不了」的另一种形态(SearXNG 还会 308 跳到 /search,重定向那一跳同样 - // 要过守卫,所以加宽后的名单也必须交给客户端,见 tools.clientFor)。 - // - // 刻意只并入 web-search:http / web-fetch 的 URL 由模型给出,它们共用 - // t.allowedHosts,把回环塞进**那份**名单会让模型能伸手去摸本机服务。 - allowedHosts := withSearchEndpointHost(t.allowedHosts, t.searchURL) +// newWebSearchTool 构造工具:汇总接线 → 校验选定 → 建注册表 → 按各家主机加宽 +// **本工具自己**的 SSRF 白名单。 +// +// SSRF 说明(沿用 WS1-A 的口径):端点是**运维配置**、不是模型输入(模型的 query 只 +// 进查询串),所以把配置里的 provider 主机并入 web-search 自己的白名单**不会**给模型 +// 打开新的目标面——工具能去的主机恒等于配置里的那几个。http / web-fetch 共用白名单 +// 保持不变(TestWebSearch_DoesNotWidenSharedAllowlist 钉住)。 +func newWebSearchTool(t *tools) (*webSearchTool, error) { + wires := t.searchWires() + primary := strings.TrimSpace(t.searchPrimary) + if primary == "" { + primary = search.NameSearXNG + } + // 校验只在「配置路径」生效(组合根显式给了选择或接线表):io 包的既有默认构造 + // (WS1-A 之前就在的调用方与测试)刻意保留「工具在、调用时如实报错」的历史行为。 + if t.searchSelected || len(t.searchProviders) > 0 { + if err := checkSearchSelection(primary, wires); err != nil { + return nil, err + } + } + allowedHosts := t.allowedHosts + for _, w := range wires { + allowedHosts = withSearchEndpointHost(allowedHosts, w.BaseURL) + } + client := t.clientFor(allowedHosts) + maxResults := t.maxResults + if t.searchMaxResults > 0 { + maxResults = t.searchMaxResults + } return &webSearchTool{ - client: t.clientFor(allowedHosts), - endpoint: t.searchURL, + client: client, + registry: search.Build(wires, client, t.maxHTTPBytes, t.searchGuard(allowedHosts)), + primary: primary, + fallback: t.searchFallback, allowedHosts: allowedHosts, - maxBytes: t.maxHTTPBytes, - maxResults: t.maxResults, - log: t.log, - timeout: t.searchTimeout, + // endpoint 是**searxng provider 实际用的端点**(诊断/既有测试用):从接线表里 + // 取,因此无论走 WithSearchEndpoint 还是组合根的接线表,它都反映真实取值。 + endpoint: searxngEndpoint(wires), + maxBytes: t.maxHTTPBytes, + maxResults: maxResults, + log: t.log, + timeout: t.searchTimeout, + }, nil +} + +// searxngEndpoint 从接线表里取出 searxng 那一条的基地址(没有则空串)。 +func searxngEndpoint(wires []search.Wire) string { + for _, w := range wires { + if strings.EqualFold(w.Name, search.NameSearXNG) { + return w.BaseURL + } + } + return "" +} + +// checkSearchSelection 是启动期校验(design/27 §3 的语义硬约束): +// +// - 名字**不认识**(写错了 / 本期没做,如 tavily)⇒ ErrUnknownProvider; +// - 名字认识但**没配齐**(云家缺 key、searxng 缺端点)⇒ ErrNotConfigured。 +// +// 两者都让启动显式失败,**绝不静默回落到兜底** —— 否则「我配了 Tavily」就是一句假话。 +func checkSearchSelection(primary string, wires []search.Wire) error { + if !search.Known(primary) { + return fmt.Errorf("io: web-search: %w: %q (known: %s)", + search.ErrUnknownProvider, primary, strings.Join(search.KnownNames(), ", ")) + } + for _, w := range wires { + if strings.EqualFold(w.Name, primary) && w.Configured { + return nil + } + } + return fmt.Errorf("io: web-search: %w: %q (its API key / base URL is missing)", search.ErrNotConfigured, primary) +} + +// searchGuard 造**本工具自己**的 SSRF 守卫:复用 io 包既有的 validateRequestURL 与 +// 「配置里各 provider 主机」那份加宽白名单(allowedHosts),因此 +// - 端点/基地址是**运维配置**(不是模型输入)⇒ 回环的本机兜底仍然可达; +// - 模型只能改查询串,改不了主机 ⇒ 放行这几个主机不给模型开新的目标面; +// - http / web-fetch 共用的那份白名单**不受影响**(见 clientFor 的分工)。 +func (t *tools) searchGuard(allowedHosts []string) search.Guard { + return func(ctx context.Context, rawURL string) error { + if _, err := validateRequestURL(ctx, rawURL, allowedHosts); err != nil { + // 守卫的判定已在 validateRequestURL 里,这里只补上是谁拦的(保持 %w 链)。 + return fmt.Errorf("io: web-search: %w", err) + } + return nil } } // Name implements sdk.Tool. func (s *webSearchTool) Name() string { return "web-search" } -// Description implements sdk.Tool. +// Description implements sdk.Tool. 工具面对模型只有这一把,后端由运维配置决定。 func (s *webSearchTool) Description() string { - return "Search the web via a configured SearXNG or custom search endpoint and return ranked results." + return "Search the web through the configured search provider and return ranked results." } -// Parameters implements sdk.Tool. +// Parameters implements sdk.Tool. Schema 与既往逐字一致(design/27 §4:不破坏契约)。 func (s *webSearchTool) Parameters() (map[string]any, error) { return map[string]any{ "type": "object", @@ -77,91 +156,154 @@ func (s *webSearchTool) Execute(ctx context.Context, args any) (any, error) { if !ok { return nil, fmt.Errorf("io: web-search: args must be an object") } - query := argString(m, "query") - if strings.TrimSpace(query) == "" { + query := strings.TrimSpace(argString(m, "query")) + if query == "" { return nil, fmt.Errorf("io: web-search: query is required") } - if strings.TrimSpace(s.endpoint) == "" { - return nil, fmt.Errorf("io: web-search: no search endpoint configured (set a SearXNG or custom URL)") - } - // 每次调用各自的超时预算:ctx 在此派生,取消随本次调用一起结束。 - if s.timeout > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, s.timeout) - defer cancel() - } - target, err := buildSearchURL(s.endpoint, query) - if err != nil { - return nil, err - } - if _, err := validateRequestURL(ctx, target, s.allowedHosts); err != nil { - return nil, err - } - if s.client == nil { - return nil, fmt.Errorf("io: web-search: no underlying client") - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) - if err != nil { - return nil, fmt.Errorf("io: web-search: build request: %w", err) - } - resp, err := s.client.Do(req) - if err != nil { - return nil, fmt.Errorf("io: web-search: do: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("io: web-search: endpoint returned status %d", resp.StatusCode) - } - data, truncated, err := readLimited(resp.Body, s.maxBytes) - if err != nil { - return nil, fmt.Errorf("io: web-search: read: %w", err) - } - if truncated { - return nil, fmt.Errorf("io: web-search: response exceeds the %d-byte limit", s.maxBytes) - } - results, err := parseSearchResults(data) - if err != nil { - return nil, err - } n := s.maxResults if v, ok := argInt(m, "count"); ok && v > 0 { n = v } - if len(results) > n { - results = results[:n] + + raw, provider, err := s.dispatch(ctx, query, n) + if err != nil { + return nil, err + } + // 归一化之后**统一做一次**后处理:域名过滤/去重/排序/截断(design/27 §4)。 + results := search.Finalize(raw, search.Options{Limit: n}) + if len(results) == 0 { + // 空结果 ≠ 成功(design/27 §1.4):如实报「一家都没查到」,绝不冒充成功。 + // 它同样**不**触发降级链前进(那是「不可用」的处置,见 dispatch)。 + return nil, fmt.Errorf("io: web-search: %w (provider=%s)", search.ErrNoResults, provider) } return map[string]any{ - "query": query, - "count": len(results), - "results": results, - "truncated": truncated, + "query": query, + "count": len(results), + "results": toWireResults(results), + // truncated 沿用既有字段(成功路径恒为 false:超限在 provider 层就是错误)。 + "truncated": false, + // provider 是**如实标注**:降级链里谁应答就写谁,绝不拿兜底冒充主力。 + "provider": provider, }, nil } -// buildSearchURL expands an endpoint against a query. It supports a "{query}" -// placeholder, otherwise appends q= and format=json for a SearXNG JSON API. -func buildSearchURL(endpoint, query string) (string, error) { - if strings.TrimSpace(endpoint) == "" { - return "", fmt.Errorf("io: web-search: no search endpoint configured") +// dispatch 按 主 provider → 降级链 依次尝试,返回结果与**实际应答的 provider 名**。 +// +// 只有「该家不可用」才前进一格(unavailable:无 key / 鉴权失败 / 限流 / 额度 / 网络 / +// 上游错误);「空结果」与「预算耗尽」**不**前进 —— 空是空,不是不可用(design/27 §1.4/§3)。 +func (s *webSearchTool) dispatch(ctx context.Context, query string, count int) ([]search.Result, string, error) { + names := make([]string, 0, len(s.fallback)+1) + names = append(names, s.primary) + names = append(names, s.fallback...) + + attempted := make([]string, 0, len(names)) + var lastErr error + for _, name := range names { + p, ok := s.registry.Lookup(name) + if !ok { + // 未配置/未登记的家在构造期就不在注册表里,链上直接跳过(不是错误)。 + s.log.Debug("web-search: provider not available, skipped", + "provider", name, "available", s.registry.Names()) + continue + } + if err := ctx.Err(); err != nil { + // 上游预算已耗尽,再开一格也没有意义。 + lastErr = err + break + } + attempted = append(attempted, name) + res, err := s.searchWith(p, ctx, query, count) + if err == nil { + return res, name, nil + } + lastErr = err + if !searchUnavailable(err) { + // 空结果 / 上下文取消:停在原地,如实返回。 + return nil, name, fmt.Errorf("io: web-search: %w", err) + } + // 错误文案**不含密钥**(密钥只在请求头里,从不进错误/日志)。 + s.log.Warn("web-search: provider unavailable, advancing fallback chain", + "provider", name, "err", err) } - q := url.QueryEscape(query) - if strings.Contains(endpoint, "{query}") { - return strings.ReplaceAll(endpoint, "{query}", q), nil + if lastErr == nil { + lastErr = search.ErrNotConfigured } - sep := "?" - if strings.Contains(endpoint, "?") { - sep = "&" + if len(attempted) == 0 { + return nil, "", fmt.Errorf("io: web-search: %w (no provider available)", lastErr) } - return endpoint + sep + "q=" + q + "&format=json", nil + return nil, "", fmt.Errorf("io: web-search: %w (tried: %s)", lastErr, strings.Join(attempted, ", ")) } -// withSearchEndpointHost 把**配置里给定的**搜索端点主机并入 web-search 自己的 +// searchWith 调一家 provider,并按**这一次尝试**的预算加超时。 +// +// 「上游正常应答但一条都没查到」在这里就被点成 ErrNoResults(并**带上是谁答的**)—— +// 它是「空」不是「成功」,也不是「该家不可用」:dispatch 据此**停在原地**、如实报出去, +// 而不是换一家假装就有结果了(design/27 §1.4/§3)。把这次判定放在这里,是为了让 +// 「空结果不前进」这条规则只有一处,且真的被执行到。 +func (s *webSearchTool) searchWith(p search.Provider, ctx context.Context, query string, count int) ([]search.Result, error) { + callCtx := ctx + if s.timeout > 0 { + var cancel context.CancelFunc + callCtx, cancel = context.WithTimeout(ctx, s.timeout) + defer cancel() + } + res, err := p.Search(callCtx, search.Query{Text: query, Count: count}) + if err != nil { + return nil, err + } + if len(res) == 0 { + return nil, fmt.Errorf("%w (provider=%s)", search.ErrNoResults, p.Name()) + } + return res, nil +} + +// searchUnavailable 报告一个错误是否属于「该家不可用」。 +// +// 上下文取消/超时**不算**:那是本次调用的预算花光了,换一家也一样没时间, +// 应该如实报出去而不是掩盖成「前进一格」。 +func searchUnavailable(err error) bool { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + return search.Unavailable(err) +} + +// searchResult 是一条结果的**线格式**(既有字段一字不改;published/score 是本期追加的 +// 可选字段 —— design/27 §4:只许追加,不许改名)。 +type searchResult struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + Snippet string `json:"snippet"` + // Published 是发布时间(各家口径不一:ISO 字符串或毫秒时间戳)。 + Published string `json:"published,omitempty"` + // Score 是上游给的排序分(多数家不给,此时为 0 并被省略)。 + Score float64 `json:"score,omitempty"` +} + +// toWireResults 把归一化结果映射成线格式(追加 provider/score 等字段,不改既有字段名)。 +func toWireResults(results []search.Result) []searchResult { + out := make([]searchResult, 0, len(results)) + for _, r := range results { + out = append(out, searchResult{ + Title: r.Title, + URL: r.Link, + Content: r.Content, + Snippet: r.Snippet, + Published: r.Published, + Score: r.Score, + }) + } + return out +} + +// withSearchEndpointHost 把**配置里给定的**provider 主机并入 web-search 自己的 // SSRF 白名单(返回新切片,不改动入参切片——组合根可能把它交给别的工具)。 // 端点解析不出主机、或主机已在名单里时原样返回。 // // 理由见 newWebSearchTool:本机兜底端点必然是回环地址(127.0.0.1:38471),而 // SSRF 守卫默认拒绝回环;端点是运维配置而非模型输入,因此对它放行**不会**给模型 -// 打开新的目标面——工具能去的主机恒等于配置里的那一个。 +// 打开新的目标面——工具能去的主机恒等于配置里的那几个。 func withSearchEndpointHost(allowed []string, endpoint string) []string { host := searchEndpointHost(endpoint) if host == "" || isHostAllowed(host, allowed) { @@ -172,7 +314,7 @@ func withSearchEndpointHost(allowed []string, endpoint string) []string { return append(out, host) } -// searchEndpointHost 取出搜索端点的主机名(去端口、去 IPv6 方括号);解析不出来 +// searchEndpointHost 取出 provider 基地址的主机名(去端口、去 IPv6 方括号);解析不出来 // 或没有主机时返回空串。 func searchEndpointHost(endpoint string) string { u, err := url.Parse(strings.TrimSpace(endpoint)) @@ -181,28 +323,3 @@ func searchEndpointHost(endpoint string) string { } return hostOnly(u.Host) } - -// searchResult is one entry of a SearXNG-compatible results payload. -type searchResult struct { - Title string `json:"title"` - URL string `json:"url"` - Content string `json:"content"` - Snippet string `json:"snippet"` -} - -// parseSearchResults decodes a {"results":[...]} payload, falling back to the -// snippet field when content is absent. -func parseSearchResults(data []byte) ([]searchResult, error) { - var payload struct { - Results []searchResult `json:"results"` - } - if err := json.Unmarshal(data, &payload); err != nil { - return nil, fmt.Errorf("io: web-search: decode results: %w", err) - } - for i := range payload.Results { - if payload.Results[i].Content == "" { - payload.Results[i].Content = payload.Results[i].Snippet - } - } - return payload.Results, nil -} diff --git a/plugins/tools/io/web_search_provider_test.go b/plugins/tools/io/web_search_provider_test.go new file mode 100644 index 0000000..8e89a19 --- /dev/null +++ b/plugins/tools/io/web_search_provider_test.go @@ -0,0 +1,366 @@ +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) 目标必须被拦下") + } +}