feat(search): 原生 provider 层(searxng/智谱/阿里IQS/博查/火山/百度)+ 降级链(契约无改动)

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

No files matched your search

+107 -22
View File
@@ -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_<NAME>_*`(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
}
@@ -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 <key>(密钥必须真的被用上)", 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)
}
+12 -4
View File
@@ -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)
+78 -7
View File
@@ -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_<NAME>_API_KEY` 给凭据、`WEB_SEARCH_<NAME>_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)
+128
View File
@@ -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_<NAME>_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)
}
})
}
}
+14 -8
View File
@@ -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
+58
View File
@@ -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=<q>)。
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"},
}
}
+98
View File
@@ -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 <API_KEY>`**
// ⇒ 标准库即可。
//
// 分册 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"},
}
}
+89
View File
@@ -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 候选。
}
}
+16
View File
@@ -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
+125
View File
@@ -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 ""
}
+237
View File
@@ -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()
}
+137
View File
@@ -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)
}
}
+99
View File
@@ -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
}
+77
View File
@@ -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
}
+380
View File
@@ -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)
}
}
}
+159
View File
@@ -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
+256
View File
@@ -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)
}
}
}
+67
View File
@@ -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=<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
}
+95
View File
@@ -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 <API_KEY>`**
// ⇒ 标准库即可,无需签名/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"},
}
}
+74
View File
@@ -0,0 +1,74 @@
package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// zhipuProfile 是智谱 BigModel 联网搜索(design/27 §2.1 国内首选;分册 11)。
//
// 认证:`Authorization: Bearer <api_key>`;纯 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"},
}
}
+95 -11
View File
@@ -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
+236 -119
View File
@@ -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
}
@@ -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) 目标必须被拦下")
}
}