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

+4 -21
View File
@@ -252,29 +252,12 @@ func maskedProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimecon
}
// effectiveBaseURL 给出一条配置**实际会用的**基地址:盘上有就用盘上的,没有就回落到
// 代码默认值(各云家的厂商默认地址)。
// **该接入位的代码默认基地址**(`plugins/tools/io/search` 的接入位表,唯一出处)。
//
// searxng 不回落(空地址 = 关掉这家,回落会让「清空地址」变成假话)—— 与
// `effectiveSearchConfig` 的同一条权威边界。
// 进位链与装配期**同一个函数**(resolveBaseURL):searxng 的默认基地址是空 —— 它的地址
// 为空本身就是「关掉这家」,回落会让「清空地址」变成假话。
func (e webSearchActionEnv) effectiveBaseURL(name string, entry runtimeconfig.ProviderEntry) string {
if base := strings.TrimSpace(entry.BaseURL); base != "" {
return base
}
if strings.EqualFold(name, search.NameSearXNG) {
return ""
}
return e.defaultBaseURL(name)
}
// defaultBaseURL 取某家云 provider 的代码默认基地址(来自 `.env` 解析后的配置,未写该键
// 时就是标签默认值)。名字表复用组合根既有那张(`searchProviderVector`),不新造一份。
func (e webSearchActionEnv) defaultBaseURL(name string) string {
for _, p := range searchProviderVector(e.envCfg) {
if strings.EqualFold(p.Name, name) {
return strings.TrimSpace(p.BaseURL)
}
}
return ""
return resolveBaseURL(name, entry.BaseURL)
}
// probeTimeout 给出一次探针的超时上限:面板文件的 timeout 优先(面板里刚存的就是它),
+431
View File
@@ -0,0 +1,431 @@
package main
// 本文件钉住 web-search「各家 provider 配置投影」的新形状(切片 WS-P2 的「去 env 化」)。
//
// 为什么要有这一片:旧形状是**一家一个 `.env` 字段**(`config.WebSearchConfig` 的
// `ZhipuAPIKey` / `BochaAPIKey` / …)+ 组合根里一个逐家写死的 switch。那个形状的后果是
// 「加一家 provider 就必须先加一个 env 键」—— 直接破主人红线(`.env` 已冻结)。
// 新形状把名字表收回到插件自己的接入位表(`plugins/tools/io/search`),组合根只按名字
// 从**面板文件**里取值;`.env` 退居**播种层**(只在文件缺失/为空时落盘一次)。
//
// 四条判据(任务书 ①②③④)与一条边界(文件权威 vs `.env` 字段表)都在这里:
// ① 文件里有 zhipu 的 apiKey ⇒ 该家进注册表;
// ② 文件里没有 zhipu、但 `.env` 有(播种层)⇒ 与改动前逐字一致;
// ③ **没有 env 字段的家**在文件里有 key ⇒ 也能生效(本片的核心判据,用假接入位驱动);
// ④ 文件里的 baseUrl 缺位 ⇒ 回落该接入位的代码默认基地址。
//
// 夹具(假上游、日志字段读取、`wsSurfaceApp`)在 wire_web_search_test.go 与
// websearch_testenv_test.go;`.env` 与面板文件的既有语义在各 wire_web_search_*_test.go。
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// wsFakeNewProviderDef 造一个**测试专用的假接入位**:模拟「下一片要接的新家」。
//
// 它在 search 包里**没有**登记(`search.Known` 为 false),也没有对应的 `.env` 字段 ——
// 于是它正好是判据 ③ 的载体:「没有 env 字段的家,只认文件也能被投影出生效配置」。
func wsFakeNewProviderDef() search.Descriptor {
return search.Descriptor{
Name: "tencent_wsa",
NeedKey: true,
DefaultBaseURL: "https://wsa.tencentcloudapi.com",
}
}
// wsSavePanel 写一份面板文件(临时 DataDir 里的真实落点,与面板保存走同一个写口)。
func wsSavePanel(t *testing.T, cfg *config.Config, f runtimeconfig.WebSearchFile) {
t.Helper()
if f.Version == 0 {
f.Version = runtimeconfig.WebSearchVersion
}
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), f); err != nil {
t.Fatalf("写面板配置: %v", err)
}
}
// wsEffectiveProviders 走组合根的**同一条解析路**(effectiveWebSearch),返回生效配置与来源标签。
func wsEffectiveProviders(t *testing.T, cfg *config.Config) (effectiveWebSearchConfig, string) {
t.Helper()
a := &app{cfg: cfg, log: testLog()}
return a.effectiveWebSearch(a.log)
}
// wsFindWire 在投影里按名字取一家(名字大小写与空白不敏感,与注册表同口径)。
func wsFindWire(providers []searchProviderWire, name string) (searchProviderWire, bool) {
for _, p := range providers {
if strings.EqualFold(p.Name, name) {
return p, true
}
}
return searchProviderWire{}, false
}
// wsZhipuSearchBody 造一份智谱官方形状的应答(`search_result[]`,见 search 包的 zhipuSpec)。
func wsZhipuSearchBody(title string) string {
return `{"search_result":[{"title":"` + title + `","link":"https://z.example/1","content":"` +
title + ` 正文","publish_date":"2025-05-23"}]}`
}
// wsExecuteSearch 从注册面取出 web-search 真打一次(返回线格式结果)。
func wsExecuteSearch(t *testing.T, a *app, query string) map[string]any {
t.Helper()
tl, err := a.caps.Get(webSearchToolName)
if err != nil {
t.Fatalf("web-search 不在注册面里: %v(工具面 = %v)", err, toolNames(a))
}
exec, ok := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
if !ok {
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
}
got, err := exec.Execute(context.Background(), map[string]any{"query": query})
if err != nil {
t.Fatalf("Execute(%q): %v", query, err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("返回值类型 = %T", got)
}
return m
}
// --- ④ 默认基地址只有一个出处:接入位表 vs `.env` 标签默认值,逐字对齐 ----------
// TestWebSearchAccessSlotDefaultsAlignWithEnvDefaults:接入位表里的默认基地址必须与
// `.env` 解析出的**标签默认值**逐字一致。
//
// 只钉一侧不够:`.env` 的那几个键仍然存在(冻结的基础键,仍然参与播种),接入位表是本片
// 新增的出处。两侧漂移时,同一家 provider 会因「值从哪来」指向两个不同的上游。
func TestWebSearchAccessSlotDefaultsAlignWithEnvDefaults(t *testing.T) {
t.Parallel()
// 一个 WEB_SEARCH_*_BASE_URL 都不写 ⇒ 下面每项取的都是标签默认值。
cfg := wsLoadConfig(t)
cases := []struct {
name string
envDefault string
}{
{search.NameZhipu, cfg.WebSearch.ZhipuBaseURL},
{search.NameAliyunIQS, cfg.WebSearch.AliyunIQSBaseURL},
{search.NameBocha, cfg.WebSearch.BochaBaseURL},
{search.NameVolcengine, cfg.WebSearch.VolcengineBaseURL},
{search.NameBaiduQianfan, cfg.WebSearch.BaiduQianfanBaseURL},
}
for _, tc := range cases {
// 进程环境里的同名覆盖会让「标签默认值」这一侧变成用户值 —— 那不是本表要钉的事实
// (缺省值),如实跳过而不是报假红。
envKey := "WEB_SEARCH_" + strings.ToUpper(tc.name) + "_BASE_URL"
if os.Getenv(envKey) != "" {
t.Skipf("%s 在进程环境里有覆盖值,跳过默认值对齐(本表只钉缺省值)", envKey)
}
if tc.envDefault == "" {
t.Fatalf("%s 的标签默认值解析出来是空的(.env 冻结键不许被清空)", tc.name)
}
if got := search.DefaultBaseURL(tc.name); got != tc.envDefault {
t.Errorf("接入位表 %s 的默认基地址 = %q,.env 的标签默认值 = %q(必须逐字一致)",
tc.name, got, tc.envDefault)
}
}
// searxng 的「默认地址」是 WEB_SEARCH_ENDPOINT 的标签默认值,且**不进**接入位表的默认表
// ——它的地址为空是「关掉这家」的开关语义。
if got := search.DefaultBaseURL(search.NameSearXNG); got != "" || cfg.WebSearch.Endpoint == "" {
t.Errorf("searxng: 接入位默认地址 = %q(want 空),.env 端点默认值 = %q(want 非空)",
got, cfg.WebSearch.Endpoint)
}
}
// --- ① 文件里有 key ⇒ 进注册表 ----------------------------------------------
// TestWebSearchFileProviderEntersRegistry 是判据 ①:**文件里有 zhipu 的 apiKey** ⇒ 该家进
// 注册表(`.env` 一个 zhipu 凭据键都没写),且请求真的带着文件里那把 key 打到文件里那个地址。
func TestWebSearchFileProviderEntersRegistry(t *testing.T) {
t.Parallel()
const key = "test-file-zhipu-key"
up := newWSFakeUpstream(t, http.StatusOK, wsZhipuSearchBody("文件里的智谱"))
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameZhipu)
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
Provider: search.NameZhipu,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameZhipu, APIKey: key, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
},
})
eff, source := wsEffectiveProviders(t, cfg)
if source != webSearchSourcePanel {
t.Fatalf("configSource = %q, want %q(文件存在时它是唯一来源)", source, webSearchSourcePanel)
}
if got := availableProviderNames(searchProviderSpecs(eff.Scalar, eff.Providers)); len(got) != 1 || got[0] != search.NameZhipu {
t.Fatalf("可用清单 = %v, want [%s](凭据来自面板文件)", got, search.NameZhipu)
}
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, webSearchToolName) {
t.Fatalf("文件里配好的主家应让工具进注册面: %v", toolNames(a))
}
if got := wsExecuteSearch(t, a, "昔涟"); got["provider"] != search.NameZhipu {
t.Fatalf("provider = %v, want %q", got["provider"], search.NameZhipu)
}
select {
case r := <-up.hits:
if r.Header.Get("Authorization") != "Bearer "+key {
t.Errorf("Authorization = %q,want 文件里那把 key(凭据必须以文件为准)", r.Header.Get("Authorization"))
}
default:
t.Fatal("文件里配好的家一个请求都没收到")
}
}
// --- ② `.env` 是播种层:文件缺失时与改动前逐字一致 ----------------------------
// TestWebSearchProviderFromEnvSeedLayer 是判据 ②:**文件里没有 zhipu、但 `.env` 有**
// ⇒ 与改动前逐字一致:文件缺失/为空时由 `.env` 播种落盘,本次就按 `.env` 运行。
//
// 「与改动前一致」的落点有三处:播种内容、来源标签(env)、生效凭据。
func TestWebSearchProviderFromEnvSeedLayer(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-001",
)
// 第一次解析发生在文件缺失时 ⇒ 它就是「播种那一次」。
eff, source := wsEffectiveProviders(t, cfg)
if source != webSearchSourceEnv {
t.Fatalf("configSource = %q, want %q(文件缺失时按 .env 运行)", source, webSearchSourceEnv)
}
z, ok := wsFindWire(eff.Providers, search.NameZhipu)
if !ok || z.APIKey != "sk-env-001" || z.BaseURL != search.DefaultBaseURL(search.NameZhipu) {
t.Fatalf("播种层的 zhipu 投影 = %+v, want 凭据来自 .env、基地址回落默认值", z)
}
if got := availableProviderNames(searchProviderSpecs(eff.Scalar, eff.Providers)); len(got) != 2 {
t.Fatalf("可用清单 = %v, want searxng + zhipu 两家", got)
}
// 播种真的落了盘:条目里带 `.env` 的凭据(`.env` 是播种层的输入)。
f, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg))
if err != nil || !found {
t.Fatalf("文件缺失时必须按 .env 播种落盘: found=%v err=%v", found, err)
}
entry, ok := findProviderEntry(f, search.NameZhipu)
if !ok || entry.APIKey != "sk-env-001" {
t.Fatalf("播种的 zhipu 条目 = %+v, want 带 .env 的凭据", entry)
}
if f.Provider != search.NameZhipu {
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameZhipu)
}
// 播种之后工具照常在注册面里(`.env` 走的是与面板键同一条装配路)。
if !wsHasTool(wsSurfaceApp(t, cfg, nil), webSearchToolName) {
t.Fatal("按 .env 播种后 web-search 应在注册面里")
}
}
// --- ③ 没有 env 字段的家:只认文件(本片的**核心判据**) ----------------------
// TestWebSearchProviderWithoutEnvFieldIsProjected 是判据 ③ 的投影层:**没有 env 字段的家**
// (测试专用的假接入位 `tencent_wsa`)在文件里有 key ⇒ 照样被投影出生效配置。
//
// 这条判据就是本片存在的理由:旧形状的逐家 switch 只认 `.env` 字段表里的 6 家,第 7 家
// 想进来只能先加 env 键(破红线)。新形状按**接入位表**逐家投影,因此第 7 家只需在
// `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动。
//
// 最后一跳(进注册表)由同文件的 TestWebSearchFileOnlyProviderEntersRegistry 用真实接入位
// 走完:注册表按名字查的正是同一张接入位表。
func TestWebSearchProviderWithoutEnvFieldIsProjected(t *testing.T) {
t.Parallel()
fake := wsFakeNewProviderDef()
cfg := wsLoadConfig(t)
// 前提一:它确实不在 `.env` 的字段表里(否则这条用例会退化成「既有 6 家」的回归)。
for _, p := range searchProviderVector(cfg.WebSearch) {
if strings.EqualFold(p.Name, fake.Name) {
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", fake.Name)
}
}
// 前提二:它确实还没登记(登记工作是下一片的:加接入位文件 + 表里一行)。
if search.Known(fake.Name) {
t.Fatalf("前提被破坏:假接入位 %s 不该已经登记", fake.Name)
}
f := runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Providers: []runtimeconfig.ProviderEntry{
// 连 baseUrl 都不给:顺带钉住判据 ④ 在「没有 env 字段」的家上同样成立。
{Provider: fake.Name, APIKey: "sk-wsa-001"},
},
}
got := resolveSearchProviders(append(search.Descriptors(), fake), f)
w, ok := wsFindWire(got, fake.Name)
if !ok {
t.Fatalf("没有 env 字段的家在文件里有 key 时也必须被投影出来,实际 = %+v", got)
}
if w.APIKey != "sk-wsa-001" {
t.Fatalf("投影的凭据 = %q, want 文件里的 sk-wsa-001", w.APIKey)
}
if w.BaseURL != fake.DefaultBaseURL {
t.Fatalf("投影的基地址 = %q, want 接入位默认值 %q", w.BaseURL, fake.DefaultBaseURL)
}
// 没配的家照样占位(它带着默认基地址进 SSRF 白名单)。
if _, ok := wsFindWire(got, search.NameBaiduQianfan); !ok {
t.Fatalf("未配置的家也要出现在投影里(否则 SSRF 白名单会少一家): %+v", got)
}
}
// TestWebSearchFileOnlyProviderEntersRegistry 是判据 ③ 的端到端:文件里有 key、
// `.env` 里没有任何该家的值 ⇒ 该家真的进注册表,且请求带着**文件里那把** key 打出去。
//
// 落在真实接入位(bocha)上,是因为注册表按名字查的正是那张接入位表:这条路径一旦通了,
// 「没有 env 字段的家」登记之后走的就是同一条路。
func TestWebSearchFileOnlyProviderEntersRegistry(t *testing.T) {
t.Parallel()
const key = "test-file-only-key"
got := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got <- r
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`))
}))
defer srv.Close()
// `.env` 只给「谁当主家」,凭据与地址一个都不给。
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameBocha)
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
Provider: search.NameBocha,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameBocha, APIKey: key, BaseURL: srv.URL, Enabled: runtimeconfig.EnableFlag(true)},
},
})
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, webSearchToolName) {
t.Fatalf("文件里配好的家应让工具进注册面: %v", toolNames(a))
}
if m := wsExecuteSearch(t, a, "昔涟"); m["provider"] != search.NameBocha {
t.Fatalf("provider = %v, want %q", m["provider"], search.NameBocha)
}
select {
case r := <-got:
if r.Header.Get("Authorization") != "Bearer "+key {
t.Errorf("Authorization = %q,want 文件里那把 key", r.Header.Get("Authorization"))
}
default:
t.Fatal("文件里配好的家一个请求都没收到")
}
}
// --- ④ 基地址的进位链:文件值 → 接入位默认值 ---------------------------------
// TestWebSearchBaseURLFallbackChain 是判据 ④ 的表驱动:
// 文件里的 baseUrl 缺位 ⇒ 回落**该接入位的代码默认基地址**(逐字对上 registry 里的值)。
func TestWebSearchBaseURLFallbackChain(t *testing.T) {
t.Parallel()
cases := []struct {
name string
entries []runtimeconfig.ProviderEntry
wantKey string
wantURL string
}{
{
"文件没给地址 ⇒ 回落接入位默认值",
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1"}},
"sk-1", search.DefaultBaseURL(search.NameZhipu),
},
{
"文件给了地址 ⇒ 用文件的(进位链第一跳)",
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1", BaseURL: "https://proxy.example"}},
"sk-1", "https://proxy.example",
},
{
"空串地址等同于没给 ⇒ 回落接入位默认值",
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1", BaseURL: " "}},
"sk-1", search.DefaultBaseURL(search.NameZhipu),
},
{
"被停用的家仍带默认地址(白名单口径),但没有凭据",
[]runtimeconfig.ProviderEntry{
{Provider: search.NameZhipu, APIKey: "sk-1", Enabled: runtimeconfig.EnableFlag(false)},
},
"", search.DefaultBaseURL(search.NameZhipu),
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := runtimeconfig.WebSearchFile{Version: runtimeconfig.WebSearchVersion, Providers: tc.entries}
got := resolveSearchProviders(search.Descriptors(), f)
w, ok := wsFindWire(got, search.NameZhipu)
if !ok {
t.Fatalf("zhipu 必须出现在投影里: %+v", got)
}
if w.BaseURL != tc.wantURL {
t.Errorf("基地址 = %q, want %q", w.BaseURL, tc.wantURL)
}
if w.APIKey != tc.wantKey {
t.Errorf("凭据 = %q, want %q", w.APIKey, tc.wantKey)
}
})
}
// 逐字对齐:这个「接入位默认值」必须就是 registry 里那张表的值(不是从 `.env` 抄来的)。
if got := search.DefaultBaseURL(search.NameZhipu); got != "https://open.bigmodel.cn" {
t.Fatalf("registry 的默认基地址 = %q, want 逐字 https://open.bigmodel.cn", got)
}
}
// --- 边界:文件存在时它**就是**权威(`.env` 不复活任何条目) ------------------
// TestWebSearchFileAuthorityOverEnvFieldTable 钉住投影层的权威边界:文件里**没有** zhipu
// 条目 ⇒ 即使 `.env` 里有 zhipu 的 key,这家也不生效(凭据不复活)。`.env` 只在**文件
// 缺失/为空**时经 seedWebSearchFile 播种(判据 ②)—— 文件一旦存在,权威就在文件那边。
//
// 这是工具层既有回归用例(`wire_web_search_panel_test.go` 的
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv`:主家因此没配齐 ⇒ 启动
// 报错)在**投影层**的同一条口径,也是与本体形状契约(runtimeconfig:providers 为空
// 数组 ⇒ 一家都没配)一致的行为。
func TestWebSearchFileAuthorityOverEnvFieldTable(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-stale",
)
f := runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Provider: search.NameZhipu,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"},
},
}
// 文件真的落到盘上(否则「文件里没有这家」这条用例会退化成「文件缺失 ⇒ 播种」)。
wsSavePanel(t, cfg, f)
got := resolveSearchProviders(search.Descriptors(), f)
w, ok := wsFindWire(got, search.NameZhipu)
if !ok {
t.Fatalf("未配置的家也要在投影里占位(它带着默认基地址进 SSRF 白名单): %+v", got)
}
if w.APIKey != "" {
t.Fatalf("文件里没有的家不得从 .env 复活凭据,得到 %q(.env 只是播种层)", w.APIKey)
}
if w.BaseURL != search.DefaultBaseURL(search.NameZhipu) {
t.Fatalf("基地址 = %q, want 接入位默认值 %q", w.BaseURL, search.DefaultBaseURL(search.NameZhipu))
}
// 与 `.env` 无关的旁证:那份 `.env` 里确实有 key(否则这条用例是空转)。
if cfg.WebSearch.ZhipuAPIKey != "sk-env-stale" {
t.Fatalf(".env 里的 zhipu key = %q(用例前提不成立)", cfg.WebSearch.ZhipuAPIKey)
}
// 工具层:主家没配齐 ⇒ 启动报错(既不复活,也不静默换一家)。
if err := wsNewTools(t, cfg); !errors.Is(err, search.ErrNotConfigured) {
t.Fatalf("err = %v, want errors.Is(..., ErrNotConfigured):文件里没有的家不得从 .env 复活", err)
}
}
+196
View File
@@ -0,0 +1,196 @@
package main
// 本文件是 web-search「**各家 provider 的配置投影**」—— 组合根里唯一把配置翻译成各家接线
// 的地方。它存在的理由是一条硬约束:**`.env` 已冻结**(基础键不加/不改/不删)。
//
// 旧形状是「一家一个 env 字段」:`config.WebSearchConfig` 有 `ZhipuAPIKey` / `BochaAPIKey` / …,
// 组合根再逐家读这些字段(外加一个逐家写死的 switch 把面板文件的值搬回去)。那个形状的
// 后果是**加一家 provider 就要先加一个 env 键** —— 直接破 `.env` 冻结红线。
//
// 新形状把**名字表收回到插件自己手里**(`plugins/tools/io/search` 的接入位表):
//
// - 各家叫什么、要不要 key、没给基地址时回落哪个默认地址 ⇒ 全在 `search.Descriptors()`;
// - 组合根只做一件事:**按名字**在面板文件里找同名条目,有就取文件的值,没有就是没配;
// - `.env` 退居**播种层**:只有「文件缺失/为空」时才由 `seedWebSearchFile` 落盘一次
// (既有 6 家的键与值逐字不变,主人已在用的配置不受影响)。
//
// ⇒ 加一家 provider = `plugins/tools/io/search` 加一个接入位文件 + 表里一行,**本文件一行
// 都不用改**,`.env` 一个键都不用加。
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// effectiveWebSearchConfig 是一次装配的**生效配置**,分两层:
//
// - Scalar:文件级标量(主 provider / 降级链 / 条数上限 / 超时)与 searxng 的端点。
// 它沿用 config.WebSearchConfig,因为那几个裁决函数都挂在它上面
// (ResolveProvider / ResolveEndpoint),而它们是 `.env` 与面板**共用**的一套语义。
// - Providers:各家(**不含 searxng**)的生效投影,按**接入位表**逐家给出。
//
// 为什么各家的 key/基地址不再写回 Scalar 的逐家字段:那张字段表是 `.env` 的形状,碰它就要
// 动 env 键(见文件头)。逐家字段在这个结构里**不再承载生效值**(清零),免得被读成
// 「生效配置里的凭据」—— 那正是旧形状最容易被误用的地方。
type effectiveWebSearchConfig struct {
// Scalar 是文件级标量与 searxng 端点(逐家字段一律清零,见上方说明)。
Scalar config.WebSearchConfig
// Providers 是各家的生效投影(不含 searxng),顺序 = 接入位表的展示顺序。
Providers []searchProviderWire
}
// searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。
// searxng 不在这张表里(它的「基地址」就是端点,由 ResolveEndpoint 裁决整把工具的存亡)。
type searchProviderWire struct {
Name string
APIKey string
BaseURL string
}
// searchProviderVector 是 **`.env` 播种层**给出的各家投影(既有 6 家的 env 字段表,
// 云家部分)。它只服务两处:`.env` 缺失时的播种内容(seedWebSearchFile),以及
// 「没有可用文件」时的生效投影(envEffectiveWebSearch)。
//
// ⚠️ 它**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。
// 这条口径由 `wire_web_search_panel_test.go` 的
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv` 钉住。
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},
}
}
// envSearchProviders 是 `.env` 播种层的**全量**投影:searxng(基地址 = WEB_SEARCH_ENDPOINT)
// 打头,其余各家跟随 —— 顺序与既有播种逐字一致。
func envSearchProviders(ws config.WebSearchConfig) []searchProviderWire {
out := make([]searchProviderWire, 0, len(searchProviderVector(ws))+1)
out = append(out, searchProviderWire{Name: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint)})
return append(out, searchProviderVector(ws)...)
}
// effectiveSearchProviders 是各家(**不含 searxng**)在本次装配里的生效投影。
//
// 生产路径只有薄薄这一层:接入位表从插件自己那里取(`search.Descriptors()`)。加一家
// provider 时这里**零改动** —— 这正是本片「去 env 化」的核心。
func effectiveSearchProviders(f runtimeconfig.WebSearchFile) []searchProviderWire {
return resolveSearchProviders(search.Descriptors(), f)
}
// resolveSearchProviders 逐家算出生效投影。**判定顺序(文件权威)**:
//
// 1. 面板文件里有同名条目且**启用** ⇒ 取条目里的 apiKey / baseUrl(凭据以文件为准:
// 条目里 key 为空 = 这家没配,绝不拿 `.env` 的旧 key 顶上,否则「我在面板里删了它」
// 会变成一句假话);
// 2. 面板文件里有同名条目但**被显式停用** ⇒ 这家不生效(停用是显式选择,不回落别处);
// 3. 面板文件里**没有**该条目 ⇒ 这家不生效(`.env` 只在**文件缺失/为空**时播种,文件一旦
// 存在就是权威;见 searchProviderVector 的注释与那条既有回归用例)。
//
// ⚠️ **一处经核对的偏差(记在这里,免得后人踩)**:任务书里有一句「既有 6 家在文件里没有该
// 条目时仍按现状回落 `.env` 值」。本仓现状**没有**这层回落,且有两条既有约束钉着它:
// ① `runtimeconfig.WebSearchFile.Providers` 的形状契约写明「允许为空数组(= 一家都没配 ⇒
// 组合根不注册 web-search)」;② 回归用例 `TestWireIOTools_PanelConfigClearedProviderDoesNot
// ReviveFromEnv` 明确要求「文件里没有的家不得从 `.env` 复活」。本片按「**不改变既有行为**」
// 实现(既有 websearch 系列用例逐条未改语义、全绿),需要那层回落时改的就是下面这个循环。
//
// 被跳过与未配置的家**仍带着基地址**(回落到接入位的默认值):接线表的 BaseURL 会被组合根
// 逐个加进 SSRF 白名单(io 包的 withSearchEndpointHost),这里悄悄少一家等于改了那份白名单。
//
// defs 由调用方给(生产 = `search.Descriptors()`):测试可注入一个**假接入位**,从而驱动
// 「没有 env 字段的家也能只认文件生效」这条判据 —— 那是本片存在的理由,必须可证。
func resolveSearchProviders(defs []search.Descriptor, f runtimeconfig.WebSearchFile) []searchProviderWire {
out := make([]searchProviderWire, 0, len(defs))
for _, d := range defs {
// searxng 的「基地址」是端点(`WEB_SEARCH_ENDPOINT` / 文件里那条的 baseUrl),
// 由 effectiveSearchConfig 单独裁决:空地址 = 关掉这家,语义与云家不同。
if strings.EqualFold(d.Name, search.NameSearXNG) {
continue
}
entry, found := findProviderEntry(f, d.Name)
if !found || !entry.IsEnabled() {
out = append(out, searchProviderWire{Name: d.Name, BaseURL: baseURLChain("", d.DefaultBaseURL)})
continue
}
out = append(out, searchProviderWire{
Name: d.Name,
APIKey: strings.TrimSpace(entry.APIKey),
BaseURL: baseURLChain(entry.BaseURL, d.DefaultBaseURL),
})
}
return out
}
// baseURLChain 是「一家的基地址」的**唯一进位链**:条目里的值 → 该接入位的代码默认基地址
// → 空。
//
// 空只可能出现在没有默认地址的家上(searxng):它的地址为空本身就是「关掉这家」的开关语义,
// 回落一个本机地址会让「清空地址」变成假话。
func baseURLChain(fromEntry, accessSlotDefault string) string {
if s := strings.TrimSpace(fromEntry); s != "" {
return s
}
return accessSlotDefault
}
// resolveBaseURL 按**名字**走同一条进位链(动作面只拿得到名字与条目):默认基地址由接入位表
// 给出(`search.DefaultBaseURL`,未登记的名字 ⇒ 空)。
//
// 装配期**不用**这个入口而是直接用 baseURLChain:那里手里已经有接入位描述,而「没有 env
// 字段的家」在登记之前按名字查不到默认地址 —— 那正是本片要打通的那一类家。
func resolveBaseURL(name, fromEntry string) string {
return baseURLChain(fromEntry, search.DefaultBaseURL(name))
}
// searchProviderSpecs 把生效配置翻译成各家接线。
//
// searxng 单独一行:它的基地址是端点、可用性由 ResolveEndpoint 裁决(不是「有没有 key」);
// 其余各家按接入位表逐家给出,Configured 的口径仍由 `search.Configured` 统一判定
// (searxng 看端点、云家看 key;false ⇒ 该家不进注册表、不出现在可用清单里)。
//
// **密钥只在这里流转,绝不写日志。**
func searchProviderSpecs(ws config.WebSearchConfig, providers []searchProviderWire) []iotools.SearchProviderSpec {
endpoint := ws.ResolveEndpoint()
specs := make([]iotools.SearchProviderSpec, 0, len(providers)+1)
specs = append(specs, iotools.SearchProviderSpec{
Name: search.NameSearXNG,
BaseURL: endpoint.URL,
Configured: endpoint.Enabled,
})
for _, p := range providers {
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 条目:显式关闭的哨兵落成空串
// (两者语义相同:都表示不出这家,但面板里不该显示一个不是地址的 "off")。
func seedEndpoint(endpoint string) string {
if config.IsWebSearchOffSentinel(endpoint) {
return ""
}
return strings.TrimSpace(endpoint)
}
+19 -11
View File
@@ -80,9 +80,9 @@ func (e webSearchActionEnv) mergeSelectionPatch(
// checkPrimaryUsable 判定一个主 provider 名在**落盘后的文件**里是否可用。
//
// 判定口径与工具接线同源(searchProviderSpecs + effectiveBaseURL):云家看 API Key、
// searxng 看接口地址,基地址缺位时回落到代码默认值;条目被显式停用视同没配
// (effectiveSearchConfig 会跳过它)。
// 判定口径与工具接线同源(resolveSearchProviders + resolveBaseURL):云家看 API Key、
// searxng 看接口地址,基地址缺位时回落**该接入位的代码默认基地址**;条目被显式停用
// 视同没配(装配期会跳过它)。判据本身**不放松**:真的没配齐的主家仍必须被拒。
func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, name string) error {
// 显式关闭(空串 / off / none)永远合法:它表示「不要这把工具」,工具不进注册面,
// 后端照常启动(与 WEB_SEARCH_PROVIDER 的既有语义逐字一致)。
@@ -93,19 +93,27 @@ func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, na
return err
}
entry, found := findProviderEntry(f, name)
base := ""
if found {
base = e.effectiveBaseURL(name, entry)
if !found || !entry.IsEnabled() {
return primaryNotConfiguredError(name)
}
if !found || !entry.IsEnabled() || !search.Configured(name, entry.APIKey, base) {
return pluginmgr.NewActionError(actionCodePrimaryNotConfigured,
fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+
"先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name),
http.StatusBadRequest, nil)
// 基地址的进位链与装配期**同一个函数**:条目里的值 → 该接入位的默认基地址 → 空。
// 云家的可用性只看 key(地址取默认值也照样能打通),searxng 的默认值是空
// —— 因此「searxng 主家必须有地址」这条判据与既往逐字一致。
if !search.Configured(name, entry.APIKey, resolveBaseURL(name, entry.BaseURL)) {
return primaryNotConfiguredError(name)
}
return nil
}
// primaryNotConfiguredError 造「主家没配齐」这一条的 400:文案与码只有一份
// (两个判据分支共用,免得两处措辞漂移)。
func primaryNotConfiguredError(name string) error {
return pluginmgr.NewActionError(actionCodePrimaryNotConfigured,
fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+
"先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name),
http.StatusBadRequest, nil)
}
// selectionNames 返回降级链的线形状(**非 nil 空切片**:nil 会在 JSON 里变成 null,
// 而「一条降级都没有」与「这个字段没值」在前端不该长得不一样)。
func selectionNames(names []string) []string {
+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
}
+2 -2
View File
@@ -152,8 +152,8 @@ type webSearchActionEnv struct {
log *slog.Logger
// path 是可变源落点 `$DATA_DIR/config/plugins/web-search.json`。
path string
// envCfg 是 `.env` 解析结果(**只用来取代码默认值**:各云家的默认基地址与超时)。
// 把面板没写的字段落回代码默认值,与工具运行时的 `orDefault` 口径一致。
// envCfg 是 `.env` 解析结果(**只用来取标量默认值**:探针超时;各家的默认基地址改由
// 接入位表给出,见 resolveBaseURL)。
envCfg config.WebSearchConfig
}
+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) {