主人定调「先把原生的做进插件,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),并有「未登记⇒启动报错」的防回归断言
160 lines
5.6 KiB
Go
160 lines
5.6 KiB
Go
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
|