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)