diff --git a/cmd/cyrene/websearch_action_data.go b/cmd/cyrene/websearch_action_data.go index 069c1c7..79a80a5 100644 --- a/cmd/cyrene/websearch_action_data.go +++ b/cmd/cyrene/websearch_action_data.go @@ -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 优先(面板里刚存的就是它), diff --git a/cmd/cyrene/websearch_projection_test.go b/cmd/cyrene/websearch_projection_test.go new file mode 100644 index 0000000..75fba0f --- /dev/null +++ b/cmd/cyrene/websearch_projection_test.go @@ -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) + } +} diff --git a/cmd/cyrene/websearch_providers.go b/cmd/cyrene/websearch_providers.go new file mode 100644 index 0000000..babdb6b --- /dev/null +++ b/cmd/cyrene/websearch_providers.go @@ -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) +} diff --git a/cmd/cyrene/websearch_selection.go b/cmd/cyrene/websearch_selection.go index 0f256ea..8c594f4 100644 --- a/cmd/cyrene/websearch_selection.go +++ b/cmd/cyrene/websearch_selection.go @@ -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 { diff --git a/cmd/cyrene/wire_io_tools.go b/cmd/cyrene/wire_io_tools.go index 5435faa..77a4650 100644 --- a/cmd/cyrene/wire_io_tools.go +++ b/cmd/cyrene/wire_io_tools.go @@ -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 -} diff --git a/cmd/cyrene/wire_plugin_actions.go b/cmd/cyrene/wire_plugin_actions.go index d8abc07..3d92a01 100644 --- a/cmd/cyrene/wire_plugin_actions.go +++ b/cmd/cyrene/wire_plugin_actions.go @@ -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 } diff --git a/plugins/tools/io/search/registry.go b/plugins/tools/io/search/registry.go index 723263d..c015701 100644 --- a/plugins/tools/io/search/registry.go +++ b/plugins/tools/io/search/registry.go @@ -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)) diff --git a/plugins/tools/io/search/registry_test.go b/plugins/tools/io/search/registry_test.go index 364b43d..6b14577 100644 --- a/plugins/tools/io/search/registry_test.go +++ b/plugins/tools/io/search/registry_test.go @@ -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) {