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 字段的家,只认文件也能被投影出生效配置」。 // // ⚠️ 名字取 `fake_next_provider` 而不是某个真接入位(早先这里写的是 `tencent_wsa`): // `tencent_wsa` 已经**真的登记**(本片新增),沿用旧名会让这条用例的前提断言直接失败 —— // 假接入位必须是一个**永远不会被登记**的名字,用例才钉得住「没 env 字段的家」这件事。 func wsFakeNewProviderDef() search.Descriptor { return search.Descriptor{ Name: "fake_next_provider", NeedKey: true, DefaultBaseURL: "https://fake-next.example", } } // 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 字段的家** // (测试专用的假接入位 `fake_next_provider`)在文件里有 key ⇒ 照样被投影出生效配置。 // // 这条判据就是本片存在的理由:旧形状的逐家 switch 只认 `.env` 字段表里的 6 家,第 7 家 // 想进来只能先加 env 键(破红线)。新形状按**接入位表**逐家投影,因此第 7 家只需在 // `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动 // —— 本片新接的 `tencent_wsa` / `metaso` 走的正是这条路(它们没有 env 键)。 // // 最后一跳(进注册表)由同文件的 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) } }