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

+236 -119
View File
@@ -2,7 +2,7 @@ package io
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
@@ -11,55 +11,134 @@ import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// Compile-time assertion that webSearchTool implements sdk.Tool.
var _ sdk.Tool = (*webSearchTool)(nil)
// webSearchTool queries a configured SearXNG-compatible (or custom) search
// endpoint and returns ranked results, capped in size and count.
// webSearchTool 是**一把**能力面对模型的工具(design/27 §1.1:能力 ≠ 单一后端,
// 换 provider 不改工具 schema)。它把「选谁当后端」交给注入的注册表,
// 并按 主 provider → 降级链 依次尝试(design/27 §3)。
type webSearchTool struct {
client *http.Client
endpoint string
client *http.Client
registry *search.Registry
// primary 是主 provider 名;fallback 是降级链(按序)。
primary string
fallback []string
// allowedHosts 是**本工具自己**那份 SSRF 白名单(含配置里各 provider 的主机),
// 不是 http / web-fetch 共用的那份(见 newWebSearchTool 的说明)。
allowedHosts []string
maxBytes int64
maxResults int
log *slog.Logger
// timeout 是本次搜索的超时覆盖(0 = 沿用共享客户端自己的超时)。它只约束
// web-search 一次请求,不牵动 http / web-fetch 共用的那个客户端(WS1-A)。
// endpoint 是 searxng provider 的端点(WS1-A 的 WEB_SEARCH_ENDPOINT 语义,
// 保留为字段以便既有测试与诊断继续可用)。
endpoint string
maxBytes int64
maxResults int
log *slog.Logger
// timeout 是**每次**上游尝试的超时预算(0 = 沿用共享客户端自己的超时)。
// 按「每次尝试」而不是「整个调用」计,降级链才真的有意义:主家超时后,
// 备家仍有自己的预算可用。
timeout time.Duration
}
func newWebSearchTool(t *tools) *webSearchTool {
// 端点来自**运维配置**,不是模型输入(模型的 query 只会进查询串,主机名恒为
// 配置里那一个):因此把它并入本工具**自己**的 SSRF 白名单。否则本机兜底端点
// http://127.0.0.1:38471 会被 SSRF 守卫按回环地址拒掉——那正是「端点配了却
// 仍然用不了」的另一种形态(SearXNG 还会 308 跳到 /search,重定向那一跳同样
// 要过守卫,所以加宽后的名单也必须交给客户端,见 tools.clientFor)。
//
// 刻意只并入 web-search:http / web-fetch 的 URL 由模型给出,它们共用
// t.allowedHosts,把回环塞进**那份**名单会让模型能伸手去摸本机服务。
allowedHosts := withSearchEndpointHost(t.allowedHosts, t.searchURL)
// newWebSearchTool 构造工具:汇总接线 → 校验选定 → 建注册表 → 按各家主机加宽
// **本工具自己**的 SSRF 白名单。
//
// SSRF 说明(沿用 WS1-A 的口径):端点是**运维配置**、不是模型输入(模型的 query 只
// 进查询串),所以把配置里的 provider 主机并入 web-search 自己的白名单**不会**给模型
// 打开新的目标面——工具能去的主机恒等于配置里的那几个。http / web-fetch 共用白名单
// 保持不变(TestWebSearch_DoesNotWidenSharedAllowlist 钉住)。
func newWebSearchTool(t *tools) (*webSearchTool, error) {
wires := t.searchWires()
primary := strings.TrimSpace(t.searchPrimary)
if primary == "" {
primary = search.NameSearXNG
}
// 校验只在「配置路径」生效(组合根显式给了选择或接线表):io 包的既有默认构造
// (WS1-A 之前就在的调用方与测试)刻意保留「工具在、调用时如实报错」的历史行为。
if t.searchSelected || len(t.searchProviders) > 0 {
if err := checkSearchSelection(primary, wires); err != nil {
return nil, err
}
}
allowedHosts := t.allowedHosts
for _, w := range wires {
allowedHosts = withSearchEndpointHost(allowedHosts, w.BaseURL)
}
client := t.clientFor(allowedHosts)
maxResults := t.maxResults
if t.searchMaxResults > 0 {
maxResults = t.searchMaxResults
}
return &webSearchTool{
client: t.clientFor(allowedHosts),
endpoint: t.searchURL,
client: client,
registry: search.Build(wires, client, t.maxHTTPBytes, t.searchGuard(allowedHosts)),
primary: primary,
fallback: t.searchFallback,
allowedHosts: allowedHosts,
maxBytes: t.maxHTTPBytes,
maxResults: t.maxResults,
log: t.log,
timeout: t.searchTimeout,
// endpoint 是**searxng provider 实际用的端点**(诊断/既有测试用):从接线表里
// 取,因此无论走 WithSearchEndpoint 还是组合根的接线表,它都反映真实取值。
endpoint: searxngEndpoint(wires),
maxBytes: t.maxHTTPBytes,
maxResults: maxResults,
log: t.log,
timeout: t.searchTimeout,
}, nil
}
// searxngEndpoint 从接线表里取出 searxng 那一条的基地址(没有则空串)。
func searxngEndpoint(wires []search.Wire) string {
for _, w := range wires {
if strings.EqualFold(w.Name, search.NameSearXNG) {
return w.BaseURL
}
}
return ""
}
// checkSearchSelection 是启动期校验(design/27 §3 的语义硬约束):
//
// - 名字**不认识**(写错了 / 本期没做,如 tavily)⇒ ErrUnknownProvider;
// - 名字认识但**没配齐**(云家缺 key、searxng 缺端点)⇒ ErrNotConfigured。
//
// 两者都让启动显式失败,**绝不静默回落到兜底** —— 否则「我配了 Tavily」就是一句假话。
func checkSearchSelection(primary string, wires []search.Wire) error {
if !search.Known(primary) {
return fmt.Errorf("io: web-search: %w: %q (known: %s)",
search.ErrUnknownProvider, primary, strings.Join(search.KnownNames(), ", "))
}
for _, w := range wires {
if strings.EqualFold(w.Name, primary) && w.Configured {
return nil
}
}
return fmt.Errorf("io: web-search: %w: %q (its API key / base URL is missing)", search.ErrNotConfigured, primary)
}
// searchGuard 造**本工具自己**的 SSRF 守卫:复用 io 包既有的 validateRequestURL 与
// 「配置里各 provider 主机」那份加宽白名单(allowedHosts),因此
// - 端点/基地址是**运维配置**(不是模型输入)⇒ 回环的本机兜底仍然可达;
// - 模型只能改查询串,改不了主机 ⇒ 放行这几个主机不给模型开新的目标面;
// - http / web-fetch 共用的那份白名单**不受影响**(见 clientFor 的分工)。
func (t *tools) searchGuard(allowedHosts []string) search.Guard {
return func(ctx context.Context, rawURL string) error {
if _, err := validateRequestURL(ctx, rawURL, allowedHosts); err != nil {
// 守卫的判定已在 validateRequestURL 里,这里只补上是谁拦的(保持 %w 链)。
return fmt.Errorf("io: web-search: %w", err)
}
return nil
}
}
// Name implements sdk.Tool.
func (s *webSearchTool) Name() string { return "web-search" }
// Description implements sdk.Tool.
// Description implements sdk.Tool. 工具面对模型只有这一把,后端由运维配置决定。
func (s *webSearchTool) Description() string {
return "Search the web via a configured SearXNG or custom search endpoint and return ranked results."
return "Search the web through the configured search provider and return ranked results."
}
// Parameters implements sdk.Tool.
// Parameters implements sdk.Tool. Schema 与既往逐字一致(design/27 §4:不破坏契约)。
func (s *webSearchTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
@@ -77,91 +156,154 @@ func (s *webSearchTool) Execute(ctx context.Context, args any) (any, error) {
if !ok {
return nil, fmt.Errorf("io: web-search: args must be an object")
}
query := argString(m, "query")
if strings.TrimSpace(query) == "" {
query := strings.TrimSpace(argString(m, "query"))
if query == "" {
return nil, fmt.Errorf("io: web-search: query is required")
}
if strings.TrimSpace(s.endpoint) == "" {
return nil, fmt.Errorf("io: web-search: no search endpoint configured (set a SearXNG or custom URL)")
}
// 每次调用各自的超时预算:ctx 在此派生,取消随本次调用一起结束。
if s.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, s.timeout)
defer cancel()
}
target, err := buildSearchURL(s.endpoint, query)
if err != nil {
return nil, err
}
if _, err := validateRequestURL(ctx, target, s.allowedHosts); err != nil {
return nil, err
}
if s.client == nil {
return nil, fmt.Errorf("io: web-search: no underlying client")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, fmt.Errorf("io: web-search: build request: %w", err)
}
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("io: web-search: do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("io: web-search: endpoint returned status %d", resp.StatusCode)
}
data, truncated, err := readLimited(resp.Body, s.maxBytes)
if err != nil {
return nil, fmt.Errorf("io: web-search: read: %w", err)
}
if truncated {
return nil, fmt.Errorf("io: web-search: response exceeds the %d-byte limit", s.maxBytes)
}
results, err := parseSearchResults(data)
if err != nil {
return nil, err
}
n := s.maxResults
if v, ok := argInt(m, "count"); ok && v > 0 {
n = v
}
if len(results) > n {
results = results[:n]
raw, provider, err := s.dispatch(ctx, query, n)
if err != nil {
return nil, err
}
// 归一化之后**统一做一次**后处理:域名过滤/去重/排序/截断(design/27 §4)。
results := search.Finalize(raw, search.Options{Limit: n})
if len(results) == 0 {
// 空结果 ≠ 成功(design/27 §1.4):如实报「一家都没查到」,绝不冒充成功。
// 它同样**不**触发降级链前进(那是「不可用」的处置,见 dispatch)。
return nil, fmt.Errorf("io: web-search: %w (provider=%s)", search.ErrNoResults, provider)
}
return map[string]any{
"query": query,
"count": len(results),
"results": results,
"truncated": truncated,
"query": query,
"count": len(results),
"results": toWireResults(results),
// truncated 沿用既有字段(成功路径恒为 false:超限在 provider 层就是错误)。
"truncated": false,
// provider 是**如实标注**:降级链里谁应答就写谁,绝不拿兜底冒充主力。
"provider": provider,
}, nil
}
// buildSearchURL expands an endpoint against a query. It supports a "{query}"
// placeholder, otherwise appends q= and format=json for a SearXNG JSON API.
func buildSearchURL(endpoint, query string) (string, error) {
if strings.TrimSpace(endpoint) == "" {
return "", fmt.Errorf("io: web-search: no search endpoint configured")
// dispatch 按 主 provider → 降级链 依次尝试,返回结果与**实际应答的 provider 名**。
//
// 只有「该家不可用」才前进一格(unavailable:无 key / 鉴权失败 / 限流 / 额度 / 网络 /
// 上游错误);「空结果」与「预算耗尽」**不**前进 —— 空是空,不是不可用(design/27 §1.4/§3)。
func (s *webSearchTool) dispatch(ctx context.Context, query string, count int) ([]search.Result, string, error) {
names := make([]string, 0, len(s.fallback)+1)
names = append(names, s.primary)
names = append(names, s.fallback...)
attempted := make([]string, 0, len(names))
var lastErr error
for _, name := range names {
p, ok := s.registry.Lookup(name)
if !ok {
// 未配置/未登记的家在构造期就不在注册表里,链上直接跳过(不是错误)。
s.log.Debug("web-search: provider not available, skipped",
"provider", name, "available", s.registry.Names())
continue
}
if err := ctx.Err(); err != nil {
// 上游预算已耗尽,再开一格也没有意义。
lastErr = err
break
}
attempted = append(attempted, name)
res, err := s.searchWith(p, ctx, query, count)
if err == nil {
return res, name, nil
}
lastErr = err
if !searchUnavailable(err) {
// 空结果 / 上下文取消:停在原地,如实返回。
return nil, name, fmt.Errorf("io: web-search: %w", err)
}
// 错误文案**不含密钥**(密钥只在请求头里,从不进错误/日志)。
s.log.Warn("web-search: provider unavailable, advancing fallback chain",
"provider", name, "err", err)
}
q := url.QueryEscape(query)
if strings.Contains(endpoint, "{query}") {
return strings.ReplaceAll(endpoint, "{query}", q), nil
if lastErr == nil {
lastErr = search.ErrNotConfigured
}
sep := "?"
if strings.Contains(endpoint, "?") {
sep = "&"
if len(attempted) == 0 {
return nil, "", fmt.Errorf("io: web-search: %w (no provider available)", lastErr)
}
return endpoint + sep + "q=" + q + "&format=json", nil
return nil, "", fmt.Errorf("io: web-search: %w (tried: %s)", lastErr, strings.Join(attempted, ", "))
}
// withSearchEndpointHost 把**配置里给定的**搜索端点主机并入 web-search 自己的
// searchWith 调一家 provider,并按**这一次尝试**的预算加超时。
//
// 「上游正常应答但一条都没查到」在这里就被点成 ErrNoResults(并**带上是谁答的**)——
// 它是「空」不是「成功」,也不是「该家不可用」:dispatch 据此**停在原地**、如实报出去,
// 而不是换一家假装就有结果了(design/27 §1.4/§3)。把这次判定放在这里,是为了让
// 「空结果不前进」这条规则只有一处,且真的被执行到。
func (s *webSearchTool) searchWith(p search.Provider, ctx context.Context, query string, count int) ([]search.Result, error) {
callCtx := ctx
if s.timeout > 0 {
var cancel context.CancelFunc
callCtx, cancel = context.WithTimeout(ctx, s.timeout)
defer cancel()
}
res, err := p.Search(callCtx, search.Query{Text: query, Count: count})
if err != nil {
return nil, err
}
if len(res) == 0 {
return nil, fmt.Errorf("%w (provider=%s)", search.ErrNoResults, p.Name())
}
return res, nil
}
// searchUnavailable 报告一个错误是否属于「该家不可用」。
//
// 上下文取消/超时**不算**:那是本次调用的预算花光了,换一家也一样没时间,
// 应该如实报出去而不是掩盖成「前进一格」。
func searchUnavailable(err error) bool {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
return search.Unavailable(err)
}
// searchResult 是一条结果的**线格式**(既有字段一字不改;published/score 是本期追加的
// 可选字段 —— design/27 §4:只许追加,不许改名)。
type searchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Snippet string `json:"snippet"`
// Published 是发布时间(各家口径不一:ISO 字符串或毫秒时间戳)。
Published string `json:"published,omitempty"`
// Score 是上游给的排序分(多数家不给,此时为 0 并被省略)。
Score float64 `json:"score,omitempty"`
}
// toWireResults 把归一化结果映射成线格式(追加 provider/score 等字段,不改既有字段名)。
func toWireResults(results []search.Result) []searchResult {
out := make([]searchResult, 0, len(results))
for _, r := range results {
out = append(out, searchResult{
Title: r.Title,
URL: r.Link,
Content: r.Content,
Snippet: r.Snippet,
Published: r.Published,
Score: r.Score,
})
}
return out
}
// withSearchEndpointHost 把**配置里给定的**provider 主机并入 web-search 自己的
// SSRF 白名单(返回新切片,不改动入参切片——组合根可能把它交给别的工具)。
// 端点解析不出主机、或主机已在名单里时原样返回。
//
// 理由见 newWebSearchTool:本机兜底端点必然是回环地址(127.0.0.1:38471),而
// SSRF 守卫默认拒绝回环;端点是运维配置而非模型输入,因此对它放行**不会**给模型
// 打开新的目标面——工具能去的主机恒等于配置里的那一个。
// 打开新的目标面——工具能去的主机恒等于配置里的那几个。
func withSearchEndpointHost(allowed []string, endpoint string) []string {
host := searchEndpointHost(endpoint)
if host == "" || isHostAllowed(host, allowed) {
@@ -172,7 +314,7 @@ func withSearchEndpointHost(allowed []string, endpoint string) []string {
return append(out, host)
}
// searchEndpointHost 取出搜索端点的主机名(去端口、去 IPv6 方括号);解析不出来
// searchEndpointHost 取出 provider 基地址的主机名(去端口、去 IPv6 方括号);解析不出来
// 或没有主机时返回空串。
func searchEndpointHost(endpoint string) string {
u, err := url.Parse(strings.TrimSpace(endpoint))
@@ -181,28 +323,3 @@ func searchEndpointHost(endpoint string) string {
}
return hostOnly(u.Host)
}
// searchResult is one entry of a SearXNG-compatible results payload.
type searchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Snippet string `json:"snippet"`
}
// parseSearchResults decodes a {"results":[...]} payload, falling back to the
// snippet field when content is absent.
func parseSearchResults(data []byte) ([]searchResult, error) {
var payload struct {
Results []searchResult `json:"results"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, fmt.Errorf("io: web-search: decode results: %w", err)
}
for i := range payload.Results {
if payload.Results[i].Content == "" {
payload.Results[i].Content = payload.Results[i].Snippet
}
}
return payload.Results, nil
}