@qjp88995/provider-client 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/dist/direct-resolver.d.ts.map +1 -1
  2. package/dist/direct-resolver.js +2 -0
  3. package/dist/direct-resolver.js.map +1 -1
  4. package/dist/image/http.d.ts +16 -0
  5. package/dist/image/http.d.ts.map +1 -0
  6. package/dist/image/http.js +62 -0
  7. package/dist/image/http.js.map +1 -0
  8. package/dist/image/index.d.ts +32 -0
  9. package/dist/image/index.d.ts.map +1 -0
  10. package/dist/image/index.js +39 -0
  11. package/dist/image/index.js.map +1 -0
  12. package/dist/image/types.d.ts +94 -0
  13. package/dist/image/types.d.ts.map +1 -0
  14. package/dist/image/types.js +44 -0
  15. package/dist/image/types.js.map +1 -0
  16. package/dist/image/volcengine.adapter.d.ts +8 -0
  17. package/dist/image/volcengine.adapter.d.ts.map +1 -0
  18. package/dist/image/volcengine.adapter.js +85 -0
  19. package/dist/image/volcengine.adapter.js.map +1 -0
  20. package/dist/index.d.ts +6 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +5 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/justone/client.d.ts +101 -0
  25. package/dist/justone/client.d.ts.map +1 -0
  26. package/dist/justone/client.js +272 -0
  27. package/dist/justone/client.js.map +1 -0
  28. package/dist/justone/endpoint-spec.d.ts +49 -0
  29. package/dist/justone/endpoint-spec.d.ts.map +1 -0
  30. package/dist/justone/endpoint-spec.js +2 -0
  31. package/dist/justone/endpoint-spec.js.map +1 -0
  32. package/dist/justone/endpoints.generated.d.ts +7927 -0
  33. package/dist/justone/endpoints.generated.d.ts.map +1 -0
  34. package/dist/justone/endpoints.generated.js +8508 -0
  35. package/dist/justone/endpoints.generated.js.map +1 -0
  36. package/dist/justone/errors.d.ts +52 -0
  37. package/dist/justone/errors.d.ts.map +1 -0
  38. package/dist/justone/errors.js +73 -0
  39. package/dist/justone/errors.js.map +1 -0
  40. package/dist/justone/index.d.ts +8 -0
  41. package/dist/justone/index.d.ts.map +1 -0
  42. package/dist/justone/index.js +7 -0
  43. package/dist/justone/index.js.map +1 -0
  44. package/dist/justone/params.d.ts +17 -0
  45. package/dist/justone/params.d.ts.map +1 -0
  46. package/dist/justone/params.js +72 -0
  47. package/dist/justone/params.js.map +1 -0
  48. package/dist/justone/pricing.d.ts +6 -0
  49. package/dist/justone/pricing.d.ts.map +1 -0
  50. package/dist/justone/pricing.generated.d.ts +303 -0
  51. package/dist/justone/pricing.generated.d.ts.map +1 -0
  52. package/dist/justone/pricing.generated.js +305 -0
  53. package/dist/justone/pricing.generated.js.map +1 -0
  54. package/dist/justone/pricing.js +11 -0
  55. package/dist/justone/pricing.js.map +1 -0
  56. package/dist/media/bibigpt.adapter.d.ts +43 -0
  57. package/dist/media/bibigpt.adapter.d.ts.map +1 -0
  58. package/dist/media/bibigpt.adapter.js +302 -0
  59. package/dist/media/bibigpt.adapter.js.map +1 -0
  60. package/dist/media/http.d.ts +21 -0
  61. package/dist/media/http.d.ts.map +1 -0
  62. package/dist/media/http.js +39 -0
  63. package/dist/media/http.js.map +1 -0
  64. package/dist/media/index.d.ts +44 -0
  65. package/dist/media/index.d.ts.map +1 -0
  66. package/dist/media/index.js +47 -0
  67. package/dist/media/index.js.map +1 -0
  68. package/dist/media/types.d.ts +207 -0
  69. package/dist/media/types.d.ts.map +1 -0
  70. package/dist/media/types.js +21 -0
  71. package/dist/media/types.js.map +1 -0
  72. package/dist/media/zhipu-parser-package.d.ts +37 -0
  73. package/dist/media/zhipu-parser-package.d.ts.map +1 -0
  74. package/dist/media/zhipu-parser-package.js +108 -0
  75. package/dist/media/zhipu-parser-package.js.map +1 -0
  76. package/dist/media/zhipu.adapter.d.ts +37 -0
  77. package/dist/media/zhipu.adapter.d.ts.map +1 -0
  78. package/dist/media/zhipu.adapter.js +361 -0
  79. package/dist/media/zhipu.adapter.js.map +1 -0
  80. package/dist/provider-client.d.ts +62 -0
  81. package/dist/provider-client.d.ts.map +1 -1
  82. package/dist/provider-client.js +176 -0
  83. package/dist/provider-client.js.map +1 -1
  84. package/dist/types.d.ts +23 -2
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/types.js +12 -2
  87. package/dist/types.js.map +1 -1
  88. package/dist/web-search/deepseek.adapter.d.ts +19 -0
  89. package/dist/web-search/deepseek.adapter.d.ts.map +1 -0
  90. package/dist/web-search/deepseek.adapter.js +82 -0
  91. package/dist/web-search/deepseek.adapter.js.map +1 -0
  92. package/dist/web-search/http.d.ts +28 -0
  93. package/dist/web-search/http.d.ts.map +1 -0
  94. package/dist/web-search/http.js +95 -0
  95. package/dist/web-search/http.js.map +1 -0
  96. package/dist/web-search/index.d.ts +27 -0
  97. package/dist/web-search/index.d.ts.map +1 -0
  98. package/dist/web-search/index.js +36 -0
  99. package/dist/web-search/index.js.map +1 -0
  100. package/dist/web-search/minimax.adapter.d.ts +13 -0
  101. package/dist/web-search/minimax.adapter.d.ts.map +1 -0
  102. package/dist/web-search/minimax.adapter.js +82 -0
  103. package/dist/web-search/minimax.adapter.js.map +1 -0
  104. package/dist/web-search/moonshotai.adapter.d.ts +19 -0
  105. package/dist/web-search/moonshotai.adapter.d.ts.map +1 -0
  106. package/dist/web-search/moonshotai.adapter.js +151 -0
  107. package/dist/web-search/moonshotai.adapter.js.map +1 -0
  108. package/dist/web-search/types.d.ts +120 -0
  109. package/dist/web-search/types.d.ts.map +1 -0
  110. package/dist/web-search/types.js +60 -0
  111. package/dist/web-search/types.js.map +1 -0
  112. package/dist/web-search/zhipu.adapter.d.ts +14 -0
  113. package/dist/web-search/zhipu.adapter.d.ts.map +1 -0
  114. package/dist/web-search/zhipu.adapter.js +98 -0
  115. package/dist/web-search/zhipu.adapter.js.map +1 -0
  116. package/package.json +4 -3
  117. package/src/direct-resolver.ts +2 -0
  118. package/src/image/http.ts +68 -0
  119. package/src/image/index.ts +87 -0
  120. package/src/image/types.ts +134 -0
  121. package/src/image/volcengine.adapter.ts +133 -0
  122. package/src/index.ts +126 -0
  123. package/src/justone/client.ts +462 -0
  124. package/src/justone/endpoint-spec.ts +66 -0
  125. package/src/justone/endpoints.generated.ts +9258 -0
  126. package/src/justone/errors.ts +97 -0
  127. package/src/justone/index.ts +34 -0
  128. package/src/justone/params.ts +91 -0
  129. package/src/justone/pricing.generated.ts +308 -0
  130. package/src/justone/pricing.ts +11 -0
  131. package/src/media/bibigpt.adapter.ts +372 -0
  132. package/src/media/http.ts +43 -0
  133. package/src/media/index.ts +114 -0
  134. package/src/media/types.ts +233 -0
  135. package/src/media/zhipu-parser-package.ts +149 -0
  136. package/src/media/zhipu.adapter.ts +510 -0
  137. package/src/provider-client.ts +303 -1
  138. package/src/types.ts +31 -2
  139. package/src/web-search/deepseek.adapter.ts +149 -0
  140. package/src/web-search/http.ts +108 -0
  141. package/src/web-search/index.ts +70 -0
  142. package/src/web-search/minimax.adapter.ts +148 -0
  143. package/src/web-search/moonshotai.adapter.ts +228 -0
  144. package/src/web-search/types.ts +172 -0
  145. package/src/web-search/zhipu.adapter.ts +184 -0
@@ -0,0 +1,70 @@
1
+ import { DeepseekWebSearchAdapter } from './deepseek.adapter.js';
2
+ import { MinimaxWebSearchAdapter } from './minimax.adapter.js';
3
+ import { MoonshotaiWebSearchAdapter } from './moonshotai.adapter.js';
4
+ import type {
5
+ WebPageOutcome,
6
+ WebSearchAdapter,
7
+ WebSearchConnection,
8
+ WebSearchInput,
9
+ WebSearchKind,
10
+ WebSearchOutcome,
11
+ WebSearchSettings,
12
+ WebSearchTraits,
13
+ } from './types.js';
14
+ import { ZhipuWebSearchAdapter } from './zhipu.adapter.js';
15
+
16
+ /** kind → 适配器。映射类型保证每个 kind 都有、且设置类型对得上;加一家漏了这里编译报错。 */
17
+ export const WEB_SEARCH_ADAPTERS: {
18
+ readonly [K in WebSearchKind]: WebSearchAdapter<K>;
19
+ } = {
20
+ deepseek: new DeepseekWebSearchAdapter(),
21
+ moonshotai: new MoonshotaiWebSearchAdapter(),
22
+ minimax: new MinimaxWebSearchAdapter(),
23
+ zhipu: new ZhipuWebSearchAdapter(),
24
+ };
25
+
26
+ export function isWebSearchKind(kind: string): kind is WebSearchKind {
27
+ return Object.hasOwn(WEB_SEARCH_ADAPTERS, kind);
28
+ }
29
+
30
+ /** 一家联网服务,连接与设置都已绑好。`readPage` 只在 `traits.readsPages` 时存在。 */
31
+ export interface WebSearch {
32
+ readonly kind: WebSearchKind;
33
+ readonly traits: WebSearchTraits;
34
+ search(input: WebSearchInput, signal: AbortSignal): Promise<WebSearchOutcome>;
35
+ readPage?(
36
+ input: { url: string },
37
+ signal: AbortSignal
38
+ ): Promise<WebPageOutcome>;
39
+ }
40
+
41
+ export interface CreateWebSearchInput<K extends WebSearchKind> {
42
+ kind: K;
43
+ connection: WebSearchConnection;
44
+ settings?: WebSearchSettings<K>;
45
+ }
46
+
47
+ /**
48
+ * 绑好连接与设置的联网服务。中心模式用 `ProviderClient.webSearch`;这里是直连(本地开发、测试)与中心模式共用的底层:
49
+ * 直连时 `connection` 给厂商的 `web-search` 根地址 + `apiKey`。
50
+ */
51
+ export function createWebSearch<K extends WebSearchKind>(
52
+ input: CreateWebSearchInput<K>
53
+ ): WebSearch {
54
+ // 映射类型按 K 取出来是联合,TS 收窄不了泛型索引;kind 与 settings 由签名绑在一起。
55
+ const adapter = WEB_SEARCH_ADAPTERS[input.kind] as WebSearchAdapter<K>;
56
+ const settings = (input.settings ?? {}) as WebSearchSettings<K>;
57
+ const conn = input.connection;
58
+ const traits = adapter.traits(settings);
59
+ const readPage = adapter.readPage?.bind(adapter);
60
+ return {
61
+ kind: input.kind,
62
+ traits,
63
+ search: (q, signal) => adapter.search(conn, settings, q, signal),
64
+ ...(readPage
65
+ ? { readPage: (q, signal) => readPage(conn, settings, q, signal) }
66
+ : {}),
67
+ };
68
+ }
69
+
70
+ export * from './types.js';
@@ -0,0 +1,148 @@
1
+ import { clip, codeOf, joinUrl, parseJson, postJson } from './http.js';
2
+ import {
3
+ type WebSearchAdapter,
4
+ type WebSearchConnection,
5
+ type WebSearchFilter,
6
+ type WebSearchInput,
7
+ type WebSearchOutcome,
8
+ type WebSearchSettings,
9
+ type WebSearchTraits,
10
+ WebSearchUpstreamError,
11
+ } from './types.js';
12
+
13
+ /**
14
+ * MiniMax 联网搜索(`/v1/coding_plan/search`)。**官方没有文档页**(2026-10-24 只找到
15
+ * OpenClaw / Hermes 的第三方集成文档,两家对参数名说法还不一),下面每一条都是真机探测
16
+ * 出来的:请求体键是 `q`(`query` 回 2013);没有条数参数(`num` / `count` / `limit` 一律
17
+ * 回 10 条);查询里的 `site:` 不生效、`after:` 只是影响排序(仍混进更早的结果)。
18
+ * 普通 key 有余额就认,不必是 Token Plan key。
19
+ *
20
+ * `web-search` 端点的根是 `https://api.minimaxi.com/v1/coding_plan`(国际站 `api.minimax.io`,两站 key 不通用,
21
+ * 在中心是两家厂商)——**与对话端点不同域**,这正是端点要按协议分开登记的原因。
22
+ *
23
+ * 没有读网页接口:不实现 `readPage`,`traits().readsPages` 为 false。
24
+ */
25
+
26
+ /** 失败也常是 HTTP 200:判据是 `base_resp.status_code`,0 才算成功。 */
27
+ interface MinimaxSearchBody {
28
+ base_resp?: { status_code?: number; status_msg?: string };
29
+ organic?: {
30
+ title?: string;
31
+ link?: string;
32
+ snippet?: string;
33
+ /** `2026/05/05 00:00:00` 或空串。 */
34
+ date?: string;
35
+ }[];
36
+ }
37
+
38
+ type Settings = WebSearchSettings<'minimax'>;
39
+
40
+ /** 上游定死的条数(实测没有条数参数)。 */
41
+ const UPSTREAM_COUNT = 10;
42
+
43
+ /**
44
+ * 时间 / 域名过滤一律报回 `ignored`、不拼进查询:实测两种操作符都靠不住,拼进去只会
45
+ * 改变关键词本身。条数上游定死 10 条,`count` 在本地截。
46
+ */
47
+ export class MinimaxWebSearchAdapter implements WebSearchAdapter<'minimax'> {
48
+ readonly kind = 'minimax' as const;
49
+
50
+ traits(): WebSearchTraits {
51
+ return {
52
+ readsPages: false,
53
+ maxResults: UPSTREAM_COUNT,
54
+ filters: [],
55
+ linksOftenMissing: false,
56
+ pageImagesArePlaceholders: false,
57
+ };
58
+ }
59
+
60
+ async search(
61
+ conn: WebSearchConnection,
62
+ _settings: Settings,
63
+ input: WebSearchInput,
64
+ signal: AbortSignal
65
+ ): Promise<WebSearchOutcome> {
66
+ const label = 'minimax search';
67
+ const res = await postJson(
68
+ label,
69
+ joinUrl(conn.baseUrl, 'search'),
70
+ conn,
71
+ { q: input.query },
72
+ signal
73
+ );
74
+ const data = parseBody(label, res.status, res.text);
75
+ const code = data.base_resp?.status_code;
76
+ if (!res.ok || code !== 0) {
77
+ throw upstreamError(label, res.status, code, data.base_resp?.status_msg);
78
+ }
79
+
80
+ const items = (data.organic ?? []).map(r => ({
81
+ title: r.title ?? '',
82
+ link: r.link ?? '',
83
+ content: r.snippet ?? '',
84
+ media: null,
85
+ publishDate: r.date || null,
86
+ }));
87
+ const ignored: WebSearchFilter[] = [
88
+ ...(input.recency ? (['recency'] as const) : []),
89
+ ...(input.domain ? (['domain'] as const) : []),
90
+ ];
91
+ return {
92
+ engine: 'minimax',
93
+ items: input.count !== undefined ? items.slice(0, input.count) : items,
94
+ ...(ignored.length ? { ignored } : {}),
95
+ };
96
+ }
97
+ }
98
+
99
+ /** 4xx / 5xx 也可能带 `base_resp`(参数错是 400 + 2013);不成形的非 2xx 当没有错误体。 */
100
+ function parseBody(
101
+ label: string,
102
+ status: number,
103
+ text: string
104
+ ): MinimaxSearchBody {
105
+ if (status >= 200 && status < 300) return parseJson(label, text);
106
+ try {
107
+ return JSON.parse(text) as MinimaxSearchBody;
108
+ } catch {
109
+ return { base_resp: { status_msg: clip(text) } };
110
+ }
111
+ }
112
+
113
+ function upstreamError(
114
+ label: string,
115
+ status: number,
116
+ code: number | undefined,
117
+ msg: string | undefined
118
+ ): WebSearchUpstreamError {
119
+ const hint = code !== undefined ? CODE_HINTS[code] : undefined;
120
+ const head = code !== undefined ? `${label} ${code}` : `${label} ${status}`;
121
+ return new WebSearchUpstreamError(
122
+ `${head}${hint ? `(${hint})` : ''}${msg ? `: ${msg}` : ''}`,
123
+ {
124
+ code: codeOf(code),
125
+ retryable:
126
+ status >= 500 ||
127
+ status === 429 ||
128
+ (code !== undefined && RETRYABLE_CODES.has(code)),
129
+ }
130
+ );
131
+ }
132
+
133
+ /**
134
+ * MiniMax 通用错误码里表示「稍后再试有用」的:未知错误、超时、限流(RPM / TPM)、
135
+ * 服务内部错误。鉴权、余额、参数类改了请求或账户才有用。
136
+ */
137
+ const RETRYABLE_CODES: ReadonlySet<number> = new Set([
138
+ 1000, 1001, 1002, 1013, 1024, 1033, 1039,
139
+ ]);
140
+
141
+ /** 实测见过、或 MiniMax 通用错误码里含义确定的,带进错误文案让模型 / 运营不必去查。 */
142
+ const CODE_HINTS: Readonly<Record<number, string>> = {
143
+ 1002: '超过频率上限',
144
+ 1004: 'API key 无效或没带上',
145
+ 1008: '账户余额不足,需要充值',
146
+ 2013: '请求参数不合法',
147
+ 2049: 'API key 无效,或与所选站点不匹配',
148
+ };
@@ -0,0 +1,228 @@
1
+ import { clip, codeOf, joinUrl, parseJson, postJson } from './http.js';
2
+ import {
3
+ recencySince,
4
+ utcDate,
5
+ type WebPageOutcome,
6
+ type WebSearchAdapter,
7
+ type WebSearchConnection,
8
+ type WebSearchFilter,
9
+ type WebSearchInput,
10
+ type WebSearchOutcome,
11
+ type WebSearchSettings,
12
+ type WebSearchTraits,
13
+ WebSearchUpstreamError,
14
+ } from './types.js';
15
+
16
+ /**
17
+ * Kimi 工具网关(<https://platform.kimi.com/docs/api/tools-search>):搜索基础版 `/search`、
18
+ * 专业版 `/search_pro`、网页抓取 `/fetch`。与对话 API 同一把 Bearer key;`web-search` 端点的根是
19
+ * `https://api.moonshot.cn/v1/tools`(国际站 `api.moonshot.ai`,两站账号与 key 不通用,在中心是两家厂商)。
20
+ * 专业版与抓取的文档只列了国内站,国际站沿用同一组路径。
21
+ *
22
+ * **不是 Formula 的 `moonshot/web-search`**:那个回 `encrypted_output`,只有 Kimi 模型读得了。
23
+ */
24
+ /** 文档:两档的 `limit` 都是 1–20。工具层允许到 50,超出的压到上限而不是让上游 400。 */
25
+ const MAX_LIMIT = 20;
26
+
27
+ /** 文档:每个字段都是字符串,没有数据时是空串。 */
28
+ interface KimiSearchBody {
29
+ search_results?: {
30
+ title?: string;
31
+ url?: string;
32
+ snippet?: string;
33
+ site_name?: string;
34
+ date?: string;
35
+ /** 只有专业版有:按相关性打分的正文片段,没正文时是空数组。 */
36
+ chunks?: { text?: string; score?: number }[];
37
+ }[];
38
+ }
39
+
40
+ interface KimiFetchBody {
41
+ url?: string;
42
+ title?: string;
43
+ /** 正文与图片按页面顺序拼接,图片是 `![imageN](url)` 占位。 */
44
+ markdown?: string;
45
+ }
46
+
47
+ type Settings = WebSearchSettings<'moonshotai'>;
48
+
49
+ /**
50
+ * 基础版没有时间 / 域名过滤字段,给了就报回 `ignored`——文档没说认 `site:` 之类的操作符,
51
+ * 拼进查询是在猜。专业版有 `sites` 与 `time_window`,两项都施加。`include_content` 不开:
52
+ * 会把每条结果的全文塞回来,体量没有上限。
53
+ */
54
+ export class MoonshotaiWebSearchAdapter implements WebSearchAdapter<'moonshotai'> {
55
+ readonly kind = 'moonshotai' as const;
56
+
57
+ constructor(private readonly now: () => Date = () => new Date()) {}
58
+
59
+ traits(settings: Settings): WebSearchTraits {
60
+ return {
61
+ readsPages: true,
62
+ maxResults: MAX_LIMIT,
63
+ filters: settings.searchTier === 'pro' ? ['recency', 'domain'] : [],
64
+ linksOftenMissing: false,
65
+ // 抓取的正文里图片是占位符(`![imageN](url)`),模型得知道那不是它能看的图。
66
+ pageImagesArePlaceholders: true,
67
+ };
68
+ }
69
+
70
+ async search(
71
+ conn: WebSearchConnection,
72
+ settings: Settings,
73
+ input: WebSearchInput,
74
+ signal: AbortSignal
75
+ ): Promise<WebSearchOutcome> {
76
+ const pro = settings.searchTier === 'pro';
77
+ const label = pro ? 'kimi search_pro' : 'kimi search';
78
+ const body: Record<string, unknown> = {
79
+ text_query: input.query,
80
+ ...(input.count !== undefined
81
+ ? { limit: Math.min(input.count, MAX_LIMIT) }
82
+ : {}),
83
+ ...(pro && input.domain ? { sites: [input.domain] } : {}),
84
+ ...(pro && input.recency
85
+ ? {
86
+ time_window: {
87
+ start: recencySince(input.recency, this.now()),
88
+ end: utcDate(this.now()),
89
+ },
90
+ }
91
+ : {}),
92
+ };
93
+ const res = await postJson(
94
+ label,
95
+ joinUrl(conn.baseUrl, pro ? 'search_pro' : 'search'),
96
+ conn,
97
+ body,
98
+ signal
99
+ );
100
+ if (!res.ok) throw upstreamError(label, res.status, res.text);
101
+
102
+ const data = parseJson<KimiSearchBody>(label, res.text);
103
+ const items = (data.search_results ?? []).map(r => ({
104
+ title: r.title ?? '',
105
+ link: r.url ?? '',
106
+ content: r.snippet || bestChunk(r.chunks) || '',
107
+ media: r.site_name || null,
108
+ publishDate: r.date || null,
109
+ }));
110
+ const ignored: WebSearchFilter[] = pro
111
+ ? []
112
+ : [
113
+ ...(input.recency ? (['recency'] as const) : []),
114
+ ...(input.domain ? (['domain'] as const) : []),
115
+ ];
116
+ return {
117
+ engine: pro ? 'kimi-pro' : 'kimi',
118
+ items,
119
+ ...(ignored.length ? { ignored } : {}),
120
+ };
121
+ }
122
+
123
+ async readPage(
124
+ conn: WebSearchConnection,
125
+ _settings: Settings,
126
+ input: { url: string },
127
+ signal: AbortSignal
128
+ ): Promise<WebPageOutcome> {
129
+ const label = 'kimi fetch';
130
+ const res = await postJson(
131
+ label,
132
+ joinUrl(conn.baseUrl, 'fetch'),
133
+ conn,
134
+ { url: input.url },
135
+ signal
136
+ );
137
+ if (!res.ok) {
138
+ const err = errorBodyOf(res.text);
139
+ // 「读到了但没有可提取的正文」不是故障(也不计费):交给工具层说「正文为空」,
140
+ // 别让模型当成链接坏了去重试。
141
+ if (res.status === 404 && err.type === 'markdown_not_found') {
142
+ return { url: input.url, title: null, description: null, content: '' };
143
+ }
144
+ throw upstreamError(label, res.status, res.text);
145
+ }
146
+ const data = parseJson<KimiFetchBody>(label, res.text);
147
+ if (typeof data.markdown !== 'string') {
148
+ throw new WebSearchUpstreamError('kimi fetch 响应缺少 markdown');
149
+ }
150
+ return {
151
+ url: data.url || input.url,
152
+ title: data.title || null,
153
+ description: null,
154
+ content: data.markdown,
155
+ };
156
+ }
157
+ }
158
+
159
+ /** 专业版的摘要偶尔为空,退回得分最高的正文片段。 */
160
+ function bestChunk(
161
+ chunks: { text?: string; score?: number }[] | undefined
162
+ ): string | undefined {
163
+ let best: { text?: string; score?: number } | undefined;
164
+ for (const c of chunks ?? []) {
165
+ if (!c.text) continue;
166
+ if (!best || (c.score ?? 0) > (best.score ?? 0)) best = c;
167
+ }
168
+ return best?.text;
169
+ }
170
+
171
+ /**
172
+ * 错误体是 `{error: {message, type, code?}}`,判别靠 `type`(`rate_limited` /
173
+ * `upstream_failed` …);401 / 403 没有错误体(抓取的 403 `security_risk` 有)。
174
+ */
175
+ function upstreamError(
176
+ label: string,
177
+ status: number,
178
+ text: string
179
+ ): WebSearchUpstreamError {
180
+ const err = errorBodyOf(text);
181
+ const detail = err.message ?? clip(text);
182
+ const hint =
183
+ (err.type ? TYPE_HINTS[err.type] : undefined) ?? STATUS_HINTS[status];
184
+ return new WebSearchUpstreamError(
185
+ `${label} ${status}${hint ? `(${hint})` : ''}${detail ? `: ${detail}` : ''}`,
186
+ {
187
+ code: err.type ?? err.code,
188
+ // 文档:429(限流 / 限流服务不可用)、500、502、504 稍后重试;400 / 403 改请求才有用。
189
+ retryable: status >= 500 || status === 429,
190
+ }
191
+ );
192
+ }
193
+
194
+ function errorBodyOf(text: string): {
195
+ type: string | undefined;
196
+ code: string | undefined;
197
+ message: string | undefined;
198
+ } {
199
+ try {
200
+ const body = JSON.parse(text) as {
201
+ error?: { type?: unknown; code?: unknown; message?: unknown };
202
+ };
203
+ return {
204
+ type: codeOf(body.error?.type),
205
+ code: codeOf(body.error?.code),
206
+ message:
207
+ typeof body.error?.message === 'string'
208
+ ? body.error.message
209
+ : undefined,
210
+ };
211
+ } catch {
212
+ return { type: undefined, code: undefined, message: undefined };
213
+ }
214
+ }
215
+
216
+ /** 文档里有确定含义的状态码,带进错误文案让模型 / 运营不必去查。 */
217
+ const STATUS_HINTS: Readonly<Record<number, string>> = {
218
+ 401: 'API key 无效,或与所选站点不匹配',
219
+ 403: '账户未激活或已停用',
220
+ 429: '超过频率或并发上限',
221
+ 504: '超时,搜索就换个更短的关键词,读网页就稍后再试',
222
+ };
223
+
224
+ /** 同一个状态码含义不止一种时按 `type` 区分(抓取的 403 不是账户问题)。 */
225
+ const TYPE_HINTS: Readonly<Record<string, string>> = {
226
+ security_risk: '这个网址被 Kimi 的安全策略拦下,换一个网址,重试没用',
227
+ invalid_url: '网址不合法,只支持 http / https',
228
+ };
@@ -0,0 +1,172 @@
1
+ import type { VendorFetch } from '../vendors/index.js';
2
+
3
+ /**
4
+ * 联网搜索 / 读网页的厂商适配(从 agent-base `web-search/adapters` 搬来)。适配器只管「这家的请求长什么样、
5
+ * 响应怎么翻、哪些失败值得重试」;给模型看的工具说明是 APP 的事,适配器只交出事实(`WebSearchTraits`)。
6
+ */
7
+
8
+ /** 时效过滤取值(缺省 = 不限时间)。各适配器自己翻成上游的写法。 */
9
+ export const WEB_SEARCH_RECENCIES = [
10
+ 'oneDay',
11
+ 'oneWeek',
12
+ 'oneMonth',
13
+ 'oneYear',
14
+ ] as const;
15
+ export type WebSearchRecency = (typeof WEB_SEARCH_RECENCIES)[number];
16
+
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+ const RECENCY_DAYS: Readonly<Record<WebSearchRecency, number>> = {
19
+ oneDay: 1,
20
+ oneWeek: 7,
21
+ oneMonth: 30,
22
+ oneYear: 365,
23
+ };
24
+
25
+ /** 「最近一段时间」的起始日期(UTC,YYYY-MM-DD)——上游只收日期的家(DeepSeek、Kimi 专业版)共用。 */
26
+ export function recencySince(recency: WebSearchRecency, now: Date): string {
27
+ return utcDate(new Date(now.getTime() - RECENCY_DAYS[recency] * DAY_MS));
28
+ }
29
+
30
+ /** UTC 日期 YYYY-MM-DD。 */
31
+ export function utcDate(d: Date): string {
32
+ return d.toISOString().slice(0, 10);
33
+ }
34
+
35
+ export interface WebSearchInput {
36
+ query: string;
37
+ count?: number;
38
+ recency?: WebSearchRecency;
39
+ domain?: string;
40
+ }
41
+
42
+ export interface WebSearchItem {
43
+ title: string;
44
+ link: string;
45
+ /** 搜索引擎给的摘要,不是网页全文。 */
46
+ content: string;
47
+ /** 来源站点 / 媒体名(常为空)。 */
48
+ media: string | null;
49
+ publishDate: string | null;
50
+ }
51
+
52
+ /** 搜索的两项过滤。不是每家都支持(Kimi 基础版、MiniMax 都没有),不支持的由适配器报回 `ignored`。 */
53
+ export const WEB_SEARCH_FILTERS = ['recency', 'domain'] as const;
54
+ export type WebSearchFilter = (typeof WEB_SEARCH_FILTERS)[number];
55
+
56
+ export interface WebSearchOutcome {
57
+ /** 「用的是哪个引擎」(智谱是档位名,其余是服务名)。 */
58
+ engine: string;
59
+ items: WebSearchItem[];
60
+ /**
61
+ * 调用方给了、这家却没法施加的过滤(缺省 = 都施加了)。调用方据此说破,别让模型以为
62
+ * 结果已经按时间 / 域名筛过。
63
+ */
64
+ ignored?: readonly WebSearchFilter[];
65
+ }
66
+
67
+ export interface WebPageOutcome {
68
+ url: string;
69
+ title: string | null;
70
+ description: string | null;
71
+ /** markdown 正文。可能为空串(纯脚本渲染 / 反爬页面、Kimi 的「没有可提取的正文」),由调用方解释。 */
72
+ content: string;
73
+ }
74
+
75
+ /**
76
+ * 调一次上游要的东西:这家 `web-search` 端点的根地址与出站 fetch。中心模式下 fetch 是中心的透传 fetch、
77
+ * 不带 key(中心注入);直连模式给 `apiKey`,适配器以 Bearer 发出。
78
+ */
79
+ export interface WebSearchConnection {
80
+ baseUrl: string;
81
+ fetch: VendorFetch;
82
+ apiKey?: string;
83
+ }
84
+
85
+ /** 智谱的搜索档位(`search_engine`)。 */
86
+ export const ZHIPU_SEARCH_ENGINES = [
87
+ 'search_std',
88
+ 'search_pro',
89
+ 'search_pro_sogou',
90
+ 'search_pro_quark',
91
+ ] as const;
92
+ export type ZhipuSearchEngine = (typeof ZHIPU_SEARCH_ENGINES)[number];
93
+
94
+ /** Kimi 搜索:基础版 `/search`、专业版 `/search_pro`。 */
95
+ export const KIMI_SEARCH_TIERS = ['basic', 'pro'] as const;
96
+ export type KimiSearchTier = (typeof KIMI_SEARCH_TIERS)[number];
97
+
98
+ /**
99
+ * 各家的设置(APP 存、调用时传;缺省值见各适配器)。**站点(国内 / 海外)不是设置**:两站账号与 key 不通用,
100
+ * 在中心是两家厂商,地址取自各自的 `web-search` 端点。
101
+ */
102
+ export interface WebSearchSettingsByKind {
103
+ deepseek: Record<string, never>;
104
+ moonshotai: { searchTier?: KimiSearchTier };
105
+ minimax: Record<string, never>;
106
+ zhipu: { searchEngine?: ZhipuSearchEngine };
107
+ }
108
+ export type WebSearchKind = keyof WebSearchSettingsByKind;
109
+ export const WEB_SEARCH_KINDS = [
110
+ 'deepseek',
111
+ 'moonshotai',
112
+ 'minimax',
113
+ 'zhipu',
114
+ ] as const satisfies readonly WebSearchKind[];
115
+ export type WebSearchSettings<K extends WebSearchKind = WebSearchKind> =
116
+ WebSearchSettingsByKind[K];
117
+
118
+ /**
119
+ * 这家(在这组设置下)的能力事实,给调用方写工具说明用。只依赖设置,不依赖「现在」——写进工具表的东西
120
+ * 相邻两轮必须一样。
121
+ */
122
+ export interface WebSearchTraits {
123
+ /** 有读网页接口(MiniMax 没有)。 */
124
+ readsPages: boolean;
125
+ /** 一次最多返回几条;null = 没有比调用方更紧的上限。 */
126
+ maxResults: number | null;
127
+ /** 真能施加的过滤;不在里面的给了也会报回 `ignored`。 */
128
+ filters: readonly WebSearchFilter[];
129
+ /** 结果常不带链接(智谱 search_std,真机探针 2026-08-31):「把链接交给读网页」常走不通。 */
130
+ linksOftenMissing: boolean;
131
+ /** 读网页的正文里图片只以 `![imageN](url)` 占位(Kimi),看不到图片内容。 */
132
+ pageImagesArePlaceholders: boolean;
133
+ }
134
+
135
+ export interface WebSearchAdapter<K extends WebSearchKind> {
136
+ readonly kind: K;
137
+ traits(settings: WebSearchSettings<K>): WebSearchTraits;
138
+ search(
139
+ conn: WebSearchConnection,
140
+ settings: WebSearchSettings<K>,
141
+ input: WebSearchInput,
142
+ signal: AbortSignal
143
+ ): Promise<WebSearchOutcome>;
144
+ /** 没有读网页接口的家(MiniMax)不实现,`traits().readsPages` 同步写 false(spec 钉着)。 */
145
+ readPage?(
146
+ conn: WebSearchConnection,
147
+ settings: WebSearchSettings<K>,
148
+ input: { url: string },
149
+ signal: AbortSignal
150
+ ): Promise<WebPageOutcome>;
151
+ }
152
+
153
+ /**
154
+ * 联网服务的上游失败(非 2xx / 网络错误 / 响应体不成形 / 2xx 里的业务错误)。中心自己报的错(没有可用 key、
155
+ * 接入方不许用这家……)不是这个,是 `ProviderClientError`。
156
+ */
157
+ export class WebSearchUpstreamError extends Error {
158
+ override readonly name = 'WebSearchUpstreamError';
159
+ /** 上游业务错误码(智谱的 '1701' 并发超限、DeepSeek 的 error.code、Kimi 的 error.type),没有就是 null。 */
160
+ readonly code: string | null;
161
+ /** 这次失败值不值得重试。判据收在各适配器的抛出处,调用方只读这个标志,不解析消息文本。 */
162
+ readonly retryable: boolean;
163
+
164
+ constructor(
165
+ reason: string,
166
+ opts: { code?: string; retryable?: boolean } = {}
167
+ ) {
168
+ super(reason);
169
+ this.code = opts.code ?? null;
170
+ this.retryable = opts.retryable ?? false;
171
+ }
172
+ }