ds-bot 0.0.0-stage → 0.1.1
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 +201 -0
- package/README.md +236 -3
- package/client.js +12618 -0
- package/cordis.patch.yml +17 -0
- package/index.js +54 -0
- package/package.json +67 -6
- package/src/host/agents.js +186 -0
- package/src/host/browser.js +258 -0
- package/src/host/channel.js +320 -0
- package/src/host/chat-index.js +293 -0
- package/src/host/connectors.js +169 -0
- package/src/host/constants.js +62 -0
- package/src/host/delivery.js +59 -0
- package/src/host/diagnostics.js +247 -0
- package/src/host/dsh-update.js +168 -0
- package/src/host/groups.js +444 -0
- package/src/host/home-lock.js +91 -0
- package/src/host/images.js +174 -0
- package/src/host/memory.js +1351 -0
- package/src/host/models.js +202 -0
- package/src/host/parts.js +902 -0
- package/src/host/permissions.js +42 -0
- package/src/host/prompt.js +67 -0
- package/src/host/roster.js +410 -0
- package/src/host/schedules.js +151 -0
- package/src/host/secrets.js +530 -0
- package/src/host/steward.js +164 -0
- package/src/host/store.js +259 -0
- package/src/host/text.js +47 -0
- package/src/host/tools.js +374 -0
- package/src/host/turns.js +250 -0
- package/src/host/update.js +210 -0
- package/src/host/usage.js +594 -0
- package/web-search.js +748 -0
package/web-search.js
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
// Search provider `auto` for dsh's web seam (`ctx.web`). Each query first tries native search
|
|
2
|
+
// behind the asking Session's own model route, with that route's own key, on that route's own
|
|
3
|
+
// origin. Native search is found by protocol, not by vendor: the route's wire protocol defines a
|
|
4
|
+
// standard search tool (Anthropic Messages, OpenAI Responses, Gemini), and a host may also serve
|
|
5
|
+
// a separate search API, declared as data (SEARCH_APIS, or `searchApis` in config). Whether an
|
|
6
|
+
// endpoint supports either is only knowable by trying, so every outcome is remembered. It never
|
|
7
|
+
// borrows another route's key: every key bills its own account. When native search is
|
|
8
|
+
// unavailable, a keyless fallback parses a public results page fetched through ctx.web.fetch.
|
|
9
|
+
// Every result says which path answered it; when both fail, the error tells the model not to
|
|
10
|
+
// guess. Independent of the Bot team code in index.js.
|
|
11
|
+
import { WebError } from '@deepseek-ai/dsh-web'
|
|
12
|
+
|
|
13
|
+
export const name = 'web-search-auto'
|
|
14
|
+
export const inject = ['web']
|
|
15
|
+
|
|
16
|
+
export const AUTO_PROVIDER_ID = 'auto'
|
|
17
|
+
export const DEFAULTS = Object.freeze({
|
|
18
|
+
native: true,
|
|
19
|
+
fallback: true,
|
|
20
|
+
engines: ['bing', 'duckduckgo'],
|
|
21
|
+
searchApis: [],
|
|
22
|
+
nativeTimeoutMs: 40_000,
|
|
23
|
+
fallbackTimeoutMs: 15_000,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const USER_AGENT = 'deepseek-harness/0.0.1'
|
|
27
|
+
const SNIPPET_CHARS = 500
|
|
28
|
+
const PROMPT = query => `Perform a web search for the query: ${query}`
|
|
29
|
+
|
|
30
|
+
const MINUTE = 60_000
|
|
31
|
+
// How long one outcome keeps a candidate from being tried again.
|
|
32
|
+
const SKIP_FOR = {
|
|
33
|
+
absent: 24 * 60 * MINUTE,
|
|
34
|
+
// The query itself may be what was refused (length, content moderation).
|
|
35
|
+
rejected: 30 * MINUTE,
|
|
36
|
+
// The user may enable search for the key right away.
|
|
37
|
+
denied: 30 * MINUTE,
|
|
38
|
+
// Each retry of an endpoint that answers without searching costs a model call.
|
|
39
|
+
ignored: 6 * 60 * MINUTE,
|
|
40
|
+
}
|
|
41
|
+
const STATUS_OUTCOMES = { 404: 'absent', 405: 'absent', 501: 'absent', 400: 'rejected', 415: 'rejected', 422: 'rejected', 401: 'denied', 402: 'denied', 403: 'denied' }
|
|
42
|
+
|
|
43
|
+
// Routes whose profile leaves protocol, endpoint, or key name to the adapter: dsh's DeepSeek
|
|
44
|
+
// adapters, and the pi-ai 0.87.1 catalog routes that sign in with an API key
|
|
45
|
+
// (models.generated.js, env-api-keys.js). The protocol is undefined where it differs per model.
|
|
46
|
+
const CATALOG = {
|
|
47
|
+
'deepseek-official': ['anthropic-messages', 'https://api.deepseek.com/anthropic', 'DEEPSEEK_API_KEY'],
|
|
48
|
+
'deepseek-account': ['anthropic-messages', 'https://api.deepseek.com/anthropic', undefined],
|
|
49
|
+
'ant-ling': ['openai-completions', 'https://api.ant-ling.com/v1', 'ANT_LING_API_KEY'],
|
|
50
|
+
anthropic: ['anthropic-messages', 'https://api.anthropic.com', 'ANTHROPIC_API_KEY'],
|
|
51
|
+
baseten: ['openai-completions', 'https://inference.baseten.co/v1', 'BASETEN_API_KEY'],
|
|
52
|
+
cerebras: ['openai-completions', 'https://api.cerebras.ai/v1', 'CEREBRAS_API_KEY'],
|
|
53
|
+
deepseek: ['openai-completions', 'https://api.deepseek.com', 'DEEPSEEK_API_KEY'],
|
|
54
|
+
fireworks: [undefined, 'https://api.fireworks.ai/inference/v1', 'FIREWORKS_API_KEY'],
|
|
55
|
+
google: ['google-generative-ai', 'https://generativelanguage.googleapis.com/v1beta', 'GEMINI_API_KEY'],
|
|
56
|
+
groq: ['openai-completions', 'https://api.groq.com/openai/v1', 'GROQ_API_KEY'],
|
|
57
|
+
huggingface: ['openai-completions', 'https://router.huggingface.co/v1', 'HF_TOKEN'],
|
|
58
|
+
'kimi-coding': ['anthropic-messages', 'https://api.kimi.com/coding', 'KIMI_API_KEY'],
|
|
59
|
+
meta: ['openai-responses', 'https://api.meta.ai/v1', 'META_API_KEY'],
|
|
60
|
+
minimax: ['anthropic-messages', 'https://api.minimax.io/anthropic', 'MINIMAX_API_KEY'],
|
|
61
|
+
'minimax-cn': ['anthropic-messages', 'https://api.minimaxi.com/anthropic', 'MINIMAX_CN_API_KEY'],
|
|
62
|
+
moonshotai: ['openai-completions', 'https://api.moonshot.ai/v1', 'MOONSHOT_API_KEY'],
|
|
63
|
+
'moonshotai-cn': ['openai-completions', 'https://api.moonshot.cn/v1', 'MOONSHOT_API_KEY'],
|
|
64
|
+
nvidia: ['openai-completions', 'https://integrate.api.nvidia.com/v1', 'NVIDIA_API_KEY'],
|
|
65
|
+
openai: ['openai-responses', 'https://api.openai.com/v1', 'OPENAI_API_KEY'],
|
|
66
|
+
opencode: [undefined, 'https://opencode.ai/zen/v1', 'OPENCODE_API_KEY'],
|
|
67
|
+
'opencode-go': [undefined, 'https://opencode.ai/zen/go/v1', 'OPENCODE_API_KEY'],
|
|
68
|
+
openrouter: [undefined, 'https://openrouter.ai/api/v1', 'OPENROUTER_API_KEY'],
|
|
69
|
+
'qwen-token-plan': ['openai-completions', 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', 'QWEN_TOKEN_PLAN_API_KEY'],
|
|
70
|
+
'qwen-token-plan-cn': ['openai-completions', 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', 'QWEN_TOKEN_PLAN_CN_API_KEY'],
|
|
71
|
+
'qwen-token-plan-individual': ['openai-completions', 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', 'QWEN_TOKEN_PLAN_API_KEY'],
|
|
72
|
+
together: ['openai-completions', 'https://api.together.ai/v1', 'TOGETHER_API_KEY'],
|
|
73
|
+
'vercel-ai-gateway': ['anthropic-messages', 'https://ai-gateway.vercel.sh', 'AI_GATEWAY_API_KEY'],
|
|
74
|
+
xai: ['openai-responses', 'https://api.x.ai/v1', 'XAI_API_KEY'],
|
|
75
|
+
xiaomi: ['openai-completions', 'https://api.xiaomimimo.com/v1', 'XIAOMI_API_KEY'],
|
|
76
|
+
'xiaomi-token-plan-ams': ['openai-completions', 'https://token-plan-ams.xiaomimimo.com/v1', 'XIAOMI_TOKEN_PLAN_AMS_API_KEY'],
|
|
77
|
+
'xiaomi-token-plan-cn': ['openai-completions', 'https://token-plan-cn.xiaomimimo.com/v1', 'XIAOMI_TOKEN_PLAN_CN_API_KEY'],
|
|
78
|
+
'xiaomi-token-plan-sgp': ['openai-completions', 'https://token-plan-sgp.xiaomimimo.com/v1', 'XIAOMI_TOKEN_PLAN_SGP_API_KEY'],
|
|
79
|
+
zai: ['openai-completions', 'https://api.z.ai/api/coding/paas/v4', 'ZAI_API_KEY'],
|
|
80
|
+
'zai-coding-cn': ['openai-completions', 'https://open.bigmodel.cn/api/coding/paas/v4', 'ZAI_CODING_CN_API_KEY'],
|
|
81
|
+
}
|
|
82
|
+
const DSH_DEEPSEEK_ROUTES = new Set(['deepseek-official', 'deepseek-account'])
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Small helpers
|
|
86
|
+
|
|
87
|
+
const str = value => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined)
|
|
88
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, Math.floor(value)))
|
|
89
|
+
const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
90
|
+
const httpUrl = (value) => {
|
|
91
|
+
if (typeof value !== 'string') return undefined
|
|
92
|
+
try {
|
|
93
|
+
const url = new URL(value)
|
|
94
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined
|
|
95
|
+
} catch { return undefined }
|
|
96
|
+
}
|
|
97
|
+
const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
|
|
98
|
+
const reasonOf = error => (error?.cause?.message && error.message === 'fetch failed' ? error.cause.message : error?.message ?? String(error))
|
|
99
|
+
const seconds = ms => `${Math.round(ms / 1000)}s`
|
|
100
|
+
const trimSlash = url => url.replace(/\/+$/, '')
|
|
101
|
+
|
|
102
|
+
const NAMED_ENTITIES = {
|
|
103
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ',
|
|
104
|
+
middot: '·', hellip: '…', mdash: '—', ndash: '–', laquo: '«', raquo: '»', lsquo: '‘', rsquo: '’',
|
|
105
|
+
ldquo: '“', rdquo: '”', bull: '•', copy: '©', reg: '®', trade: '™', zwj: '', zwnj: '',
|
|
106
|
+
}
|
|
107
|
+
export const decodeEntities = text => text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, ref) => {
|
|
108
|
+
if (ref[0] === '#') {
|
|
109
|
+
const code = ref[1] === 'x' || ref[1] === 'X' ? Number.parseInt(ref.slice(2), 16) : Number(ref.slice(1))
|
|
110
|
+
return Number.isInteger(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole
|
|
111
|
+
}
|
|
112
|
+
return NAMED_ENTITIES[ref.toLowerCase()] ?? whole
|
|
113
|
+
})
|
|
114
|
+
// Inline tags vanish so "<strong>deep</strong>seek" stays one word; block ends become spaces.
|
|
115
|
+
export const textOf = html => decodeEntities(html
|
|
116
|
+
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ' ')
|
|
117
|
+
.replace(/<(br|\/p|\/div|\/li|\/h\d|\/td|\/tr)\b[^>]*>/gi, ' ')
|
|
118
|
+
.replace(/<[^>]*>/g, ''))
|
|
119
|
+
.replace(/\s+/g, ' ')
|
|
120
|
+
.trim()
|
|
121
|
+
const attrOf = (attrs, attr) => {
|
|
122
|
+
const match = new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i').exec(attrs)
|
|
123
|
+
return match === null ? undefined : decodeEntities(match[2] ?? match[3] ?? '')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const plain = value => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '')
|
|
127
|
+
|
|
128
|
+
/** One portable source from plain-text fields; empty optional fields are omitted rather than invented. */
|
|
129
|
+
const sourceOf = (url, title, snippet, publishedAt) => {
|
|
130
|
+
const href = httpUrl(url)
|
|
131
|
+
if (href === undefined) return undefined
|
|
132
|
+
const cleanTitle = plain(title)
|
|
133
|
+
const cleanSnippet = plain(snippet)
|
|
134
|
+
const date = str(publishedAt)
|
|
135
|
+
return {
|
|
136
|
+
url: href,
|
|
137
|
+
...cleanTitle ? { title: clip(cleanTitle, 300) } : {},
|
|
138
|
+
...cleanSnippet ? { snippet: clip(cleanSnippet, SNIPPET_CHARS) } : {},
|
|
139
|
+
...date ? { publishedAt: date } : {},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const dedupe = (sources) => {
|
|
143
|
+
const seen = new Map()
|
|
144
|
+
for (const source of sources) {
|
|
145
|
+
if (source === undefined) continue
|
|
146
|
+
const known = seen.get(source.url)
|
|
147
|
+
if (known === undefined) seen.set(source.url, source)
|
|
148
|
+
else seen.set(source.url, { ...source, ...known })
|
|
149
|
+
}
|
|
150
|
+
return [...seen.values()]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Native search: response readers. Each returns `{ ran, sources, error? }`: whether the endpoint
|
|
155
|
+
// actually ran a search, and only the structured sources it reported (URL, title, excerpt,
|
|
156
|
+
// date), never the answer text a model wrote around them.
|
|
157
|
+
|
|
158
|
+
/** Anthropic Messages with the `web_search_20250305` server tool. */
|
|
159
|
+
export function readMessages(body) {
|
|
160
|
+
const blocks = Array.isArray(body?.content) ? body.content : []
|
|
161
|
+
const results = blocks.filter(block => block?.type === 'web_search_tool_result')
|
|
162
|
+
const excerpts = new Map()
|
|
163
|
+
for (const block of blocks) {
|
|
164
|
+
if (block?.type !== 'text' || !Array.isArray(block.citations)) continue
|
|
165
|
+
for (const cite of block.citations) {
|
|
166
|
+
if (typeof cite?.url === 'string' && str(cite.cited_text) && !excerpts.has(cite.url)) excerpts.set(cite.url, cite.cited_text)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const sources = dedupe(results.flatMap(block => (Array.isArray(block.content) ? block.content : []))
|
|
170
|
+
.filter(item => item?.type === 'web_search_result')
|
|
171
|
+
.map(item => sourceOf(item.url, item.title, excerpts.get(item.url) ?? (typeof item.content === 'string' ? item.content : undefined), item.page_age)))
|
|
172
|
+
const failed = results.map(block => block.content).find(content => content?.type === 'web_search_tool_result_error')
|
|
173
|
+
return {
|
|
174
|
+
ran: results.length > 0 || blocks.some(block => block?.type === 'server_tool_use'),
|
|
175
|
+
cut: body?.stop_reason === 'max_tokens',
|
|
176
|
+
sources,
|
|
177
|
+
...sources.length === 0 && failed !== undefined ? { error: `web search failed: ${failed.error_code ?? 'unknown error'}` } : {},
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** OpenAI Responses with the `web_search` tool. */
|
|
182
|
+
export function readResponses(body) {
|
|
183
|
+
const output = Array.isArray(body?.output) ? body.output : []
|
|
184
|
+
const calls = output.filter(item => item?.type === 'web_search_call')
|
|
185
|
+
const found = []
|
|
186
|
+
for (const call of calls) {
|
|
187
|
+
for (const source of Array.isArray(call.action?.sources) ? call.action.sources : []) found.push(sourceOf(source?.url, source?.title))
|
|
188
|
+
}
|
|
189
|
+
for (const item of output) {
|
|
190
|
+
if (item?.type !== 'message' || !Array.isArray(item.content)) continue
|
|
191
|
+
for (const part of item.content) {
|
|
192
|
+
for (const note of Array.isArray(part?.annotations) ? part.annotations : []) {
|
|
193
|
+
if (note?.type === 'url_citation') found.push(sourceOf(note.url, note.title))
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const cite of Array.isArray(body?.citations) ? body.citations : []) {
|
|
198
|
+
found.push(typeof cite === 'string' ? sourceOf(cite) : sourceOf(cite?.url, cite?.title))
|
|
199
|
+
}
|
|
200
|
+
const sources = dedupe(found)
|
|
201
|
+
return { ran: calls.length > 0 || sources.length > 0, cut: body?.status === 'incomplete', sources }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Gemini `generateContent` with the `google_search` tool. */
|
|
205
|
+
export function readGemini(body) {
|
|
206
|
+
const candidates = Array.isArray(body?.candidates) ? body.candidates : []
|
|
207
|
+
return {
|
|
208
|
+
ran: candidates.some(candidate => candidate?.groundingMetadata),
|
|
209
|
+
cut: candidates.some(candidate => candidate?.finishReason === 'MAX_TOKENS'),
|
|
210
|
+
sources: dedupe(candidates
|
|
211
|
+
.flatMap(candidate => candidate?.groundingMetadata?.groundingChunks ?? [])
|
|
212
|
+
.map(chunk => sourceOf(chunk?.web?.uri, chunk?.web?.title))),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const URL_KEYS = ['url', 'uri', 'link']
|
|
217
|
+
const TITLE_KEYS = ['title', 'name', 'site_name']
|
|
218
|
+
const SNIPPET_KEYS = ['snippet', 'summary', 'content', 'description', 'cited_text', 'text']
|
|
219
|
+
const DATE_KEYS = ['published_date', 'publish_date', 'publish_time', 'page_age', 'date', 'last_updated']
|
|
220
|
+
// Media links are not results, and a response that echoes the request's tools is not a search.
|
|
221
|
+
const NOT_SOURCES = new Set(['image_url', 'images', 'image', 'icon', 'favicon', 'logo', 'logo_url', 'thumbnail', 'tools'])
|
|
222
|
+
const SEARCH_MARK = /search|grounding|citation/i
|
|
223
|
+
const firstText = (record, keys) => keys.map(key => record[key]).find(value => typeof value === 'string' && value.trim() !== '')
|
|
224
|
+
|
|
225
|
+
/** Every object in a JSON body that carries an http(s) URL, as sources: the shape of any search API. */
|
|
226
|
+
export function findSources(body) {
|
|
227
|
+
const found = []
|
|
228
|
+
const visit = (value, depth) => {
|
|
229
|
+
if (depth > 16 || value === null || typeof value !== 'object') return
|
|
230
|
+
if (Array.isArray(value)) { for (const item of value) visit(item, depth + 1); return }
|
|
231
|
+
const url = URL_KEYS.map(key => httpUrl(value[key])).find(Boolean)
|
|
232
|
+
if (url !== undefined) found.push(sourceOf(url, firstText(value, TITLE_KEYS), firstText(value, SNIPPET_KEYS), firstText(value, DATE_KEYS)))
|
|
233
|
+
for (const [key, child] of Object.entries(value)) if (!NOT_SOURCES.has(key)) visit(child, depth + 1)
|
|
234
|
+
}
|
|
235
|
+
visit(body, 0)
|
|
236
|
+
return dedupe(found)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Whether a JSON body shows a search ran, read from its keys and `type` tags, never from prose. */
|
|
240
|
+
export function showsSearch(body) {
|
|
241
|
+
const visit = (value, depth) => {
|
|
242
|
+
if (depth > 16 || value === null || typeof value !== 'object') return false
|
|
243
|
+
if (Array.isArray(value)) return value.some(item => visit(item, depth + 1))
|
|
244
|
+
return Object.entries(value).some(([key, child]) => !NOT_SOURCES.has(key) && (
|
|
245
|
+
SEARCH_MARK.test(key) || key === 'results'
|
|
246
|
+
|| (key === 'type' && typeof child === 'string' && SEARCH_MARK.test(child))
|
|
247
|
+
|| visit(child, depth + 1)))
|
|
248
|
+
}
|
|
249
|
+
return visit(body, 0)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A declared search API's response: any structured sources, wherever they sit. */
|
|
253
|
+
export function readDeclared(body) {
|
|
254
|
+
const sources = findSources(body)
|
|
255
|
+
return { ran: sources.length > 0 || showsSearch(body), sources }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Native search: what to try for one route. Nothing here names a vendor except SEARCH_APIS,
|
|
260
|
+
// which is data in the same shape as `searchApis` config.
|
|
261
|
+
|
|
262
|
+
const bearer = key => ({ authorization: `Bearer ${key}` })
|
|
263
|
+
export const AUTH_STYLES = { bearer, 'x-api-key': key => ({ 'x-api-key': key }), 'api-key': key => ({ 'api-key': key }) }
|
|
264
|
+
// One trailing /v1 belongs to the root, as in dsh's DeepSeek adapter and pi-ai's model listing.
|
|
265
|
+
const messagesRoot = base => (new URL(base).pathname.replace(/\/+$/, '').endsWith('/v1') ? trimSlash(base) : `${trimSlash(base)}/v1`)
|
|
266
|
+
|
|
267
|
+
// Bounds what an endpoint that ignores the search tool can cost, while leaving a thinking model
|
|
268
|
+
// room to reach the search: dsh's own DeepSeek search uses the same cap.
|
|
269
|
+
const OUTPUT_CAP = 4096
|
|
270
|
+
|
|
271
|
+
/** The standard search tool of each wire protocol. */
|
|
272
|
+
export const PROTOCOL_SEARCHES = {
|
|
273
|
+
messages: {
|
|
274
|
+
name: 'Anthropic Messages web_search tool',
|
|
275
|
+
url: base => `${messagesRoot(base)}/messages`,
|
|
276
|
+
// Anthropic reads x-api-key; compatible gateways often read Bearer.
|
|
277
|
+
auth: key => ({ 'x-api-key': key, ...bearer(key) }),
|
|
278
|
+
request: ({ model, query }) => ({
|
|
279
|
+
headers: { 'anthropic-version': '2023-06-01' },
|
|
280
|
+
body: {
|
|
281
|
+
model,
|
|
282
|
+
max_tokens: OUTPUT_CAP,
|
|
283
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: PROMPT(query) }] }],
|
|
284
|
+
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 }],
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
read: readMessages,
|
|
288
|
+
},
|
|
289
|
+
responses: {
|
|
290
|
+
name: 'OpenAI Responses web_search tool',
|
|
291
|
+
url: base => `${trimSlash(base)}/responses`,
|
|
292
|
+
auth: bearer,
|
|
293
|
+
request: ({ model, query }) => ({ body: { model, input: PROMPT(query), tools: [{ type: 'web_search' }], max_output_tokens: OUTPUT_CAP } }),
|
|
294
|
+
read: readResponses,
|
|
295
|
+
},
|
|
296
|
+
gemini: {
|
|
297
|
+
name: 'Gemini google_search tool',
|
|
298
|
+
url: (base, model) => `${trimSlash(base)}/models/${encodeURIComponent(model)}:generateContent`,
|
|
299
|
+
auth: key => ({ 'x-goog-api-key': key }),
|
|
300
|
+
request: ({ query }) => ({
|
|
301
|
+
body: { contents: [{ role: 'user', parts: [{ text: PROMPT(query) }] }], tools: [{ google_search: {} }], generationConfig: { maxOutputTokens: OUTPUT_CAP } },
|
|
302
|
+
}),
|
|
303
|
+
read: readGemini,
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
const BY_PROTOCOL = {
|
|
307
|
+
'anthropic-messages': ['messages'],
|
|
308
|
+
'openai-responses': ['responses'],
|
|
309
|
+
// Many OpenAI-compatible servers also serve Responses and Messages under the same base.
|
|
310
|
+
'openai-completions': ['responses', 'messages'],
|
|
311
|
+
'google-generative-ai': ['gemini'],
|
|
312
|
+
}
|
|
313
|
+
const UNKNOWN_PROTOCOL = ['responses', 'messages']
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Search APIs a host serves beside its model protocol. No standard advertises these, so they
|
|
317
|
+
* are declared; `searchApis` in config adds more in the same shape. `$query`, `$model`, and
|
|
318
|
+
* `$maxResults` in a body are replaced per call. Each path is joined to the route's own origin.
|
|
319
|
+
*/
|
|
320
|
+
export const SEARCH_APIS = Object.freeze([
|
|
321
|
+
// docs.ollama.com/capabilities/web-search
|
|
322
|
+
{ id: 'ollama', name: 'Ollama web search API', hosts: ['ollama.com'], path: '/api/web_search', maxResults: 10, body: { query: '$query', max_results: '$maxResults' } },
|
|
323
|
+
// platform.kimi.ai; an empty result is not billed.
|
|
324
|
+
{ id: 'kimi', name: 'Kimi search API', hosts: ['api.moonshot.ai', 'api.moonshot.cn'], path: '/v1/tools/search', maxResults: 20, body: { text_query: '$query', limit: '$maxResults', timeout_seconds: 30 } },
|
|
325
|
+
// docs.bigmodel.cn; the API rejects queries over 70 characters.
|
|
326
|
+
{ id: 'zhipu', name: 'Zhipu web search API', hosts: ['open.bigmodel.cn', 'api.z.ai'], path: '/api/paas/v4/web_search', maxResults: 50, maxQueryChars: 70, body: { search_query: '$query', search_engine: 'search_std', count: '$maxResults' } },
|
|
327
|
+
// MiMo searches only through this Chat Completions plugin tool, once the Web Search plugin is
|
|
328
|
+
// enabled for the key. Each keyword is one billed search, so one keyword per query; the answer
|
|
329
|
+
// text is discarded, so it stays short and without thinking.
|
|
330
|
+
{
|
|
331
|
+
id: 'mimo', name: 'MiMo web_search plugin', hosts: ['xiaomimimo.com', '*.xiaomimimo.com'], path: '/v1/chat/completions', auth: ['bearer', 'api-key'], maxResults: 5,
|
|
332
|
+
body: {
|
|
333
|
+
model: '$model',
|
|
334
|
+
messages: [{ role: 'user', content: '$query' }],
|
|
335
|
+
tools: [{ type: 'web_search', force_search: true, max_keyword: 1, limit: '$maxResults' }],
|
|
336
|
+
max_completion_tokens: 256,
|
|
337
|
+
thinking: { type: 'disabled' },
|
|
338
|
+
stream: false,
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
])
|
|
342
|
+
|
|
343
|
+
const hostMatches = (patterns, host) => patterns.some(pattern => (pattern.startsWith('*.') ? host.endsWith(pattern.slice(1)) : host === pattern.toLowerCase()))
|
|
344
|
+
const fill = (template, values) => {
|
|
345
|
+
if (Array.isArray(template)) return template.map(item => fill(item, values))
|
|
346
|
+
if (isRecord(template)) return Object.fromEntries(Object.entries(template).map(([key, value]) => [key, fill(value, values)]))
|
|
347
|
+
return typeof template === 'string' && Object.hasOwn(values, template) ? values[template] : template
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const usesModel = api => JSON.stringify(api.body).includes('"$model"')
|
|
351
|
+
|
|
352
|
+
function declaredCandidate(api, origin, model) {
|
|
353
|
+
const url = `${origin}${api.path}`
|
|
354
|
+
// A path such as `//other.example` must not move the key to another origin.
|
|
355
|
+
if (new URL(url).origin !== origin) return undefined
|
|
356
|
+
const styles = (api.auth ?? ['bearer']).map(style => AUTH_STYLES[style])
|
|
357
|
+
return {
|
|
358
|
+
id: api.id,
|
|
359
|
+
name: api.name ?? `${api.id} search API`,
|
|
360
|
+
url,
|
|
361
|
+
model: usesModel(api) ? model : undefined,
|
|
362
|
+
auth: key => Object.assign({}, ...styles.map(style => style(key))),
|
|
363
|
+
request: ({ query, maxResults }) => ({
|
|
364
|
+
body: fill(api.body, {
|
|
365
|
+
$query: api.maxQueryChars === undefined ? query : [...query].slice(0, api.maxQueryChars).join(''),
|
|
366
|
+
$model: model,
|
|
367
|
+
$maxResults: clamp(maxResults ?? 8, 1, api.maxResults ?? 10),
|
|
368
|
+
}),
|
|
369
|
+
}),
|
|
370
|
+
read: readDeclared,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function protocolCandidate(id, base, model) {
|
|
375
|
+
const shape = PROTOCOL_SEARCHES[id]
|
|
376
|
+
return { id, name: shape.name, url: shape.url(base, model), model, auth: shape.auth, request: call => shape.request({ ...call, model }), read: shape.read }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Everything worth trying for one route, cheapest first: search APIs declared for its host
|
|
381
|
+
* (no model call), then the standard search tool of its protocol.
|
|
382
|
+
* @returns `{ candidates }`, with `reason` when there is nothing to try.
|
|
383
|
+
*/
|
|
384
|
+
export function planNative(route, searchApis = SEARCH_APIS) {
|
|
385
|
+
const base = httpUrl(route.baseURL)
|
|
386
|
+
if (base === undefined) return { candidates: [], reason: `route "${route.provider}" has no known endpoint` }
|
|
387
|
+
const url = new URL(base)
|
|
388
|
+
const model = str(route.model)
|
|
389
|
+
const candidates = []
|
|
390
|
+
for (const api of searchApis) {
|
|
391
|
+
if (!hostMatches(api.hosts, url.hostname)) continue
|
|
392
|
+
if (model === undefined && usesModel(api)) continue
|
|
393
|
+
const candidate = declaredCandidate(api, url.origin, model)
|
|
394
|
+
if (candidate !== undefined) candidates.push(candidate)
|
|
395
|
+
}
|
|
396
|
+
const shapes = route.api === undefined ? UNKNOWN_PROTOCOL : BY_PROTOCOL[route.api] ?? []
|
|
397
|
+
if (model !== undefined) for (const id of shapes) candidates.push(protocolCandidate(id, base, model))
|
|
398
|
+
if (candidates.length > 0) return { candidates }
|
|
399
|
+
return {
|
|
400
|
+
candidates,
|
|
401
|
+
reason: model === undefined && shapes.length > 0
|
|
402
|
+
? `route "${route.provider}" has no current model to search with`
|
|
403
|
+
: `route "${route.provider}" speaks ${route.api}, which has no standard search tool, and ${url.host} declares no search API`,
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Keyless fallback: public results pages, parsed into sources.
|
|
409
|
+
|
|
410
|
+
/** Bing wraps result links in `bing.com/ck/a?…&u=a1<base64url>`; recover the target. */
|
|
411
|
+
export function decodeBingUrl(href) {
|
|
412
|
+
const url = httpUrl(href)
|
|
413
|
+
if (url === undefined) return undefined
|
|
414
|
+
const parsed = new URL(url)
|
|
415
|
+
if (!/(^|\.)bing\.com$/i.test(parsed.hostname)) return url
|
|
416
|
+
const target = parsed.searchParams.get('u')
|
|
417
|
+
if (parsed.pathname !== '/ck/a' || target === null || !target.startsWith('a1')) return undefined
|
|
418
|
+
try { return httpUrl(Buffer.from(target.slice(2), 'base64url').toString('utf8')) } catch { return undefined }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Bing results page → `{ sources }`, or `{ error }` when the page is not a results page. */
|
|
422
|
+
export function parseBing(html) {
|
|
423
|
+
const starts = [...html.matchAll(/<li\b[^>]*\bclass\s*=\s*"[^"]*\bb_algo\b[^"]*"[^>]*>/gi)].map(match => match.index)
|
|
424
|
+
const sources = []
|
|
425
|
+
starts.forEach((start, index) => {
|
|
426
|
+
const block = html.slice(start, starts[index + 1] ?? start + 12_000)
|
|
427
|
+
const heading = /<h2\b[^>]*>\s*<a\b([^>]*)>([\s\S]*?)<\/a>/i.exec(block)
|
|
428
|
+
if (heading === null) return
|
|
429
|
+
const href = attrOf(heading[1], 'href')
|
|
430
|
+
const caption = block.slice(Math.max(0, block.search(/<div\b[^>]*\bclass\s*=\s*"[^"]*\bb_caption\b/i)))
|
|
431
|
+
const paragraph = /<p\b[^>]*>([\s\S]*?)<\/p>/i.exec(caption)
|
|
432
|
+
sources.push(sourceOf(decodeBingUrl(href), textOf(heading[2]), paragraph ? textOf(paragraph[1]) : undefined))
|
|
433
|
+
})
|
|
434
|
+
const parsed = dedupe(sources)
|
|
435
|
+
if (parsed.length > 0) return { sources: parsed }
|
|
436
|
+
if (/\bclass\s*=\s*"[^"]*\bb_no\b/i.test(html)) return { sources: [] }
|
|
437
|
+
return { error: /id\s*=\s*"b_results"/i.test(html) ? 'results page had no parsable results' : 'not a results page (bot check or changed layout)' }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** DuckDuckGo HTML results page → `{ sources }`, or `{ error }`. */
|
|
441
|
+
export function parseDuckDuckGo(html) {
|
|
442
|
+
if (/anomaly-modal|bots use DuckDuckGo too|challenge-form/i.test(html)) return { error: 'bot check page' }
|
|
443
|
+
const links = [...html.matchAll(/<a\b([^>]*\bclass\s*=\s*"[^"]*\bresult__a\b[^"]*"[^>]*)>([\s\S]*?)<\/a>/gi)]
|
|
444
|
+
const sources = links.map((link, index) => {
|
|
445
|
+
const href = attrOf(link[1], 'href') ?? ''
|
|
446
|
+
let target
|
|
447
|
+
try {
|
|
448
|
+
const parsed = new URL(href, 'https://duckduckgo.com')
|
|
449
|
+
// `/y.js` links are ads.
|
|
450
|
+
if (/(^|\.)duckduckgo\.com$/i.test(parsed.hostname)) target = parsed.pathname === '/l/' ? parsed.searchParams.get('uddg') : undefined
|
|
451
|
+
else target = parsed.href
|
|
452
|
+
} catch { target = undefined }
|
|
453
|
+
const rest = html.slice(link.index + link[0].length, links[index + 1]?.index ?? link.index + 6000)
|
|
454
|
+
const snippet = /<(?:a|div|td)\b[^>]*\bclass\s*=\s*"[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/(?:a|div|td)>/i.exec(rest)
|
|
455
|
+
return sourceOf(target, textOf(link[2]), snippet ? textOf(snippet[1]) : undefined)
|
|
456
|
+
})
|
|
457
|
+
const parsed = dedupe(sources)
|
|
458
|
+
if (parsed.length > 0) return { sources: parsed }
|
|
459
|
+
if (/\bresult--no-results\b|\bno-results\b/i.test(html)) return { sources: [] }
|
|
460
|
+
return { error: 'not a results page (bot check or changed layout)' }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export const ENGINES = {
|
|
464
|
+
// No market or language parameter: Bing picks them from the network, as it does for a browser.
|
|
465
|
+
// `regional` is the hosts Bing itself may redirect to (mainland networks go to cn.bing.com).
|
|
466
|
+
bing: { name: 'Bing', url: query => `https://www.bing.com/search?q=${encodeURIComponent(query)}`, parse: parseBing, regional: /^https:\/\/([a-z0-9-]+\.)*bing\.com$/i },
|
|
467
|
+
duckduckgo: { name: 'DuckDuckGo', url: query => `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, parse: parseDuckDuckGo },
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// The provider
|
|
472
|
+
|
|
473
|
+
const aborted = signal => new WebError('web search aborted', 'WEB_ABORTED', { cause: signal?.reason })
|
|
474
|
+
|
|
475
|
+
function checkSearchApi(api, index) {
|
|
476
|
+
const where = `web-search-auto: searchApis[${index}]`
|
|
477
|
+
if (!isRecord(api)) throw new Error(`${where} must be an object`)
|
|
478
|
+
if (str(api.id) === undefined) throw new Error(`${where} needs an id`)
|
|
479
|
+
if (!Array.isArray(api.hosts) || api.hosts.length === 0 || !api.hosts.every(host => typeof host === 'string' && /^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)*$/i.test(host))) {
|
|
480
|
+
throw new Error(`${where}.hosts must be a list of host names; a leading *. matches subdomains`)
|
|
481
|
+
}
|
|
482
|
+
if (typeof api.path !== 'string' || !/^\/[^/]/.test(api.path)) throw new Error(`${where}.path must be a path on the route's host, starting with one /`)
|
|
483
|
+
if (!isRecord(api.body)) throw new Error(`${where}.body must be an object`)
|
|
484
|
+
if (api.auth !== undefined && (!Array.isArray(api.auth) || api.auth.length === 0 || api.auth.some(style => !Object.hasOwn(AUTH_STYLES, style)))) {
|
|
485
|
+
throw new Error(`${where}.auth must be a list of ${Object.keys(AUTH_STYLES).join(', ')}`)
|
|
486
|
+
}
|
|
487
|
+
for (const key of ['maxResults', 'maxQueryChars']) {
|
|
488
|
+
if (api[key] !== undefined && !(Number.isInteger(api[key]) && api[key] >= 1)) throw new Error(`${where}.${key} must be a positive integer`)
|
|
489
|
+
}
|
|
490
|
+
if (api.name !== undefined && str(api.name) === undefined) throw new Error(`${where}.name must be text`)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Normalize plugin config. Invalid entries fail loud at load rather than silently searching less.
|
|
495
|
+
* Declared `searchApis` come before the built-in SEARCH_APIS, so a user entry wins on its host.
|
|
496
|
+
*/
|
|
497
|
+
export function resolveConfig(config = {}) {
|
|
498
|
+
const resolved = { ...DEFAULTS, ...Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined)) }
|
|
499
|
+
if (!Array.isArray(resolved.engines) || resolved.engines.some(engine => !Object.hasOwn(ENGINES, engine))) {
|
|
500
|
+
throw new Error(`web-search-auto: engines must be a list of ${Object.keys(ENGINES).join(', ')}`)
|
|
501
|
+
}
|
|
502
|
+
for (const key of ['nativeTimeoutMs', 'fallbackTimeoutMs']) {
|
|
503
|
+
if (!Number.isFinite(resolved[key]) || resolved[key] < 1000) throw new Error(`web-search-auto: ${key} must be at least 1000`)
|
|
504
|
+
}
|
|
505
|
+
if (!Array.isArray(resolved.searchApis)) throw new Error('web-search-auto: searchApis must be a list')
|
|
506
|
+
resolved.searchApis.forEach(checkSearchApi)
|
|
507
|
+
return {
|
|
508
|
+
...resolved,
|
|
509
|
+
native: resolved.native !== false,
|
|
510
|
+
fallback: resolved.fallback !== false,
|
|
511
|
+
searchApis: [...resolved.searchApis, ...SEARCH_APIS],
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const errorDetail = (text) => {
|
|
516
|
+
try {
|
|
517
|
+
const parsed = JSON.parse(text)
|
|
518
|
+
return str(typeof parsed?.error === 'string' ? parsed.error : parsed?.error?.message ?? parsed?.message ?? parsed?.msg) ?? ''
|
|
519
|
+
} catch { return '' }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Build the `auto` search provider over injected effects, so tests run it without a host.
|
|
524
|
+
* @param deps.route - the asking Session's route: `{ provider, model, api, baseURL, apiKeyEnv, account, headers }`.
|
|
525
|
+
* @param deps.resolveKey - credential reference → key, or undefined.
|
|
526
|
+
* @param deps.resolveAccountToken - endpoint → DeepSeek account token, or undefined.
|
|
527
|
+
* @param deps.fetch - the HTTP client for native searches.
|
|
528
|
+
* @param deps.webFetch - `ctx.web.fetch`.
|
|
529
|
+
*/
|
|
530
|
+
export function createAutoSearchProvider(deps, config = {}) {
|
|
531
|
+
const options = resolveConfig(config)
|
|
532
|
+
const now = deps.now ?? Date.now
|
|
533
|
+
// route + endpoint + model → `{ works: true }` or `{ until, reason }`, for this process.
|
|
534
|
+
const memory = new Map()
|
|
535
|
+
|
|
536
|
+
async function credentialFor(route, endpoint) {
|
|
537
|
+
if (route.account) {
|
|
538
|
+
let token
|
|
539
|
+
try { token = str(await deps.resolveAccountToken?.(endpoint)) } catch (error) { return { reason: `the DeepSeek account token could not be read: ${reasonOf(error)}` } }
|
|
540
|
+
return token === undefined
|
|
541
|
+
? { reason: 'it is not signed in to a DeepSeek account' }
|
|
542
|
+
: { headers: () => ({ 'x-dsh-auth-token': token }), via: 'the DeepSeek account sign-in' }
|
|
543
|
+
}
|
|
544
|
+
if (route.apiKeyEnv === undefined) return { reason: 'it names no API key' }
|
|
545
|
+
let key
|
|
546
|
+
try { key = str(await deps.resolveKey(route.apiKeyEnv)) } catch (error) { return { reason: `the key ${route.apiKeyEnv} could not be read: ${reasonOf(error)}` } }
|
|
547
|
+
return key === undefined
|
|
548
|
+
? { reason: `no API key is stored for ${route.apiKeyEnv}` }
|
|
549
|
+
: { headers: candidate => candidate.auth(key), via: `the API key ${route.apiKeyEnv}` }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function attempt(candidate, route, credential, request, signal, budget, known) {
|
|
553
|
+
const call = candidate.request({ query: request.query, maxResults: request.maxResults })
|
|
554
|
+
const timeout = AbortSignal.timeout(budget)
|
|
555
|
+
let response
|
|
556
|
+
let text
|
|
557
|
+
try {
|
|
558
|
+
response = await deps.fetch(candidate.url, {
|
|
559
|
+
method: 'POST',
|
|
560
|
+
// Credential-bearing requests never follow redirects (deepseek-harness/packages/web/AGENTS.md).
|
|
561
|
+
redirect: 'error',
|
|
562
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': USER_AGENT, ...route.headers, ...call.headers, ...credential.headers(candidate) },
|
|
563
|
+
body: JSON.stringify(call.body),
|
|
564
|
+
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
|
|
565
|
+
})
|
|
566
|
+
text = await response.text()
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if (signal?.aborted) throw aborted(signal)
|
|
569
|
+
return { reason: timeout.aborted ? `timed out after ${seconds(budget)}` : `request failed: ${reasonOf(error)}` }
|
|
570
|
+
}
|
|
571
|
+
if (!response.ok) {
|
|
572
|
+
const detail = errorDetail(text)
|
|
573
|
+
return { reason: `HTTP ${response.status}${detail ? `: ${clip(detail, 200)}` : ''}`, skip: STATUS_OUTCOMES[response.status] }
|
|
574
|
+
}
|
|
575
|
+
let body
|
|
576
|
+
try { body = JSON.parse(text) } catch { return { reason: 'answered with a body that is not JSON', skip: 'ignored' } }
|
|
577
|
+
const read = candidate.read(body)
|
|
578
|
+
if (read.sources.length > 0) return { sources: read.sources }
|
|
579
|
+
if (read.error !== undefined) return { reason: read.error, final: true }
|
|
580
|
+
if (read.ran || known) return { reason: 'returned no results', final: true }
|
|
581
|
+
// The cap says nothing about support, so a cut-off answer is not remembered.
|
|
582
|
+
if (read.cut) return { reason: 'stopped at the output cap before searching' }
|
|
583
|
+
return { reason: 'answered without running a search, so it does not seem to support this one', skip: 'ignored' }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function runNative(request, signal) {
|
|
587
|
+
if (!options.native) return { reason: 'native search is turned off (web-search-auto native: false)' }
|
|
588
|
+
let route
|
|
589
|
+
try { route = deps.route() } catch (error) { return { reason: `the conversation's model route could not be read: ${reasonOf(error)}` } }
|
|
590
|
+
if (route === undefined) return { reason: 'no conversation model route is known for this search' }
|
|
591
|
+
const plan = planNative(route, options.searchApis)
|
|
592
|
+
if (plan.candidates.length === 0) return { reason: plan.reason }
|
|
593
|
+
const keyOf = candidate => [route.provider, candidate.url, candidate.model ?? ''].join('\n')
|
|
594
|
+
const works = candidate => memory.get(keyOf(candidate))?.works === true
|
|
595
|
+
const ordered = [...plan.candidates.filter(works), ...plan.candidates.filter(candidate => !works(candidate))]
|
|
596
|
+
const deadline = now() + options.nativeTimeoutMs
|
|
597
|
+
const notes = []
|
|
598
|
+
let credential
|
|
599
|
+
for (const candidate of ordered) {
|
|
600
|
+
const where = `${candidate.name} (${new URL(candidate.url).host}${new URL(candidate.url).pathname})`
|
|
601
|
+
const memo = memory.get(keyOf(candidate))
|
|
602
|
+
if (memo?.until !== undefined && memo.until > now()) {
|
|
603
|
+
notes.push(`${where}: ${memo.reason}; not retried for ${Math.ceil((memo.until - now()) / MINUTE)} min`)
|
|
604
|
+
continue
|
|
605
|
+
}
|
|
606
|
+
credential ??= await credentialFor(route, candidate.url)
|
|
607
|
+
if (signal?.aborted) throw aborted(signal)
|
|
608
|
+
if (credential.reason !== undefined) return { reason: `route "${route.provider}" could not search natively: ${credential.reason}` }
|
|
609
|
+
const remaining = deadline - now()
|
|
610
|
+
if (remaining <= 0) { notes.push(`${where}: skipped, out of time`); continue }
|
|
611
|
+
const outcome = await attempt(candidate, route, credential, request, signal, remaining, memo?.works === true)
|
|
612
|
+
if (outcome.sources !== undefined) {
|
|
613
|
+
memory.set(keyOf(candidate), { works: true })
|
|
614
|
+
return {
|
|
615
|
+
result: {
|
|
616
|
+
content: `Search backend: native search of this conversation's model route "${route.provider}": ${where}, billed to ${credential.via}.`,
|
|
617
|
+
sources: outcome.sources,
|
|
618
|
+
truncated: false,
|
|
619
|
+
},
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
notes.push(`${where}: ${outcome.reason}`)
|
|
623
|
+
if (outcome.skip !== undefined) memory.set(keyOf(candidate), { until: now() + SKIP_FOR[outcome.skip], reason: outcome.reason })
|
|
624
|
+
if (outcome.final) break
|
|
625
|
+
}
|
|
626
|
+
return { reason: `route "${route.provider}": ${notes.join('; ')}` }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function fetchPage(engine, url, signal) {
|
|
630
|
+
try {
|
|
631
|
+
return { page: await deps.webFetch({ url }, signal), url }
|
|
632
|
+
} catch (error) {
|
|
633
|
+
// The fetch provider refuses cross-origin redirects and names the target; follow one hop
|
|
634
|
+
// when it is the engine's own regional host.
|
|
635
|
+
const origin = error?.code === 'WEB_REDIRECT_BLOCKED' ? /https?:\/\/[a-z0-9.-]+(:\d+)?/i.exec(error.message ?? '')?.[0] : undefined
|
|
636
|
+
if (origin === undefined || engine.regional?.test(origin) !== true || origin === new URL(url).origin) throw error
|
|
637
|
+
const { pathname, search } = new URL(url)
|
|
638
|
+
const regional = `${origin}${pathname}${search}`
|
|
639
|
+
return { page: await deps.webFetch({ url: regional }, signal), url: regional }
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function runFallback(request, signal) {
|
|
644
|
+
const attempts = []
|
|
645
|
+
let empty
|
|
646
|
+
const deadline = now() + options.fallbackTimeoutMs
|
|
647
|
+
for (const [index, id] of options.engines.entries()) {
|
|
648
|
+
const engine = ENGINES[id]
|
|
649
|
+
const url = engine.url(request.query)
|
|
650
|
+
const host = new URL(url).host
|
|
651
|
+
const remaining = deadline - now()
|
|
652
|
+
if (remaining <= 0) { attempts.push(`${host}: skipped, out of time`); continue }
|
|
653
|
+
// Leave later engines a share of the budget when an earlier one hangs.
|
|
654
|
+
const timeout = AbortSignal.timeout(index === options.engines.length - 1 ? remaining : Math.min(remaining, Math.ceil(options.fallbackTimeoutMs / 2)))
|
|
655
|
+
let fetched
|
|
656
|
+
try {
|
|
657
|
+
fetched = await fetchPage(engine, url, signal === undefined ? timeout : AbortSignal.any([signal, timeout]))
|
|
658
|
+
} catch (error) {
|
|
659
|
+
if (signal?.aborted) throw aborted(signal)
|
|
660
|
+
attempts.push(`${host}: ${timeout.aborted ? 'timed out' : `${error?.code ? `${error.code} ` : ''}${reasonOf(error)}`}`)
|
|
661
|
+
continue
|
|
662
|
+
}
|
|
663
|
+
const at = new URL(fetched.url).host
|
|
664
|
+
if (fetched.page.statusCode !== 200) { attempts.push(`${at}: HTTP ${fetched.page.statusCode}`); continue }
|
|
665
|
+
const parsed = engine.parse(fetched.page.body?.content ?? '')
|
|
666
|
+
if (parsed.error !== undefined) { attempts.push(`${at}: ${parsed.error}`); continue }
|
|
667
|
+
if (parsed.sources.length === 0) { empty ??= { engine, at }; attempts.push(`${at}: no results`); continue }
|
|
668
|
+
return { result: { engine, at, sources: parsed.sources } }
|
|
669
|
+
}
|
|
670
|
+
return empty === undefined ? { attempts } : { result: { ...empty, sources: [] } }
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
id: AUTO_PROVIDER_ID,
|
|
675
|
+
available: () => options.native || options.fallback,
|
|
676
|
+
async search(request, signal) {
|
|
677
|
+
if (signal?.aborted) throw aborted(signal)
|
|
678
|
+
const native = await runNative(request, signal)
|
|
679
|
+
if (native.result !== undefined) return native.result
|
|
680
|
+
let fallbackReason = 'turned off (web-search-auto fallback: false)'
|
|
681
|
+
if (options.fallback) {
|
|
682
|
+
const fallback = await runFallback(request, signal)
|
|
683
|
+
if (fallback.result !== undefined) {
|
|
684
|
+
return {
|
|
685
|
+
content: `Search backend: keyless fallback, parsed from the public ${fallback.result.engine.name} results page at ${fallback.result.at} (no API key, no charge; less reliable than an API search). Native search was not used: ${native.reason}.`,
|
|
686
|
+
sources: fallback.result.sources,
|
|
687
|
+
truncated: false,
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
fallbackReason = fallback.attempts.join('; ')
|
|
691
|
+
}
|
|
692
|
+
throw new WebError([
|
|
693
|
+
'Web search is unavailable for this query.',
|
|
694
|
+
`Native search: ${native.reason}.`,
|
|
695
|
+
`Keyless fallback: ${fallbackReason}.`,
|
|
696
|
+
'Do not guess URLs or present pages you remember as search results. If you have a browser tool, open a search engine in it; otherwise tell the user that web search is unavailable and why.',
|
|
697
|
+
].join('\n'), 'WEB_SEARCH_UNAVAILABLE')
|
|
698
|
+
},
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// Host wiring: read the asking Session's route through public dsh services only.
|
|
704
|
+
|
|
705
|
+
const stringRecord = value => (isRecord(value) ? Object.fromEntries(Object.entries(value).filter(([, item]) => typeof item === 'string')) : {})
|
|
706
|
+
|
|
707
|
+
/** The route of the Session that started the current tool call: protocol, endpoint, key name, and headers. */
|
|
708
|
+
export function routeOf(ctx) {
|
|
709
|
+
const request = ctx.get('agents')?.currentInitiator()?.session.requestContext()
|
|
710
|
+
const provider = str(request?.provider)
|
|
711
|
+
if (provider === undefined) return undefined
|
|
712
|
+
let section
|
|
713
|
+
try {
|
|
714
|
+
const entry = ctx.get('llm')?.listConfigurableProviders().find(item => item.provider === provider)
|
|
715
|
+
if (entry !== undefined) {
|
|
716
|
+
const descriptor = ctx.get('settings')?.describe().find(item => item.ns === entry.settingsNs)
|
|
717
|
+
section = entry.settingsPath.reduce((value, key) => value?.[key], descriptor?.value)
|
|
718
|
+
}
|
|
719
|
+
} catch { section = undefined }
|
|
720
|
+
const [catalogApi, catalogBase, catalogKey] = CATALOG[provider] ?? []
|
|
721
|
+
const account = provider === 'deepseek-account'
|
|
722
|
+
return {
|
|
723
|
+
provider,
|
|
724
|
+
model: str(request.model),
|
|
725
|
+
api: str(section?.api) ?? catalogApi,
|
|
726
|
+
baseURL: str(section?.baseURL) ?? (DSH_DEEPSEEK_ROUTES.has(provider) ? str(envOf(ctx)('DEEPSEEK_BASE_URL')) : undefined) ?? catalogBase,
|
|
727
|
+
apiKeyEnv: account ? undefined : str(section?.apiKeyEnv) ?? catalogKey,
|
|
728
|
+
account,
|
|
729
|
+
headers: stringRecord(section?.headers),
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const envOf = ctx => name => ctx.get('launchEnvironment')?.get(name)?.value ?? process.env[name]
|
|
734
|
+
|
|
735
|
+
export function apply(ctx, config = {}) {
|
|
736
|
+
const env = envOf(ctx)
|
|
737
|
+
ctx.web.registerSearchProvider(createAutoSearchProvider({
|
|
738
|
+
route: () => routeOf(ctx),
|
|
739
|
+
resolveKey: async (ref) => {
|
|
740
|
+
const credentials = ctx.get('credentials')
|
|
741
|
+
// Without the seam the environment is the whole credential plane, as in dsh's own adapters.
|
|
742
|
+
return credentials === undefined ? env(ref) : (await credentials.resolve(ref))?.value
|
|
743
|
+
},
|
|
744
|
+
resolveAccountToken: endpoint => ctx.get('deepseekAccount')?.resolveToken(endpoint),
|
|
745
|
+
fetch: (url, init) => fetch(url, init),
|
|
746
|
+
webFetch: (request, signal) => ctx.web.fetch(request, signal),
|
|
747
|
+
}, config))
|
|
748
|
+
}
|