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
@@ -0,0 +1,99 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// defaultMaxBytes 是单次响应体的兜底读取上限(1 MiB,与 io 包的 http 响应上限同口径)。
|
||||
// 组合根总会传入显式值;这里只保证 Config.MaxBytes 为零值时不是「无限读」。
|
||||
const defaultMaxBytes = 1 << 20
|
||||
|
||||
// request 描述一次上游调用。provider 只填这些,其余(读取上限、错误分类)走共享通路
|
||||
// —— 六家绝不能各写一套「发请求 + 判状态 + 读 body」的样板(AGENTS §10 三次复制铁律)。
|
||||
type request struct {
|
||||
// Method 是 HTTP 方法(http.MethodGet / http.MethodPost)。
|
||||
Method string
|
||||
// URL 是完整目标地址(provider 自己拼 base + path + query)。
|
||||
URL string
|
||||
// Headers 是附加请求头(含认证头)。**绝不写进日志**。
|
||||
Headers map[string]string
|
||||
// Body 是请求体(非空时默认 Content-Type: application/json)。
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// do 发一次请求并返回读取上限内的响应体:
|
||||
//
|
||||
// - SSRF 守卫(组合根注入)先判最终地址 ⇒ 被拦时如实报错(不静默放行);
|
||||
// - 网络层失败 ⇒ ErrNetwork(含 context 超时/取消,调用方可用 errors.Is 区分);
|
||||
// - 非 2xx ⇒ 按业务错误码 + 状态码分类(classifyStatus);
|
||||
// - 超过 MaxBytes ⇒ ErrResponseTooLarge(不静默截断成半截 JSON)。
|
||||
//
|
||||
// 报文正文与认证头**都不进日志**(本函数不记日志,日志由调用方在工具层记)。
|
||||
func do(ctx context.Context, cfg Config, r request) ([]byte, error) {
|
||||
if cfg.Client == nil {
|
||||
return nil, fmt.Errorf("search: no http client: %w", ErrNotConfigured)
|
||||
}
|
||||
if cfg.Guard != nil {
|
||||
if err := cfg.Guard(ctx, r.URL); err != nil {
|
||||
return nil, fmt.Errorf("search: blocked upstream url: %w", err)
|
||||
}
|
||||
}
|
||||
var body io.Reader
|
||||
if len(r.Body) > 0 {
|
||||
body = bytes.NewReader(r.Body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, r.Method, r.URL, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: build request: %w", err)
|
||||
}
|
||||
for k, v := range r.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if len(r.Body) > 0 && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := cfg.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrNetwork, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := readCapped(resp.Body, cfg.MaxBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
// 错误体可能带业务错误码(如阿里云 Retrieval.Arrears);错误体本身不入错误文案
|
||||
// 以外的去处,也不会被当成结果。
|
||||
return nil, fmt.Errorf("search: upstream status %d: %w", resp.StatusCode, classifyStatus(resp.StatusCode, upstreamCode(data)))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// readCapped 读回最多 max 字节;超限返回 ErrResponseTooLarge。
|
||||
func readCapped(r io.Reader, max int64) ([]byte, error) {
|
||||
if max <= 0 {
|
||||
max = defaultMaxBytes
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(r, max+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: read response: %w", err)
|
||||
}
|
||||
if int64(len(data)) > max {
|
||||
return nil, fmt.Errorf("%w: exceeds the %d-byte limit", ErrResponseTooLarge, max)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// baseURLJoin 把基地址与各家固定路径拼起来(容忍基地址尾部的 "/")。
|
||||
func baseURLJoin(baseURL, path string) string {
|
||||
trimmed := baseURL
|
||||
for len(trimmed) > 0 && trimmed[len(trimmed)-1] == '/' {
|
||||
trimmed = trimmed[:len(trimmed)-1]
|
||||
}
|
||||
return trimmed + path
|
||||
}
|
||||
Reference in new issue
Block a user