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:
1 parent
236b202fda
commit
69a3b73777
24 files changed
+3251
-171
No files matched your search
+236
-119
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user