动因:`.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)两片随后串行落地。
207 lines
8.3 KiB
Go
207 lines
8.3 KiB
Go
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
|