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

+125
View File
@@ -0,0 +1,125 @@
package search
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
// 本包的哨兵错误:把「上游为什么不行」如实分成几类
// (design/27 §1.4/§3:provider 报错与空结果必须如实区分)。
// 调用方一律用 errors.Is 判定。
var (
// ErrUnknownProvider 表示名字不在本期接入表里(写错了,或本期没做,如 tavily)。
ErrUnknownProvider = errors.New("search: unknown provider")
// ErrNotConfigured 表示这家 provider 没配齐(缺 key / 缺 base URL)。
ErrNotConfigured = errors.New("search: provider not configured")
// ErrUnauthorized 表示上游拒了凭据(401/403)。
ErrUnauthorized = errors.New("search: upstream rejected credentials")
// ErrRateLimited 表示被上游限流(429)。
ErrRateLimited = errors.New("search: upstream rate limited")
// ErrQuotaExceeded 表示额度用尽/欠费/未开通(含 403 + 业务码)。
ErrQuotaExceeded = errors.New("search: upstream quota exceeded")
// ErrNetwork 表示网络层失败(连不上、DNS、超时)——它**是**「该家不可用」的一种。
ErrNetwork = errors.New("search: network error")
// ErrUpstream 表示上游 5xx 或其他无法进一步分类的失败状态。
ErrUpstream = errors.New("search: upstream error")
// ErrResponseTooLarge 表示上游响应超过本仓的读取上限(不静默截断成半截 JSON)。
ErrResponseTooLarge = errors.New("search: response exceeds size limit")
// ErrBadResponse 表示响应不是预期结构(JSON 解不开,或按 spec 压根找不到结果数组)。
ErrBadResponse = errors.New("search: malformed upstream response")
// ErrNoResults 表示上游正常应答但**没有任何结果**。
//
// 它是「空」不是「成功」,也**不是**「该家不可用」:降级链遇到它要停下并如实
// 报「查到了 0 条」,而不是前进一格假装换一家就有了(design/27 §1.4/§3)。
ErrNoResults = errors.New("search: no results")
)
// Unavailable 报告一个错误是否属于「该家不可用」——只有这类错误才让降级链前进一格
// (design/27 §3:无 key / 网络失败 / 额度错误…)。
//
// 空结果(ErrNoResults)与上下文取消/超时**不在**其列:前者是「空」不是「不可用」,
// 后者是预算已经花光,换一家也不会有时间。
func Unavailable(err error) bool {
switch {
case err == nil:
return false
case errors.Is(err, ErrNoResults):
return false
case errors.Is(err, ErrNotConfigured),
errors.Is(err, ErrUnauthorized),
errors.Is(err, ErrRateLimited),
errors.Is(err, ErrQuotaExceeded),
errors.Is(err, ErrNetwork),
errors.Is(err, ErrUpstream),
errors.Is(err, ErrResponseTooLarge),
errors.Is(err, ErrBadResponse):
return true
default:
return false
}
}
// classifyStatus 把上游的失败状态码归成本仓的哨兵错误。
// 先看业务错误码(各家官方文档确证的那几个,见 upstreamCodeClass),再看 HTTP 状态。
func classifyStatus(status int, code string) error {
if e, ok := upstreamCodeClass(code); ok {
return e
}
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
// 403 既可能是鉴权也可能是额度,业务错误码(若上游给了)已在上面先行判定。
return ErrUnauthorized
case status == http.StatusPaymentRequired:
return ErrQuotaExceeded
case status == http.StatusTooManyRequests:
return ErrRateLimited
default:
// 5xx 与其余 4xx 一律归 ErrUpstream(工具侧只关心「不可用 ⇒ 前进一格」)。
return ErrUpstream
}
}
// upstreamCodeClass 把各家**官方文档确证**的业务错误码归类到哨兵错误(大小写不敏感)。
//
// 来源:阿里云 IQS 错误码表(help.aliyun.com/document_detail/2857020.html;
// DevDocs/reference/search-providers/15-阿里云-IQS.md 的「错误码」节)。
// 只收录确证过的条目 —— 未确证的错误码一律不猜。
func upstreamCodeClass(code string) (error, bool) {
switch strings.ToLower(strings.TrimSpace(code)) {
case "retrieval.throttling.user", "retrieval.testuserqueryperdayexceeded":
return ErrRateLimited, true
case "retrieval.arrears", "retrieval.notactivate", "retrieval.testuserperiodexpired":
return ErrQuotaExceeded, true
case "retrieval.notauthorised":
return ErrUnauthorized, true
default:
return nil, false
}
}
// codePaths 是上游错误体里「业务错误码」的候选位置(点分路径,按序取第一个非空字符串)。
// 各家形状不一,这里给的是容错候选;取不到就当没有错误码(按状态码分类)。
func codePaths() []string {
return []string{"code", "error.code", "error_code", "ErrorCode", "ResponseMetadata.Error.Code"}
}
// upstreamCode 尽力从错误响应体里取出业务错误码。取不到(不是 JSON、没有该字段)返回空串。
func upstreamCode(data []byte) string {
if len(data) == 0 {
return ""
}
var root any
if err := json.Unmarshal(data, &root); err != nil {
return ""
}
for _, p := range codePaths() {
if v, ok := resolvePath(root, p); ok {
if s := scalarString(v); s != "" {
return s
}
}
}
return ""
}