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

+54 -132
View File
@@ -9,7 +9,7 @@ package main
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
// ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。
//
// 五条口径:
// 六条口径:
//
// 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链
// 怎么走,各家的 key 与基地址从哪来(design/27 §3)。
@@ -20,10 +20,14 @@ package main
// 3. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
// 模型根本看不见它,而不是看得见、一调就报错。
// 4. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
// 4. **各家的名字表来自插件,不来自 `.env`**:文件存在时它是**唯一权威**(文件里没有的家
// 就是没有),逐家的名字 / 是否需要 key / 默认基地址全取 `plugins/tools/io/search` 的
// 接入位表 ⇒ **加一家 provider 不必加 env 键**(`.env` 冻结红线)。实现在
// `websearch_providers.go`。
// 5. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
// **启动期报错**,绝不静默回落到兜底。
// 5. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
// 6. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
// 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。
//
// 同一套解析也服务**热重载**(切片 W3,裁决 2026-10-06 #1):面板保存后由
@@ -133,7 +137,8 @@ func (a *app) buildWebSearchTool(cause string) (sdk.Tool, error) {
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
func (a *app) webSearchOptions(cause string) []iotools.Option {
log := logger.WithSource(a.log, ioLogSource)
ws, source := a.effectiveWebSearch(log)
eff, source := a.effectiveWebSearch(log)
ws := eff.Scalar
// ① 后端显式关闭(主 provider = 空 / off / none)⇒ 整件工具不注册。
if decision := ws.ResolveProvider(); decision.Disabled {
@@ -145,7 +150,7 @@ func (a *app) webSearchOptions(cause string) []iotools.Option {
return []iotools.Option{iotools.WithoutSearch()}
}
specs := searchProviderSpecs(ws)
specs := searchProviderSpecs(ws, eff.Providers)
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
@@ -191,7 +196,7 @@ func (a *app) webSearchOptions(cause string) []iotools.Option {
// - 文件存在但非法 ⇒ 记 WARN、本次回落 `.env`,**不覆盖**原文件。
//
// 返回值里的第二个值是来源标签,只用于日志(`.env` 还是面板),不参与任何裁决。
func (a *app) effectiveWebSearch(log *slog.Logger) (config.WebSearchConfig, string) {
func (a *app) effectiveWebSearch(log *slog.Logger) (effectiveWebSearchConfig, string) {
env := a.cfg.WebSearch
path := runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg))
f, found, err := runtimeconfig.LoadWebSearch(path)
@@ -199,21 +204,30 @@ func (a *app) effectiveWebSearch(log *slog.Logger) (config.WebSearchConfig, stri
case err != nil:
log.Warn("web-search 面板配置非法,本次按 .env 运行(不覆盖原文件)",
"path", path, "error", err)
return env, webSearchSourceEnv
return envEffectiveWebSearch(env), webSearchSourceEnv
case !found:
return a.seedWebSearchConfig(log, path, env), webSearchSourceEnv
return envEffectiveWebSearch(a.seedWebSearchConfig(log, path, env)), webSearchSourceEnv
}
warnUnknownSearchProviders(log, path, f)
eff, convErr := effectiveSearchConfig(env, f)
if convErr != nil {
log.Warn("web-search 面板配置无法生效,本次按 .env 运行",
"path", path, "error", convErr)
return env, webSearchSourceEnv
return envEffectiveWebSearch(env), webSearchSourceEnv
}
log.Info("web-search 面板配置生效", "path", path, "providers", len(f.Providers))
return eff, webSearchSourcePanel
}
// envEffectiveWebSearch 是「**没有可用文件**」时的生效配置:逐字沿用 `.env` 播种层的语义
// (= `seedWebSearchFile` 会落盘的那份形状)。
//
// 它是 `.env` 唯一还能决定各家接线的时刻:文件一旦存在,权威就在文件那边(design/09 §5.7 E
// 的「先读可变源、缺失回落 `.env` 播种」)。
func envEffectiveWebSearch(env config.WebSearchConfig) effectiveWebSearchConfig {
return effectiveWebSearchConfig{Scalar: env, Providers: searchProviderVector(env)}
}
// seedWebSearchConfig 把 `.env` 播种成面板文件(`.env` 只作引导面)。
//
// 播种失败**不阻断启动**:本次按 `.env` 运行,只记一条 WARN(与 models.json 的处置一致)。
@@ -232,15 +246,14 @@ func (a *app) seedWebSearchConfig(log *slog.Logger, path string, env config.WebS
//
// **本期内置的家全列**(含还没配 key 的):面板的用途就是「一次把各家配齐」,
// 让用户先看到完整清单、再逐条填,比只列已配置的家少一轮「名字得自己知道」。
//
// 「全列」的名单来自 `.env` 的字段表(`envSearchProviders`)—— 那是**播种层**的输入,
// 因此这里的条数与被播种的键集逐字保持现状(`.env` 冻结:不加、不改、不删)。
func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile {
enabled := runtimeconfig.EnableFlag(true)
entries := make([]runtimeconfig.ProviderEntry, 0, len(searchProviderVector(ws))+1)
// searxng 的基地址 = `WEB_SEARCH_ENDPOINT`。关闭哨兵(空 / off / none)播成空串:
// 两者语义相同(都表示不出这家),但面板里不该显示一个不是地址的 "off"。
entries = append(entries, runtimeconfig.ProviderEntry{
Provider: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint), Enabled: enabled,
})
for _, p := range searchProviderVector(ws) {
providers := envSearchProviders(ws)
entries := make([]runtimeconfig.ProviderEntry, 0, len(providers))
for _, p := range providers {
entries = append(entries, runtimeconfig.ProviderEntry{
Provider: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, Enabled: enabled,
})
@@ -255,19 +268,19 @@ func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile {
}
}
// effectiveSearchConfig 把面板文件翻译成工具要的配置。
// effectiveSearchConfig 把面板文件翻译成工具要的生效配置。
//
// **权威边界**(写清楚,免得「谁覆盖谁」靠猜):
//
// - **结构以文件为准**:主 provider / 降级链 / 各家条目都来自文件 —— 文件里没有的家就是
// 没有(绝不从 `.env` 复活),这正是「面板可改」的意思;
// - **凭据以文件为准**:某家的 apiKey 为空 = 这家没配(绝不拿 `.env` 的 key 顶上,
// 否则「我在面板里删了它」会变成一句假话);
// - **只有「没写」才回落 `.env`(= 代码默认值)**:maxResults / timeout / 云家的 baseUrl。
// 前两个标量在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分;
// 云家 baseUrl 回落到厂商默认地址(它是代码默认值,不是凭据),searxng 的地址**不回落**
// —— 在 searxng 身上「地址为空」就是「关掉这家」的开关语义,回落会让"清空地址"变成假话。
func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (config.WebSearchConfig, error) {
// - **标量以文件为准**:主 provider / 降级链来自文件(文件里写空 / off / none = 显式关闭,
// 与 `.env` 同一套哨兵语义);maxResults / timeout 在「没写」时沿用 `.env`(那两个标量
// 在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分)。
// - **各家以文件为准**:文件里没有的家就是没有(绝不从 `.env` 复活,否则「我在面板里
// 删了它」会变成一句假话),凭据为空 = 这家没配。逐家的名字表**不再由 `.env` 的字段表
// 决定**,而是来自接入位表 —— 见 effectiveSearchProviders / resolveSearchProviders。
// - **searxng 的端点是开关**:文件里那条的 baseUrl 为空 = 关掉这家,**不回落** `.env`
// (回落会让「清空地址」变成假话)。
func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (effectiveWebSearchConfig, error) {
out := env
out.Provider = f.Provider
out.Fallback = append([]string(nil), f.Fallback...)
@@ -276,45 +289,24 @@ func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearch
}
d, err := f.TimeoutDuration()
if err != nil {
return env, err
return effectiveWebSearchConfig{}, err
}
if d > 0 {
out.Timeout = d
}
// 凭据一律清零后按条目回填:文件没给的家就是没配。
out.ZhipuAPIKey = ""
out.AliyunIQSAPIKey = ""
out.BochaAPIKey = ""
out.VolcengineAPIKey = ""
out.BaiduQianfanAPIKey = ""
// 逐家字段一律清零:它们承载的是 `.env` 的**播种**形状,而生效值现在由 Providers 承载
// (见 effectiveWebSearchConfig 的注释)。清零也顺带保证了任何误用都不会把 `.env` 的
// 旧凭据当成「生效凭据」。
out.ZhipuAPIKey, out.ZhipuBaseURL = "", ""
out.AliyunIQSAPIKey, out.AliyunIQSBaseURL = "", ""
out.BochaAPIKey, out.BochaBaseURL = "", ""
out.VolcengineAPIKey, out.VolcengineBaseURL = "", ""
out.BaiduQianfanAPIKey, out.BaiduQianfanBaseURL = "", ""
out.Endpoint = ""
for _, e := range f.Providers {
if !e.IsEnabled() {
continue
}
name := strings.ToLower(strings.TrimSpace(e.Provider))
base := strings.TrimSpace(e.BaseURL)
switch name {
case search.NameSearXNG:
out.Endpoint = base
case search.NameZhipu:
out.ZhipuAPIKey = e.APIKey
out.ZhipuBaseURL = orDefault(base, out.ZhipuBaseURL)
case search.NameAliyunIQS:
out.AliyunIQSAPIKey = e.APIKey
out.AliyunIQSBaseURL = orDefault(base, out.AliyunIQSBaseURL)
case search.NameBocha:
out.BochaAPIKey = e.APIKey
out.BochaBaseURL = orDefault(base, out.BochaBaseURL)
case search.NameVolcengine:
out.VolcengineAPIKey = e.APIKey
out.VolcengineBaseURL = orDefault(base, out.VolcengineBaseURL)
case search.NameBaiduQianfan:
out.BaiduQianfanAPIKey = e.APIKey
out.BaiduQianfanBaseURL = orDefault(base, out.BaiduQianfanBaseURL)
}
if e, ok := findProviderEntry(f, search.NameSearXNG); ok && e.IsEnabled() {
out.Endpoint = strings.TrimSpace(e.BaseURL)
}
return out, nil
return effectiveWebSearchConfig{Scalar: out, Providers: effectiveSearchProviders(f)}, nil
}
// warnUnknownSearchProviders 对文件里本期没登记的家记一条 WARN。
@@ -337,70 +329,9 @@ func selectsSearXNG(ws config.WebSearchConfig) bool {
return strings.EqualFold(strings.TrimSpace(ws.Provider), search.NameSearXNG)
}
// searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。
// searxng 不在这张表里(它的「基地址」就是 `WEB_SEARCH_ENDPOINT`,由 ResolveEndpoint 裁决)。
type searchProviderWire struct {
Name string
APIKey string
BaseURL string
}
// searchProviderVector 是本期内置各家的配置投影(**一家一行**)。
//
// 它同时服务两处:装配接线表(searchProviderSpecs)与 `.env` 播种(seedWebSearchFile)——
// 一家一处,新增一家 = 这里加一行 + search 包加一个文件。
func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire {
return []searchProviderWire{
{Name: search.NameZhipu, APIKey: ws.ZhipuAPIKey, BaseURL: ws.ZhipuBaseURL},
{Name: search.NameAliyunIQS, APIKey: ws.AliyunIQSAPIKey, BaseURL: ws.AliyunIQSBaseURL},
{Name: search.NameBocha, APIKey: ws.BochaAPIKey, BaseURL: ws.BochaBaseURL},
{Name: search.NameVolcengine, APIKey: ws.VolcengineAPIKey, BaseURL: ws.VolcengineBaseURL},
{Name: search.NameBaiduQianfan, APIKey: ws.BaiduQianfanAPIKey, BaseURL: ws.BaiduQianfanBaseURL},
}
}
// searchProviderSpecs 把生效配置翻译成各家接线。
//
// Configured 的口径由 search.Configured 统一判定:searxng 看端点、云家看 API key;
// false ⇒ 该家不进注册表、不出现在可用清单里。**密钥只在这里流转,绝不写日志。**
func searchProviderSpecs(ws config.WebSearchConfig) []iotools.SearchProviderSpec {
endpoint := ws.ResolveEndpoint()
specs := []iotools.SearchProviderSpec{{
Name: search.NameSearXNG,
BaseURL: endpoint.URL,
Configured: endpoint.Enabled,
}}
// 各家独立一组键(design/27 §3):新增一家 = searchProviderVector 加一行 + 一个文件。
for _, p := range searchProviderVector(ws) {
specs = append(specs, iotools.SearchProviderSpec{
Name: p.Name,
APIKey: p.APIKey,
BaseURL: p.BaseURL,
Configured: search.Configured(p.Name, p.APIKey, p.BaseURL),
})
}
return specs
}
// availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。
// **不含任何密钥** —— 只回名字。
func availableProviderNames(specs []iotools.SearchProviderSpec) []string {
out := make([]string, 0, len(specs))
for _, s := range specs {
if s.Configured {
out = append(out, s.Name)
}
}
return out
}
// seedEndpoint 把 `WEB_SEARCH_ENDPOINT` 的取值播进 searxng 条目:显式关闭的哨兵落成空串。
func seedEndpoint(endpoint string) string {
if config.IsWebSearchOffSentinel(endpoint) {
return ""
}
return strings.TrimSpace(endpoint)
}
// 各家的配置投影(searchProviderWire / searchProviderVector / searchProviderSpecs /
// availableProviderNames)在 `websearch_providers.go` —— 那一片是本片「去 env 化」的本体
// (按**接入位表**逐家投影,而不是按 `.env` 的字段表)。
// seedTimeout 把超时播成 duration 字符串:未声明 / 笔误(≤0)落成空 = 未声明。
func seedTimeout(d time.Duration) string {
@@ -409,12 +340,3 @@ func seedTimeout(d time.Duration) string {
}
return d.String()
}
// orDefault 在 value 去空白后非空时用它,否则用 fallback(云家 baseUrl 的「没写就回落
// 代码默认值」口径,见 effectiveSearchConfig 的权威边界)。
func orDefault(value, fallback string) string {
if s := strings.TrimSpace(value); s != "" {
return s
}
return fallback
}