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