Files
Cyrene_v2/plugins/tools/io/search/registry.go
T
AskaEth bc1dee3f56
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor(search): provider 配置面去 env 化 —— 加新家不再需要动 .env
动因:`.env` 已冻结(基础键不加/不改/不删),而「加一家 provider」原本会逼出
新的 env 键(一家一个 struct 字段 + searchProviderVector 逐家读 + 逐家写死的
switch)⇒ 想接腾讯云元宝/秘塔就只能破红线。面板本来就该是插件配置的一等来源
($DATA_DIR/config/plugins/web-search.json),故让**文件成为权威**。

- plugins/tools/io/search/registry.go:接入位表加 defaultBaseURL(「没配 baseUrl
  回落代码默认值」从此只有一个出处);新增 Descriptor(s)/DefaultBaseURL。
- cmd/cyrene/websearch_providers.go(新):按接入位表逐家算出生效投影 ——
  加一家时这里**零改动**,这正是本片的核心;顺手把 wire_io_tools.go 从 430 行
  降到 342(它逼近 §10 的 500 行上限)。
- cmd/cyrene/wire_plugin_actions.go / websearch_action_data.go /
  websearch_selection.go:基地址判定统一走「文件值 → 接入位默认值」的进位链;
  checkPrimaryUsable 判据不放松(真没配齐仍 400 primary_not_configured)。

🔴 **一处对我任务书的纠正(子代理提出、我采纳)**:我要求「既有 6 家在文件里
没有该条目时仍回落 .env」。仓库现状**没有**这层回落,且有回归用例把它钉死
(TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv:「文件里没有
的家不得从 .env 复活」)。本片按「不改变既有行为」实现 —— 我的要求才是错的:
文件一旦存在就是权威,env 只在文件缺失/为空时播种。

验收(主会话复跑 + 亲自变异):go build ./... + 两包 vet 绿;search 包与
cmd/cyrene 全绿;我用 -overlay 让文件里的 apiKey 被忽略 ⇒ 4 条用例变红
(含「无 env 字段的家只认文件也能进注册表」这条核心判据)。

⚠️ 新家仍未接:腾讯云元宝(tencent_wsa)/ 秘塔(metaso)两片随后串行落地。
2026-10-07 10:18:58 +08:00

207 lines
8.3 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},
}
}
// 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