Files
Cyrene_v2/plugins/tools/io/search/registry.go
T
AskaEth 69a3b73777 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),并有「未登记⇒启动报错」的防回归断言
2026-10-05 22:38:20 +08:00

160 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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