动因:`.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)两片随后串行落地。
432 lines
19 KiB
Go
432 lines
19 KiB
Go
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)
|
||
}
|
||
}
|