Files
Cyrene_v2/plugins/tools/io/search/registry.go
T
AskaEth ce9af42945
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 新增腾讯云·元宝搜索与秘塔搜索两家 provider
接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。

- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
  两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
  Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
  (UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
  RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
  实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
  .env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
  计数不变(14 + 25 + 40 = 79)。
2026-10-07 13:42:41 +08:00

212 lines
8.8 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` 播种 —— 与 `.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
// defaultBaseURL 是这家**没给基地址时**回落的代码默认地址(空 = 没有默认值)。
//
// 它是「这家没配 baseUrl ⇒ 回落代码默认值」这件事的**唯一出处**:组合根不再把同一个
// 默认值在 `.env` 的 `*_BASE_URL` 标签里另写一份(两处各写一份,迟早会漂移成两个
// 「默认地址」)。`.env` 的那几个键仍然存在(冻结的基础键,见 AGENTS §10),但它们的
// 角色只剩**播种层**;本表的值与它们**逐字一致**,由 registry_test.go 钉住。
defaultBaseURL string
new func(Config) Provider
}
// definitions 返回本期内置的全部接入位(**含未配置的家**)。顺序即「可用清单」的展示顺序。
//
// 它每次返回新建的切片(不是包级变量),保持包内无全局状态。
func definitions() []definition {
return []definition{
// searxng 没有默认基地址:它的地址为空本身就是「关掉这家」(组合根按
// ResolveEndpoint 裁决整把工具的存亡),回落一个本机地址会让「清空地址」变成假话。
{name: NameSearXNG, needKey: false, defaultBaseURL: "", new: newSearXNG},
{name: NameZhipu, needKey: true, defaultBaseURL: "https://open.bigmodel.cn", new: newZhipu},
{name: NameAliyunIQS, needKey: true, defaultBaseURL: "https://cloud-iqs.aliyuncs.com", new: newAliyunIQS},
{name: NameBocha, needKey: true, defaultBaseURL: "https://api.bocha.cn", new: newBocha},
{name: NameVolcengine, needKey: true, defaultBaseURL: "https://open.feedcoopapi.com", new: newVolcengine},
{name: NameBaiduQianfan, needKey: true, defaultBaseURL: "https://qianfan.baidubce.com", new: newBaiduQianfan},
// 下面两家**没有对应的 `.env` 字段**(`.env` 已冻结):它们的 key / 基地址只从
// `$DATA_DIR/config/plugins/web-search.json` 取(组合根按本表逐家投影,故组合根零改动)。
// 默认基地址取官方文档给出的服务域名。
{name: NameTencentWSA, needKey: true, defaultBaseURL: "https://api.wsa.cloud.tencent.com", new: newTencentWSA},
{name: NameMetaso, needKey: true, defaultBaseURL: "https://metaso.cn", new: newMetaso},
}
}
// Descriptor 是一个接入位的**只读描述**:名字 + 是否需要 key + 默认基地址。
//
// 它给组合根用:组合根按名字**通用地**把面板配置投影成各家的接线,因此不需要认识任何一家
// provider 的名字,也不需要 `.env` 里有对应的字段(那张字段表每加一家就要一个新 env 键)。
// 「新增一家 provider」在组合根侧于是变成**零改动**。
type Descriptor struct {
// Name 是接入位名(与配置里的名字同一口径:小写 + 去首尾空白后比较)。
Name string
// NeedKey 报告这家是否必须给凭据(searxng 无 key、改看基地址)。
NeedKey bool
// DefaultBaseURL 是这家没给基地址时回落的代码默认值(空 = 没有默认值)。
DefaultBaseURL string
}
// Descriptors 返回本期全部接入位的只读描述(**含未配置的家**),顺序即展示顺序。
//
// 它每次返回新建的切片(不是包级变量),与 definitions 同一约束。
func Descriptors() []Descriptor {
defs := definitions()
out := make([]Descriptor, 0, len(defs))
for _, d := range defs {
out = append(out, Descriptor{Name: d.name, NeedKey: d.needKey, DefaultBaseURL: d.defaultBaseURL})
}
return out
}
// DefaultBaseURL 取某家接入位的**代码默认基地址**(未登记的名字 ⇒ 空串)。
//
// 与 `Known` 同样是「按名字查接入位表」的读取口:查不到就是没有,绝不猜一个地址出来。
func DefaultBaseURL(name string) string {
def, ok := lookupDef(name)
if !ok {
return ""
}
return def.defaultBaseURL
}
// 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