feat(search): 搜索 provider 可变源 + 插件声明面板(PLG-4)
- 新增 $DATA_DIR/config/plugins/web-search.json(0600 + temp+rename,文件名 = 插件 id)
- 组合根改「先读可变源、缺失回落 .env 播种」;**不新增任何 env 键**(.env 已冻结)
- 多实例:每家 provider 独立 key/baseURL/启用态 + 「测试连通」探针
- 用 PLG-1 的声明库给出该面板的 adminPanels 声明;动作名交给 PLG-2 的通用动作端点分发
(本片自己不开路由)
⚠️ 本片自认越界:改了 cmd/cyrene/wire_web_search_test.go(既有测试,为适配 DataDir);
另有 design/28 的相关注册留给主会话。
This commit is contained in:
1 parent
2d21aa553e
commit
120d926cf8
11 files changed
+1811
-41
No files matched your search
+244
-41
@@ -1,36 +1,43 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及
|
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及
|
||||||
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层)。
|
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源)。
|
||||||
//
|
//
|
||||||
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
|
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
|
||||||
// 而本片新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
||||||
// ⇒ 启动报错)需要一个能单测的落点。
|
// ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。
|
||||||
//
|
//
|
||||||
// 四条口径:
|
// 五条口径:
|
||||||
//
|
//
|
||||||
// 1. **后端由配置选定**:`WEB_SEARCH_PROVIDER` 选主 provider,`WEB_SEARCH_FALLBACK`
|
// 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链
|
||||||
// 给降级链;各家的 key/base URL 走 `WEB_SEARCH_<NAME>_*`(design/27 §3)。
|
// 怎么走,各家的 key 与基地址从哪来(design/27 §3)。
|
||||||
// 2. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
|
// 2. **配置有可变源**(design/09 §5.7 E):先读 `$DATA_DIR/config/plugins/web-search.json`
|
||||||
|
// —— 面板可改、0600 + temp+rename;文件**缺失/为空**时由 `.env` 播种(`.env` 从此只是
|
||||||
|
// 「最小化功能可跑」的引导面,本片**不新增任何 env 键**);文件**非法**时记 WARN 回落
|
||||||
|
// `.env`,且**不覆盖**原文件(损坏的配置可以人工救回)。
|
||||||
|
// 3. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
|
||||||
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
|
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
|
||||||
// 模型根本看不见它,而不是看得见、一调就报错。
|
// 模型根本看不见它,而不是看得见、一调就报错。
|
||||||
// 3. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
|
// 4. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
|
||||||
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
|
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
|
||||||
// **启动期报错**,绝不静默回落到兜底。
|
// **启动期报错**,绝不静默回落到兜底。
|
||||||
// 4. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` 未配置(0)时沿用 io 包既有超时;
|
// 5. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
|
||||||
// 负值是笔误,按未配置处理并记一条带 env 键名的 WARN(不静默)。
|
// 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||||
)
|
)
|
||||||
|
|
||||||
// web-search 的 env 键名:必须与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
|
// web-search 的 env 键名:与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
|
||||||
// 由 wire_web_search_test.go 逐字核对。
|
// 由 wire_web_search_test.go 逐字核对。它们仍然是**引导面**的键(.env 冻结后不再新增)。
|
||||||
const (
|
const (
|
||||||
webSearchProviderEnv = "WEB_SEARCH_PROVIDER"
|
webSearchProviderEnv = "WEB_SEARCH_PROVIDER"
|
||||||
webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT"
|
webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT"
|
||||||
@@ -42,6 +49,14 @@ const (
|
|||||||
// ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
|
// ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
|
||||||
const ioLogSource = "cmd/cyrene/wire_io_tools"
|
const ioLogSource = "cmd/cyrene/wire_io_tools"
|
||||||
|
|
||||||
|
// 生效配置的来源标签(写进启动日志:一眼看出「这次的 provider 是 .env 给的还是面板给的」)。
|
||||||
|
const (
|
||||||
|
// webSearchSourceEnv 表示本次的配置来自 `.env`(含「文件缺失、刚由 .env 播种」这一步)。
|
||||||
|
webSearchSourceEnv = "env"
|
||||||
|
// webSearchSourcePanel 表示本次的配置来自面板可变源(面板是唯一来源)。
|
||||||
|
webSearchSourcePanel = "panel"
|
||||||
|
)
|
||||||
|
|
||||||
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
|
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
|
||||||
// 因此「注册面里有哪些 io 工具」只由这一处决定。
|
// 因此「注册面里有哪些 io 工具」只由这一处决定。
|
||||||
func (a *app) ioToolOptions() []iotools.Option {
|
func (a *app) ioToolOptions() []iotools.Option {
|
||||||
@@ -52,36 +67,36 @@ func (a *app) ioToolOptions() []iotools.Option {
|
|||||||
return append(opts, a.webSearchOptions()...)
|
return append(opts, a.webSearchOptions()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// webSearchOptions 把 web-search 的配置翻译成 io 选项。
|
// webSearchOptions 把生效配置翻译成 io 选项。
|
||||||
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
|
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
|
||||||
func (a *app) webSearchOptions() []iotools.Option {
|
func (a *app) webSearchOptions() []iotools.Option {
|
||||||
log := logger.WithSource(a.log, ioLogSource)
|
log := logger.WithSource(a.log, ioLogSource)
|
||||||
ws := a.cfg.WebSearch
|
ws, source := a.effectiveWebSearch(log)
|
||||||
|
|
||||||
// ① 后端显式关闭(WEB_SEARCH_PROVIDER = 空 / off / none)⇒ 整件工具不注册。
|
// ① 后端显式关闭(主 provider = 空 / off / none)⇒ 整件工具不注册。
|
||||||
if decision := ws.ResolveProvider(); decision.Disabled {
|
if decision := ws.ResolveProvider(); decision.Disabled {
|
||||||
// 配置值**绝不写进日志**(只记键名与原因)。
|
// 配置值**绝不写进日志**(只记键名与原因)。
|
||||||
log.Warn("web-search tool not registered",
|
log.Warn("web-search tool not registered",
|
||||||
"env", webSearchProviderEnv, "reason", config.WebSearchReasonOff,
|
"env", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "source", source,
|
||||||
"effect", "the model will not see a web-search tool at all")
|
"effect", "the model will not see a web-search tool at all")
|
||||||
return []iotools.Option{iotools.WithoutSearch()}
|
return []iotools.Option{iotools.WithoutSearch()}
|
||||||
}
|
}
|
||||||
|
|
||||||
specs := a.searchProviderSpecs()
|
specs := searchProviderSpecs(ws)
|
||||||
|
|
||||||
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
|
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
|
||||||
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
|
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
|
||||||
// 错误,因此不 fatal,只留一条带键名的 WARN。
|
// 错误,因此不 fatal,只留一条带键名的 WARN。
|
||||||
if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && a.selectsSearXNG() {
|
if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && selectsSearXNG(ws) {
|
||||||
log.Warn("web-search tool not registered",
|
log.Warn("web-search tool not registered",
|
||||||
"env", webSearchEndpointEnv, "reason", endpoint.Reason,
|
"env", webSearchEndpointEnv, "reason", endpoint.Reason, "source", source,
|
||||||
"effect", "the model will not see a web-search tool at all")
|
"effect", "the model will not see a web-search tool at all")
|
||||||
return []iotools.Option{iotools.WithoutSearch()}
|
return []iotools.Option{iotools.WithoutSearch()}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("web-search providers configured",
|
log.Info("web-search providers configured",
|
||||||
"env", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)),
|
"env", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)),
|
||||||
"env_fallback", webSearchFallbackEnv, "fallback", ws.Fallback,
|
"env_fallback", webSearchFallbackEnv, "fallback", ws.Fallback, "source", source,
|
||||||
"available", availableProviderNames(specs))
|
"available", availableProviderNames(specs))
|
||||||
|
|
||||||
opts := []iotools.Option{
|
opts := []iotools.Option{
|
||||||
@@ -96,43 +111,206 @@ func (a *app) webSearchOptions() []iotools.Option {
|
|||||||
opts = append(opts, iotools.WithSearchTimeout(ws.Timeout))
|
opts = append(opts, iotools.WithSearchTimeout(ws.Timeout))
|
||||||
case ws.Timeout < 0:
|
case ws.Timeout < 0:
|
||||||
log.Warn("web-search timeout is negative, treated as unset",
|
log.Warn("web-search timeout is negative, treated as unset",
|
||||||
"env", webSearchTimeoutEnv, "effect", "io package default timeout applies")
|
"env", webSearchTimeoutEnv, "source", source,
|
||||||
|
"effect", "io package default timeout applies")
|
||||||
}
|
}
|
||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。
|
// effectiveWebSearch 解析 web-search 的**生效配置**,并报告它是从哪儿来的:
|
||||||
func (a *app) selectsSearXNG() bool {
|
//
|
||||||
selected := strings.TrimSpace(a.cfg.WebSearch.Provider)
|
// - 面板文件存在且合法 ⇒ 它是唯一来源(design/09 §5.7 E 的「先读可变源」);
|
||||||
return strings.EqualFold(selected, search.NameSearXNG)
|
// - 文件缺失/为空 ⇒ 用 `.env` 播种落盘,本次行为与 `.env` 完全一致(「缺失回落 `.env`
|
||||||
|
// 播种」,照 models.json materialize 范式);
|
||||||
|
// - 文件存在但非法 ⇒ 记 WARN、本次回落 `.env`,**不覆盖**原文件。
|
||||||
|
//
|
||||||
|
// 返回值里的第二个值是来源标签,只用于日志(`.env` 还是面板),不参与任何裁决。
|
||||||
|
func (a *app) effectiveWebSearch(log *slog.Logger) (config.WebSearchConfig, string) {
|
||||||
|
env := a.cfg.WebSearch
|
||||||
|
path := runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg))
|
||||||
|
f, found, err := runtimeconfig.LoadWebSearch(path)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
log.Warn("web-search 面板配置非法,本次按 .env 运行(不覆盖原文件)",
|
||||||
|
"path", path, "error", err)
|
||||||
|
return env, webSearchSourceEnv
|
||||||
|
case !found:
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
log.Info("web-search 面板配置生效", "path", path, "providers", len(f.Providers))
|
||||||
|
return eff, webSearchSourcePanel
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchProviderSpecs 把 `WEB_SEARCH_*` 配置翻译成各家接线(一家一行)。
|
// seedWebSearchConfig 把 `.env` 播种成面板文件(`.env` 只作引导面)。
|
||||||
|
//
|
||||||
|
// 播种失败**不阻断启动**:本次按 `.env` 运行,只记一条 WARN(与 models.json 的处置一致)。
|
||||||
|
func (a *app) seedWebSearchConfig(log *slog.Logger, path string, env config.WebSearchConfig) config.WebSearchConfig {
|
||||||
|
seed := seedWebSearchFile(env)
|
||||||
|
if err := runtimeconfig.SaveWebSearch(path, seed); err != nil {
|
||||||
|
log.Warn("web-search 面板配置播种失败(本次按 .env 运行)", "path", path, "error", err)
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
log.Info("web-search 面板配置已按 .env 播种(此后面板是唯一来源)",
|
||||||
|
"path", path, "providers", len(seed.Providers))
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedWebSearchFile 把 `.env` 的 web-search 段投影成可变源的初始形状。
|
||||||
|
//
|
||||||
|
// **本期内置的家全列**(含还没配 key 的):面板的用途就是「一次把各家配齐」,
|
||||||
|
// 让用户先看到完整清单、再逐条填,比只列已配置的家少一轮「名字得自己知道」。
|
||||||
|
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) {
|
||||||
|
entries = append(entries, runtimeconfig.ProviderEntry{
|
||||||
|
Provider: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, Enabled: enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return runtimeconfig.WebSearchFile{
|
||||||
|
Version: runtimeconfig.WebSearchVersion,
|
||||||
|
Provider: ws.Provider,
|
||||||
|
Fallback: ws.Fallback,
|
||||||
|
MaxResults: ws.MaxResults,
|
||||||
|
Timeout: seedTimeout(ws.Timeout),
|
||||||
|
Providers: entries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveSearchConfig 把面板文件翻译成工具要的配置。
|
||||||
|
//
|
||||||
|
// **权威边界**(写清楚,免得「谁覆盖谁」靠猜):
|
||||||
|
//
|
||||||
|
// - **结构以文件为准**:主 provider / 降级链 / 各家条目都来自文件 —— 文件里没有的家就是
|
||||||
|
// 没有(绝不从 `.env` 复活),这正是「面板可改」的意思;
|
||||||
|
// - **凭据以文件为准**:某家的 apiKey 为空 = 这家没配(绝不拿 `.env` 的 key 顶上,
|
||||||
|
// 否则「我在面板里删了它」会变成一句假话);
|
||||||
|
// - **只有「没写」才回落 `.env`(= 代码默认值)**:maxResults / timeout / 云家的 baseUrl。
|
||||||
|
// 前两个标量在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分;
|
||||||
|
// 云家 baseUrl 回落到厂商默认地址(它是代码默认值,不是凭据),searxng 的地址**不回落**
|
||||||
|
// —— 在 searxng 身上「地址为空」就是「关掉这家」的开关语义,回落会让"清空地址"变成假话。
|
||||||
|
func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (config.WebSearchConfig, error) {
|
||||||
|
out := env
|
||||||
|
out.Provider = f.Provider
|
||||||
|
out.Fallback = append([]string(nil), f.Fallback...)
|
||||||
|
if f.MaxResults > 0 {
|
||||||
|
out.MaxResults = f.MaxResults
|
||||||
|
}
|
||||||
|
d, err := f.TimeoutDuration()
|
||||||
|
if err != nil {
|
||||||
|
return env, err
|
||||||
|
}
|
||||||
|
if d > 0 {
|
||||||
|
out.Timeout = d
|
||||||
|
}
|
||||||
|
// 凭据一律清零后按条目回填:文件没给的家就是没配。
|
||||||
|
out.ZhipuAPIKey = ""
|
||||||
|
out.AliyunIQSAPIKey = ""
|
||||||
|
out.BochaAPIKey = ""
|
||||||
|
out.VolcengineAPIKey = ""
|
||||||
|
out.BaiduQianfanAPIKey = ""
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnUnknownSearchProviders 对文件里本期没登记的家记一条 WARN。
|
||||||
|
//
|
||||||
|
// 它们不可能被选中(选中的家由 io 工具集在启动期校验),但「配了却不生效」必须说出来 ——
|
||||||
|
// 静默忽略正是「我明明配了 Tavily 却搜不出」那类报障的来源。
|
||||||
|
func warnUnknownSearchProviders(log *slog.Logger, path string, f runtimeconfig.WebSearchFile) {
|
||||||
|
for _, e := range f.Providers {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(e.Provider))
|
||||||
|
if name == "" || search.Known(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Warn("web-search 面板配置里有未登记的提供商,已忽略",
|
||||||
|
"path", path, "provider", name, "known", search.KnownNames())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。
|
||||||
|
func selectsSearXNG(ws config.WebSearchConfig) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(ws.Provider), search.NameSearXNG)
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchProviderWire 是「一家云 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;
|
// Configured 的口径由 search.Configured 统一判定:searxng 看端点、云家看 API key;
|
||||||
// false ⇒ 该家不进注册表、不出现在可用清单里。**密钥只在这里流转,绝不写日志。**
|
// false ⇒ 该家不进注册表、不出现在可用清单里。**密钥只在这里流转,绝不写日志。**
|
||||||
func (a *app) searchProviderSpecs() []iotools.SearchProviderSpec {
|
func searchProviderSpecs(ws config.WebSearchConfig) []iotools.SearchProviderSpec {
|
||||||
ws := a.cfg.WebSearch
|
|
||||||
endpoint := ws.ResolveEndpoint()
|
endpoint := ws.ResolveEndpoint()
|
||||||
|
|
||||||
specs := []iotools.SearchProviderSpec{{
|
specs := []iotools.SearchProviderSpec{{
|
||||||
Name: search.NameSearXNG,
|
Name: search.NameSearXNG,
|
||||||
BaseURL: endpoint.URL,
|
BaseURL: endpoint.URL,
|
||||||
Configured: endpoint.Enabled,
|
Configured: endpoint.Enabled,
|
||||||
}}
|
}}
|
||||||
// 各家独立一组键(design/27 §3):新增一家 = 这里加一行 + search 包加一个文件。
|
// 各家独立一组键(design/27 §3):新增一家 = searchProviderVector 加一行 + 一个文件。
|
||||||
for _, p := range []struct{ name, key, base string }{
|
for _, p := range searchProviderVector(ws) {
|
||||||
{search.NameZhipu, ws.ZhipuAPIKey, ws.ZhipuBaseURL},
|
|
||||||
{search.NameAliyunIQS, ws.AliyunIQSAPIKey, ws.AliyunIQSBaseURL},
|
|
||||||
{search.NameBocha, ws.BochaAPIKey, ws.BochaBaseURL},
|
|
||||||
{search.NameVolcengine, ws.VolcengineAPIKey, ws.VolcengineBaseURL},
|
|
||||||
{search.NameBaiduQianfan, ws.BaiduQianfanAPIKey, ws.BaiduQianfanBaseURL},
|
|
||||||
} {
|
|
||||||
specs = append(specs, iotools.SearchProviderSpec{
|
specs = append(specs, iotools.SearchProviderSpec{
|
||||||
Name: p.name,
|
Name: p.Name,
|
||||||
APIKey: p.key,
|
APIKey: p.APIKey,
|
||||||
BaseURL: p.base,
|
BaseURL: p.BaseURL,
|
||||||
Configured: search.Configured(p.name, p.key, p.base),
|
Configured: search.Configured(p.Name, p.APIKey, p.BaseURL),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return specs
|
return specs
|
||||||
@@ -149,3 +327,28 @@ func availableProviderNames(specs []iotools.SearchProviderSpec) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedEndpoint 把 `WEB_SEARCH_ENDPOINT` 的取值播进 searxng 条目:显式关闭的哨兵落成空串。
|
||||||
|
func seedEndpoint(endpoint string) string {
|
||||||
|
if config.IsWebSearchOffSentinel(endpoint) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedTimeout 把超时播成 duration 字符串:未声明 / 笔误(≤0)落成空 = 未声明。
|
||||||
|
func seedTimeout(d time.Duration) string {
|
||||||
|
if d <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// orDefault 在 value 去空白后非空时用它,否则用 fallback(云家 baseUrl 的「没写就回落
|
||||||
|
// 代码默认值」口径,见 effectiveSearchConfig 的权威边界)。
|
||||||
|
func orDefault(value, fallback string) string {
|
||||||
|
if s := strings.TrimSpace(value); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件钉住 PLG-4:web-search 的**可变源**(`$DATA_DIR/config/plugins/web-search.json`)
|
||||||
|
// 在组合根里的语义 —— 「先读可变源、缺失回落 `.env` 播种」(design/09 §5.7 E):
|
||||||
|
// `.env` 从此只是**引导面**(缺失时播种一次),面板文件才是配置面。
|
||||||
|
//
|
||||||
|
// 单独成文件的理由:`wire_web_search_test.go` 已经接近文件行数上限(AGENTS §10),
|
||||||
|
// 而这一组是另一片(PLG-4)的判据,混进去会同时踩两条纪律。
|
||||||
|
|
||||||
|
// wsPanelPath 返回某份配置对应的可变源落点。
|
||||||
|
func wsPanelPath(cfg *config.Config) string {
|
||||||
|
return runtimeconfig.WebSearchConfigPath(cfg.DataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsWriteRawPanel 手写一份面板配置(建好父目录;用于模拟人工救回 / 损坏文件)。
|
||||||
|
func wsWriteRawPanel(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative 是可变源的主判据:
|
||||||
|
// - 文件缺失 ⇒ 由 `.env` 播种落盘(本次行为与 `.env` 一致),且**凭据不进日志**;
|
||||||
|
// - 播种之后 ⇒ 面板文件是**唯一来源**(`.env` 后来改成关闭也不影响它)。
|
||||||
|
func TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := runtimeconfig.WebSearchConfigPath(dir)
|
||||||
|
|
||||||
|
first := wsLoadConfig(t,
|
||||||
|
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||||
|
"WEB_SEARCH_BOCHA_API_KEY=sk-seed",
|
||||||
|
"WEB_SEARCH_BOCHA_BASE_URL=https://seed.example",
|
||||||
|
)
|
||||||
|
first.DataDir = dir
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if !wsHasTool(wsSurfaceApp(t, first, &buf), "web-search") {
|
||||||
|
t.Fatal("首次启动(.env 配好 bocha)应在注册面里")
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "已按 .env 播种") {
|
||||||
|
t.Fatalf("播种必须留下可诊断日志: %q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "sk-seed") {
|
||||||
|
t.Fatalf("播种日志泄漏了凭据: %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, found, err := runtimeconfig.LoadWebSearch(path)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("播种后文件必须在盘上: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if f.Provider != search.NameBocha {
|
||||||
|
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameBocha)
|
||||||
|
}
|
||||||
|
if len(f.Providers) != len(search.KnownNames()) {
|
||||||
|
t.Fatalf("播种必须列全本期内置的家:%d 条, want %d", len(f.Providers), len(search.KnownNames()))
|
||||||
|
}
|
||||||
|
seeded := false
|
||||||
|
for _, e := range f.Providers {
|
||||||
|
if e.Provider != search.NameBocha {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seeded = true
|
||||||
|
if e.APIKey != "sk-seed" || e.BaseURL != "https://seed.example" || !e.IsEnabled() {
|
||||||
|
t.Fatalf("播种的 bocha 条目 = %+v,want .env 的凭据/地址/启用", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seeded {
|
||||||
|
t.Fatal("播种里没有 bocha 条目")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二次启动:.env 明确关掉搜索 —— 面板文件仍是唯一来源(.env 只在缺失时播种一次)。
|
||||||
|
second := wsLoadConfig(t, "WEB_SEARCH_PROVIDER=")
|
||||||
|
second.DataDir = dir
|
||||||
|
if !wsHasTool(wsSurfaceApp(t, second, nil), "web-search") {
|
||||||
|
t.Fatal(".env 改成关闭后,面板文件(bocha 已配)仍应是唯一来源")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWireIOTools_PanelConfigWinsOverEnv 钉住「面板优先」不是一句口号:同一份 `.env` 下,
|
||||||
|
// 面板把 searxng 指到假上游,查询就必须真的打到那里(`.env` 的默认端点 127.0.0.1:38471
|
||||||
|
// 根本没被用)。
|
||||||
|
func TestWireIOTools_PanelConfigWinsOverEnv(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
hits := make(chan *http.Request, 1)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits <- r
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||||
|
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||||
|
Version: runtimeconfig.WebSearchVersion,
|
||||||
|
Provider: search.NameSearXNG,
|
||||||
|
MaxResults: 5,
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: search.NameSearXNG, BaseURL: srv.URL + "/search", Enabled: runtimeconfig.EnableFlag(true)},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("写面板配置: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a := wsSurfaceApp(t, cfg, nil)
|
||||||
|
tl, err := a.caps.Get("web-search")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("web-search 不在注册面里: %v", err)
|
||||||
|
}
|
||||||
|
exec, ok := tl.Value.(interface {
|
||||||
|
Execute(context.Context, any) (any, error)
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
|
||||||
|
}
|
||||||
|
if _, err := exec.Execute(context.Background(), map[string]any{"query": "cyrene"}); err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r := <-hits:
|
||||||
|
if r.URL.Path != "/search" {
|
||||||
|
t.Errorf("请求路径 = %q, want /search(面板里那个端点必须被原样使用)", r.URL.Path)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("面板里的端点一个请求都没收到 —— 可变源没被读进去(还在用 .env 的默认端点)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWireIOTools_PanelConfigCorruptFallsBackToEnv:非法文件 ⇒ 记 WARN、回落 `.env`、
|
||||||
|
// **不覆盖**原文件(损坏的配置可以人工救回)。
|
||||||
|
func TestWireIOTools_PanelConfigCorruptFallsBackToEnv(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t,
|
||||||
|
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||||
|
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||||||
|
)
|
||||||
|
path := wsPanelPath(cfg)
|
||||||
|
const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}`
|
||||||
|
wsWriteRawPanel(t, path, broken)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if !wsHasTool(wsSurfaceApp(t, cfg, &buf), "web-search") {
|
||||||
|
t.Fatal("面板配置非法时必须回落 .env(这份 .env 里 bocha 是配好的)")
|
||||||
|
}
|
||||||
|
if out := buf.String(); !strings.Contains(out, "面板配置非法") {
|
||||||
|
t.Fatalf("非法文件必须留下可诊断的 WARN: %q", out)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if string(raw) != broken {
|
||||||
|
t.Fatalf("回落 .env 时不得覆盖原文件:%q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWireIOTools_PanelConfigUnknownProviderIsWarned 钉住「配了却不生效必须说出来」:
|
||||||
|
// 面板文件里出现本期没登记的家 ⇒ 一条带名字的 WARN(绝不静默忽略),且不拖累整把工具。
|
||||||
|
func TestWireIOTools_PanelConfigUnknownProviderIsWarned(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||||
|
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||||
|
Version: runtimeconfig.WebSearchVersion,
|
||||||
|
Provider: search.NameSearXNG,
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"},
|
||||||
|
{Provider: "tavily", APIKey: "sk-not-impl"},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("写面板配置: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if !wsHasTool(wsSurfaceApp(t, cfg, &buf), "web-search") {
|
||||||
|
t.Fatal("未登记的家不该把整把工具拖下水(它只是被忽略)")
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "未登记的提供商") || !strings.Contains(out, "tavily") {
|
||||||
|
t.Fatalf("未登记的家必须留下带名字的 WARN: %q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "sk-not-impl") {
|
||||||
|
t.Fatalf("WARN 泄漏了凭据: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv 钉住「凭据以文件为准」:
|
||||||
|
// 面板文件里**没有**这一家(或它的 key 为空)= 这家没配,**不得**从 `.env` 的旧 key 复活
|
||||||
|
// —— 否则「我在面板里删了它」会变成一句假话。主 provider 此时没配齐 ⇒ 按既有语义启动报错。
|
||||||
|
func TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t,
|
||||||
|
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||||||
|
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||||||
|
"WEB_SEARCH_BOCHA_BASE_URL=https://env.example",
|
||||||
|
)
|
||||||
|
// 面板文件里只留 searxng:bocha 在文件里**不存在**。
|
||||||
|
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||||
|
Version: runtimeconfig.WebSearchVersion,
|
||||||
|
Provider: search.NameBocha,
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("写面板配置: %v", err)
|
||||||
|
}
|
||||||
|
err := wsNewTools(t, cfg)
|
||||||
|
if !errors.Is(err, search.ErrNotConfigured) {
|
||||||
|
t.Fatalf("err = %v, want errors.Is(..., ErrNotConfigured):文件里没有的家不得从 .env 复活", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWireIOTools_PanelSeedNormalizesOffSentinel 钉住播种时的关闭哨兵归一化:
|
||||||
|
// `WEB_SEARCH_ENDPOINT=off` 落盘应写成**空地址**(语义相同:不出这家),而不是把
|
||||||
|
// 字面量 "off" 当成地址存进面板(面板里不该出现一个不是地址的地址)。
|
||||||
|
func TestWireIOTools_PanelSeedNormalizesOffSentinel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_ENDPOINT=off")
|
||||||
|
if wsHasTool(wsSurfaceApp(t, cfg, nil), "web-search") {
|
||||||
|
t.Fatal("端点显式关闭 ⇒ web-search 不得进注册面")
|
||||||
|
}
|
||||||
|
f, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg))
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("播种后文件必须在盘上: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
for _, e := range f.Providers {
|
||||||
|
if e.Provider != search.NameSearXNG {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.BaseURL != "" {
|
||||||
|
t.Fatalf("关闭哨兵必须落成空串,得到 %q", e.BaseURL)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("播种里没有 searxng 条目")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWireIOTools_PanelConfigDisabledProviderIsNotConfigured 钉住「停用态真的生效」:
|
||||||
|
// 文件里把 searxng 停用(enabled=false)而它又是主 provider ⇒ 工具不进注册面
|
||||||
|
// (与 `.env` 里把端点写成 off 的既有语义一致,绝不静默回落到 .env 的端点)。
|
||||||
|
func TestWireIOTools_PanelConfigDisabledProviderIsNotConfigured(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||||
|
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||||
|
Version: runtimeconfig.WebSearchVersion,
|
||||||
|
Provider: search.NameSearXNG,
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471", Enabled: runtimeconfig.EnableFlag(false)},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("写面板配置: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
a := wsSurfaceApp(t, cfg, &buf)
|
||||||
|
if wsHasTool(a, "web-search") {
|
||||||
|
t.Fatalf("searxng 被停用后 web-search 不得进注册面,实际 = %v", toolNames(a))
|
||||||
|
}
|
||||||
|
for _, name := range []string{"file", "http", "web-fetch", "knowledge", "document-read"} {
|
||||||
|
if !wsHasTool(a, name) {
|
||||||
|
t.Errorf("%q 不得因 web-search 停用而消失", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out := buf.String(); !strings.Contains(out, "web-search tool not registered") {
|
||||||
|
t.Fatalf("关闭必须留一条可诊断的 WARN: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,12 +39,17 @@ func wsTestEnvFile(t *testing.T, extra ...string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wsLoadConfig 按给定 .env 行加载配置(进程环境优先级更高,由 config 层负责)。
|
// wsLoadConfig 按给定 .env 行加载配置(进程环境优先级更高,由 config 层负责)。
|
||||||
|
//
|
||||||
|
// DataDir 一律指到本用例自己的临时目录:web-search 的可变源
|
||||||
|
// (`$DATA_DIR/config/plugins/web-search.json`,PLG-4)会**按启动期语义**读/播种它,
|
||||||
|
// 默认的 `./data` 会让并行用例互相踩同一份文件(且把仓库工作目录写脏)。
|
||||||
func wsLoadConfig(t *testing.T, extra ...string) *config.Config {
|
func wsLoadConfig(t *testing.T, extra ...string) *config.Config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cfg, err := config.Load(wsTestEnvFile(t, extra...))
|
cfg, err := config.Load(wsTestEnvFile(t, extra...))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config.Load: %v", err)
|
t.Fatalf("config.Load: %v", err)
|
||||||
}
|
}
|
||||||
|
cfg.DataDir = t.TempDir()
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// websearch.go 是 web-search 插件的**可变源**:`$DATA_DIR/config/plugins/web-search.json`
|
||||||
|
// (design/13 §2.1「插件配置数据一律进 config/plugins/<插件 id>.json,文件名 = 插件 id」;
|
||||||
|
// design/09 §5.7 E 的首个用例 = 搜索 provider 配置页 —— 让多个第三方搜索 provider
|
||||||
|
// 在面板里配,不再靠 `.env`)。
|
||||||
|
//
|
||||||
|
// 三件事划清边界:
|
||||||
|
//
|
||||||
|
// 1. 本文件只管**文件形状 + 读写 + 结构校验**:它不认识 `WEB_SEARCH_*` 键、不认识
|
||||||
|
// `config.WebSearchConfig`,也不认识任何一家 provider 的名字。「把 `.env` 播种成
|
||||||
|
// 这份形状」与「把这份形状读回工具要的配置」是**组合根**的事
|
||||||
|
// (`cmd/cyrene/wire_io_tools.go`)—— 那里本来就只有一份 provider 名字表,
|
||||||
|
// 多一份就是第二份真源。
|
||||||
|
// 2. **清单 vs 数据分离**(design/13 §2.1):`config/plugins.json` 只有插件管理器自己的
|
||||||
|
// 清单;插件落下来的配置数据一个插件一个文件。
|
||||||
|
// 3. 凭据只落盘、**绝不进日志/响应**:读侧出口只有 `MaskedWebSearchFile`。
|
||||||
|
//
|
||||||
|
// 读写口径与 `plugins.json` / `models.json` 同源:JSON、0600、temp+rename(并发读者永远
|
||||||
|
// 看不到半个文件)。文件缺失/为空 = 「还没配过」,由调用方用 `.env` 播种,**不是错误**。
|
||||||
|
package runtimeconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 文件形状的常量。
|
||||||
|
const (
|
||||||
|
// webSearchPluginID 是 web-search 插件的 id。文件名 = 插件 id(design/13 §2.1 的硬约定,
|
||||||
|
// 它让落地文件能人工对上插件;插件声明侧的同名常量在 plugins/tools/io/search,
|
||||||
|
// 两边由 search 包的测试钉住同源)。
|
||||||
|
webSearchPluginID = "web-search"
|
||||||
|
// webSearchConfigSubdir / webSearchPluginsDir 是落点的两段目录
|
||||||
|
// (`$DATA_DIR/config/plugins/<插件 id>.json`)。
|
||||||
|
webSearchConfigSubdir = "config"
|
||||||
|
webSearchPluginsDir = "plugins"
|
||||||
|
// WebSearchVersion 是当前形状版本。读盘时接受 0(人工手写没写版本)与它,拒绝更高版本
|
||||||
|
// —— 未来版本的文件用今天的语义去解释,只会读出一份**看起来正常**的错配置。
|
||||||
|
WebSearchVersion = 1
|
||||||
|
// webSearchFileMode 是落盘权限:文件会带 API Key。
|
||||||
|
webSearchFileMode = 0o600
|
||||||
|
// webSearchDirMode 是落盘目录权限。
|
||||||
|
webSearchDirMode = 0o755
|
||||||
|
)
|
||||||
|
|
||||||
|
// webSearchProviderRe 是 provider 名的字符集。本仓内置名全是小写字母 + 下划线
|
||||||
|
// (searxng / zhipu / aliyun_iqs / bocha / volcengine / baidu_qianfan),限定字符集是为了
|
||||||
|
// 让名字能直接当端点段、文件名与日志字段用。
|
||||||
|
//
|
||||||
|
// 它**不**判定「这个名字是否已登记」—— 那是 `plugins/tools/io/search` 的事实,本包不去
|
||||||
|
// 反向依赖插件包(组合根负责核对并记 WARN)。
|
||||||
|
var webSearchProviderRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||||
|
|
||||||
|
// ErrInvalidWebSearch 是 web-search.json 结构不合法时的分类错误(errors.Is 可判类)。
|
||||||
|
var ErrInvalidWebSearch = errors.New("runtimeconfig: invalid web-search config")
|
||||||
|
|
||||||
|
// WebSearchConfigPath 返回 web-search 面板配置的落点:
|
||||||
|
// `$DATA_DIR/config/plugins/web-search.json`。
|
||||||
|
func WebSearchConfigPath(dataDir string) string {
|
||||||
|
return filepath.Join(dataDir, webSearchConfigSubdir, webSearchPluginsDir, webSearchPluginID+".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableFlag 返回 ProviderEntry.Enabled 的三态指针(`&true` / `&false` 的简写)。
|
||||||
|
func EnableFlag(v bool) *bool { return &v }
|
||||||
|
|
||||||
|
// ProviderEntry 是一条 provider 配置 —— 「多实例」= providers 数组里的多条,
|
||||||
|
// 每家**独立** key / 基地址 / 启用态,互不影响(design/27 §1.2)。
|
||||||
|
type ProviderEntry struct {
|
||||||
|
// Provider 是 provider 名。它在文件内**唯一**:注册表、主 provider 与降级链都以名字
|
||||||
|
// 为键,同名两条在语义上无法区分(校验期直接拒掉,而不是「后者覆盖前者」这种静默取一)。
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
// APIKey 是上游凭据。**绝不进日志/响应**(读侧经 MaskedWebSearchFile)。
|
||||||
|
APIKey string `json:"apiKey,omitempty"`
|
||||||
|
// BaseURL 是上游基地址(searxng 的基地址就是原有 `WEB_SEARCH_ENDPOINT` 的端点)。
|
||||||
|
BaseURL string `json:"baseUrl,omitempty"`
|
||||||
|
// Enabled 是启用态。三态:缺省(nil)与 true 都算**启用**,显式 false 才是停用 ——
|
||||||
|
// 用指针而不是 bool,是为了让「手写文件只填了 provider 与 apiKey」这种常见情形不被
|
||||||
|
// 误判成停用(与 sdk.When.Enabled 同一取舍)。
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled 报告该条是否启用(缺省视为启用)。
|
||||||
|
func (e ProviderEntry) IsEnabled() bool { return e.Enabled == nil || *e.Enabled }
|
||||||
|
|
||||||
|
// WebSearchFile 是 `web-search.json` 的形状。
|
||||||
|
type WebSearchFile struct {
|
||||||
|
// Version 是形状版本(0 = 人工手写没写版本,按当前版本读)。
|
||||||
|
Version int `json:"version"`
|
||||||
|
// Provider 是主 provider,取值语义与 `WEB_SEARCH_PROVIDER` **完全一致**:空串 / off /
|
||||||
|
// none(大小写与空白不敏感)是「显式关闭」的哨兵,由组合根按同一套裁决处理
|
||||||
|
// —— 面板与 `.env` 不许有第二套关闭语义。
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
// Fallback 是降级链,从左到右试;空 = 只用手选的那一家。
|
||||||
|
Fallback []string `json:"fallback,omitempty"`
|
||||||
|
// MaxResults 是单次检索的条数上限;0 = 未声明,由组合根回落到代码默认值。
|
||||||
|
MaxResults int `json:"maxResults,omitempty"`
|
||||||
|
// Timeout 是单次上游尝试的超时(Go duration 字符串,如 "20s");空 = 未声明。
|
||||||
|
// 用字符串而不是纳秒整数,是因为这份文件是给人看、给人改的。
|
||||||
|
Timeout string `json:"timeout,omitempty"`
|
||||||
|
// Providers 是各家的接线。允许为空数组(= 一家都没配 ⇒ 组合根不注册 web-search)。
|
||||||
|
Providers []ProviderEntry `json:"providers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeoutDuration 解析 Timeout:未声明(空/全空白)⇒ 0(= 沿用代码默认值)。
|
||||||
|
// 写法非法或非正数 ⇒ ErrInvalidWebSearch(非正数的超时是笔误,不是「关掉超时」)。
|
||||||
|
func (f WebSearchFile) TimeoutDuration() (time.Duration, error) {
|
||||||
|
s := strings.TrimSpace(f.Timeout)
|
||||||
|
if s == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: timeout %q 不是合法的 Go duration: %w", ErrInvalidWebSearch, s, err)
|
||||||
|
}
|
||||||
|
if d <= 0 {
|
||||||
|
return 0, fmt.Errorf("%w: timeout 必须为正(得到 %q)", ErrInvalidWebSearch, s)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateWebSearch 校验文件的结构。
|
||||||
|
//
|
||||||
|
// 刻意**不**校验「provider 名是否已登记」:那是插件自己的事实(search.Known),
|
||||||
|
// 本包反向依赖插件包会把配置层与插件层绑死。未登记的名字由组合根记一条 WARN 并忽略
|
||||||
|
// (它不可能被选中,因为选中的家由 io 工具集在启动期校验)。
|
||||||
|
func ValidateWebSearch(f WebSearchFile) error {
|
||||||
|
if f.Version < 0 || f.Version > WebSearchVersion {
|
||||||
|
return fmt.Errorf("%w: version %d 超出本仓支持的 0..%d",
|
||||||
|
ErrInvalidWebSearch, f.Version, WebSearchVersion)
|
||||||
|
}
|
||||||
|
if f.MaxResults < 0 {
|
||||||
|
return fmt.Errorf("%w: maxResults 不得为负(得到 %d)", ErrInvalidWebSearch, f.MaxResults)
|
||||||
|
}
|
||||||
|
if _, err := f.TimeoutDuration(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(f.Providers))
|
||||||
|
for i, e := range f.Providers {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(e.Provider))
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("%w: providers[%d].provider 必填", ErrInvalidWebSearch, i)
|
||||||
|
}
|
||||||
|
if !webSearchProviderRe.MatchString(name) {
|
||||||
|
return fmt.Errorf("%w: providers[%d].provider %q 非法:只允许小写字母、数字与下划线",
|
||||||
|
ErrInvalidWebSearch, i, e.Provider)
|
||||||
|
}
|
||||||
|
if seen[name] {
|
||||||
|
return fmt.Errorf("%w: provider %q 重复(同名两条无法区分:注册表与降级链都以名字为键)",
|
||||||
|
ErrInvalidWebSearch, name)
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadWebSearch 读取 web-search.json。
|
||||||
|
//
|
||||||
|
// found 报告「文件存在且非空」:缺失或全空白 = 还没配过,由调用方用 `.env` 播种
|
||||||
|
// (design/09 §5.7 E 的「先读可变源、缺失回落 `.env` 播种」),**不是错误**。
|
||||||
|
//
|
||||||
|
// 存在但不合法(解不开 / 结构不合法)⇒ 返回错误:调用方应记 WARN 并回落 `.env`,
|
||||||
|
// 且**不得覆盖**原文件 —— 损坏的配置可以人工救回,用 `.env` 盖掉它会把人工写的条目
|
||||||
|
// 一起丢掉(与 models.json 的同一处置)。
|
||||||
|
func LoadWebSearch(path string) (f WebSearchFile, found bool, err error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return WebSearchFile{}, false, nil
|
||||||
|
}
|
||||||
|
return WebSearchFile{}, false, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(raw)) == "" {
|
||||||
|
return WebSearchFile{}, false, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &f); err != nil {
|
||||||
|
return WebSearchFile{}, false, fmt.Errorf("%w: decode %s: %w", ErrInvalidWebSearch, path, err)
|
||||||
|
}
|
||||||
|
if err := ValidateWebSearch(f); err != nil {
|
||||||
|
return WebSearchFile{}, false, err
|
||||||
|
}
|
||||||
|
if f.Providers == nil {
|
||||||
|
f.Providers = []ProviderEntry{}
|
||||||
|
}
|
||||||
|
return f, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveWebSearch 把 f 原子写回 path:校验结构 → 建目录 → 写临时文件(0600)→ rename。
|
||||||
|
//
|
||||||
|
// temp+rename 的理由与 plugins.json 一致:并发读者要么看到旧文件、要么看到新文件,
|
||||||
|
// 不会读到半个 JSON。
|
||||||
|
func SaveWebSearch(path string, f WebSearchFile) error {
|
||||||
|
if err := ValidateWebSearch(f); err != nil {
|
||||||
|
return fmt.Errorf("runtimeconfig: save %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if f.Providers == nil {
|
||||||
|
f.Providers = []ProviderEntry{}
|
||||||
|
}
|
||||||
|
raw, err := json.MarshalIndent(f, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("runtimeconfig: marshal: %w", err)
|
||||||
|
}
|
||||||
|
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, webSearchDirMode); err != nil {
|
||||||
|
return fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, raw, webSearchFileMode); err != nil {
|
||||||
|
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
return fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertProviderEntry 返回把 e 插入或**就位替换**后的文件(其余条目的顺序不变)。
|
||||||
|
//
|
||||||
|
// 匹配键是 provider 名(大小写与首尾空白不敏感,写回时归一成小写):这样「面板里改一条
|
||||||
|
// 已存在的家」不会因为大小写不同而多出一条,也不会把顺序打乱(顺序 = 面板里的稳定顺序)。
|
||||||
|
func UpsertProviderEntry(f WebSearchFile, e ProviderEntry) WebSearchFile {
|
||||||
|
e.Provider = strings.ToLower(strings.TrimSpace(e.Provider))
|
||||||
|
out := make([]ProviderEntry, 0, len(f.Providers)+1)
|
||||||
|
replaced := false
|
||||||
|
for _, cur := range f.Providers {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(cur.Provider), e.Provider) {
|
||||||
|
out = append(out, e)
|
||||||
|
replaced = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, cur)
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
f.Providers = out
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskedWebSearchFile 返回读侧出口用的副本:每条 APIKey 只留尾 4 位(≤4 位则全掩),
|
||||||
|
// 其余字段原样。落盘的 `web-search.json` 不受影响。
|
||||||
|
//
|
||||||
|
// 这是本包唯一的「回给客户端」的出口:凭据读侧**永不回显明文**(design/09 §5.4.2)。
|
||||||
|
// 掩码后的值**不是**可直接回写的凭据 —— 「未改动」必须由写侧的显式哨兵表达。
|
||||||
|
func MaskedWebSearchFile(f WebSearchFile) WebSearchFile {
|
||||||
|
out := f
|
||||||
|
if f.Fallback != nil {
|
||||||
|
out.Fallback = append([]string(nil), f.Fallback...)
|
||||||
|
}
|
||||||
|
out.Providers = make([]ProviderEntry, 0, len(f.Providers))
|
||||||
|
for _, e := range f.Providers {
|
||||||
|
if strings.TrimSpace(e.APIKey) != "" {
|
||||||
|
e.APIKey = maskToken(strings.TrimSpace(e.APIKey))
|
||||||
|
}
|
||||||
|
out.Providers = append(out.Providers, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package runtimeconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件钉住 web-search 可变源(PLG-4 / design/13 §2.1)的形状与读写语义:
|
||||||
|
// 路径 = `$DATA_DIR/config/plugins/web-search.json`(文件名 = 插件 id)、
|
||||||
|
// 0600 + temp+rename、缺失/为空不是错误、非法文件报可分类的错、读侧掩码。
|
||||||
|
|
||||||
|
// wsPath 是本用例自己的落点(落临时目录,绝不碰仓库工作目录)。
|
||||||
|
func wsPath(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
return WebSearchConfigPath(t.TempDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsWriteFile 落一份手写的文件(建好父目录 —— 真实部署里 `$DATA_DIR/config/plugins/`
|
||||||
|
// 由播种或面板保存建出来,人工救回时也得自己建)。
|
||||||
|
func wsWriteFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ①:文件名 = 插件 id,落点在 `config/plugins/` 下(design/13 §2.1 的硬约定)。
|
||||||
|
func TestWebSearchConfigPathIsPluginIdNamed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := filepath.ToSlash(WebSearchConfigPath("/data"))
|
||||||
|
if want := "/data/config/plugins/web-search.json"; got != want {
|
||||||
|
t.Fatalf("路径 = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ②:文件缺失或全空白 = 「还没配过」,不是错误(组合根据此用 .env 播种)。
|
||||||
|
func TestLoadWebSearchMissingOrBlankIsNotAnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
path := wsPath(t)
|
||||||
|
if _, found, err := LoadWebSearch(path); err != nil || found {
|
||||||
|
t.Fatalf("缺失文件:found=%v err=%v,want false/nil", found, err)
|
||||||
|
}
|
||||||
|
wsWriteFile(t, path, " \n\t")
|
||||||
|
if _, found, err := LoadWebSearch(path); err != nil || found {
|
||||||
|
t.Fatalf("空白文件:found=%v err=%v,want false/nil", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ③:写盘 → 读回逐字一致(含三态 enabled:缺省 = 启用、显式 false = 停用)。
|
||||||
|
func TestSaveLoadWebSearchRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
path := wsPath(t)
|
||||||
|
in := WebSearchFile{
|
||||||
|
Version: WebSearchVersion,
|
||||||
|
Provider: "bocha",
|
||||||
|
Fallback: []string{"searxng", "zhipu"},
|
||||||
|
MaxResults: 5,
|
||||||
|
Timeout: "20s",
|
||||||
|
Providers: []ProviderEntry{
|
||||||
|
{Provider: "searxng", BaseURL: "http://127.0.0.1:38471", Enabled: EnableFlag(true)},
|
||||||
|
{Provider: "bocha", APIKey: "sk-x", BaseURL: "https://api.bocha.cn", Enabled: EnableFlag(false)},
|
||||||
|
{Provider: "zhipu"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := SaveWebSearch(path, in); err != nil {
|
||||||
|
t.Fatalf("SaveWebSearch: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("temp+rename 之后不得留下 .tmp(err=%v)", err)
|
||||||
|
}
|
||||||
|
out, found, err := LoadWebSearch(path)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("LoadWebSearch: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if out.Provider != in.Provider || out.MaxResults != in.MaxResults || out.Timeout != in.Timeout {
|
||||||
|
t.Fatalf("标量不往返:%+v", out)
|
||||||
|
}
|
||||||
|
if strings.Join(out.Fallback, ",") != strings.Join(in.Fallback, ",") {
|
||||||
|
t.Fatalf("fallback = %v, want %v", out.Fallback, in.Fallback)
|
||||||
|
}
|
||||||
|
if len(out.Providers) != len(in.Providers) {
|
||||||
|
t.Fatalf("providers 条数 = %d, want %d", len(out.Providers), len(in.Providers))
|
||||||
|
}
|
||||||
|
// 三态:显式 false 必须仍是 false;缺省(没写 enabled)读回来必须是**启用**。
|
||||||
|
if out.Providers[1].IsEnabled() {
|
||||||
|
t.Fatal("显式 enabled=false 读回来变成了启用")
|
||||||
|
}
|
||||||
|
if !out.Providers[2].IsEnabled() {
|
||||||
|
t.Fatal("没写 enabled 的条目必须是启用(缺省 = 启用)")
|
||||||
|
}
|
||||||
|
if got, err := out.TimeoutDuration(); err != nil || got != 20*time.Second {
|
||||||
|
t.Fatalf("TimeoutDuration = %v (err=%v), want 20s", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ③b:落盘权限 0600(文件可能带 API Key)。Windows 的 os.WriteFile 不表达 POSIX
|
||||||
|
// 权限位,故只在非 Windows 上断言 —— 不断言也不代表口径可以放宽,落盘调用本身就写了 0600。
|
||||||
|
func TestSaveWebSearchFileModeIs0600(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Windows 不表达 POSIX 权限位")
|
||||||
|
}
|
||||||
|
path := wsPath(t)
|
||||||
|
if err := SaveWebSearch(path, WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{}}); err != nil {
|
||||||
|
t.Fatalf("SaveWebSearch: %v", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat: %v", err)
|
||||||
|
}
|
||||||
|
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||||
|
t.Fatalf("权限 = %o, want 600", perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ④:结构非法一律拒绝,且错误可按 errors.Is 判类(调用方据此记 WARN 而不是崩)。
|
||||||
|
func TestSaveWebSearchRejectsInvalidShapes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
file WebSearchFile
|
||||||
|
}{
|
||||||
|
{"版本过高", WebSearchFile{Version: WebSearchVersion + 1}},
|
||||||
|
{"条数为负", WebSearchFile{Version: WebSearchVersion, MaxResults: -1}},
|
||||||
|
{"超时不可解析", WebSearchFile{Version: WebSearchVersion, Timeout: "20"}},
|
||||||
|
{"超时非正", WebSearchFile{Version: WebSearchVersion, Timeout: "-5s"}},
|
||||||
|
{"provider 空", WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{{}}}},
|
||||||
|
{"provider 含大写/横线", WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{{Provider: "Baidu-Qianfan"}}}},
|
||||||
|
{"provider 重复", WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{{Provider: "bocha"}, {Provider: "BOCHA"}}}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if err := SaveWebSearch(wsPath(t), tc.file); !errors.Is(err, ErrInvalidWebSearch) {
|
||||||
|
t.Fatalf("err = %v, want errors.Is(ErrInvalidWebSearch)", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ⑤:损坏的文件**报错但不被改写**(Load 只读;调用方据此回落 .env 并保留现场)。
|
||||||
|
func TestLoadWebSearchCorruptFileKeepsContents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
path := wsPath(t)
|
||||||
|
const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}`
|
||||||
|
wsWriteFile(t, path, broken)
|
||||||
|
if _, found, err := LoadWebSearch(path); err == nil || found {
|
||||||
|
t.Fatalf("非法文件必须报错:found=%v err=%v", found, err)
|
||||||
|
} else if !errors.Is(err, ErrInvalidWebSearch) {
|
||||||
|
t.Fatalf("err = %v, want errors.Is(ErrInvalidWebSearch)", err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if string(raw) != broken {
|
||||||
|
t.Fatalf("读侧不得改写原文件:%q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ⑥:upsert 按 provider 名就位替换(不打乱顺序、不因大小写多出一条)。
|
||||||
|
func TestUpsertProviderEntryReplacesInPlaceAndAppends(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f := WebSearchFile{Providers: []ProviderEntry{{Provider: "searxng"}, {Provider: "bocha"}}}
|
||||||
|
f = UpsertProviderEntry(f, ProviderEntry{Provider: " BOCHA ", APIKey: "sk-new"})
|
||||||
|
if len(f.Providers) != 2 {
|
||||||
|
t.Fatalf("条数 = %d, want 2(同名必须就位替换而不是追加)", len(f.Providers))
|
||||||
|
}
|
||||||
|
if f.Providers[1].Provider != "bocha" || f.Providers[1].APIKey != "sk-new" {
|
||||||
|
t.Fatalf("替换结果 = %+v(名字要归一化、顺序要保住)", f.Providers[1])
|
||||||
|
}
|
||||||
|
f = UpsertProviderEntry(f, ProviderEntry{Provider: "zhipu"})
|
||||||
|
if len(f.Providers) != 3 || f.Providers[2].Provider != "zhipu" {
|
||||||
|
t.Fatalf("新增必须追加到末尾:%+v", f.Providers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ⑦:读侧出口掩码凭据(只留尾 4 位),且落盘内容不受影响。
|
||||||
|
func TestMaskedWebSearchFileHidesCredentials(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
in := WebSearchFile{
|
||||||
|
Providers: []ProviderEntry{
|
||||||
|
{Provider: "bocha", APIKey: "sk-1234567890"},
|
||||||
|
{Provider: "zhipu", APIKey: "abc"},
|
||||||
|
{Provider: "searxng", BaseURL: "http://127.0.0.1:38471"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := MaskedWebSearchFile(in)
|
||||||
|
if out.Providers[0].APIKey != "****7890" {
|
||||||
|
t.Fatalf("掩码 = %q, want ****7890", out.Providers[0].APIKey)
|
||||||
|
}
|
||||||
|
if out.Providers[1].APIKey != "****" {
|
||||||
|
t.Fatalf("短凭据必须全掩,得到 %q", out.Providers[1].APIKey)
|
||||||
|
}
|
||||||
|
if in.Providers[0].APIKey != "sk-1234567890" {
|
||||||
|
t.Fatal("掩码不得改到原值(落盘内容必须原样)")
|
||||||
|
}
|
||||||
|
// 掩码后的值绝不是可直接回写的凭据,但仍要能看出「这家已配置」。
|
||||||
|
raw, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "sk-1234567890") {
|
||||||
|
t.Fatalf("读侧出口泄漏了明文:%s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,11 @@ func (w WebSearchConfig) ResolveProvider() ProviderDecision {
|
|||||||
return ProviderDecision{Selected: raw}
|
return ProviderDecision{Selected: raw}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsWebSearchOffSentinel 是 isOffSentinel 的导出形式,供**组合根**在把 `.env` 的
|
||||||
|
// web-search 段播种成面板可变源时复用同一套判定(PLG-4):播种要判「端点是不是显式关闭」,
|
||||||
|
// 若在别处重写一遍判定,两处迟早会漂移成不同的关闭语义。
|
||||||
|
func IsWebSearchOffSentinel(raw string) bool { return isOffSentinel(raw) }
|
||||||
|
|
||||||
// isOffSentinel 报告取值是否属于「显式关闭」的哨兵词(空串 / off / none,大小写与
|
// isOffSentinel 报告取值是否属于「显式关闭」的哨兵词(空串 / off / none,大小写与
|
||||||
// 首尾空白不敏感)。它同时服务 WEB_SEARCH_PROVIDER 与 WEB_SEARCH_ENDPOINT 两个键。
|
// 首尾空白不敏感)。它同时服务 WEB_SEARCH_PROVIDER 与 WEB_SEARCH_ENDPOINT 两个键。
|
||||||
func isOffSentinel(raw string) bool {
|
func isOffSentinel(raw string) bool {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
// 本文件是 web-search 插件向**面板**声明的管理页面:design/09 §5.2 的 `adminPanels`,
|
||||||
|
// 形状即 `pkg/plugins/sdk` 的 `Panel`。
|
||||||
|
//
|
||||||
|
// 声明只说三件事:**要什么字段、怎么渲染、动作叫什么**。它
|
||||||
|
//
|
||||||
|
// - 不注入任何 JS/CSS/HTML、
|
||||||
|
// - 不给 URL(只有动作名,走宿主的通用动作端点分发)、
|
||||||
|
// - 不开任何路由。
|
||||||
|
//
|
||||||
|
// 这三条是 design/09 §5.2.1 的红线:声明而非编码、声明里塞不进请求面(防 SSRF)、
|
||||||
|
// 面板数据一律经插件管理器的端点。渲染与流程由宿主负责,本文件只负责「说得清楚」。
|
||||||
|
|
||||||
|
import "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||||
|
|
||||||
|
// PanelPluginID 是本插件在面板体系里的 id。它与可变源的文件名**同源**:
|
||||||
|
// `$DATA_DIR/config/plugins/web-search.json`(design/13 §2.1:文件名 = 插件 id),
|
||||||
|
// 两边由 panel_test.go 钉住。
|
||||||
|
const PanelPluginID = "web-search"
|
||||||
|
|
||||||
|
// PanelIDProviders 是「搜索提供商」页的面板 id。
|
||||||
|
// 前端路由为 `/admin/plugin/web-search/providers`(design/09 §5.7 C)。
|
||||||
|
const PanelIDProviders = "providers"
|
||||||
|
|
||||||
|
// 本插件声明的**全部**动作名(design/09 §5.2 示例给出的那四个)。
|
||||||
|
//
|
||||||
|
// 表里的动作名一律取自这里,通用动作端点也只接受这几个名字(未声明 ⇒ 404/403,
|
||||||
|
// design/09 §5.2.1 B)。把「声明」与「动作名」放同一个文件,是为了让「表里写了一个
|
||||||
|
// 没实现的动作」这种半渲染在 review 时一眼可见 —— 它们就挨着。
|
||||||
|
const (
|
||||||
|
// ActionListProviders 列出全部 provider 配置(表格区块的数据源)。
|
||||||
|
ActionListProviders = "plugin.listProviders"
|
||||||
|
// ActionSaveProvider 新增 / 修改一条 provider 配置(表单提交)。
|
||||||
|
// 语义是 upsert:按 `provider` 名就位替换或追加。
|
||||||
|
ActionSaveProvider = "plugin.saveProvider"
|
||||||
|
// ActionTestProvider 测试连通:对一条 provider 配置真发一次最小检索(见 probe.go)。
|
||||||
|
ActionTestProvider = "plugin.testProvider"
|
||||||
|
// ActionReauthProvider 重配凭据:把该条带回表单。写侧的「未改动」必须用显式哨兵表达,
|
||||||
|
// **不能**靠空字符串(那会静默清空凭据,design/09 §5.4.2)。
|
||||||
|
ActionReauthProvider = "plugin.reauthProvider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActionNames 返回本插件声明的动作名,顺序与 AdminPanels 里出现的顺序一致
|
||||||
|
// (表格数据源 → 表单提交 → 行内动作)。调用方(插件管理器的动作端点)据此建白名单:
|
||||||
|
// 白名单外的动作名一律拒,绝不「先执行再看结果」。
|
||||||
|
func ActionNames() []string {
|
||||||
|
return []string{ActionListProviders, ActionSaveProvider, ActionTestProvider, ActionReauthProvider}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPanels 返回本插件声明的管理页面(design/09 §5.2 示例的 providers 页)。
|
||||||
|
//
|
||||||
|
// 两处相对示例的**明确增补**(示例是形状示范,不是字段清单的穷举;两处都被本片的任务书
|
||||||
|
// 要求):
|
||||||
|
// - 表单多一个 `enabled` 开关:任务书要求每家 provider **独立启用态**;
|
||||||
|
// - 表单的 `baseUrl` 用示例的 `endpoint` 语义(searxng 的端点 = 那家的基地址),
|
||||||
|
// 名字跟可变源的字段名走,免得面板字段名与文件字段名对不上。
|
||||||
|
func AdminPanels() []sdk.Panel {
|
||||||
|
return []sdk.Panel{{
|
||||||
|
ID: PanelIDProviders,
|
||||||
|
Title: "搜索提供商",
|
||||||
|
Description: "为 web-search 能力配置多个第三方搜索后端:每条独立凭据与基地址," +
|
||||||
|
"可单独启停并测试连通。改动落 $DATA_DIR/config/plugins/web-search.json,重启后仍然生效。",
|
||||||
|
Group: sdk.GroupPlugins,
|
||||||
|
Order: 20,
|
||||||
|
Icon: "search",
|
||||||
|
// 未启用的插件 ⇒ 入口不出现(design/09 §5.2.1 nav.item)。web-search 是编进二进制的
|
||||||
|
// 内置能力,没有卸载路径 ⇒ 恒为启用;这里写条件是为了让「启用事实」由插件管理器
|
||||||
|
// 给出(单一事实来源),而不是由面板猜。
|
||||||
|
When: &sdk.When{Enabled: boolFlag(true)},
|
||||||
|
Sections: []sdk.Section{
|
||||||
|
{
|
||||||
|
Type: sdk.SectionForm,
|
||||||
|
ID: "add",
|
||||||
|
Title: "添加 / 修改提供商",
|
||||||
|
Fields: []sdk.Field{
|
||||||
|
{Key: "provider", Label: "提供商", Type: sdk.FieldSelect,
|
||||||
|
OptionsFrom: "plugin:providers", Required: true},
|
||||||
|
// secret 字段刻意不标 required:它同时承担「编辑既有条目」的输入,
|
||||||
|
// 而编辑时不重填凭据是常态(写侧哨兵语义,见 ActionReauthProvider)。
|
||||||
|
{Key: "apiKey", Label: "API Key", Type: sdk.FieldSecret,
|
||||||
|
Placeholder: "留空 = 保持原值"},
|
||||||
|
{Key: "baseUrl", Label: "接口地址", Type: sdk.FieldText,
|
||||||
|
Placeholder: "https://..."},
|
||||||
|
{Key: "enabled", Label: "启用", Type: sdk.FieldSwitch},
|
||||||
|
},
|
||||||
|
Submit: &sdk.Submit{Action: ActionSaveProvider},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: sdk.SectionTable,
|
||||||
|
ID: "list",
|
||||||
|
Title: "已配置的提供商",
|
||||||
|
Source: &sdk.Source{Action: ActionListProviders},
|
||||||
|
// 列 key 与 panelrow.go 的行字段同源;凭据不进任何列(读侧无明文)。
|
||||||
|
Columns: []sdk.Column{
|
||||||
|
{Key: "provider", Label: "提供商"},
|
||||||
|
{Key: "state", Label: "状态"},
|
||||||
|
},
|
||||||
|
RowActions: []sdk.RowAction{
|
||||||
|
{ID: "test", Label: "测试连通", Action: ActionTestProvider},
|
||||||
|
{ID: "reauth", Label: "重配凭据", Action: ActionReauthProvider},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolFlag 是 `&true` / `&false` 的简写(sdk.When.Enabled 是三态指针)。
|
||||||
|
func boolFlag(v bool) *bool { return &v }
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件钉住 web-search 插件向面板声明的管理页面(design/09 §5.2 / §5.2.1):
|
||||||
|
// 声明必须能过宿主的**闭集校验**(否则 §5.2.1 的「非法声明 ⇒ 该面板不注册」会真的
|
||||||
|
// 把它挡在门外),动作名必须与 ActionNames() 同源,表格状态的语义必须可断言。
|
||||||
|
|
||||||
|
// 判据 ①:声明必须过宿主校验(这是「面板真的会出现」的唯一凭据)。
|
||||||
|
func TestAdminPanelsPassHostValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if err := paneldecl.Validate(PanelPluginID, AdminPanels()); err != nil {
|
||||||
|
t.Fatalf("宿主校验不通过(面板会被整体拒掉): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ②:面板里的每一个动作名都必须是 ActionNames() 里的成员(声明与动作表同源),
|
||||||
|
// 且 ActionNames() 本身无重复 —— 否则「声明了却没实现」或「白名单漏一个」都会变成
|
||||||
|
// 前端点了没反应的半渲染。
|
||||||
|
func TestAdminPanelsActionsAreDeclaredOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
declared := make(map[string]bool)
|
||||||
|
for _, n := range ActionNames() {
|
||||||
|
if declared[n] {
|
||||||
|
t.Fatalf("ActionNames() 里 %q 重复", n)
|
||||||
|
}
|
||||||
|
declared[n] = true
|
||||||
|
}
|
||||||
|
used := make([]string, 0, len(declared))
|
||||||
|
for _, p := range AdminPanels() {
|
||||||
|
for _, s := range p.Sections {
|
||||||
|
if s.Submit != nil {
|
||||||
|
used = append(used, s.Submit.Action)
|
||||||
|
}
|
||||||
|
if s.Source != nil {
|
||||||
|
used = append(used, s.Source.Action)
|
||||||
|
}
|
||||||
|
for _, a := range s.RowActions {
|
||||||
|
used = append(used, a.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(used) == 0 {
|
||||||
|
t.Fatal("声明里一个动作都没有:这张表点不动")
|
||||||
|
}
|
||||||
|
for _, a := range used {
|
||||||
|
if !declared[a] {
|
||||||
|
t.Errorf("面板声明了动作 %q,但它不在 ActionNames() 里(动作白名单会把它拒掉)", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 反向:声明的动作都必须真的出现在面板里(否则白名单里挂着一个点不到的名字)。
|
||||||
|
seen := make(map[string]bool, len(used))
|
||||||
|
for _, a := range used {
|
||||||
|
seen[a] = true
|
||||||
|
}
|
||||||
|
for _, n := range ActionNames() {
|
||||||
|
if !seen[n] {
|
||||||
|
t.Errorf("ActionNames() 里的 %q 在面板声明里没有用到", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ③:面板 id 与可变源的文件名同源(design/13 §2.1:文件名 = 插件 id)。
|
||||||
|
// 这两个字面量分处两个包,只有这条断言能把「改了插件 id 却忘了改文件落点」拦下来。
|
||||||
|
func TestPanelPluginIDMatchesConfigFileName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := runtimeconfig.WebSearchConfigPath("/data")
|
||||||
|
if !strings.HasSuffix(got, PanelPluginID+".json") {
|
||||||
|
t.Fatalf("可变源落点 %q 的文件名必须等于插件 id %q", got, PanelPluginID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ④:表格区块必须声明 source + columns + 行内动作(§5.2.1 的形状矩阵由宿主校验
|
||||||
|
// 兜底,这里再钉一层语义:这张表就是用来看状态与点动作的)。
|
||||||
|
func TestAdminPanelsTableShapeIsUsable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var table *sdk.Section
|
||||||
|
for _, p := range AdminPanels() {
|
||||||
|
for i := range p.Sections {
|
||||||
|
if p.Sections[i].Type == sdk.SectionTable {
|
||||||
|
table = &p.Sections[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if table == nil {
|
||||||
|
t.Fatal("声明里没有 table 区块:多实例列表无处可看")
|
||||||
|
}
|
||||||
|
if table.Source == nil || table.Source.Action != ActionListProviders {
|
||||||
|
t.Fatalf("表格数据源 = %+v, want %s", table.Source, ActionListProviders)
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(table.Columns))
|
||||||
|
for _, c := range table.Columns {
|
||||||
|
keys = append(keys, c.Key)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"provider", "state"} {
|
||||||
|
if !contains(keys, want) {
|
||||||
|
t.Errorf("表格列里缺 %q(行数据必须能显示出来): %v", want, keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 行内动作至少要有「测试连通」(本片任务书点名的动作)。
|
||||||
|
found := false
|
||||||
|
for _, a := range table.RowActions {
|
||||||
|
if a.Action == ActionTestProvider {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("表格行内动作里没有 %s: %+v", ActionTestProvider, table.RowActions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ⑤:表格行与列同源(列 key 都能在行数据里取到)。
|
||||||
|
func TestBuildRowsFieldsCoverDeclaredColumns(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
rows := BuildRows([]ProviderInput{
|
||||||
|
{Provider: NameBocha, APIKey: "sk-x", BaseURL: "https://api.bocha.cn", Enabled: true},
|
||||||
|
})
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("rows = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(rows[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var obj map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"provider", "state"} {
|
||||||
|
if _, ok := obj[want]; !ok {
|
||||||
|
t.Errorf("行数据缺列 key %q: %s", want, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "sk-x") {
|
||||||
|
t.Fatalf("行数据泄漏了凭据: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ⑥:state 的判定顺序(根因在前):未登记 > 停用 > 未配齐 > 可用。
|
||||||
|
func TestBuildRowsStates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in ProviderInput
|
||||||
|
state string
|
||||||
|
}{
|
||||||
|
{"已登记已配齐", ProviderInput{Provider: NameBocha, APIKey: "k", BaseURL: "https://b", Enabled: true}, StateOK},
|
||||||
|
{"名字没登记", ProviderInput{Provider: "tavily", APIKey: "k", BaseURL: "https://t", Enabled: true}, StateUnknown},
|
||||||
|
{"名字没登记且停用(名字错了才是根因)", ProviderInput{Provider: "tavily", Enabled: false}, StateUnknown},
|
||||||
|
{"显式停用", ProviderInput{Provider: NameBocha, APIKey: "k", BaseURL: "https://b", Enabled: false}, StateDisabled},
|
||||||
|
{"云家缺 key", ProviderInput{Provider: NameBocha, BaseURL: "https://b", Enabled: true}, StateNotConfigured},
|
||||||
|
{"searxng 缺地址", ProviderInput{Provider: NameSearXNG, Enabled: true}, StateNotConfigured},
|
||||||
|
{"searxng 有地址", ProviderInput{Provider: NameSearXNG, BaseURL: "http://127.0.0.1:38471", Enabled: true}, StateOK},
|
||||||
|
{"名字大小写与空白不敏感", ProviderInput{Provider: " BOCHA ", APIKey: "k", BaseURL: "https://b", Enabled: true}, StateOK},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := BuildRows([]ProviderInput{tc.in})[0]
|
||||||
|
if got.State != tc.state {
|
||||||
|
t.Fatalf("state = %q, want %q", got.State, tc.state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ⑦:下拉选项 = 本期内置的全部已登记家(稳定排序),且**含尚未配置的家**
|
||||||
|
// —— 表单的用途就是第一次配置,只列已配置的会让用户无从选起。
|
||||||
|
func TestProviderOptionsAreAllKnownNames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := ProviderOptions()
|
||||||
|
want := append([]string(nil), KnownNames()...)
|
||||||
|
sort.Strings(want)
|
||||||
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("ProviderOptions() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("选项为空:表单的下拉一上来就选不了东西")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ⑧:动作名是与插件管理器的**对外契约**(设计示例里的那四个字面量)。
|
||||||
|
// 改字号就是改契约:这里钉住字面量,免得一次「顺手重命名」让前端与设计文档对不上。
|
||||||
|
func TestDeclaredActionNamesMatchDesignExample(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
want := []string{
|
||||||
|
"plugin.listProviders", "plugin.saveProvider", "plugin.testProvider", "plugin.reauthProvider",
|
||||||
|
}
|
||||||
|
got := ActionNames()
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("ActionNames() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("ActionNames()[%d] = %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains 是字符串切片成员判定(测试里只此一处,不值得抽包)。
|
||||||
|
func contains(list []string, want string) bool {
|
||||||
|
for _, s := range list {
|
||||||
|
if s == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// 本文件是「搜索提供商」页的**数据面**:表格行与下拉选项的来源。
|
||||||
|
//
|
||||||
|
// 它只做**翻译**(事实 → 面板要的形状),不读文件、不开路由、不鉴权 —— 那些是插件管理器
|
||||||
|
// 与宿主端点的事(design/09 §5.2.1 B)。放这里的理由只有一个:`state` 与选项值的语义
|
||||||
|
// 属于 provider 领域(要问 `Configured` / `KnownNames`),散到宿主里就会与面板声明各写一套。
|
||||||
|
|
||||||
|
// 表格 `state` 列的取值(design/09 §5.2 示例:「ok | 未配置 | 凭据失效 | 未知」)。
|
||||||
|
//
|
||||||
|
// 「凭据失效」不在这里:它只能由一次真实调用得知(那是 plugin.testProvider 的结论,
|
||||||
|
// 见 probe.go),列表接口不该为了它去逐家发请求。
|
||||||
|
const (
|
||||||
|
// StateOK 表示这家可以用了(名字已登记且已配齐且启用)。
|
||||||
|
StateOK = "ok"
|
||||||
|
// StateNotConfigured 表示这个名字已登记,但没配齐(云家缺 key、searxng 缺地址)。
|
||||||
|
StateNotConfigured = "not_configured"
|
||||||
|
// StateDisabled 表示这条已被显式停用(enabled=false)。
|
||||||
|
StateDisabled = "disabled"
|
||||||
|
// StateUnknown 表示这个名字本期没登记(写错了 / 还没做,如 tavily)。
|
||||||
|
StateUnknown = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProviderInput 是构造一行所需的最小事实(一条 provider 配置)。
|
||||||
|
//
|
||||||
|
// 刻意用平铺字段而不是引用 runtimeconfig 的类型:search 包是插件实现,不该认识宿主的
|
||||||
|
// 配置文件形状(那份形状属于宿主,见 internal/config/runtimeconfig/websearch.go)。
|
||||||
|
type ProviderInput struct {
|
||||||
|
// Provider 是 provider 名。
|
||||||
|
Provider string
|
||||||
|
// APIKey 是上游凭据(判定是否配齐用,**绝不进行数据**)。
|
||||||
|
APIKey string
|
||||||
|
// BaseURL 是上游基地址(同上,判定用)。
|
||||||
|
BaseURL string
|
||||||
|
// Enabled 是启用态。
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderRow 是「已配置的提供商」表格的一行。字段名即表格列的 key(panel.go 的 columns)。
|
||||||
|
//
|
||||||
|
// 凭据与基地址**都不进行数据**:列表只需要状态,凭据读侧无明文(design/09 §5.4.2),
|
||||||
|
// 而基地址可能带用户名口令。要看地址就点「重配凭据」,由表单一对一带回。
|
||||||
|
type ProviderRow struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRows 把各条配置翻译成表格行。
|
||||||
|
//
|
||||||
|
// 判定顺序是把「根因」排在前面:名字没登记(写错了)> 显式停用 > 没配齐 > 可用。
|
||||||
|
// 一个停用的陌生名字报 `unknown` —— 名字错了才是要修的那处。
|
||||||
|
func BuildRows(inputs []ProviderInput) []ProviderRow {
|
||||||
|
out := make([]ProviderRow, 0, len(inputs))
|
||||||
|
for _, in := range inputs {
|
||||||
|
out = append(out, ProviderRow{
|
||||||
|
Provider: strings.ToLower(strings.TrimSpace(in.Provider)),
|
||||||
|
State: providerState(in),
|
||||||
|
Enabled: in.Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// providerState 判定一条配置的表格状态。
|
||||||
|
func providerState(in ProviderInput) string {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(in.Provider))
|
||||||
|
if !Known(name) {
|
||||||
|
return StateUnknown
|
||||||
|
}
|
||||||
|
if !in.Enabled {
|
||||||
|
return StateDisabled
|
||||||
|
}
|
||||||
|
if !Configured(name, in.APIKey, in.BaseURL) {
|
||||||
|
return StateNotConfigured
|
||||||
|
}
|
||||||
|
return StateOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderOptions 返回 `optionsFrom: plugin:providers` 的选项值 —— 本期**全部已登记**的
|
||||||
|
// provider 名(含尚未配置的家),稳定排序。
|
||||||
|
//
|
||||||
|
// 为什么给全部而不是「已配置的家」:表单的用途就是**新增**一家,只列已配置的会让
|
||||||
|
// 「第一次配置」无从选起。可用性不靠下拉限制,靠列表的 `state` 列如实呈现。
|
||||||
|
func ProviderOptions() []string { return KnownNames() }
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件是「测试连通」动作(design/09 §5.2 示例的 plugin.testProvider)的数据面:
|
||||||
|
// 对**一条** provider 配置真发一次最小检索,把结论分成「通了」与「为什么没通」。
|
||||||
|
//
|
||||||
|
// 三条口径:
|
||||||
|
//
|
||||||
|
// 1. **真发一次请求**:连通性不靠 ping 主机、也不靠「配置看起来齐了」来假装 ——
|
||||||
|
// 那会给出「连通 OK 但一搜就报错」的假信号。这里走的就是工具运行时走的那条通路
|
||||||
|
// (同一个 provider 构造、同一个共享归一化)。
|
||||||
|
// 2. **空结果 ≠ 不通**(design/27 §1.4):上游正常应答但一条都没查到,如实报
|
||||||
|
// no_results —— 既不冒充成功,也不冤枉成「该家不可用」。
|
||||||
|
// 3. **密钥绝不进结果**:只回结构化错误码 + 固定文案,**绝不回上游原始报文**
|
||||||
|
// (错误体可能回显请求 URL 或凭据)。文案与错误码分离,前端可本地化(§5.4.6)。
|
||||||
|
//
|
||||||
|
// 本文件不读配置文件、不开路由、不落审计 —— 那些是插件管理器与宿主端点的事。
|
||||||
|
|
||||||
|
// 探针的查询词与条数:一次**最小但真实**的检索(只要 1 条,省上游额度)。
|
||||||
|
const (
|
||||||
|
probeQuery = "cyrene"
|
||||||
|
probeCount = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// 探针结论的错误码(稳定标识;design/09 §5.4.6「错误码与文案分离」)。
|
||||||
|
const (
|
||||||
|
// ProbeUnknownProvider 报告名字本期没登记(写错了 / 还没做)。
|
||||||
|
ProbeUnknownProvider = "unknown_provider"
|
||||||
|
// ProbeNotConfigured 报告这条没配齐(云家缺 API Key,或任何一家缺接口地址)。
|
||||||
|
ProbeNotConfigured = "not_configured"
|
||||||
|
// ProbeUnauthorized 报告上游拒了凭据(401/403)。
|
||||||
|
ProbeUnauthorized = "unauthorized"
|
||||||
|
// ProbeRateLimited 报告被上游限流(429)。
|
||||||
|
ProbeRateLimited = "rate_limited"
|
||||||
|
// ProbeQuotaExceeded 报告额度用尽 / 欠费 / 未开通。
|
||||||
|
ProbeQuotaExceeded = "quota_exceeded"
|
||||||
|
// ProbeNetwork 报告网络层失败(连不上 / DNS / 超时)。
|
||||||
|
ProbeNetwork = "network"
|
||||||
|
// ProbeUpstream 报告上游 5xx 或其它无法进一步分类的失败状态。
|
||||||
|
ProbeUpstream = "upstream"
|
||||||
|
// ProbeBadResponse 报告上游应答不是预期结构(或超过读取上限)。
|
||||||
|
ProbeBadResponse = "bad_response"
|
||||||
|
// ProbeNoResults 报告上游正常应答但没有任何结果(**不算失败**,只是没内容)。
|
||||||
|
ProbeNoResults = "no_results"
|
||||||
|
// ProbeUnknown 是兜底:未分类的失败。
|
||||||
|
ProbeUnknown = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProbeResult 是「测试连通」的结论(面板行内动作的返回体)。
|
||||||
|
type ProbeResult struct {
|
||||||
|
// OK 报告这次探针真的拿到结果了(空结果不是 OK)。
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
// Provider 是被探的那一家(小写归一后的名字)。
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
// Count 是这次探针拿到的结果条数(OK 时 > 0)。
|
||||||
|
Count int `json:"count"`
|
||||||
|
// LatencyMS 是这次探针的耗时(毫秒),含建连与读体。
|
||||||
|
LatencyMS int64 `json:"latencyMs"`
|
||||||
|
// ErrorCode 是失败时的稳定错误码(OK 时为空)。
|
||||||
|
ErrorCode string `json:"errorCode,omitempty"`
|
||||||
|
// Error 是与错误码配对的固定文案(**不含上游原文、不含凭据**)。
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe 对 name 这一家做一次真实的最小检索(count ≤ 0 时用 probeCount)。
|
||||||
|
//
|
||||||
|
// cfg 由调用方填好(凭据 / 基地址 / 共享 HTTP 客户端 / SSRF 守卫),与工具运行时用的是
|
||||||
|
// 同一套接线;cfg.Client 为 nil 时如实报 not_configured(发不出请求就是没配齐)。
|
||||||
|
func Probe(ctx context.Context, name string, cfg Config, count int) (res ProbeResult) {
|
||||||
|
res.Provider = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if count <= 0 {
|
||||||
|
count = probeCount
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
defer func() { res.LatencyMS = time.Since(start).Milliseconds() }()
|
||||||
|
|
||||||
|
res.ErrorCode, res.Error = probePreflight(res.Provider, cfg)
|
||||||
|
if res.ErrorCode != "" {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
items, err := probeSearch(ctx, res.Provider, cfg, count)
|
||||||
|
if err != nil {
|
||||||
|
res.ErrorCode = ProbeErrorCode(err)
|
||||||
|
res.Error = probeMessage(res.ErrorCode)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
res.ErrorCode = ProbeNoResults
|
||||||
|
res.Error = probeMessage(ProbeNoResults)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.OK = true
|
||||||
|
res.Count = len(items)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// probePreflight 做发请求前的判定,返回 (错误码, 文案);都为空表示可以发。
|
||||||
|
//
|
||||||
|
// 为什么比运行时更严(运行时云家只看 API Key):运行时的基地址有代码默认值兜底
|
||||||
|
// (`WEB_SEARCH_<NAME>_BASE_URL` 的 tag 默认值),而面板里存下来的那条**可能真的没有地址**
|
||||||
|
// —— 那种情况下探针必须说「没配齐」,而不是发一个相对 URL 出去报一句看不懂的错。
|
||||||
|
func probePreflight(name string, cfg Config) (string, string) {
|
||||||
|
if !Known(name) {
|
||||||
|
return ProbeUnknownProvider, "未登记的搜索提供商(本期接入位:" +
|
||||||
|
strings.Join(KnownNames(), ", ") + ")"
|
||||||
|
}
|
||||||
|
if !Configured(name, cfg.APIKey, cfg.BaseURL) || strings.TrimSpace(cfg.BaseURL) == "" {
|
||||||
|
return ProbeNotConfigured, probeMessage(ProbeNotConfigured)
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeSearch 建一家 provider 并真发一次检索。error 为 nil 时 items 可能为空
|
||||||
|
// (空结果由调用方按「空 ≠ 不通」处置)。
|
||||||
|
func probeSearch(ctx context.Context, name string, cfg Config, count int) ([]Result, error) {
|
||||||
|
reg := Build(
|
||||||
|
[]Wire{{Name: name, APIKey: cfg.APIKey, BaseURL: cfg.BaseURL, Configured: true}},
|
||||||
|
cfg.Client, cfg.MaxBytes, cfg.Guard)
|
||||||
|
p, ok := reg.Lookup(name)
|
||||||
|
if !ok {
|
||||||
|
// 只有「已登记却构造不出来」才走到这里(与 checkSearchSelection 的 ErrNotConfigured 同源)。
|
||||||
|
return nil, fmt.Errorf("%w: probe: provider %q cannot be built", ErrNotConfigured, name)
|
||||||
|
}
|
||||||
|
return p.Search(ctx, Query{Text: probeQuery, Count: count})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeErrorCode 把一次探针失败归类成稳定错误码(errors.Is 判定)。
|
||||||
|
func ProbeErrorCode(err error) string {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return ""
|
||||||
|
case errors.Is(err, ErrUnknownProvider):
|
||||||
|
return ProbeUnknownProvider
|
||||||
|
case errors.Is(err, ErrNotConfigured):
|
||||||
|
return ProbeNotConfigured
|
||||||
|
case errors.Is(err, ErrUnauthorized):
|
||||||
|
return ProbeUnauthorized
|
||||||
|
case errors.Is(err, ErrRateLimited):
|
||||||
|
return ProbeRateLimited
|
||||||
|
case errors.Is(err, ErrQuotaExceeded):
|
||||||
|
return ProbeQuotaExceeded
|
||||||
|
case errors.Is(err, ErrNetwork):
|
||||||
|
return ProbeNetwork
|
||||||
|
case errors.Is(err, ErrResponseTooLarge), errors.Is(err, ErrBadResponse):
|
||||||
|
return ProbeBadResponse
|
||||||
|
case errors.Is(err, ErrNoResults):
|
||||||
|
return ProbeNoResults
|
||||||
|
case errors.Is(err, ErrUpstream):
|
||||||
|
return ProbeUpstream
|
||||||
|
default:
|
||||||
|
return ProbeUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeMessage 给出与错误码配对的固定中文文案。
|
||||||
|
//
|
||||||
|
// 刻意**不回上游原文**:错误体与网络错误都可能回显请求 URL(某些上游把 key 放在查询串里)
|
||||||
|
// 或凭据,而这份文案会进面板响应。要看原文就走服务端日志(那里也不含凭据)。
|
||||||
|
func probeMessage(code string) string {
|
||||||
|
switch code {
|
||||||
|
case ProbeUnknownProvider:
|
||||||
|
return "未登记的搜索提供商:名字写错了,或本期还没做这一家"
|
||||||
|
case ProbeNotConfigured:
|
||||||
|
return "这条没配齐:云家需要 API Key,任何一家都需要接口地址"
|
||||||
|
case ProbeUnauthorized:
|
||||||
|
return "上游拒绝了凭据(401/403):请核对 API Key 是否有效"
|
||||||
|
case ProbeRateLimited:
|
||||||
|
return "被上游限流(429):稍后重试"
|
||||||
|
case ProbeQuotaExceeded:
|
||||||
|
return "上游额度用尽 / 欠费 / 未开通该服务"
|
||||||
|
case ProbeNetwork:
|
||||||
|
return "连不上上游:网络/域名/超时问题"
|
||||||
|
case ProbeUpstream:
|
||||||
|
return "上游返回了失败状态(5xx 或其它)"
|
||||||
|
case ProbeBadResponse:
|
||||||
|
return "上游应答不是预期结构(或超过读取上限)"
|
||||||
|
case ProbeNoResults:
|
||||||
|
return "连通正常,但这次探针没有查到结果(空结果不等于失败,换别的查询词再试)"
|
||||||
|
default:
|
||||||
|
return "探针失败,原因未分类(详见服务端日志)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件钉住「测试连通」探针(design/09 §5.2 示例的 plugin.testProvider)的结论语义:
|
||||||
|
// 真发一次请求、空结果 ≠ 不通、凭据绝不进结果、失败必须可分类。
|
||||||
|
|
||||||
|
// wsProbeServer 起一个只说博查形状的假上游,把收到的请求送进 got 供断言。
|
||||||
|
func wsProbeServer(t *testing.T, status int, body string) (*httptest.Server, chan *http.Request) {
|
||||||
|
t.Helper()
|
||||||
|
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.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv, got
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ①:通了 ⇒ OK + 真实条数;且真的打到了那家的固定路径上,凭据真的带上了。
|
||||||
|
func TestProbeOKHitsProviderWithCredential(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const key = "probe-secret-key"
|
||||||
|
srv, got := wsProbeServer(t, http.StatusOK,
|
||||||
|
`{"webPages":{"value":[{"name":"标题","url":"https://a.example/1","summary":"正文"}]}}`)
|
||||||
|
|
||||||
|
res := Probe(context.Background(), NameBocha, Config{
|
||||||
|
APIKey: key, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 1 << 20,
|
||||||
|
}, 1)
|
||||||
|
if !res.OK || res.Count != 1 {
|
||||||
|
t.Fatalf("探针结论 = %+v, want OK/count=1", res)
|
||||||
|
}
|
||||||
|
if res.Provider != NameBocha {
|
||||||
|
t.Fatalf("provider = %q, want %q", res.Provider, NameBocha)
|
||||||
|
}
|
||||||
|
if res.ErrorCode != "" || res.Error != "" {
|
||||||
|
t.Fatalf("成功时不该带错误码/文案:%+v", res)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r := <-got:
|
||||||
|
if r.URL.Path != "/v1/web-search" {
|
||||||
|
t.Errorf("请求路径 = %q, want /v1/web-search(各家固定路径)", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Bearer "+key {
|
||||||
|
t.Errorf("Authorization = %q(凭据必须真的被用上)", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("探针一个请求都没发 —— 连通性是发出来的,不是算出来的")
|
||||||
|
}
|
||||||
|
wsAssertNoSecret(t, res, key, srv.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ②:空结果 ≠ 不通 —— 报 no_results,既不算成功、也不算「这家不可用」。
|
||||||
|
func TestProbeEmptyResultsIsNoResultsNotFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, _ := wsProbeServer(t, http.StatusOK, `{"webPages":{"value":[]}}`)
|
||||||
|
res := Probe(context.Background(), NameBocha, Config{
|
||||||
|
APIKey: "k", BaseURL: srv.URL, Client: srv.Client(),
|
||||||
|
}, 1)
|
||||||
|
if res.OK {
|
||||||
|
t.Fatalf("空结果不得算成功:%+v", res)
|
||||||
|
}
|
||||||
|
if res.ErrorCode != ProbeNoResults {
|
||||||
|
t.Fatalf("ErrorCode = %q, want %q", res.ErrorCode, ProbeNoResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ③:失败必须可分类,且**文案里不许出现上游 URL/凭据**。
|
||||||
|
func TestProbeFailureClassesAndNoLeak(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"凭据被拒", http.StatusUnauthorized, `{"error":{"code":"unauthorized"}}`, ProbeUnauthorized},
|
||||||
|
{"限流", http.StatusTooManyRequests, `{}`, ProbeRateLimited},
|
||||||
|
{"上游 5xx", http.StatusBadGateway, `{}`, ProbeUpstream},
|
||||||
|
{"应答不是预期结构", http.StatusOK, `{"unexpected":true}`, ProbeBadResponse},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const key = "probe-secret-key"
|
||||||
|
srv, _ := wsProbeServer(t, tc.status, tc.body)
|
||||||
|
res := Probe(context.Background(), NameBocha, Config{
|
||||||
|
APIKey: key, BaseURL: srv.URL, Client: srv.Client(),
|
||||||
|
}, 1)
|
||||||
|
if res.OK {
|
||||||
|
t.Fatalf("失败不得报 OK:%+v", res)
|
||||||
|
}
|
||||||
|
if res.ErrorCode != tc.want {
|
||||||
|
t.Fatalf("ErrorCode = %q, want %q(文案=%q)", res.ErrorCode, tc.want, res.Error)
|
||||||
|
}
|
||||||
|
if res.Error == "" {
|
||||||
|
t.Fatal("失败必须带一句可读文案(错误码与文案分离,前端可本地化)")
|
||||||
|
}
|
||||||
|
wsAssertNoSecret(t, res, key, srv.URL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ④:名字没登记 / 没配齐 / 没客户端,三类都别去发请求,且分类准确。
|
||||||
|
func TestProbePreflightRejectsBeforeSending(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
prov string
|
||||||
|
cfg Config
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"没登记的名字", "tavily", Config{APIKey: "k", BaseURL: "https://t.example"}, ProbeUnknownProvider},
|
||||||
|
{"云家缺 key", NameBocha, Config{BaseURL: "https://b.example"}, ProbeNotConfigured},
|
||||||
|
{"缺接口地址", NameBocha, Config{APIKey: "k"}, ProbeNotConfigured},
|
||||||
|
{"没有 http 客户端", NameBocha, Config{APIKey: "k", BaseURL: "https://b.example"}, ProbeNotConfigured},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
res := Probe(context.Background(), tc.prov, tc.cfg, 1)
|
||||||
|
if res.ErrorCode != tc.want {
|
||||||
|
t.Fatalf("ErrorCode = %q, want %q(文案=%q)", res.ErrorCode, tc.want, res.Error)
|
||||||
|
}
|
||||||
|
if res.OK || res.Count != 0 {
|
||||||
|
t.Fatalf("发不出去的探针不得报成功:%+v", res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ⑤:网络层失败归 network(连不上也有稳定错误码,不是一团未知)。
|
||||||
|
func TestProbeNetworkFailureIsClassified(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, _ := wsProbeServer(t, http.StatusOK, `{}`)
|
||||||
|
url := srv.URL
|
||||||
|
srv.Close() // 关掉:后续请求必然连不上
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
res := Probe(ctx, NameBocha, Config{APIKey: "k", BaseURL: url, Client: &http.Client{}}, 1)
|
||||||
|
if res.ErrorCode != ProbeNetwork {
|
||||||
|
t.Fatalf("ErrorCode = %q, want %q", res.ErrorCode, ProbeNetwork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 ⑥:错误码 ↔ 文案一一对应,且未分类有兜底(不许出现「空文案的失败」)。
|
||||||
|
func TestProbeMessagesCoverEveryCode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, code := range []string{
|
||||||
|
ProbeUnknownProvider, ProbeNotConfigured, ProbeUnauthorized, ProbeRateLimited,
|
||||||
|
ProbeQuotaExceeded, ProbeNetwork, ProbeUpstream, ProbeBadResponse,
|
||||||
|
ProbeNoResults, ProbeUnknown,
|
||||||
|
} {
|
||||||
|
if msg := probeMessage(code); strings.TrimSpace(msg) == "" {
|
||||||
|
t.Errorf("错误码 %q 没有文案", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ProbeErrorCode(nil) != "" {
|
||||||
|
t.Fatal("nil 错误没有错误码")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsAssertNoSecret 断言探针结论里既没有凭据、也没有上游 URL(两者都可能出现在上游
|
||||||
|
// 错误体或网络错误里,而这份结构会进面板响应)。
|
||||||
|
func wsAssertNoSecret(t *testing.T, res ProbeResult, secret, upstreamURL string) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(res)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), secret) {
|
||||||
|
t.Fatalf("探针结论泄漏了凭据: %s", raw)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), upstreamURL) {
|
||||||
|
t.Fatalf("探针结论泄漏了上游地址: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user