主人定调「先把原生的做进插件,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),并有「未登记⇒启动报错」的防回归断言
75 lines
3.2 KiB
Go
75 lines
3.2 KiB
Go
package search
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
)
|
||
|
||
// zhipuProfile 是智谱 BigModel 联网搜索(design/27 §2.1 国内首选;分册 11)。
|
||
//
|
||
// 认证:`Authorization: Bearer <api_key>`;纯 REST(POST + JSON)⇒ 零新依赖。
|
||
// 官方**定价已确证**(分册 11 的官方定价页逐字):Search-Std 0.01 元/次、
|
||
// Search-Pro 0.03、Search-Pro-Sogou 0.05、Search-Pro-Quark 0.05。
|
||
//
|
||
// ⚠️ 诚实边界(分册 11「未确证」):**完整请求/响应 JSON schema 未取到**,只确证了
|
||
// 字段名(请求侧 search_engine/search_query/…,响应侧 title/link/content)。
|
||
// 因此这里:① 请求只发确证过的两个字段;② 响应用共享提取器按**多组候选键**容错,
|
||
// 不把任何猜测的字段当事实。拿到真实 key 后应按官方 API Reference 复核并收窄候选键。
|
||
type zhipu struct{ cfg Config }
|
||
|
||
// zhipuPath 是联网搜索端点路径(分册 11 官方原文:POST https://open.bigmodel.cn/api/paas/v4/web_search)。
|
||
const zhipuPath = "/api/paas/v4/web_search"
|
||
|
||
// zhipuEngineStd 是默认搜索型号。取 `search_std`(自研基础搜索,官方定价页确证
|
||
// 0.01 元/次,四档里最便宜)。⚠️ `search_engine` 是否可选官方未明说(分册 11),
|
||
// 显式写死一档以固定成本;要换档请改这里或后续开放为配置项。
|
||
const zhipuEngineStd = "search_std"
|
||
|
||
// newZhipu 造一家智谱 provider。
|
||
func newZhipu(cfg Config) Provider { return &zhipu{cfg: cfg} }
|
||
|
||
// Name 实现 Provider。
|
||
func (p *zhipu) Name() string { return NameZhipu }
|
||
|
||
// Search 实现 Provider。
|
||
func (p *zhipu) Search(ctx context.Context, q Query) ([]Result, error) {
|
||
// TODO(未确证):响应可能很大(自带网页正文,分册 11 提示注意本仓 1 MiB 上限)。
|
||
// 官方另有 `content_size` 可限正文长度,但其取值域未确证 ⇒ 不写死,留待拿到
|
||
// 真实 key 后按官方文档补上(当前超限会如实返回 ErrResponseTooLarge)。
|
||
payload := map[string]any{
|
||
"search_engine": zhipuEngineStd,
|
||
"search_query": q.Text,
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("search: zhipu: encode request: %w", err)
|
||
}
|
||
data, err := do(ctx, p.cfg, request{
|
||
Method: http.MethodPost,
|
||
URL: baseURLJoin(p.cfg.BaseURL, zhipuPath),
|
||
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey},
|
||
Body: body,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return extractResults(data, zhipuSpec())
|
||
}
|
||
|
||
// zhipuSpec 是智谱响应的候选形状。官方示例以「工具块」给出 WebBrowserOutput{title,link,content}
|
||
// (分册 11),故 title/link/content 是确证键;外层数组键名未确证 ⇒ 给多组候选。
|
||
func zhipuSpec() spec {
|
||
return spec{
|
||
Arrays: []string{"search_result", "results", "data", "data.results", "web_search_result"},
|
||
Title: []string{"title", "name"},
|
||
Link: []string{"link", "url"},
|
||
Snippet: []string{"snippet", "summary", "abstract"},
|
||
Content: []string{"content", "text"},
|
||
// 官方示例未见发布时间/评分字段 ⇒ 只给通用候选,取不到就是空。
|
||
Published: []string{"publish_date", "published", "date"},
|
||
Score: []string{"score", "rank"},
|
||
}
|
||
}
|