Files
Cyrene_v2/cmd/cyrene/wire_io_tools.go
T
AskaEth bc1dee3f56
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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)两片随后串行落地。
2026-10-07 10:18:58 +08:00

343 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志、
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源),
// 以及 design/28 §8 B5 的**浏览器降级档挂载**(`bodyFallbackOption()`,实现与口径在
// `wire_body_fallback.go` —— 挂载条件 = 能力面里确实有可用浏览器,详见那个文件)。
//
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
// ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。
//
// 六条口径:
//
// 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链
// 怎么走,各家的 key 与基地址从哪来(design/27 §3)。
// 2. **配置有可变源**(design/09 §5.7 E):先读 `$DATA_DIR/config/plugins/web-search.json`
// —— 面板可改、0600 + temp+rename;文件**缺失/为空**时由 `.env` 播种(`.env` 从此只是
// 「最小化功能可跑」的引导面,本片**不新增任何 env 键**);文件**非法**时记 WARN 回落
// `.env`,且**不覆盖**原文件(损坏的配置可以人工救回)。
// 3. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
// 模型根本看不见它,而不是看得见、一调就报错。
// 4. **各家的名字表来自插件,不来自 `.env`**:文件存在时它是**唯一权威**(文件里没有的家
// 就是没有),逐家的名字 / 是否需要 key / 默认基地址全取 `plugins/tools/io/search` 的
// 接入位表 ⇒ **加一家 provider 不必加 env 键**(`.env` 冻结红线)。实现在
// `websearch_providers.go`。
// 5. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
// **启动期报错**,绝不静默回落到兜底。
// 6. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
// 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。
//
// 同一套解析也服务**热重载**(切片 W3,裁决 2026-10-06 #1):面板保存后由
// `buildWebSearchTool` 按**当前落盘配置**重建这一把工具并替换注册表条目,因此
// 「启动期的接线」与「保存后的接线」恒为同一条路径(不是两份语义)。两份调用的差别
// 只有日志里的 cause 字段(startup / panel-save),便于排障时区分。
import (
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// web-search 的 env 键名:与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
// 由 wire_web_search_test.go 逐字核对。它们仍然是**引导面**的键(.env 冻结后不再新增)。
const (
webSearchProviderEnv = "WEB_SEARCH_PROVIDER"
webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT"
webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT"
webSearchMaxResultsEnv = "WEB_SEARCH_MAX_RESULTS"
webSearchFallbackEnv = "WEB_SEARCH_FALLBACK"
)
// ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
const ioLogSource = "cmd/cyrene/wire_io_tools"
// 生效配置的来源标签(写进启动日志:一眼看出「这次的 provider 是 .env 给的还是面板给的」)。
const (
// webSearchSourceEnv 表示本次的配置来自 `.env`(含「文件缺失、刚由 .env 播种」这一步)。
webSearchSourceEnv = "env"
// webSearchSourcePanel 表示本次的配置来自面板可变源(面板是唯一来源)。
webSearchSourcePanel = "panel"
)
// 解析一次 web-search 配置的**触发场景**(只进日志的 cause 字段)。
const (
// webSearchCauseStartup 是启动期装配(`ioToolOptions`)。
webSearchCauseStartup = "startup"
// webSearchCausePanelSave 是面板保存后的热重载(`buildWebSearchTool`,见 websearch_reload.go)。
webSearchCausePanelSave = "panel-save"
)
// webSearchToolName 是 web-search 工具在能力注册表里的名字。
//
// 它与 io 包 `func (s *webSearchTool) Name()` 里的字面量同源,而那个名字没有导出常量
// (`plugins/tools/io` 不在本片文件面内,不去改它):因此这里用字面量,并由
// websearch_reload_test.go 钉住 —— 名字一旦漂移,热重载会立刻找不到工具(测试红)。
const webSearchToolName = "web-search"
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
// 因此「注册面里有哪些 io 工具」只由这一处决定。
//
// 它**只做 io 选项**(切片 X3 的收口):browser 的接线挂点原先是借这里转交的
// (V5 当时改不了 wire_runtime.go),现已归位到 `wireRuntime` 里与 `wireVision` /
// `wireTranscode` 并列的那一行(见 wire_runtime.go)。转交留下的第二个代价是副作用:
// 凡是只为了要一份 iotools.Option 而调用本函数的路径(例如 web-search 的接线测试)
// 都会顺带探测并注册浏览器工具 —— 那不该是「取选项」这件事的含义。
func (a *app) ioToolOptions() []iotools.Option {
opts := []iotools.Option{
iotools.WithKnowledgeRetriever(a.retriever),
iotools.WithLogger(a.log),
// B5 的挂载点:有可用浏览器就挂上浏览器渲染取正文,没有就不挂(口径与实现
// 都在 wire_body_fallback.go)。挂载条件必须在**浏览器装配之后**求值 ——
// 这就是 wireRuntime 里 `a.wireBrowser()` 排在 io 工具集之前的原因。
a.bodyFallbackOption(),
}
return append(opts, a.webSearchOptions(webSearchCauseStartup)...)
}
// buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它
// 重建工具,见 websearch_reload.go)。
//
// 三种返回值,调用方必须分开处置:
// - (tool, nil) ⇒ 正常,工具该在注册面里;
// - (nil, nil) ⇒ 当前配置下它**不该在注册面里**(端点/主 provider 显式关闭,或选定
// provider 没配齐被 WithoutSearch 摘掉)—— 调用方据此把旧条目摘掉;
// - (nil, err) ⇒ 这份配置会让后端**下次启动直接失败**(主 provider 未登记/未配置,
// design/27 §3 的 fail-fast)—— 调用方必须如实报错,绝不留下一个起不来的文件。
//
// 为什么要构造整份 io 工具集再挑出 web-search:io 包的公开构造入口只有
// `iotools.New(cfg, opts...)`(整份集合),没有「只造 web-search」的入口,而
// `plugins/tools/io` 不在本片文件面内。代价是一次 New(几个工具对象 + 一个 http.Client
// 壳;Transport 是共享的 http.DefaultTransport,不新开连接池)—— 面板保存是低频动作,
// 付得起,换来的是**重载后的工具与启动期构造的逐字段同源**(不是第二份接线逻辑)。
func (a *app) buildWebSearchTool(cause string) (sdk.Tool, error) {
set, err := iotools.New(a.cfg, a.webSearchOptions(cause)...)
if err != nil {
return nil, err
}
for _, t := range set {
if t.Name() == webSearchToolName {
return t, nil
}
}
return nil, nil
}
// webSearchOptions 把生效配置翻译成 io 选项。cause 说明**为什么**解析这一次(启动装配 /
// 面板保存后的热重载),只进日志:同一个函数被两处调用,日志里必须能分清是哪一次。
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
func (a *app) webSearchOptions(cause string) []iotools.Option {
log := logger.WithSource(a.log, ioLogSource)
eff, source := a.effectiveWebSearch(log)
ws := eff.Scalar
// ① 后端显式关闭(主 provider = 空 / off / none)⇒ 整件工具不注册。
if decision := ws.ResolveProvider(); decision.Disabled {
// 配置值**绝不写进日志**(只记键名与原因)。
log.Warn("web-search tool not registered",
"selectorKey", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "configSource", source,
"cause", cause,
"effect", "the model will not see a web-search tool at all")
return []iotools.Option{iotools.WithoutSearch()}
}
specs := searchProviderSpecs(ws, eff.Providers)
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
// 错误,因此不 fatal,只留一条带键名的 WARN。
if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && selectsSearXNG(ws) {
log.Warn("web-search tool not registered",
"endpointKey", webSearchEndpointEnv, "reason", endpoint.Reason, "configSource", source,
"cause", cause,
"effect", "the model will not see a web-search tool at all")
return []iotools.Option{iotools.WithoutSearch()}
}
// primary 是**生效值**(可能来自面板文件、也可能来自 `.env` 播种)⇒ 它旁边的键名只是
// 「这个键在哪份配置里」,而 source 才是「本次的值从哪来」。两者都打出来,不混为一谈。
log.Info("web-search providers configured", "cause", cause,
"selectorKey", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)),
"fallbackKey", webSearchFallbackEnv, "fallback", ws.Fallback, "configSource", source,
"available", availableProviderNames(specs))
opts := []iotools.Option{
iotools.WithSearchProviders(specs...),
// 选定主 provider + 降级链:未登记(如本期未做的 tavily)或未配置(缺 key)
// ⇒ io 工具集在启动期报错(不许静默回落)。
iotools.WithSearchSelection(ws.Provider, ws.Fallback),
iotools.WithSearchMaxResults(ws.MaxResults),
}
switch {
case ws.Timeout > 0:
opts = append(opts, iotools.WithSearchTimeout(ws.Timeout))
case ws.Timeout < 0:
log.Warn("web-search timeout is negative, treated as unset",
"key", webSearchTimeoutEnv, "configSource", source, "cause", cause,
"effect", "io package default timeout applies")
}
return opts
}
// effectiveWebSearch 解析 web-search 的**生效配置**,并报告它是从哪儿来的:
//
// - 面板文件存在且合法 ⇒ 它是唯一来源(design/09 §5.7 E 的「先读可变源」);
// - 文件缺失/为空 ⇒ 用 `.env` 播种落盘,本次行为与 `.env` 完全一致(「缺失回落 `.env`
// 播种」,照 models.json materialize 范式);
// - 文件存在但非法 ⇒ 记 WARN、本次回落 `.env`,**不覆盖**原文件。
//
// 返回值里的第二个值是来源标签,只用于日志(`.env` 还是面板),不参与任何裁决。
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)
switch {
case err != nil:
log.Warn("web-search 面板配置非法,本次按 .env 运行(不覆盖原文件)",
"path", path, "error", err)
return envEffectiveWebSearch(env), webSearchSourceEnv
case !found:
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 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 的处置一致)。
func (a *app) seedWebSearchConfig(log *slog.Logger, path string, env config.WebSearchConfig) config.WebSearchConfig {
seed := seedWebSearchFile(env)
if err := runtimeconfig.SaveWebSearch(path, seed); err != nil {
log.Warn("web-search 面板配置播种失败(本次按 .env 运行)", "path", path, "error", err)
return env
}
log.Info("web-search 面板配置已按 .env 播种(此后面板是唯一来源)",
"path", path, "providers", len(seed.Providers))
return env
}
// seedWebSearchFile 把 `.env` 的 web-search 段投影成可变源的初始形状。
//
// **本期内置的家全列**(含还没配 key 的):面板的用途就是「一次把各家配齐」,
// 让用户先看到完整清单、再逐条填,比只列已配置的家少一轮「名字得自己知道」。
//
// 「全列」的名单来自 `.env` 的字段表(`envSearchProviders`)—— 那是**播种层**的输入,
// 因此这里的条数与被播种的键集逐字保持现状(`.env` 冻结:不加、不改、不删)。
func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile {
enabled := runtimeconfig.EnableFlag(true)
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,
})
}
return runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Provider: ws.Provider,
Fallback: ws.Fallback,
MaxResults: ws.MaxResults,
Timeout: seedTimeout(ws.Timeout),
Providers: entries,
}
}
// effectiveSearchConfig 把面板文件翻译成工具要的生效配置。
//
// **权威边界**(写清楚,免得「谁覆盖谁」靠猜):
//
// - **标量以文件为准**:主 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...)
if f.MaxResults > 0 {
out.MaxResults = f.MaxResults
}
d, err := f.TimeoutDuration()
if err != nil {
return effectiveWebSearchConfig{}, err
}
if d > 0 {
out.Timeout = d
}
// 逐家字段一律清零:它们承载的是 `.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 = ""
if e, ok := findProviderEntry(f, search.NameSearXNG); ok && e.IsEnabled() {
out.Endpoint = strings.TrimSpace(e.BaseURL)
}
return effectiveWebSearchConfig{Scalar: out, Providers: effectiveSearchProviders(f)}, nil
}
// warnUnknownSearchProviders 对文件里本期没登记的家记一条 WARN。
//
// 它们不可能被选中(选中的家由 io 工具集在启动期校验),但「配了却不生效」必须说出来 ——
// 静默忽略正是「我明明配了 Tavily 却搜不出」那类报障的来源。
func warnUnknownSearchProviders(log *slog.Logger, path string, f runtimeconfig.WebSearchFile) {
for _, e := range f.Providers {
name := strings.ToLower(strings.TrimSpace(e.Provider))
if name == "" || search.Known(name) {
continue
}
log.Warn("web-search 面板配置里有未登记的提供商,已忽略",
"path", path, "provider", name, "known", search.KnownNames())
}
}
// selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。
func selectsSearXNG(ws config.WebSearchConfig) bool {
return strings.EqualFold(strings.TrimSpace(ws.Provider), search.NameSearXNG)
}
// 各家的配置投影(searchProviderWire / searchProviderVector / searchProviderSpecs /
// availableProviderNames)在 `websearch_providers.go` —— 那一片是本片「去 env 化」的本体
// (按**接入位表**逐家投影,而不是按 `.env` 的字段表)。
// seedTimeout 把超时播成 duration 字符串:未声明 / 笔误(≤0)落成空 = 未声明。
func seedTimeout(d time.Duration) string {
if d <= 0 {
return ""
}
return d.String()
}