refactor(search): provider 配置面去 env 化 —— 加新家不再需要动 .env
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

动因:`.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)两片随后串行落地。
This commit is contained in:
AskaEth committed 2026-10-07 10:18:58 +08:00
1 parent e5e5207c94
commit bc1dee3f56
8 files changed
+825 -175

No files matched your search

+56 -9
View File
@@ -7,7 +7,8 @@ import (
"strings"
)
// Wire 是组合根给**一家** provider 的接线值(来自 env 配置)。
// Wire 是组合根给**一家** provider 的接线值(来自组合根解析出的生效配置:面板文件权威、
// 文件缺失/为空时由 `.env` 播种 —— 与 `.env` 的字段表无关)。
//
// Configured 由组合根**显式**判定后给出(判定口径见 Configured 函数):false ⇒
// 这家不进注册表、不出现在可用清单里(design/27 §1.2 的 fail-closed)。刻意不用
@@ -56,12 +57,19 @@ func (r *Registry) Names() []string {
return out
}
// definition 是一家 provider 的**接入位**:名字 + 是否需要 key + 构造函数。
// definition 是一家 provider 的**接入位**:名字 + 是否需要 key + 默认基地址 + 构造函数。
// 新增一家 = 新建一个文件(design/27 §4 一 provider 一文件)并在此表加一行。
type definition struct {
name string
needKey bool
new func(Config) Provider
// defaultBaseURL 是这家**没给基地址时**回落的代码默认地址(空 = 没有默认值)。
//
// 它是「这家没配 baseUrl ⇒ 回落代码默认值」这件事的**唯一出处**:组合根不再把同一个
// 默认值在 `.env` 的 `*_BASE_URL` 标签里另写一份(两处各写一份,迟早会漂移成两个
// 「默认地址」)。`.env` 的那几个键仍然存在(冻结的基础键,见 AGENTS §10),但它们的
// 角色只剩**播种层**;本表的值与它们**逐字一致**,由 registry_test.go 钉住。
defaultBaseURL string
new func(Config) Provider
}
// definitions 返回本期内置的全部接入位(**含未配置的家**)。顺序即「可用清单」的展示顺序。
@@ -69,15 +77,54 @@ type definition struct {
// 它每次返回新建的切片(不是包级变量),保持包内无全局状态。
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},
// 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))
+63
View File
@@ -103,6 +103,69 @@ func TestRegistry_EmptyAndNilSafe(t *testing.T) {
}
}
// TestRegistry_DefaultBaseURLMatchesEnvDefaults 钉住接入位表里的**默认基地址**逐字等于
// `.env` 的标签默认值(`internal/config/websearch.go` 的 `*BaseURL` 那一组,`.env` 冻结的
// 基础键)。
//
// 为什么值得一条逐字表:接入位表是本片新增的「这家没配 baseUrl ⇒ 回落哪个地址」的
// **唯一出处**,而 `.env` 侧那几个键仍然存在、仍然参与播种。两处一旦漂移,同一家 provider
// 会因为「值从哪来」指向两个不同的上游 —— 只有逐字对得上的表拦得住这种不一致。
func TestRegistry_DefaultBaseURLMatchesEnvDefaults(t *testing.T) {
t.Parallel()
want := map[string]string{
NameZhipu: "https://open.bigmodel.cn",
NameAliyunIQS: "https://cloud-iqs.aliyuncs.com",
NameBocha: "https://api.bocha.cn",
NameVolcengine: "https://open.feedcoopapi.com",
NameBaiduQianfan: "https://qianfan.baidubce.com",
}
for name, base := range want {
if got := DefaultBaseURL(name); got != base {
t.Errorf("DefaultBaseURL(%q) = %q, want %q(必须与 .env 的标签默认值逐字一致)", name, got, base)
}
}
// searxng 没有默认基地址:它的地址为空本身就是「关掉这家」(组合根按 ResolveEndpoint
// 裁决整把工具的存亡),回落一个本机地址会让「清空地址」变成假话。
if got := DefaultBaseURL(NameSearXNG); got != "" {
t.Errorf("DefaultBaseURL(%q) = %q, want 空串", NameSearXNG, got)
}
// 未登记的接入位:不猜地址。
if got := DefaultBaseURL("tavily"); got != "" {
t.Errorf("未登记的接入位不该有默认地址, got %q", got)
}
}
// TestRegistry_DescriptorsMatchDefinitions 钉住「只读描述」与接入位表同源:
// 组合根按它逐家投影配置,两处一旦不同步,加一家 provider 就会「漏在注册面外」。
func TestRegistry_DescriptorsMatchDefinitions(t *testing.T) {
t.Parallel()
desc := Descriptors()
if len(desc) != len(KnownNames()) {
t.Fatalf("Descriptors() 有 %d 家,KnownNames() 有 %d 家(两者必须同一张表)",
len(desc), len(KnownNames()))
}
byName := make(map[string]Descriptor, len(desc))
for _, d := range desc {
if _, dup := byName[d.Name]; dup {
t.Fatalf("Descriptors() 里 %q 出现了两次", d.Name)
}
byName[d.Name] = d
}
for _, name := range KnownNames() {
d, ok := byName[name]
if !ok {
t.Fatalf("KnownNames() 里的 %q 不在 Descriptors() 里", name)
}
if d.NeedKey != (name != NameSearXNG) {
t.Errorf("Descriptor(%q).NeedKey = %v(只有 searxng 不需要 key)", name, d.NeedKey)
}
if d.DefaultBaseURL != DefaultBaseURL(name) {
t.Errorf("Descriptor(%q).DefaultBaseURL = %q, DefaultBaseURL(%q) = %q(必须同源)",
name, d.DefaultBaseURL, name, DefaultBaseURL(name))
}
}
}
// TestProviders_ErrorClassification 是验收 ⑤:鉴权 / 限流 / 额度 / 上游 各一条。
// 用同一家 provider + 不同应答,确认分类**如实区分**(错误分类决定降级链是否前进)。
func TestProviders_ErrorClassification(t *testing.T) {