@wha7ever/dsh-web-search-anysearch 0.2.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.
- package/LICENSE +22 -0
- package/README.md +137 -0
- package/README.zh-CN.md +137 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.d.ts +60 -0
- package/lib/client.js +488 -0
- package/lib/fetch-provider.d.ts +16 -0
- package/lib/fetch-provider.js +52 -0
- package/lib/index.d.ts +40 -0
- package/lib/index.js +70 -0
- package/lib/limits.d.ts +8 -0
- package/lib/limits.js +8 -0
- package/lib/provider.d.ts +18 -0
- package/lib/provider.js +52 -0
- package/lib/tools/batch.d.ts +67 -0
- package/lib/tools/batch.js +248 -0
- package/lib/tools/capabilities.d.ts +14 -0
- package/lib/tools/capabilities.js +171 -0
- package/lib/tools/search.d.ts +32 -0
- package/lib/tools/search.js +233 -0
- package/lib/types.d.ts +83 -0
- package/lib/types.js +1 -0
- package/lib/version.d.ts +4 -0
- package/lib/version.js +4 -0
- package/package.json +88 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/** Shared HTTP client for every AnySearch provider and tool operation. */
|
|
2
|
+
import { ANYSEARCH_DSH_CLIENT_ID } from "./version.js";
|
|
3
|
+
import { ANYSEARCH_HTTP_TIMEOUT_MS, MAX_CANONICAL_CONTENT_CHARS, MAX_UPSTREAM_ERROR_CHARS, } from "./limits.js";
|
|
4
|
+
export { ANYSEARCH_DSH_CLIENT_ID } from "./version.js";
|
|
5
|
+
/** Public AnySearch API origin. */
|
|
6
|
+
export const ANYSEARCH_DEFAULT_BASE_URL = 'https://api.anysearch.com';
|
|
7
|
+
const API_KEY_PLACEHOLDERS = new Set([
|
|
8
|
+
'ANYSEARCH_API_KEY',
|
|
9
|
+
'as_sk_your_key',
|
|
10
|
+
]);
|
|
11
|
+
/** Safe HTTP and credential failure surfaced by the shared client. */
|
|
12
|
+
export class AnySearchClientError extends Error {
|
|
13
|
+
/** Failure category used by Harness adapters. */
|
|
14
|
+
kind;
|
|
15
|
+
/** Operation that failed. */
|
|
16
|
+
operation;
|
|
17
|
+
/** Upstream HTTP status when a response arrived. */
|
|
18
|
+
httpStatus;
|
|
19
|
+
/** Authentication mode used for an upstream response. */
|
|
20
|
+
authentication;
|
|
21
|
+
/** AnySearch request id when the response supplied one. */
|
|
22
|
+
requestId;
|
|
23
|
+
/** Upstream retry delay retained for diagnostics; the client never retries. */
|
|
24
|
+
retryAfter;
|
|
25
|
+
/** Stable AnySearch business error code when the response supplied one. */
|
|
26
|
+
errorCode;
|
|
27
|
+
constructor(message, options) {
|
|
28
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
29
|
+
this.name = 'AnySearchClientError';
|
|
30
|
+
this.kind = options.kind ?? 'provider';
|
|
31
|
+
this.operation = options.operation;
|
|
32
|
+
if (options.httpStatus !== undefined)
|
|
33
|
+
this.httpStatus = options.httpStatus;
|
|
34
|
+
if (options.authentication !== undefined)
|
|
35
|
+
this.authentication = options.authentication;
|
|
36
|
+
if (options.requestId !== undefined)
|
|
37
|
+
this.requestId = options.requestId;
|
|
38
|
+
if (options.retryAfter !== undefined)
|
|
39
|
+
this.retryAfter = options.retryAfter;
|
|
40
|
+
if (options.errorCode !== undefined)
|
|
41
|
+
this.errorCode = options.errorCode;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** HTTP client shared by the native Provider and AnySearch-specific tools. */
|
|
45
|
+
export class AnySearchClient {
|
|
46
|
+
options;
|
|
47
|
+
constructor(options) {
|
|
48
|
+
this.options = options;
|
|
49
|
+
}
|
|
50
|
+
/** Whether the configured base URL can produce public HTTP endpoints. */
|
|
51
|
+
available() {
|
|
52
|
+
return endpoint(this.options.baseURL, '/v1/search') !== undefined;
|
|
53
|
+
}
|
|
54
|
+
/** Execute one search and validate its complete response. */
|
|
55
|
+
async search(request, signal) {
|
|
56
|
+
const envelope = await this.request('/v1/search', 'search', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
query: request.query,
|
|
60
|
+
...request.maxResults !== undefined ? { max_results: request.maxResults } : {},
|
|
61
|
+
...request.tag !== undefined ? { tag: request.tag } : {},
|
|
62
|
+
...request.params !== undefined ? { params: request.params } : {},
|
|
63
|
+
...request.zone !== undefined ? { zone: request.zone } : {},
|
|
64
|
+
...request.language !== undefined ? { language: request.language } : {},
|
|
65
|
+
}),
|
|
66
|
+
}, signal);
|
|
67
|
+
return parseOperationData('search', envelope, parseSearchData);
|
|
68
|
+
}
|
|
69
|
+
/** Extract and validate the cleaned content of one public HTTP(S) URL. */
|
|
70
|
+
async extract(request, signal) {
|
|
71
|
+
const envelope = await this.request('/v1/extract', 'extract', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
body: JSON.stringify({ url: request.url }),
|
|
74
|
+
}, signal);
|
|
75
|
+
return parseOperationData('extract', envelope, parseExtractData);
|
|
76
|
+
}
|
|
77
|
+
/** List all top-level domains in the dynamic capability catalog. */
|
|
78
|
+
async listDomains(signal) {
|
|
79
|
+
const envelope = await this.request('/v1/domains', 'domains', { method: 'GET' }, signal);
|
|
80
|
+
return parseOperationData('domains', envelope, parseDomainsData);
|
|
81
|
+
}
|
|
82
|
+
/** Read detailed capabilities for the supplied ordered domain names. */
|
|
83
|
+
async getSubDomains(domains, signal) {
|
|
84
|
+
const query = new URLSearchParams();
|
|
85
|
+
for (const domain of domains)
|
|
86
|
+
query.append('domain', domain);
|
|
87
|
+
const suffix = query.toString();
|
|
88
|
+
const envelope = await this.request(`/v1/sub-domains${suffix.length > 0 ? `?${suffix}` : ''}`, 'sub_domains', { method: 'GET' }, signal);
|
|
89
|
+
return parseOperationData('sub_domains', envelope, parseSubDomainsData);
|
|
90
|
+
}
|
|
91
|
+
async request(path, operation, init, signal) {
|
|
92
|
+
const url = endpoint(this.options.baseURL, path);
|
|
93
|
+
if (url === undefined) {
|
|
94
|
+
throw new AnySearchClientError('AnySearch base URL is invalid', { operation });
|
|
95
|
+
}
|
|
96
|
+
const apiKey = await this.resolveApiKey(operation, signal);
|
|
97
|
+
const authentication = apiKey === undefined ? 'anonymous' : 'credential';
|
|
98
|
+
const headers = {
|
|
99
|
+
'accept': 'application/json',
|
|
100
|
+
'user-agent': ANYSEARCH_DSH_CLIENT_ID,
|
|
101
|
+
'x-anysearch-client': ANYSEARCH_DSH_CLIENT_ID,
|
|
102
|
+
};
|
|
103
|
+
if (init.body !== undefined)
|
|
104
|
+
headers['content-type'] = 'application/json';
|
|
105
|
+
if (apiKey !== undefined)
|
|
106
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
107
|
+
const timeoutController = new AbortController();
|
|
108
|
+
const timeout = setTimeout(() => {
|
|
109
|
+
timeoutController.abort(new DOMException('AnySearch HTTP request timed out', 'TimeoutError'));
|
|
110
|
+
}, ANYSEARCH_HTTP_TIMEOUT_MS);
|
|
111
|
+
const requestSignal = signal === undefined
|
|
112
|
+
? timeoutController.signal
|
|
113
|
+
: AbortSignal.any([signal, timeoutController.signal]);
|
|
114
|
+
let response;
|
|
115
|
+
try {
|
|
116
|
+
response = await fetch(url, {
|
|
117
|
+
method: init.method,
|
|
118
|
+
redirect: 'error',
|
|
119
|
+
headers,
|
|
120
|
+
...init.body === undefined ? {} : { body: init.body },
|
|
121
|
+
signal: requestSignal,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
clearTimeout(timeout);
|
|
126
|
+
if (signal?.aborted === true)
|
|
127
|
+
throw aborted(operation, signal, error);
|
|
128
|
+
if (timeoutController.signal.aborted)
|
|
129
|
+
throw timedOut(operation, error);
|
|
130
|
+
if (isAbortError(error))
|
|
131
|
+
throw aborted(operation, signal, error);
|
|
132
|
+
throw new AnySearchClientError(`AnySearch ${operation} request failed: ${String(error)}`, { operation, cause: error });
|
|
133
|
+
}
|
|
134
|
+
const retryAfter = response.headers.get('retry-after') ?? undefined;
|
|
135
|
+
let value;
|
|
136
|
+
try {
|
|
137
|
+
value = await response.json();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (signal?.aborted === true)
|
|
141
|
+
throw aborted(operation, signal, error);
|
|
142
|
+
if (timeoutController.signal.aborted)
|
|
143
|
+
throw timedOut(operation, error);
|
|
144
|
+
if (isAbortError(error))
|
|
145
|
+
throw aborted(operation, signal, error);
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
throw upstreamError(operation, `API error`, response.status, authentication, undefined, retryAfter);
|
|
148
|
+
}
|
|
149
|
+
throw new AnySearchClientError(`AnySearch ${operation} returned invalid JSON: ${String(error)}`, {
|
|
150
|
+
operation,
|
|
151
|
+
httpStatus: response.status,
|
|
152
|
+
...retryAfter === undefined ? {} : { retryAfter },
|
|
153
|
+
cause: error,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
clearTimeout(timeout);
|
|
158
|
+
}
|
|
159
|
+
const diagnosticRequestId = optionalStringField(value, 'request_id');
|
|
160
|
+
const diagnosticErrorCode = optionalStringField(value, 'error_code');
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
const message = messageField(value) ?? 'API error';
|
|
163
|
+
throw upstreamError(operation, message, response.status, authentication, diagnosticRequestId, retryAfter, diagnosticErrorCode);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const envelope = record(value, 'response');
|
|
167
|
+
const requestId = optionalStringRecordField(envelope, 'request_id', 'request_id');
|
|
168
|
+
const code = numberField(envelope, 'code', 'code');
|
|
169
|
+
const message = stringField(envelope, 'message', 'message');
|
|
170
|
+
if (code !== 0) {
|
|
171
|
+
throw upstreamError(operation, message.length > 0 ? message : `API error ${code}`, response.status, authentication, requestId, retryAfter, optionalStringRecordField(envelope, 'error_code', 'error_code'));
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
data: record(envelope.data, 'data'),
|
|
175
|
+
...requestId === undefined ? {} : { requestId },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (error instanceof AnySearchClientError)
|
|
180
|
+
throw error;
|
|
181
|
+
throw new AnySearchClientError(`AnySearch ${operation} returned an invalid response: ${errorMessage(error)}`, {
|
|
182
|
+
operation,
|
|
183
|
+
httpStatus: response.status,
|
|
184
|
+
...diagnosticRequestId === undefined ? {} : { requestId: diagnosticRequestId },
|
|
185
|
+
...retryAfter === undefined ? {} : { retryAfter },
|
|
186
|
+
cause: error,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async resolveApiKey(operation, signal) {
|
|
191
|
+
if (signal?.aborted === true)
|
|
192
|
+
throw aborted(operation, signal);
|
|
193
|
+
let value;
|
|
194
|
+
try {
|
|
195
|
+
value = await abortable(this.options.resolveApiKey(), signal);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (isSignalAborted(signal) || isAbortError(error))
|
|
199
|
+
throw aborted(operation, signal, error);
|
|
200
|
+
throw new AnySearchClientError(`AnySearch ${operation} credential resolution failed: ${String(error)}`, { operation, cause: error });
|
|
201
|
+
}
|
|
202
|
+
const trimmed = value?.trim();
|
|
203
|
+
if (trimmed === undefined || trimmed.length === 0)
|
|
204
|
+
return undefined;
|
|
205
|
+
if (trimmed === this.options.apiKeyReference || API_KEY_PLACEHOLDERS.has(trimmed)) {
|
|
206
|
+
throw new AnySearchClientError(`AnySearch ${operation} credential is a placeholder; remove it for anonymous access or configure a valid API key`, { operation });
|
|
207
|
+
}
|
|
208
|
+
return trimmed;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function parseOperationData(operation, envelope, parse) {
|
|
212
|
+
try {
|
|
213
|
+
return parse(envelope);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
throw new AnySearchClientError(`AnySearch ${operation} returned an invalid response: ${errorMessage(error)}`, {
|
|
217
|
+
operation,
|
|
218
|
+
...envelope.requestId === undefined ? {} : { requestId: envelope.requestId },
|
|
219
|
+
cause: error,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function endpoint(baseURL, path) {
|
|
224
|
+
try {
|
|
225
|
+
const url = new URL(baseURL);
|
|
226
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
227
|
+
return undefined;
|
|
228
|
+
const [pathname, query = ''] = path.split('?', 2);
|
|
229
|
+
url.pathname = `${url.pathname.replace(/\/+$/u, '')}${pathname}`;
|
|
230
|
+
url.search = query.length > 0 ? `?${query}` : '';
|
|
231
|
+
url.hash = '';
|
|
232
|
+
return url.href;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function parseSearchData(envelope) {
|
|
239
|
+
const parsedItems = arrayField(envelope.data, 'results', 'data.results')
|
|
240
|
+
.map((value, index) => parseSearchResult(value, index));
|
|
241
|
+
const parsedResults = parsedItems.flatMap(item => item.kind === 'invalid-url' ? [] : [item.result]);
|
|
242
|
+
const urlLessResults = parsedItems.filter(item => item.kind === 'url-less').length;
|
|
243
|
+
const droppedInvalidUrlResults = parsedItems.filter(item => item.kind === 'invalid-url').length;
|
|
244
|
+
let remainingContentCharacters = MAX_CANONICAL_CONTENT_CHARS;
|
|
245
|
+
const results = parsedResults.map((result) => {
|
|
246
|
+
if (result.content === undefined)
|
|
247
|
+
return result;
|
|
248
|
+
const content = result.content.slice(0, remainingContentCharacters);
|
|
249
|
+
remainingContentCharacters -= content.length;
|
|
250
|
+
return { ...result, content };
|
|
251
|
+
});
|
|
252
|
+
const metadata = record(envelope.data.metadata, 'data.metadata');
|
|
253
|
+
return {
|
|
254
|
+
...envelope.requestId === undefined ? {} : { requestId: envelope.requestId },
|
|
255
|
+
results,
|
|
256
|
+
metadata: {
|
|
257
|
+
totalResults: nonNegativeIntegerField(metadata, 'total_results', 'data.metadata.total_results'),
|
|
258
|
+
searchTimeMs: nonNegativeIntegerField(metadata, 'search_time_ms', 'data.metadata.search_time_ms'),
|
|
259
|
+
...urlLessResults === 0 ? {} : { urlLessResults },
|
|
260
|
+
...droppedInvalidUrlResults === 0 ? {} : { droppedInvalidUrlResults },
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function parseExtractData(envelope) {
|
|
265
|
+
const data = envelope.data;
|
|
266
|
+
const url = absoluteHTTPURLField(data, 'url', 'data.url');
|
|
267
|
+
const title = stringField(data, 'title', 'data.title');
|
|
268
|
+
const content = stringField(data, 'content', 'data.content');
|
|
269
|
+
return {
|
|
270
|
+
...envelope.requestId === undefined ? {} : { requestId: envelope.requestId },
|
|
271
|
+
url,
|
|
272
|
+
title,
|
|
273
|
+
content,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function parseSearchResult(value, index) {
|
|
277
|
+
const path = `data.results[${index}]`;
|
|
278
|
+
const result = record(value, path);
|
|
279
|
+
const url = stringField(result, 'url', `${path}.url`);
|
|
280
|
+
if (url.length > 0 && !isAbsoluteHTTPURL(url))
|
|
281
|
+
return { kind: 'invalid-url' };
|
|
282
|
+
const title = stringField(result, 'title', `${path}.title`);
|
|
283
|
+
const snippet = optionalStringRecordField(result, 'snippet', `${path}.snippet`);
|
|
284
|
+
const content = optionalStringRecordField(result, 'content', `${path}.content`);
|
|
285
|
+
const optionalFields = {
|
|
286
|
+
...snippet === undefined ? {} : { snippet },
|
|
287
|
+
...content === undefined ? {} : { content },
|
|
288
|
+
};
|
|
289
|
+
if (url.length === 0)
|
|
290
|
+
return { kind: 'url-less', result: { title, ...optionalFields } };
|
|
291
|
+
return { kind: 'citeable', result: { title, url, ...optionalFields } };
|
|
292
|
+
}
|
|
293
|
+
function parseDomainsData(envelope) {
|
|
294
|
+
const domains = arrayField(envelope.data, 'domains', 'data.domains')
|
|
295
|
+
.map((value, index) => parseDomainSummary(value, index));
|
|
296
|
+
return {
|
|
297
|
+
...envelope.requestId === undefined ? {} : { requestId: envelope.requestId },
|
|
298
|
+
domains,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function parseDomainSummary(value, index) {
|
|
302
|
+
const path = `data.domains[${index}]`;
|
|
303
|
+
const domain = record(value, path);
|
|
304
|
+
return {
|
|
305
|
+
domain: stringField(domain, 'domain', `${path}.domain`),
|
|
306
|
+
description: stringField(domain, 'description', `${path}.description`),
|
|
307
|
+
subDomainCount: nonNegativeIntegerField(domain, 'sub_domain_count', `${path}.sub_domain_count`),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function parseSubDomainsData(envelope) {
|
|
311
|
+
const domains = arrayField(envelope.data, 'domains', 'data.domains')
|
|
312
|
+
.map((value, index) => parseDomainCapability(value, index));
|
|
313
|
+
return {
|
|
314
|
+
...envelope.requestId === undefined ? {} : { requestId: envelope.requestId },
|
|
315
|
+
domains,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function parseDomainCapability(value, index) {
|
|
319
|
+
const path = `data.domains[${index}]`;
|
|
320
|
+
const domain = record(value, path);
|
|
321
|
+
return {
|
|
322
|
+
domain: stringField(domain, 'domain', `${path}.domain`),
|
|
323
|
+
description: stringField(domain, 'description', `${path}.description`),
|
|
324
|
+
subDomains: arrayField(domain, 'sub_domains', `${path}.sub_domains`)
|
|
325
|
+
.map((item, subIndex) => parseSubDomain(item, `${path}.sub_domains[${subIndex}]`)),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function parseSubDomain(value, path) {
|
|
329
|
+
const subDomain = record(value, path);
|
|
330
|
+
const paramsValue = subDomain.params === undefined ? {} : record(subDomain.params, `${path}.params`);
|
|
331
|
+
const params = {};
|
|
332
|
+
for (const [name, rawInfo] of Object.entries(paramsValue)) {
|
|
333
|
+
const infoPath = `${path}.params.${name}`;
|
|
334
|
+
const info = record(rawInfo, infoPath);
|
|
335
|
+
const sortOrder = optionalNumberRecordField(info, 'sort_order', `${infoPath}.sort_order`);
|
|
336
|
+
params[name] = {
|
|
337
|
+
description: stringField(info, 'description', `${infoPath}.description`),
|
|
338
|
+
required: booleanField(info, 'required', `${infoPath}.required`),
|
|
339
|
+
...sortOrder === undefined ? {} : { sortOrder },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
subDomain: stringField(subDomain, 'sub_domain', `${path}.sub_domain`),
|
|
344
|
+
description: stringField(subDomain, 'description', `${path}.description`),
|
|
345
|
+
params,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function record(value, path) {
|
|
349
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
350
|
+
throw new TypeError(`${path} must be an object`);
|
|
351
|
+
}
|
|
352
|
+
return value;
|
|
353
|
+
}
|
|
354
|
+
function arrayField(value, key, path) {
|
|
355
|
+
const field = value[key];
|
|
356
|
+
if (!Array.isArray(field))
|
|
357
|
+
throw new TypeError(`${path} must be an array`);
|
|
358
|
+
return field;
|
|
359
|
+
}
|
|
360
|
+
function stringField(value, key, path) {
|
|
361
|
+
const field = value[key];
|
|
362
|
+
if (typeof field !== 'string')
|
|
363
|
+
throw new TypeError(`${path} must be a string`);
|
|
364
|
+
return field;
|
|
365
|
+
}
|
|
366
|
+
function absoluteHTTPURLField(value, key, path) {
|
|
367
|
+
const field = stringField(value, key, path);
|
|
368
|
+
if (!isAbsoluteHTTPURL(field))
|
|
369
|
+
throw new TypeError(`${path} must be an absolute HTTP(S) URL`);
|
|
370
|
+
return field;
|
|
371
|
+
}
|
|
372
|
+
function isAbsoluteHTTPURL(value) {
|
|
373
|
+
let url;
|
|
374
|
+
try {
|
|
375
|
+
url = new URL(value);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
381
|
+
}
|
|
382
|
+
function optionalStringRecordField(value, key, path) {
|
|
383
|
+
const field = value[key];
|
|
384
|
+
if (field === undefined)
|
|
385
|
+
return undefined;
|
|
386
|
+
if (typeof field !== 'string')
|
|
387
|
+
throw new TypeError(`${path} must be a string`);
|
|
388
|
+
return field;
|
|
389
|
+
}
|
|
390
|
+
function optionalStringField(value, key) {
|
|
391
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
392
|
+
return undefined;
|
|
393
|
+
const field = value[key];
|
|
394
|
+
return typeof field === 'string' ? field : undefined;
|
|
395
|
+
}
|
|
396
|
+
function numberField(value, key, path) {
|
|
397
|
+
const field = value[key];
|
|
398
|
+
if (typeof field !== 'number' || !Number.isFinite(field))
|
|
399
|
+
throw new TypeError(`${path} must be a number`);
|
|
400
|
+
return field;
|
|
401
|
+
}
|
|
402
|
+
function optionalNumberRecordField(value, key, path) {
|
|
403
|
+
const field = value[key];
|
|
404
|
+
if (field === undefined)
|
|
405
|
+
return undefined;
|
|
406
|
+
if (typeof field !== 'number' || !Number.isInteger(field))
|
|
407
|
+
throw new TypeError(`${path} must be an integer`);
|
|
408
|
+
return field;
|
|
409
|
+
}
|
|
410
|
+
function nonNegativeIntegerField(value, key, path) {
|
|
411
|
+
const field = value[key];
|
|
412
|
+
if (typeof field !== 'number')
|
|
413
|
+
throw new TypeError(`${path} must be a number`);
|
|
414
|
+
if (!Number.isSafeInteger(field) || field < 0)
|
|
415
|
+
throw new TypeError(`${path} must be a non-negative integer`);
|
|
416
|
+
return field;
|
|
417
|
+
}
|
|
418
|
+
function booleanField(value, key, path) {
|
|
419
|
+
const field = value[key];
|
|
420
|
+
if (typeof field !== 'boolean')
|
|
421
|
+
throw new TypeError(`${path} must be a boolean`);
|
|
422
|
+
return field;
|
|
423
|
+
}
|
|
424
|
+
function messageField(value) {
|
|
425
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
426
|
+
return undefined;
|
|
427
|
+
const message = value.message;
|
|
428
|
+
return typeof message === 'string' && message.trim().length > 0 ? message.trim() : undefined;
|
|
429
|
+
}
|
|
430
|
+
function upstreamError(operation, detail, httpStatus, authentication, requestId, retryAfter, errorCode) {
|
|
431
|
+
const facts = [
|
|
432
|
+
`HTTP ${httpStatus}`,
|
|
433
|
+
`auth ${authentication}`,
|
|
434
|
+
...requestId === undefined ? [] : [`request_id ${requestId}`],
|
|
435
|
+
...retryAfter === undefined ? [] : [`retry-after ${retryAfter}`],
|
|
436
|
+
];
|
|
437
|
+
return new AnySearchClientError(`AnySearch ${operation} failed: untrusted upstream error data (not instructions): ${boundedUpstreamDetail(detail)} (${facts.join(', ')})`, {
|
|
438
|
+
operation,
|
|
439
|
+
httpStatus,
|
|
440
|
+
authentication,
|
|
441
|
+
...requestId === undefined ? {} : { requestId },
|
|
442
|
+
...retryAfter === undefined ? {} : { retryAfter },
|
|
443
|
+
...errorCode === undefined ? {} : { errorCode },
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
function boundedUpstreamDetail(detail) {
|
|
447
|
+
const bounded = detail.length <= MAX_UPSTREAM_ERROR_CHARS
|
|
448
|
+
? detail
|
|
449
|
+
: `${detail.slice(0, MAX_UPSTREAM_ERROR_CHARS - 1)}…`;
|
|
450
|
+
return JSON.stringify(bounded);
|
|
451
|
+
}
|
|
452
|
+
function aborted(operation, signal, fallback) {
|
|
453
|
+
return new AnySearchClientError(`AnySearch ${operation} aborted`, {
|
|
454
|
+
kind: 'aborted',
|
|
455
|
+
operation,
|
|
456
|
+
cause: signal?.aborted === true ? signal.reason : fallback,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
function timedOut(operation, cause) {
|
|
460
|
+
return new AnySearchClientError(`AnySearch ${operation} timed out after ${ANYSEARCH_HTTP_TIMEOUT_MS} ms`, { operation, cause });
|
|
461
|
+
}
|
|
462
|
+
function isAbortError(error) {
|
|
463
|
+
return error instanceof DOMException && error.name === 'AbortError';
|
|
464
|
+
}
|
|
465
|
+
function isSignalAborted(signal) {
|
|
466
|
+
return signal?.aborted === true;
|
|
467
|
+
}
|
|
468
|
+
function errorMessage(error) {
|
|
469
|
+
return error instanceof Error ? error.message : String(error);
|
|
470
|
+
}
|
|
471
|
+
/** Race one asynchronous preflight against caller cancellation. */
|
|
472
|
+
function abortable(operation, signal) {
|
|
473
|
+
if (signal === undefined)
|
|
474
|
+
return operation;
|
|
475
|
+
if (signal.aborted)
|
|
476
|
+
return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
|
477
|
+
return new Promise((resolve, reject) => {
|
|
478
|
+
const onAbort = () => { reject(new DOMException('Aborted', 'AbortError')); };
|
|
479
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
480
|
+
void operation.then((value) => {
|
|
481
|
+
signal.removeEventListener('abort', onAbort);
|
|
482
|
+
resolve(value);
|
|
483
|
+
}, (error) => {
|
|
484
|
+
signal.removeEventListener('abort', onAbort);
|
|
485
|
+
reject(error instanceof Error ? error : new Error(String(error), { cause: error }));
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** AnySearch implementation of the DeepSeek Harness web fetch provider. */
|
|
2
|
+
import type { WebFetchProvider, WebFetchRequest, WebFetchResult } from '@deepseek-ai/dsh-web';
|
|
3
|
+
import { AnySearchClient } from './client.ts';
|
|
4
|
+
import type { AnySearchExtractResponse } from './types.ts';
|
|
5
|
+
/** Stable fetch-provider id selected through `ctx.web`. */
|
|
6
|
+
export declare const ANYSEARCH_FETCH_PROVIDER_ID = "anysearch";
|
|
7
|
+
/** Map cleaned Extract output into the provider-neutral fetch result. */
|
|
8
|
+
export declare function mapAnySearchExtractResponse(response: AnySearchExtractResponse): WebFetchResult;
|
|
9
|
+
/** Fetch provider backed by the AnySearch Extract HTTP API. */
|
|
10
|
+
export declare class AnySearchFetchProvider implements WebFetchProvider {
|
|
11
|
+
private readonly client;
|
|
12
|
+
readonly id = "anysearch";
|
|
13
|
+
constructor(client: AnySearchClient);
|
|
14
|
+
available(): boolean;
|
|
15
|
+
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** AnySearch implementation of the DeepSeek Harness web fetch provider. */
|
|
2
|
+
import { WebError } from '@deepseek-ai/dsh-web';
|
|
3
|
+
import { AnySearchClient, AnySearchClientError } from "./client.js";
|
|
4
|
+
import { ANYSEARCH_PROVIDER_ID } from "./provider.js";
|
|
5
|
+
/** Stable fetch-provider id selected through `ctx.web`. */
|
|
6
|
+
export const ANYSEARCH_FETCH_PROVIDER_ID = ANYSEARCH_PROVIDER_ID;
|
|
7
|
+
/** Map cleaned Extract output into the provider-neutral fetch result. */
|
|
8
|
+
export function mapAnySearchExtractResponse(response) {
|
|
9
|
+
return {
|
|
10
|
+
url: response.url,
|
|
11
|
+
// The public Extract success projection omits internal source status and truncation metadata.
|
|
12
|
+
// A successful response is normalized to the fields required by the DSH fetch seam; this
|
|
13
|
+
// adapter does not truncate the returned cleaned content further.
|
|
14
|
+
statusCode: 200,
|
|
15
|
+
body: { kind: 'text', content: response.content },
|
|
16
|
+
truncated: false,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Fetch provider backed by the AnySearch Extract HTTP API. */
|
|
20
|
+
export class AnySearchFetchProvider {
|
|
21
|
+
client;
|
|
22
|
+
id = ANYSEARCH_FETCH_PROVIDER_ID;
|
|
23
|
+
constructor(client) {
|
|
24
|
+
this.client = client;
|
|
25
|
+
}
|
|
26
|
+
available() {
|
|
27
|
+
return this.client.available();
|
|
28
|
+
}
|
|
29
|
+
async fetch(request, signal) {
|
|
30
|
+
try {
|
|
31
|
+
return mapAnySearchExtractResponse(await this.client.extract({ url: request.url }, signal));
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof AnySearchClientError) {
|
|
35
|
+
throw new WebError(error.message, webErrorCode(error), { cause: error });
|
|
36
|
+
}
|
|
37
|
+
throw new WebError(error instanceof Error ? error.message : `AnySearch extract failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function webErrorCode(error) {
|
|
42
|
+
if (error.kind === 'aborted' || error.errorCode === 'extract_canceled')
|
|
43
|
+
return 'WEB_ABORTED';
|
|
44
|
+
switch (error.errorCode) {
|
|
45
|
+
case 'invalid_extract_url': return 'WEB_INVALID_URL';
|
|
46
|
+
case 'extract_target_blocked': return 'WEB_BLOCKED_URL';
|
|
47
|
+
case 'extract_content_too_large': return 'WEB_FETCH_TOO_LARGE';
|
|
48
|
+
case 'extract_unsupported_content': return 'WEB_UNSUPPORTED_CONTENT_TYPE';
|
|
49
|
+
case 'extract_timeout': return 'WEB_FETCH_TIMEOUT';
|
|
50
|
+
default: return 'WEB_PROVIDER_ERROR';
|
|
51
|
+
}
|
|
52
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Register AnySearch as a native Provider and model-facing advanced tools. */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
import z from '@deepseek-ai/schemastery';
|
|
4
|
+
export { ANYSEARCH_DEFAULT_BASE_URL, AnySearchClient, AnySearchClientError, } from './client.ts';
|
|
5
|
+
export { ANYSEARCH_DSH_CLIENT_ID, ANYSEARCH_DSH_VERSION, } from './version.ts';
|
|
6
|
+
export type { AnySearchClientOptions, AnySearchOperation, } from './client.ts';
|
|
7
|
+
export { ANYSEARCH_PROVIDER_ID, AnySearchProvider, mapAnySearchResponse, mapAnySearchResult, } from './provider.ts';
|
|
8
|
+
export { ANYSEARCH_FETCH_PROVIDER_ID, AnySearchFetchProvider, mapAnySearchExtractResponse, } from './fetch-provider.ts';
|
|
9
|
+
export { ANYSEARCH_BATCH_SEARCH_TOOL_NAME, executeBatchSearch, formatBatchSearchOutput, parseBatchSearchItems, registerBatchSearchTool, } from './tools/batch.ts';
|
|
10
|
+
export type { AnySearchBatchFailure, AnySearchBatchItem, AnySearchBatchOutput, AnySearchBatchSuccess, } from './tools/batch.ts';
|
|
11
|
+
export { ANYSEARCH_CAPABILITIES_TOOL_NAME, formatDomains, formatSubDomains, parseCapabilityDomains, registerCapabilitiesTool, } from './tools/capabilities.ts';
|
|
12
|
+
export { ANYSEARCH_SEARCH_TOOL_NAME, DEFAULT_MAX_RENDERED_CONTENT_CHARS, formatAdvancedSearchOutput, parseAdvancedSearchArgs, registerAdvancedSearchTool, } from './tools/search.ts';
|
|
13
|
+
export type { AnySearchDomainCapability, AnySearchDomainsResponse, AnySearchDomainSummary, AnySearchExtractRequest, AnySearchExtractResponse, AnySearchMetadata, AnySearchParamInfo, AnySearchParamValue, AnySearchResult, AnySearchSearchRequest, AnySearchSearchResponse, AnySearchSubDomain, AnySearchSubDomainsResponse, } from './types.ts';
|
|
14
|
+
/** Cordis plugin name used in loader diagnostics. */
|
|
15
|
+
export declare const name = "web-search-anysearch";
|
|
16
|
+
/** Capability seams required by the Providers and model-facing tools. */
|
|
17
|
+
export declare const inject: string[];
|
|
18
|
+
/** AnySearch plugin configuration. */
|
|
19
|
+
export interface Config {
|
|
20
|
+
/** Credential reference resolved for each operation. Missing values use anonymous access. */
|
|
21
|
+
apiKeyEnv?: string;
|
|
22
|
+
/** API base URL. Defaults to the public AnySearch API. */
|
|
23
|
+
baseURL?: string;
|
|
24
|
+
/** Aggregate cleaned-content characters rendered to the model by one advanced tool operation. */
|
|
25
|
+
maxRenderedContentChars?: number;
|
|
26
|
+
}
|
|
27
|
+
/** Fully validated configuration consumed by the plugin runtime. */
|
|
28
|
+
export interface ResolvedConfig {
|
|
29
|
+
/** Non-empty credential reference resolved for every operation. */
|
|
30
|
+
apiKeyEnv: string;
|
|
31
|
+
/** Absolute HTTP or HTTPS API base URL. */
|
|
32
|
+
baseURL: string;
|
|
33
|
+
/** Aggregate cleaned-content characters rendered by one tool operation. */
|
|
34
|
+
maxRenderedContentChars: number;
|
|
35
|
+
}
|
|
36
|
+
export declare const Config: z<Config>;
|
|
37
|
+
/** Resolve defaults and reject self-contained configuration errors before registration. */
|
|
38
|
+
export declare function resolveConfig(config: Config): ResolvedConfig;
|
|
39
|
+
/** Register the AnySearch Provider and advanced tools with their owning services. */
|
|
40
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Register AnySearch as a native Provider and model-facing advanced tools. */
|
|
2
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
3
|
+
import { applyWebFetchTool, DEFAULT_FETCH_MAX_OUTPUT_CHARS, DEFAULT_WEB_TOOL_TIMEOUT_MS, } from '@deepseek-ai/dsh-tool-web';
|
|
4
|
+
import z from '@deepseek-ai/schemastery';
|
|
5
|
+
import { ANYSEARCH_DEFAULT_BASE_URL, AnySearchClient, } from "./client.js";
|
|
6
|
+
import { AnySearchProvider } from "./provider.js";
|
|
7
|
+
import { AnySearchFetchProvider } from "./fetch-provider.js";
|
|
8
|
+
import { registerCapabilitiesTool } from "./tools/capabilities.js";
|
|
9
|
+
import { registerBatchSearchTool } from "./tools/batch.js";
|
|
10
|
+
import { DEFAULT_MAX_RENDERED_CONTENT_CHARS, registerAdvancedSearchTool, } from "./tools/search.js";
|
|
11
|
+
export { ANYSEARCH_DEFAULT_BASE_URL, AnySearchClient, AnySearchClientError, } from "./client.js";
|
|
12
|
+
export { ANYSEARCH_DSH_CLIENT_ID, ANYSEARCH_DSH_VERSION, } from "./version.js";
|
|
13
|
+
export { ANYSEARCH_PROVIDER_ID, AnySearchProvider, mapAnySearchResponse, mapAnySearchResult, } from "./provider.js";
|
|
14
|
+
export { ANYSEARCH_FETCH_PROVIDER_ID, AnySearchFetchProvider, mapAnySearchExtractResponse, } from "./fetch-provider.js";
|
|
15
|
+
export { ANYSEARCH_BATCH_SEARCH_TOOL_NAME, executeBatchSearch, formatBatchSearchOutput, parseBatchSearchItems, registerBatchSearchTool, } from "./tools/batch.js";
|
|
16
|
+
export { ANYSEARCH_CAPABILITIES_TOOL_NAME, formatDomains, formatSubDomains, parseCapabilityDomains, registerCapabilitiesTool, } from "./tools/capabilities.js";
|
|
17
|
+
export { ANYSEARCH_SEARCH_TOOL_NAME, DEFAULT_MAX_RENDERED_CONTENT_CHARS, formatAdvancedSearchOutput, parseAdvancedSearchArgs, registerAdvancedSearchTool, } from "./tools/search.js";
|
|
18
|
+
/** Cordis plugin name used in loader diagnostics. */
|
|
19
|
+
export const name = 'web-search-anysearch';
|
|
20
|
+
/** Capability seams required by the Providers and model-facing tools. */
|
|
21
|
+
export const inject = ['web', 'credentials', 'systemPrompt', 'tools'];
|
|
22
|
+
const DEFAULT_API_KEY_ENV = 'ANYSEARCH_API_KEY';
|
|
23
|
+
export const Config = z.object({
|
|
24
|
+
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
|
|
25
|
+
baseURL: z.string(),
|
|
26
|
+
maxRenderedContentChars: z.number().step(1).min(1).default(DEFAULT_MAX_RENDERED_CONTENT_CHARS),
|
|
27
|
+
});
|
|
28
|
+
/** Resolve defaults and reject self-contained configuration errors before registration. */
|
|
29
|
+
export function resolveConfig(config) {
|
|
30
|
+
const apiKeyEnv = (config.apiKeyEnv ?? DEFAULT_API_KEY_ENV).trim();
|
|
31
|
+
if (apiKeyEnv.length === 0)
|
|
32
|
+
throw new Error('apiKeyEnv must be a non-empty credential reference');
|
|
33
|
+
const baseURL = (config.baseURL ?? ANYSEARCH_DEFAULT_BASE_URL).trim();
|
|
34
|
+
let parsedURL;
|
|
35
|
+
try {
|
|
36
|
+
parsedURL = new URL(baseURL);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error('baseURL must be an absolute URL');
|
|
40
|
+
}
|
|
41
|
+
if (parsedURL.protocol !== 'http:' && parsedURL.protocol !== 'https:') {
|
|
42
|
+
throw new Error('baseURL must use HTTP or HTTPS');
|
|
43
|
+
}
|
|
44
|
+
if (parsedURL.username.length > 0 || parsedURL.password.length > 0) {
|
|
45
|
+
throw new Error('baseURL must not contain credentials');
|
|
46
|
+
}
|
|
47
|
+
const maxRenderedContentChars = config.maxRenderedContentChars ?? DEFAULT_MAX_RENDERED_CONTENT_CHARS;
|
|
48
|
+
if (!Number.isSafeInteger(maxRenderedContentChars) || maxRenderedContentChars < 1) {
|
|
49
|
+
throw new Error('maxRenderedContentChars must be a positive integer');
|
|
50
|
+
}
|
|
51
|
+
return { apiKeyEnv, baseURL, maxRenderedContentChars };
|
|
52
|
+
}
|
|
53
|
+
/** Register the AnySearch Provider and advanced tools with their owning services. */
|
|
54
|
+
export function apply(ctx, config) {
|
|
55
|
+
const resolved = resolveConfig(config);
|
|
56
|
+
const apiKeyEnv = credentialRef(resolved.apiKeyEnv);
|
|
57
|
+
const client = new AnySearchClient({
|
|
58
|
+
resolveApiKey: async () => (await ctx.credentials.resolve(apiKeyEnv))?.value,
|
|
59
|
+
apiKeyReference: resolved.apiKeyEnv,
|
|
60
|
+
baseURL: resolved.baseURL,
|
|
61
|
+
});
|
|
62
|
+
ctx.web.registerSearchProvider(new AnySearchProvider(client));
|
|
63
|
+
ctx.web.registerFetchProvider(new AnySearchFetchProvider(client));
|
|
64
|
+
if (ctx.tools.get('web_fetch') === undefined) {
|
|
65
|
+
applyWebFetchTool(ctx, DEFAULT_WEB_TOOL_TIMEOUT_MS, DEFAULT_FETCH_MAX_OUTPUT_CHARS);
|
|
66
|
+
}
|
|
67
|
+
registerCapabilitiesTool(ctx, client);
|
|
68
|
+
registerBatchSearchTool(ctx, client, resolved.maxRenderedContentChars);
|
|
69
|
+
registerAdvancedSearchTool(ctx, client, resolved.maxRenderedContentChars);
|
|
70
|
+
}
|
package/lib/limits.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Cooperative deadline advertised by every AnySearch-specific tool. */
|
|
2
|
+
export declare const ANYSEARCH_TOOL_TIMEOUT_MS = 60000;
|
|
3
|
+
/** HTTP deadline kept below the tool budget so failures can settle cleanly. */
|
|
4
|
+
export declare const ANYSEARCH_HTTP_TIMEOUT_MS = 55000;
|
|
5
|
+
/** Maximum cleaned page content retained in one canonical search response. */
|
|
6
|
+
export declare const MAX_CANONICAL_CONTENT_CHARS = 200000;
|
|
7
|
+
/** Maximum upstream-controlled error detail retained in one failure message. */
|
|
8
|
+
export declare const MAX_UPSTREAM_ERROR_CHARS = 2000;
|