diff --git a/plugins/tools/io/search/errors.go b/plugins/tools/io/search/errors.go index f0af17b..5c21286 100644 --- a/plugins/tools/io/search/errors.go +++ b/plugins/tools/io/search/errors.go @@ -3,6 +3,7 @@ package search import ( "encoding/json" "errors" + "fmt" "net/http" "strings" ) @@ -63,7 +64,24 @@ func Unavailable(err error) bool { // classifyStatus 把上游的失败状态码归成本仓的哨兵错误。 // 先看业务错误码(各家官方文档确证的那几个,见 upstreamCodeClass),再看 HTTP 状态。 +// +// 业务错误码非空时,返回值会带上 `(code=…)`:**只看 HTTP 状态分不开同一状态下的不同含义** +// —— 智谱把「欠费」与「限流」都放在 HTTP 429(1113 vs 1302/1305),码是唯一判据;这段文案 +// 会被工具层记进日志,运维据此知道该充值还是该退避(分册 11 错误码节)。`errors.Is` 仍能 +// 命中对应哨兵(用 %w 包装)。 +// +// ⚠️ 只带 **code**、不带上游 message:message 是任意上游文本(可能回显请求信息),而 `do` 的 +// 契约是「错误体不入错误文案」(见 http.go),凭据更不许出现在错误/日志里。 func classifyStatus(status int, code string) error { + sentinel := classifyStatusSentinel(status, code) + if c := strings.TrimSpace(code); c != "" { + return fmt.Errorf("%w (code=%s)", sentinel, c) + } + return sentinel +} + +// classifyStatusSentinel 是「业务码优先、再看状态码」的原判定(不带码回显)。 +func classifyStatusSentinel(status int, code string) error { if e, ok := upstreamCodeClass(code); ok { return e } @@ -74,6 +92,7 @@ func classifyStatus(status int, code string) error { case status == http.StatusPaymentRequired: return ErrQuotaExceeded case status == http.StatusTooManyRequests: + // 429 有两种含义(欠费 / 限流):表里没收录的码按**保守档**当限流处理。 return ErrRateLimited default: // 5xx 与其余 4xx 一律归 ErrUpstream(工具侧只关心「不可用 ⇒ 前进一格」)。 @@ -83,10 +102,14 @@ func classifyStatus(status int, code string) error { // upstreamCodeClass 把各家**官方文档确证**的业务错误码归类到哨兵错误(大小写不敏感)。 // -// 来源:阿里云 IQS 错误码表(help.aliyun.com/document_detail/2857020.html; -// DevDocs/reference/search-providers/15-阿里云-IQS.md 的「错误码」节)。 +// 来源:① 阿里云 IQS 错误码表(help.aliyun.com/document_detail/2857020.html; +// DevDocs/reference/search-providers/15-阿里云-IQS.md 的「错误码」节);② 智谱 BigModel +// 平台通用错误码表(见 zhipuCodeClass)。 // 只收录确证过的条目 —— 未确证的错误码一律不猜。 func upstreamCodeClass(code string) (error, bool) { + if e, ok := zhipuCodeClass(code); ok { + return e, true + } switch strings.ToLower(strings.TrimSpace(code)) { case "retrieval.throttling.user", "retrieval.testuserqueryperdayexceeded": return ErrRateLimited, true @@ -99,6 +122,33 @@ func upstreamCodeClass(code string) (error, bool) { } } +// zhipuCodeClass 是智谱 BigModel 的**平台通用**错误码表(分册 11「官方 schema 确证」③ +// 逐码对应官方原文,适用 `open.bigmodel.cn/api/paas/v4/*`): +// +// 1000 / 1001 / 1003(HTTP 401)⇒ 身份验证失败 / 未收到凭据 / 凭据过期 ⇒ unauthorized +// 1113(HTTP 429) ⇒ 账户欠费 ⇒ quota_exceeded +// 1302 / 1305(HTTP 429) ⇒ 账户限流 / 平台过载 ⇒ rate_limited +// +// 🔴 1113 与 1302/1305 **同为 HTTP 429** ⇒ 不读 code 就分不开「该充值」与「该退避」。 +// 搜索专用码 1701(并发达上限)/1702/1703 只登记、不在此映射:官方未给它们的 HTTP 状态码, +// 保守交给状态码兜底(429 未收录码 ⇒ rate_limited,其余 4xx/5xx ⇒ upstream)。 +// +// ⚠️ 作用域:这些是**裸数字码**,而本函数被所有 provider 共用。当前六家里只有智谱用数字业务码 +// (阿里云是 `Retrieval.*` 点分码)⇒ 暂按码值归类。**若将来出现第二家用数字码的家,必须改成 +// 「按 provider 分表」**,不能靠码值碰运气。 +func zhipuCodeClass(code string) (error, bool) { + switch strings.TrimSpace(code) { + case "1000", "1001", "1003": + return ErrUnauthorized, true + case "1113": + return ErrQuotaExceeded, true + case "1302", "1305": + return ErrRateLimited, true + default: + return nil, false + } +} + // codePaths 是上游错误体里「业务错误码」的候选位置(点分路径,按序取第一个非空字符串)。 // 各家形状不一,这里给的是容错候选;取不到就当没有错误码(按状态码分类)。 func codePaths() []string { diff --git a/plugins/tools/io/search/providers_test.go b/plugins/tools/io/search/providers_test.go index da1f5a7..80baa6d 100644 --- a/plugins/tools/io/search/providers_test.go +++ b/plugins/tools/io/search/providers_test.go @@ -101,7 +101,7 @@ func searxngCase() providerCase { } } -// zhipuCase 是智谱的夹具(POST + Bearer;请求字段 search_engine/search_query)。 +// zhipuCase 是智谱的夹具(POST + Bearer;请求字段 search_engine/search_query/search_intent/content_size)。 func zhipuCase() providerCase { return providerCase{ name: "zhipu", newP: newZhipu, @@ -112,7 +112,8 @@ func zhipuCase() providerCase { wantContent: "智谱正文", checkRequest: func(t *testing.T, c *captured) { t.Helper() - // 只发官方文档确证过的字段(search_engine / search_query)。 + // 只发官方文档确证过的字段:search_engine / search_query / search_intent / content_size, + // 外加**条件透传**的 count(仅当 Count 落在官方区间 1–50;本夹具 Query 未给 Count ⇒ 不发)。 var body map[string]any mustUnmarshal(t, c.body, &body) if body["search_query"] != "昔涟" { @@ -121,6 +122,12 @@ func zhipuCase() providerCase { if body["search_engine"] != zhipuEngineStd { t.Errorf("zhipu body search_engine = %v, want %s", body["search_engine"], zhipuEngineStd) } + if body["search_intent"] != false { + t.Errorf("zhipu body search_intent = %v, want false(官方标 required)", body["search_intent"]) + } + if body["content_size"] != zhipuContentSize { + t.Errorf("zhipu body content_size = %v, want %s", body["content_size"], zhipuContentSize) + } }, } } diff --git a/plugins/tools/io/search/zhipu.go b/plugins/tools/io/search/zhipu.go index de2b6c1..26c9c2b 100644 --- a/plugins/tools/io/search/zhipu.go +++ b/plugins/tools/io/search/zhipu.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "unicode/utf8" ) // zhipuProfile 是智谱 BigModel 联网搜索(design/27 §2.1 国内首选;分册 11)。 @@ -13,20 +14,50 @@ import ( // 官方**定价已确证**(分册 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 复核并收窄候选键。 +// ✅ 官方 schema 已确证(2026-10-07,分册 11「官方 schema 确证」一节,逐条对应官方原文): +// - 请求 9 字段:`search_query`(≤ **70** 字)/ `search_engine`(四档 enum,必填)/ +// `search_intent`(boolean,页面标 required)/ `count`(**1–50**,默认 10)/ +// `search_domain_filter` / `search_recency_filter` / `content_size` / `request_id` / `user_id`; +// - 响应:顶层 `id`/`created`/`request_id`/`search_intent`(object[])/`search_result`(object[]), +// `search_result[]` 每条 7 键 = `title`/`content`/`link`/`media`/`icon`/`refer`/`publish_date`; +// - 🔴 同名字段语义反转:请求侧 `search_intent` 是 **boolean**,响应侧是 **object[]**。 +// +// ⚠️ 诚实边界:**没有 API key ⇒ 真机未实测**(分册 11「本轮未能确证」逐条)。因此: +// +// ① 请求只发**确证过的字段**;② 响应用共享提取器按**多组候选键**容错 —— 官方 schema +// 已排除的候选键**暂不删**(见 zhipuSpec),待真机实测通过后再收窄 —— 拿未实测的推断 +// 去砍掉容错路径,风险大于收益;③ `content_size` 的取值属**半确证**(见常量注释)。 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), -// 显式写死一档以固定成本;要换档请改这里或后续开放为配置项。 +// 0.01 元/次,四档里最便宜)。官方 API Reference 把 `search_engine` 列为**必填 enum** +// (`search_std`/`search_pro`/`search_pro_sogou`/`search_pro_quark`),这里显式写死一档 +// 以固定成本;要换档请改这里,或后续开放为配置项。 const zhipuEngineStd = "search_std" +// zhipuMaxQueryRunes 是 `search_query` 的**官方硬上限**(API Reference:Maximum string +// length 70)。超限就**如实报错、一个请求都不发** —— 静默截断会让她以为搜的是别的东西。 +// +// 按**字符(rune)**计:官方只写 "string length",未说明是字符还是字节;中文查询词按字符 +// 计最宽(一个中文 = 3 字节),拿不准时宁可少发几个字,也不误把合法查询词拒掉。 +const zhipuMaxQueryRunes = 70 + +// zhipuCount 的官方合法区间(API Reference:1–50,默认 10)。 +const ( + zhipuMinCount = 1 + zhipuMaxCount = 50 +) + +// zhipuContentSize 压短上游返回的正文(`medium` = 摘要),降低撞本仓 1 MiB 读取上限 +// (plugins/tools/io 的 http 响应上限)的风险。 +// +// ⚠️ **半确证**:API Reference 的字段表**没标 default**;「默认 medium」只来自 guide 页 +// 示例旁的注释(分册 11「本轮未能确证」第 5 条)。这里按 `medium` 发,属保守取值。 +const zhipuContentSize = "medium" + // newZhipu 造一家智谱 provider。 func newZhipu(cfg Config) Provider { return &zhipu{cfg: cfg} } @@ -35,12 +66,25 @@ 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)。 + if n := utf8.RuneCountInString(q.Text); n > zhipuMaxQueryRunes { + // 官方硬上限:超限**如实报错、不发请求**(截断 = 悄悄把她的查询词换成另一个)。 + return nil, fmt.Errorf("search: zhipu: query is %d characters, upstream limit is %d", + n, zhipuMaxQueryRunes) + } payload := map[string]any{ "search_engine": zhipuEngineStd, "search_query": q.Text, + // 官方把 search_intent 标为 required(默认 false):缺发可能触发 1213(未正常接收到 + // 该参数)。发 false = 直接搜、不做意图识别。 + // 🔴 响应侧同名字段是 object[](意图结果),两侧语义相反 —— 别混用。 + "search_intent": false, + // content_size 是**半确证**取值(见常量注释):压短正文,降低撞读取上限的风险。 + "content_size": zhipuContentSize, + } + // count 落在官方区间才透传;越界就**不发这个字段**,让上游用默认 10 —— + // 把非法值发上去换一个 1210 参数错误没有意义。 + if c, ok := zhipuCount(q.Count); ok { + payload["count"] = c } body, err := json.Marshal(payload) if err != nil { @@ -58,16 +102,43 @@ func (p *zhipu) Search(ctx context.Context, q Query) ([]Result, error) { return extractResults(data, zhipuSpec()) } -// zhipuSpec 是智谱响应的候选形状。官方示例以「工具块」给出 WebBrowserOutput{title,link,content} -// (分册 11),故 title/link/content 是确证键;外层数组键名未确证 ⇒ 给多组候选。 +// zhipuCount 把工具侧的 count 收敛到官方区间 [1,50];越界返回 ok=false(不发该字段)。 +func zhipuCount(count int) (int, bool) { + if count < zhipuMinCount || count > zhipuMaxCount { + return 0, false + } + return count, true +} + +// zhipuSpec 是智谱响应的候选形状。 +// +// ✅ 2026-10-07 官方 schema 已确证(分册 11):数组键是**顶层 `search_result`**,每条 7 键 = +// title/content/link/media/icon/refer/publish_date;官方**没有** snippet/summary/abstract/ +// url/score/rank(「摘要」就在 `content` 里)。据此: +// - `publish_date` 是确证的发布时间 ⇒ 已映射到 Published; +// - 🔴 **`media` / `icon` / `refer` 当前一律不投影**(主人 2026-10-07 裁定,撤销先前把 +// `media` 当 Snippet 候选的写法)。`media` 是**网站名称**(如「搜狐」),不是正文摘录: +// 官方响应**根本没有 `snippet`** ⇒ 若把它列为候选,`Snippet` 会被**恒由 `media` 填满** +// (不是偶尔兜底),等于每条结果的「摘要位」都变成站点名 —— 往语义位塞了另一类事实; +// 更危险的是共享提取器有一条**单向**回落(`extractResults`:`Content` 空 ⇒ 回落 +// `Snippet`)⇒ 站点名有可能被当成正文递出去。 +// `Result` 没有「来源站点」位(本片不许改 `extract.go` / `search.Result`)⇒ **将来要投影 +// `media`,正确做法是给 `Result` 加一个字段并同步契约**(不是塞进 `Snippet`)。 +// +// ⚠️ **候选键暂不删**:主人还没给 key,容错路径**未经真机验证**;官方 schema 只覆盖 +// `search_std` 的文档口径(分册 11「各档引擎返回字段是否真同构」未确证)。**待真机实测 +// 通过后**再把这些已排除的候选键收窄成单键。 func zhipuSpec() spec { return spec{ Arrays: []string{"search_result", "results", "data", "data.results", "web_search_result"}, Title: []string{"title", "name"}, Link: []string{"link", "url"}, + // 🔴 **不含 `media`**:它是网站名称、不是正文摘录,且官方响应没有 `snippet` + // ⇒ 列进来会让 Snippet **恒**被站点名填满,并被共享提取器的单向回落当成正文 + // 递出去(理由见上方注释;`media`/`icon`/`refer` 当前一律不投影)。 Snippet: []string{"snippet", "summary", "abstract"}, Content: []string{"content", "text"}, - // 官方示例未见发布时间/评分字段 ⇒ 只给通用候选,取不到就是空。 + // 官方确证:publish_date(如 "2025-05-23")。 Published: []string{"publish_date", "published", "date"}, Score: []string{"score", "rank"}, } diff --git a/plugins/tools/io/search/zhipu_test.go b/plugins/tools/io/search/zhipu_test.go new file mode 100644 index 0000000..b17b584 --- /dev/null +++ b/plugins/tools/io/search/zhipu_test.go @@ -0,0 +1,308 @@ +package search + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// 本文件钉住智谱 provider 的**请求面**与**错误映射**(依据:分册 11「官方 schema 确证(2026-10-07)」): +// 官方请求 9 字段(`search_query` ≤70 字 / `search_intent` boolean 必填 / `count` 1–50 / `content_size`)、 +// 响应 `search_result[]` 七键、错误码 `error.code`(🔴 1113 欠费与 1302/1305 限流**同为 HTTP 429**)。 +// 全部打 httptest,**不打真实网络**(无 key,真机未实测)。 + +// zhipuOK 是官方文档原文形状的应答(分册 11 响应示例逐字段照抄),含语义易混的 +// 响应侧 `search_intent`(**object[]**,与请求侧 boolean 反向)。 +const zhipuOK = `{ + "created": 1748261757, + "id": "20250526201557dda85ca6801b467b", + "request_id": "20250526201557dda85ca6801b467b", + "search_intent": [ + {"intent": "SEARCH_ALL", "keywords": "2025年4月 财经新闻", "query": "搜索2025年4月的财经新闻"} + ], + "search_result": [ + { + "content": "一、1-4月我国对外直接投资575.4亿美元,同比增长7.5%。", + "icon": "https://sfile.chatglm.cn/searchImage/sohu_icon_new.jpg", + "link": "https://www.sohu.com/a/897879632_121123890", + "media": "搜狐", + "publish_date": "2025-05-23", + "refer": "ref_1", + "title": "2025年5月23日财经早资讯" + } + ] +}` + +// zhipuStub 是一个假智谱上游:回固定状态码 + 固定报文,并数着被打了多少次 +// (用来证明「超长查询词根本不发请求」)。 +type zhipuStub struct { + srv *httptest.Server + hits int32 + body chan string +} + +func newZhipuStub(t *testing.T, status int, fixture string) *zhipuStub { + t.Helper() + st := &zhipuStub{body: make(chan string, 8)} + st.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&st.hits, 1) + raw, _ := io.ReadAll(r.Body) + select { + case st.body <- string(raw): + default: + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(fixture)) + })) + t.Cleanup(st.srv.Close) + return st +} + +func (st *zhipuStub) provider() Provider { + return newZhipu(Config{APIKey: testKey, BaseURL: st.srv.URL, Client: st.srv.Client(), MaxBytes: 1 << 20}) +} + +func (st *zhipuStub) hitCount() int { return int(atomic.LoadInt32(&st.hits)) } + +// lastBody 取最近一次请求体(一次都没发过时返回 "")。 +func (st *zhipuStub) lastBody() string { + select { + case b := <-st.body: + return b + default: + return "" + } +} + +// decodedBody 把最近一次请求体解成 map;没发过请求即 Fatal。 +func (st *zhipuStub) decodedBody(t *testing.T) map[string]any { + t.Helper() + raw := st.lastBody() + if raw == "" { + t.Fatal("没收到任何请求体 —— 请求根本没发出去") + } + var body map[string]any + mustUnmarshal(t, raw, &body) + return body +} + +// 判据 ①:请求体补发官方标 required / 半确证的字段(`search_intent:false` + `content_size:"medium"`), +// 合法 `count` 透传。 +func TestZhipuRequestCarriesConfirmedFields(t *testing.T) { + t.Parallel() + st := newZhipuStub(t, http.StatusOK, zhipuOK) + got, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: 5}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 1 { + t.Fatalf("结果条数 = %d, want 1", len(got)) + } + body := st.decodedBody(t) + if body["search_intent"] != false { + t.Errorf("search_intent = %v, want false(官方标 required,缺发可能触发 1213)", body["search_intent"]) + } + if body["content_size"] != zhipuContentSize { + t.Errorf("content_size = %v, want %q", body["content_size"], zhipuContentSize) + } + if body["count"] != float64(5) { + t.Errorf("count = %v, want 5(合法区间内应透传)", body["count"]) + } + if body["search_engine"] != zhipuEngineStd { + t.Errorf("search_engine = %v, want %q", body["search_engine"], zhipuEngineStd) + } + if body["search_query"] != "昔涟" { + t.Errorf("search_query = %v, want 昔涟", body["search_query"]) + } +} + +// 判据 ②:`count` 落在官方区间 [1,50] 才透传;越界(0 / 负数 / >50)**不发这个字段**, +// 让上游用默认 10(不许把非法值发上去)。 +func TestZhipuCountOnlySentWithinOfficialRange(t *testing.T) { + t.Parallel() + cases := []struct { + count int + want bool + }{ + {0, false}, {1, true}, {10, true}, {50, true}, {51, false}, {-3, false}, + } + for _, tc := range cases { + tc := tc + t.Run(fmt.Sprintf("count=%d", tc.count), func(t *testing.T) { + t.Parallel() + st := newZhipuStub(t, http.StatusOK, zhipuOK) + if _, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: tc.count}); err != nil { + t.Fatalf("Search: %v", err) + } + raw, present := st.decodedBody(t)["count"] + if !tc.want { + if present { + t.Fatalf("count=%d 越界却把非法值发了上去: %v", tc.count, raw) + } + return + } + if !present || raw != float64(tc.count) { + t.Fatalf("count = %v (present=%v), want %d", raw, present, tc.count) + } + }) + } +} + +// 判据 ③:`search_query` 超过官方 70 字硬上限 ⇒ **如实报错**(不截断),且**一个请求都不发** +// (服务器计数为 0 是证据)。边界:正好 70 字放行。 +func TestZhipuQueryOverLimitFailsWithoutRequest(t *testing.T) { + t.Parallel() + t.Run("71 字必须报错且不发请求", func(t *testing.T) { + t.Parallel() + st := newZhipuStub(t, http.StatusOK, zhipuOK) + // 用中文:证明按**字符**(rune)而不是字节计数(71 字 = 213 字节)。 + got, err := st.provider().Search(context.Background(), + Query{Text: strings.Repeat("昔", zhipuMaxQueryRunes+1), Count: 3}) + if err == nil { + t.Fatalf("超长查询词必须如实报错,却拿到了 %d 条结果", len(got)) + } + if !strings.Contains(err.Error(), "70") { + t.Errorf("错误文案应点明官方上限 70:%v", err) + } + if n := st.hitCount(); n != 0 { + t.Fatalf("超长查询词不该发出请求,服务器却收到 %d 次", n) + } + }) + t.Run("70 字是上限内,应放行", func(t *testing.T) { + t.Parallel() + st := newZhipuStub(t, http.StatusOK, zhipuOK) + if _, err := st.provider().Search(context.Background(), + Query{Text: strings.Repeat("昔", zhipuMaxQueryRunes), Count: 1}); err != nil { + t.Fatalf("正好 %d 字在官方上限内,不该报错: %v", zhipuMaxQueryRunes, err) + } + if n := st.hitCount(); n != 1 { + t.Fatalf("正好 %d 字应发出 1 次请求,实际 %d 次", zhipuMaxQueryRunes, n) + } + }) +} + +// 判据 ④:错误映射 —— 🔴 429 上的「欠费(1113)」与「限流(1302/1305)」必须靠 `error.code` 分开, +// 不许只看 HTTP 状态;分类要一路走到探针词汇(面板「测试连通」据此说话)。 +func TestZhipuErrorCodeMapping(t *testing.T) { + t.Parallel() + cases := []struct { + name string + status int + body string + wantSent error + wantCode string // 探针错误码(probe.go 的词汇) + wantIn string // 错误文案里必须带上的原始业务码(供日志定位) + }{ + { + name: "凭据无效 1001", status: http.StatusUnauthorized, + body: `{"error":{"code":"1001","message":"Header 中未收到 Authentication 参数,无法进行身份验证"}}`, + wantSent: ErrUnauthorized, wantCode: ProbeUnauthorized, wantIn: "1001", + }, + { + name: "凭据无效 1000", status: http.StatusUnauthorized, + body: `{"error":{"code":"1000","message":"身份验证失败"}}`, + wantSent: ErrUnauthorized, wantCode: ProbeUnauthorized, wantIn: "1000", + }, + { + name: "凭据过期 1003(数字码也要认)", status: http.StatusUnauthorized, + body: `{"error":{"code":1003,"message":"Authentication Token 已过期,请重新生成"}}`, + wantSent: ErrUnauthorized, wantCode: ProbeUnauthorized, wantIn: "1003", + }, + { + name: "欠费 1113(同为 429,必须与限流分开)", status: http.StatusTooManyRequests, + body: `{"error":{"code":"1113","message":"您的账户已欠费,请充值后重试"}}`, + wantSent: ErrQuotaExceeded, wantCode: ProbeQuotaExceeded, wantIn: "1113", + }, + { + name: "账户限流 1302", status: http.StatusTooManyRequests, + body: `{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}`, + wantSent: ErrRateLimited, wantCode: ProbeRateLimited, wantIn: "1302", + }, + { + name: "平台过载 1305", status: http.StatusTooManyRequests, + body: `{"error":{"code":"1305","message":"该模型当前访问量过大,请您稍后再试"}}`, + wantSent: ErrRateLimited, wantCode: ProbeRateLimited, wantIn: "1305", + }, + { + name: "429 + 表里没有的码 ⇒ 保守当限流,但码要进文案", status: http.StatusTooManyRequests, + body: `{"error":{"code":"1701","message":"并发已达上限"}}`, + wantSent: ErrRateLimited, wantCode: ProbeRateLimited, wantIn: "1701", + }, + { + name: "429 + 没有码 ⇒ 保守当限流", status: http.StatusTooManyRequests, + body: `{}`, + wantSent: ErrRateLimited, wantCode: ProbeRateLimited, + }, + { + name: "5xx ⇒ upstream", status: http.StatusBadGateway, + body: `bad gateway`, + wantSent: ErrUpstream, wantCode: ProbeUpstream, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + st := newZhipuStub(t, tc.status, tc.body) + got, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: 1}) + if err == nil { + t.Fatalf("非 2xx 必须如实报错,却拿到 %d 条结果", len(got)) + } + if !errors.Is(err, tc.wantSent) { + t.Errorf("errors.Is(err, %v) = false,错误分类错了: %v", tc.wantSent, err) + } + if code := ProbeErrorCode(err); code != tc.wantCode { + t.Errorf("探针错误码 = %q, want %q(面板文案会因此说错话)", code, tc.wantCode) + } + if tc.wantIn != "" && !strings.Contains(err.Error(), tc.wantIn) { + t.Errorf("错误文案没带上原始业务码 %q(运维没法从日志区分欠费/限流):%v", tc.wantIn, err) + } + if strings.Contains(err.Error(), testKey) { + t.Fatalf("错误文案泄漏了 API key: %v", err) + } + }) + } +} + +// 判据 ⑤:官方确证的键能被归一化 —— 响应侧 `search_intent` 是 **object[]**(与请求侧 boolean 反向), +// 不许被误当成结果数组;`publish_date` 真的被吃进来,而 `media`(网站名称)**不许**被投影到 Snippet。 +func TestZhipuResponseNormalizesConfirmedKeys(t *testing.T) { + t.Parallel() + st := newZhipuStub(t, http.StatusOK, zhipuOK) + got, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: 1}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 1 { + t.Fatalf("结果条数 = %d, want 1(search_intent 是意图数组,不该被当成结果数组)", len(got)) + } + r := got[0] + if r.Title != "2025年5月23日财经早资讯" { + t.Errorf("Title = %q", r.Title) + } + if r.Link != "https://www.sohu.com/a/897879632_121123890" { + t.Errorf("Link = %q", r.Link) + } + if !strings.HasPrefix(r.Content, "一、1-4月") { + t.Errorf("Content = %q(官方「摘要」就在 content 里)", r.Content) + } + if r.Published != "2025-05-23" { + t.Errorf("Published = %q, want 2025-05-23(官方 publish_date)", r.Published) + } + // 🔴 官方响应**没有 `snippet`** ⇒ 若把 `media`(网站名称,如「搜狐」)当候选塞进 `Snippet`, + // 它会被**恒定填满**(不是偶尔兜底),而 Snippet 的语义是**正文摘录**;更糟的是共享提取器 + // 有一条单向回落(`Content` 空 ⇒ 回落 `Snippet`)⇒ 站点名可能被当成正文递出去。 + if strings.Contains(r.Snippet, "搜狐") { + t.Errorf("Snippet = %q 里出现了站点名「搜狐」(官方 media=网站名称)—— media/icon/refer 当前一律不投影", r.Snippet) + } + if r.Snippet != "" { + t.Errorf("Snippet = %q, want 空(官方响应没有 snippet,Snippet 不许被别的字段填充)", r.Snippet) + } +}