feat(search): 原生 provider 层(searxng/智谱/阿里IQS/博查/火山/百度)+ 降级链(契约无改动)

主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。
- 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次**
- 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义)
- 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」)
- 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉)
- searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变)
- 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
This commit is contained in:
AskaEth committed 2026-10-05 22:38:20 +08:00
1 parent 236b202fda
commit 69a3b73777
24 files changed
+3251 -171

No files matched your search

+78 -7
View File
@@ -6,14 +6,30 @@ import (
"time"
)
// WebSearchConfig 是 web-search 工具的搜索端点配置(design/09-插件生态;切片 WS1-A)。
// WebSearchConfig 是 web-search 工具的搜索配置(design/27-网络搜索与检索提供商;切片 WS1-A/WS1-B)。
// 写法照既有 VoiceConfig 惯例:一组 provider 键 + 未配置则如实降级,绝不硬编码。
type WebSearchConfig struct {
// Endpoint 是 SearXNG 兼容搜索端点(支持 `format=json` 的 GET 接口)。
// Provider 选谁当后端(design/27 §3):`searxng`(默认)/ `zhipu` / `aliyun_iqs` /
// `bocha` / `volcengine` / `baidu_qianfan`。
//
// **语义硬约束**(design/27 §3,由组合根的 io 工具集在启动期执行):
// - 指向**未登记**(如本期未做的 tavily)或**未配置**(缺 key/端点)的名字
// ⇒ **启动期显式报错**,绝不静默回落到兜底;
// - 写空串 / `off` / `none`(大小写不敏感)⇒ **不注册 `web-search` 工具**
// (模型根本看不见它),见 ResolveProvider。
Provider string `env:"WEB_SEARCH_PROVIDER" default:"searxng"`
// Fallback 是降级链(design/27 §3):从左到右试,空 = 只用手选的 provider。
// 链上的家**只在「不可用」时**才被跳过(无 key / 网络失败 / 额度错误…);
// 结果里会如实标注**实际应答**的那一家,绝不拿兜底冒充主力。
Fallback []string `env:"WEB_SEARCH_FALLBACK"`
// MaxResults 是单次检索返回的条数上限(design/27 §3 默认 5)。
MaxResults int `env:"WEB_SEARCH_MAX_RESULTS" default:"5"`
// Endpoint 是 **searxng provider** 的端点(SearXNG 兼容的 `format=json` GET 接口)。
//
// 默认值指向**本机兜底**:`scripts/searxng/settings.yml` 配套的 SearXNG 实例
// (宿主机 http://127.0.0.1:38471)。默认指向本机而不是某个云搜索 API,是因为
// 兜底实例不需要任何第三方 key、开箱即用;要换成云 provider 请在本键里写新地址。
// 兜底实例不需要任何第三方 key、开箱即用。
//
// **显式关闭**(值写成空串 / off / none,大小写与首尾空白不敏感)⇒ 组合根
// **不注册** web-search 工具:模型根本看不见它,而不是注册后每次调用都报错
@@ -24,6 +40,63 @@ type WebSearchConfig struct {
// **未配置(零值)语义 = 「沿用 io 包既有超时」**:这里刻意不写第二个默认值,
// 否则「文档承诺的超时」与「工具真正用的超时」会各有一份而悄悄漂移。
Timeout time.Duration `env:"WEB_SEARCH_TIMEOUT"`
// ---- 各家独立一组键(design/27 §3)----
//
// 口径:`WEB_SEARCH_<NAME>_API_KEY` 给凭据、`WEB_SEARCH_<NAME>_BASE_URL` 给基地址
// (各自的固定路径由 provider 代码拼上)。**未配置的家不出现在可用清单里**
// (fail-closed:绝不挂着报错)。密钥只从配置读,**绝不进日志/响应/提示词**。
// Zhipu* 是智谱 BigModel(design/27 §2.1 国内首选)。
ZhipuAPIKey string `env:"WEB_SEARCH_ZHIPU_API_KEY"`
ZhipuBaseURL string `env:"WEB_SEARCH_ZHIPU_BASE_URL" default:"https://open.bigmodel.cn"`
// AliyunIQS* 是阿里云信息查询服务(IQS;HTTP + X-API-Key,免签名免 SDK)。
AliyunIQSAPIKey string `env:"WEB_SEARCH_ALIYUN_IQS_API_KEY"`
AliyunIQSBaseURL string `env:"WEB_SEARCH_ALIYUN_IQS_BASE_URL" default:"https://cloud-iqs.aliyuncs.com"`
// Bocha* 是博查(中文优先)。默认基地址取**官方文档现行口径** `api.bocha.cn`
// (分册 02:官网首页 curl 样例仍用 api.bochaai.com,两域关系官方未说明)。
BochaAPIKey string `env:"WEB_SEARCH_BOCHA_API_KEY"`
BochaBaseURL string `env:"WEB_SEARCH_BOCHA_BASE_URL" default:"https://api.bocha.cn"`
// Volcengine* 是火山引擎·豆包搜索(SearchInfinity;Bearer + open.feedcoopapi.com)。
VolcengineAPIKey string `env:"WEB_SEARCH_VOLCENGINE_API_KEY"`
VolcengineBaseURL string `env:"WEB_SEARCH_VOLCENGINE_BASE_URL" default:"https://open.feedcoopapi.com"`
// BaiduQianfan* 是百度·千帆 AI 搜索。
BaiduQianfanAPIKey string `env:"WEB_SEARCH_BAIDU_QIANFAN_API_KEY"`
BaiduQianfanBaseURL string `env:"WEB_SEARCH_BAIDU_QIANFAN_BASE_URL" default:"https://qianfan.baidubce.com"`
}
// ProviderDecision 是 WEB_SEARCH_PROVIDER 的解析结论。
type ProviderDecision struct {
// Selected 是选定的 provider 名(已去空白;大小写不敏感,比较时请再归一化)。
Selected string
// Disabled 为 true ⇒ 显式关闭(空串 / off / none)⇒ 不注册 web-search 工具。
Disabled bool
}
// ResolveProvider 把 WEB_SEARCH_PROVIDER 解析成选择结论:显式关闭(空串 / off / none,
// 大小写与首尾空白不敏感)⇒ Disabled=true(组合根据此**不注册** web-search 工具);
// 其余 ⇒ Selected=去空白后的名字(是否登记/是否配置齐由 io 工具集在启动期校验并报错)。
func (w WebSearchConfig) ResolveProvider() ProviderDecision {
raw := strings.TrimSpace(w.Provider)
if isOffSentinel(raw) {
return ProviderDecision{Disabled: true}
}
return ProviderDecision{Selected: raw}
}
// isOffSentinel 报告取值是否属于「显式关闭」的哨兵词(空串 / off / none,大小写与
// 首尾空白不敏感)。它同时服务 WEB_SEARCH_PROVIDER 与 WEB_SEARCH_ENDPOINT 两个键。
func isOffSentinel(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "off", "none":
return true
default:
return false
}
}
// EndpointDecision 是 WEB_SEARCH_ENDPOINT 的解析结论:要不要注册 web-search,
@@ -47,7 +120,7 @@ const (
WebSearchReasonInvalid = "不是可用的 http(s) 地址"
)
// ResolveEndpoint 把配置值解析成端点裁决:
// ResolveEndpoint 把 WEB_SEARCH_ENDPOINT(= searxng provider 的基地址)解析成端点裁决:
//
// - 显式关闭(空串 / off / none,大小写与首尾空白不敏感)⇒ Enabled=false、
// Reason=WebSearchReasonOff —— 组合根据此**不注册**该工具;
@@ -59,9 +132,7 @@ const (
// 注意「未配置」不落到这里:没写 WEB_SEARCH_ENDPOINT 时字段已被标签默认值填好。
func (w WebSearchConfig) ResolveEndpoint() EndpointDecision {
raw := strings.TrimSpace(w.Endpoint)
// 显式关闭的三个哨兵词(大小写不敏感):空串、off、none。
switch strings.ToLower(raw) {
case "", "off", "none":
if isOffSentinel(raw) {
return EndpointDecision{Reason: WebSearchReasonOff}
}
u, err := url.Parse(raw)
+128
View File
@@ -0,0 +1,128 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 本文件钉住切片 WS1-B 的搜索配置键(design/27 §3):
// WEB_SEARCH_PROVIDER / _FALLBACK / _MAX_RESULTS 与各家独立一组键
// (WEB_SEARCH_<NAME>_API_KEY / _BASE_URL),以及 ResolveProvider 的关闭语义。
// wsTestLoad 写一份只含必填键加 extra 行的 .env,加载出配置。
func wsTestLoad(t *testing.T, extra ...string) (*Config, error) {
t.Helper()
lines := append([]string{"JWT_SECRET=" + strings.Repeat("s", 40)}, extra...)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
return Load(path)
}
// TestWebSearchProvider_Defaults 钉住三个新键的默认值:provider=searxng、
// maxResults=5、fallback 为空(= 只管手选那一家)。
func TestWebSearchProvider_Defaults(t *testing.T) {
cfg, err := wsTestLoad(t)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.WebSearch.Provider != "searxng" {
t.Fatalf("WEB_SEARCH_PROVIDER 默认值 = %q, want searxng", cfg.WebSearch.Provider)
}
if cfg.WebSearch.MaxResults != 5 {
t.Fatalf("WEB_SEARCH_MAX_RESULTS 默认值 = %d, want 5", cfg.WebSearch.MaxResults)
}
if len(cfg.WebSearch.Fallback) != 0 {
t.Fatalf("WEB_SEARCH_FALLBACK 未配置时应为空, got %v", cfg.WebSearch.Fallback)
}
// 云家的 base URL 有默认值、key 一律为空(未配置 ⇒ 该家不出现在可用清单里)。
if cfg.WebSearch.ZhipuBaseURL == "" || cfg.WebSearch.BochaBaseURL == "" ||
cfg.WebSearch.AliyunIQSBaseURL == "" || cfg.WebSearch.VolcengineBaseURL == "" ||
cfg.WebSearch.BaiduQianfanBaseURL == "" {
t.Fatalf("各家的 BASE_URL 必须有默认值: %+v", cfg.WebSearch)
}
if cfg.WebSearch.ZhipuAPIKey != "" || cfg.WebSearch.BochaAPIKey != "" ||
cfg.WebSearch.AliyunIQSAPIKey != "" || cfg.WebSearch.VolcengineAPIKey != "" ||
cfg.WebSearch.BaiduQianfanAPIKey != "" {
t.Fatalf("各家 API key 未配置时必须为空: %+v", cfg.WebSearch)
}
}
// TestWebSearchProvider_EnvOverrides 钉住新键都能被 .env 覆盖(含逗号分隔的降级链)。
func TestWebSearchProvider_EnvOverrides(t *testing.T) {
cfg, err := wsTestLoad(t,
"WEB_SEARCH_PROVIDER=Bocha",
"WEB_SEARCH_FALLBACK=zhipu,bocha,searxng",
"WEB_SEARCH_MAX_RESULTS=9",
"WEB_SEARCH_ZHIPU_API_KEY=zk",
"WEB_SEARCH_ZHIPU_BASE_URL=https://zhipu.example",
"WEB_SEARCH_ALIYUN_IQS_API_KEY=ak",
"WEB_SEARCH_ALIYUN_IQS_BASE_URL=https://iqs.example",
"WEB_SEARCH_BOCHA_API_KEY=bk",
"WEB_SEARCH_BOCHA_BASE_URL=https://bocha.example",
"WEB_SEARCH_VOLCENGINE_API_KEY=vk",
"WEB_SEARCH_VOLCENGINE_BASE_URL=https://volc.example",
"WEB_SEARCH_BAIDU_QIANFAN_API_KEY=qk",
"WEB_SEARCH_BAIDU_QIANFAN_BASE_URL=https://qianfan.example",
)
if err != nil {
t.Fatalf("Load: %v", err)
}
ws := cfg.WebSearch
if ws.Provider != "Bocha" {
t.Fatalf("Provider = %q(大小写应原样保留,比较时再归一化)", ws.Provider)
}
if got := ws.Fallback; len(got) != 3 || got[0] != "zhipu" || got[2] != "searxng" {
t.Fatalf("Fallback = %v, want [zhipu bocha searxng]", got)
}
if ws.MaxResults != 9 {
t.Fatalf("MaxResults = %d, want 9", ws.MaxResults)
}
for name, got := range map[string]string{
"Zhipu": ws.ZhipuAPIKey, "AliyunIQS": ws.AliyunIQSAPIKey, "Bocha": ws.BochaAPIKey,
"Volcengine": ws.VolcengineAPIKey, "BaiduQianfan": ws.BaiduQianfanAPIKey,
} {
if got == "" {
t.Errorf("%s 的 API key 没被读进来", name)
}
}
for name, got := range map[string]string{
"Zhipu": ws.ZhipuBaseURL, "AliyunIQS": ws.AliyunIQSBaseURL, "Bocha": ws.BochaBaseURL,
"Volcengine": ws.VolcengineBaseURL, "BaiduQianfan": ws.BaiduQianfanBaseURL,
} {
if !strings.HasSuffix(got, ".example") {
t.Errorf("%s 的 BASE_URL 没被覆盖: %q", name, got)
}
}
}
// TestWebSearchConfig_ResolveProvider 是关闭语义的表驱动:空 / off / none ⇒ 关闭
// (组合根据此不注册工具);其余 ⇒ 选定名。
func TestWebSearchConfig_ResolveProvider(t *testing.T) {
cases := []struct {
name string
value string
wantDisable bool
wantSel string
}{
{"默认 searxng", "searxng", false, "searxng"},
{"大写 Zhipu", " Zhipu ", false, "Zhipu"},
{"空串 = 关闭", "", true, ""},
{"off", "off", true, ""},
{"none", "NONE", true, ""},
{"带空白的 None", " none ", true, ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := WebSearchConfig{Provider: tc.value}.ResolveProvider()
if got.Disabled != tc.wantDisable || got.Selected != tc.wantSel {
t.Fatalf("ResolveProvider(%q) = %+v, want disabled=%v selected=%q",
tc.value, got, tc.wantDisable, tc.wantSel)
}
})
}
}