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)两片随后串行落地。
This commit is contained in:
1 parent
e5e5207c94
commit
bc1dee3f56
8 files changed
+825
-175
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user