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

+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