@dbx-tools/appkit-web-search 0.3.29 → 0.3.30
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/README.md +125 -29
- package/index.ts +4 -2
- package/package.json +6 -6
- package/src/allowlist.ts +5 -2
- package/src/config.ts +218 -33
- package/src/defaults.ts +127 -0
- package/src/fetch.ts +48 -21
- package/src/html-text.ts +3 -3
- package/src/plugin.ts +217 -25
- package/src/provider.ts +28 -5
- package/src/runtime.ts +87 -3
- package/src/schema.ts +22 -0
- package/src/scrape.ts +32 -11
- package/src/search.ts +131 -47
- package/src/tool.ts +43 -31
- package/test/allowlist.test.ts +69 -3
package/src/search.ts
CHANGED
|
@@ -10,23 +10,31 @@
|
|
|
10
10
|
* agent's chat model (the agent may run on a model without web search).
|
|
11
11
|
* A pinned `model` (request or config) is fuzzy-matched; otherwise the
|
|
12
12
|
* configured fallback order (Gemini, then GPT) is walked to the first
|
|
13
|
-
* web-search-capable endpoint that exists
|
|
14
|
-
*
|
|
13
|
+
* web-search-capable endpoint that exists. An explicit but unsupported
|
|
14
|
+
* model is a hard error, not a silent fallback.
|
|
15
15
|
* 2. POSTs to the provider's REST surface (`/serving-endpoints/responses`
|
|
16
16
|
* for OpenAI, `/serving-endpoints/chat/completions` for Gemini) with the
|
|
17
|
-
* mapped tool spec,
|
|
17
|
+
* mapped tool spec, authenticated as the OBO caller.
|
|
18
18
|
* 3. Returns the synthesized answer plus the cited sources, with citations
|
|
19
19
|
* silently filtered through the configured URL allow-list.
|
|
20
20
|
*
|
|
21
21
|
* @module
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
ConfigurationError,
|
|
26
|
+
ConnectionError,
|
|
27
|
+
ExecutionError,
|
|
28
|
+
getExecutionContext,
|
|
29
|
+
ValidationError,
|
|
30
|
+
} from "@databricks/appkit";
|
|
25
31
|
import { invoke, resolve, serving } from "@dbx-tools/model";
|
|
26
|
-
import {
|
|
32
|
+
import { log, object, string } from "@dbx-tools/shared-core";
|
|
27
33
|
import { openaiChat, openaiResponses } from "@dbx-tools/shared-model";
|
|
28
|
-
import type
|
|
34
|
+
import { MODEL_ENV, SERVING_ENDPOINT_ENV, type ResolvedWebSearchConfig } from "./config";
|
|
35
|
+
import { toCallSettings, webSearchExecuteDefaults } from "./defaults";
|
|
29
36
|
import { detectWebSearchProvider, supportsWebSearch, webSearchToolSpec } from "./provider";
|
|
37
|
+
import { executeRead } from "./runtime";
|
|
30
38
|
import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
|
|
31
39
|
import { runScrapeSearch } from "./scrape";
|
|
32
40
|
|
|
@@ -35,6 +43,20 @@ const logger = log.logger("web-search/search");
|
|
|
35
43
|
const { resolveModel } = resolve;
|
|
36
44
|
const { listServingEndpoints } = serving;
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* How deep the grounding-metadata walk descends. Gemini nests its sources a
|
|
48
|
+
* handful of levels down and the shape varies by model version, so the walk
|
|
49
|
+
* is generic; the bound is what keeps a pathological payload from turning it
|
|
50
|
+
* into a full traversal of the response.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_GROUNDING_WALK_DEPTH = 6;
|
|
53
|
+
|
|
54
|
+
/** Lowest HTTP status treated as a server-side (retryable) serving failure. */
|
|
55
|
+
const SERVER_ERROR_STATUS = 500;
|
|
56
|
+
|
|
57
|
+
/** Status Model Serving uses to shed load; retryable like a 5xx. */
|
|
58
|
+
const RATE_LIMITED_STATUS = 429;
|
|
59
|
+
|
|
38
60
|
/** Context a search needs from the caller: the OBO client + workspace host. */
|
|
39
61
|
export interface WebSearchContext {
|
|
40
62
|
client: WorkspaceClientLike;
|
|
@@ -64,9 +86,13 @@ export async function resolveWebSearchContext(): Promise<WebSearchContext> {
|
|
|
64
86
|
*
|
|
65
87
|
* Returns the chosen endpoint id, or `null` when the workspace has no
|
|
66
88
|
* web-search-capable model deployed (the caller then uses the scrape
|
|
67
|
-
* fallback).
|
|
68
|
-
* model
|
|
69
|
-
*
|
|
89
|
+
* fallback). A pin the caller chose deliberately - the request's `model`, the
|
|
90
|
+
* plugin's `model`, or `WEB_SEARCH_MODEL` - throws when it resolves to an
|
|
91
|
+
* unsupported / absent endpoint, so a bad pin surfaces rather than silently
|
|
92
|
+
* degrading. A pin inherited from the shared `DATABRICKS_SERVING_ENDPOINT_NAME`
|
|
93
|
+
* binding is only a preference: that endpoint is the app's serving endpoint,
|
|
94
|
+
* not necessarily a web-search-capable one, so an unusable value falls through
|
|
95
|
+
* to the fallback order.
|
|
70
96
|
*/
|
|
71
97
|
async function resolveWebSearchModel(
|
|
72
98
|
ctx: WebSearchContext,
|
|
@@ -77,6 +103,8 @@ async function resolveWebSearchModel(
|
|
|
77
103
|
// Only deployed, web-search-capable endpoints are candidates.
|
|
78
104
|
const capable = endpoints.filter((e) => supportsWebSearch(e.name));
|
|
79
105
|
const pinned = requested ?? config.model;
|
|
106
|
+
const pinIsDeliberate =
|
|
107
|
+
requested !== undefined || config.modelSource === "config" || config.modelSource === MODEL_ENV;
|
|
80
108
|
|
|
81
109
|
if (pinned) {
|
|
82
110
|
// Resolve the explicit ask within the capable set only.
|
|
@@ -87,19 +115,33 @@ async function resolveWebSearchModel(
|
|
|
87
115
|
});
|
|
88
116
|
// resolveModel returns the input verbatim on no match; require it to be a
|
|
89
117
|
// real capable endpoint so a bad pin is a clear error, not a phantom call.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
const usable = capable.some((e) => e.name === modelId) && supportsWebSearch(modelId);
|
|
119
|
+
if (usable) return modelId;
|
|
120
|
+
const deployed = capable.map((e) => e.name).join(", ") || "none";
|
|
121
|
+
if (!pinIsDeliberate) {
|
|
122
|
+
logger.info("shared-endpoint-not-web-capable", {
|
|
123
|
+
envVar: SERVING_ENDPOINT_ENV,
|
|
124
|
+
deployed,
|
|
125
|
+
});
|
|
126
|
+
} else if (requested !== undefined) {
|
|
127
|
+
throw ValidationError.invalidValue(
|
|
128
|
+
"model",
|
|
129
|
+
pinned,
|
|
130
|
+
`a deployed web-search-capable endpoint (deployed: ${deployed})`,
|
|
131
|
+
);
|
|
132
|
+
} else {
|
|
133
|
+
throw ConfigurationError.resourceNotFound(
|
|
134
|
+
"Web-search-capable serving endpoint",
|
|
135
|
+
`Deployed web-search-capable endpoints: ${deployed}. Set model or ${MODEL_ENV} to one of them.`,
|
|
94
136
|
);
|
|
95
137
|
}
|
|
96
|
-
return modelId;
|
|
97
138
|
}
|
|
98
139
|
|
|
99
140
|
if (capable.length === 0) return null;
|
|
100
141
|
|
|
101
|
-
// Nothing pinned: prefer the configured fallback order (Gemini, then
|
|
102
|
-
// when those ids are actually deployed; else take the best capable
|
|
142
|
+
// Nothing usable pinned: prefer the configured fallback order (Gemini, then
|
|
143
|
+
// GPT) when those ids are actually deployed; else take the best capable
|
|
144
|
+
// endpoint.
|
|
103
145
|
const { modelId } = resolveModel(capable, {
|
|
104
146
|
fallbacks: config.modelFallbacks,
|
|
105
147
|
fuzzy: config.fuzzy,
|
|
@@ -108,20 +150,57 @@ async function resolveWebSearchModel(
|
|
|
108
150
|
return capable.some((e) => e.name === modelId) ? modelId : (capable[0]?.name ?? null);
|
|
109
151
|
}
|
|
110
152
|
|
|
111
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* POST a serving request as the OBO caller and return the parsed JSON body.
|
|
155
|
+
*
|
|
156
|
+
* Auth headers are minted per call from the OBO client's SDK config, which
|
|
157
|
+
* refreshes the token when it is close to expiry, so the request carries the
|
|
158
|
+
* requesting user's identity.
|
|
159
|
+
*
|
|
160
|
+
* This is the one Databricks call in the repo that does not go through
|
|
161
|
+
* `apiClient.request` + `databricks.toContext` (which does forward
|
|
162
|
+
* cancellation). Retry classification here has to distinguish a load-shed or
|
|
163
|
+
* server fault from this request's own 4xx, and `fetch` exposes the HTTP status
|
|
164
|
+
* directly; the SDK raises an `ApiError` that carries the status under
|
|
165
|
+
* inconsistent keys, which is guesswork by comparison.
|
|
166
|
+
*/
|
|
112
167
|
async function postServing(
|
|
113
168
|
ctx: WebSearchContext,
|
|
114
|
-
|
|
169
|
+
url: string,
|
|
115
170
|
body: unknown,
|
|
171
|
+
config: ResolvedWebSearchConfig,
|
|
172
|
+
cacheKey: readonly (string | number)[],
|
|
173
|
+
signal?: AbortSignal,
|
|
116
174
|
): Promise<Record<string, unknown>> {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
175
|
+
const payload = await executeRead(
|
|
176
|
+
"serving-request",
|
|
177
|
+
toCallSettings(webSearchExecuteDefaults, config.timeoutMs, cacheKey),
|
|
178
|
+
async (executeSignal): Promise<unknown> => {
|
|
179
|
+
const response = await fetch(url, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: {
|
|
182
|
+
...(await invoke.authHeaders(ctx.client)),
|
|
183
|
+
Accept: "application/json",
|
|
184
|
+
"Content-Type": "application/json",
|
|
185
|
+
},
|
|
186
|
+
body: JSON.stringify(body),
|
|
187
|
+
...(executeSignal ? { signal: executeSignal } : {}),
|
|
188
|
+
});
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
// Load-shedding and server faults are worth another attempt; a 4xx is
|
|
191
|
+
// this request's own problem, so it must not be retried.
|
|
192
|
+
const retryable =
|
|
193
|
+
response.status >= SERVER_ERROR_STATUS || response.status === RATE_LIMITED_STATUS;
|
|
194
|
+
const message = `web-search: Model Serving rejected the search request (HTTP ${response.status})`;
|
|
195
|
+
throw retryable
|
|
196
|
+
? new ConnectionError(message, { context: { status: response.status } })
|
|
197
|
+
: new ExecutionError(message, { context: { status: response.status } });
|
|
198
|
+
}
|
|
199
|
+
return response.json();
|
|
200
|
+
},
|
|
201
|
+
signal,
|
|
202
|
+
);
|
|
203
|
+
return object.isRecord(payload) ? payload : {};
|
|
125
204
|
}
|
|
126
205
|
|
|
127
206
|
/* --------------------------- response extraction --------------------------- */
|
|
@@ -149,22 +228,22 @@ function fromChatPayload(payload: Record<string, unknown>): {
|
|
|
149
228
|
answer: string;
|
|
150
229
|
citations: WebSearchCitation[];
|
|
151
230
|
} {
|
|
152
|
-
const choices = Array.isArray(payload.choices) ?
|
|
153
|
-
const
|
|
231
|
+
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
|
232
|
+
const first = choices[0];
|
|
233
|
+
const message = object.isRecord(first) && object.isRecord(first.message) ? first.message : {};
|
|
154
234
|
const answer = openaiChat.chatContentToText(message.content);
|
|
155
235
|
const citations: WebSearchCitation[] = [];
|
|
156
236
|
// Best-effort grounding extraction: walk any nested object for {uri|url,title}.
|
|
157
237
|
const seen = new Set<string>();
|
|
158
238
|
const visit = (v: unknown, depth: number): void => {
|
|
159
|
-
if (depth >
|
|
160
|
-
const
|
|
161
|
-
const url = string.trimToEmpty(o.url) || string.trimToEmpty(o.uri);
|
|
239
|
+
if (depth > MAX_GROUNDING_WALK_DEPTH || !object.isRecord(v)) return;
|
|
240
|
+
const url = string.trimToEmpty(v.url) || string.trimToEmpty(v.uri);
|
|
162
241
|
if (url && !seen.has(url)) {
|
|
163
242
|
seen.add(url);
|
|
164
|
-
const title = string.trimToEmpty(
|
|
243
|
+
const title = string.trimToEmpty(v.title);
|
|
165
244
|
citations.push({ url, ...(title ? { title } : {}) });
|
|
166
245
|
}
|
|
167
|
-
for (const val of Object.values(
|
|
246
|
+
for (const val of Object.values(v)) {
|
|
168
247
|
if (Array.isArray(val)) val.forEach((x) => visit(x, depth + 1));
|
|
169
248
|
else if (val && typeof val === "object") visit(val, depth + 1);
|
|
170
249
|
}
|
|
@@ -179,11 +258,14 @@ function fromChatPayload(payload: Record<string, unknown>): {
|
|
|
179
258
|
* workspace has no such endpoint AND the scrape fallback is enabled, falls
|
|
180
259
|
* back to a DuckDuckGo scrape so the tool still returns results instead of
|
|
181
260
|
* erroring. Citations are filtered through the configured URL allow-list.
|
|
261
|
+
*
|
|
262
|
+
* `signal` cancels the whole call, including the in-flight serving request.
|
|
182
263
|
*/
|
|
183
264
|
export async function runWebSearch(
|
|
184
265
|
request: WebSearchRequest,
|
|
185
266
|
config: ResolvedWebSearchConfig,
|
|
186
267
|
ctx: WebSearchContext,
|
|
268
|
+
signal?: AbortSignal,
|
|
187
269
|
): Promise<WebSearchResult> {
|
|
188
270
|
const modelId = await resolveWebSearchModel(ctx, config, request.model);
|
|
189
271
|
|
|
@@ -191,20 +273,21 @@ export async function runWebSearch(
|
|
|
191
273
|
// No native web-search model deployed in this workspace.
|
|
192
274
|
if (config.scrapeFallback) {
|
|
193
275
|
logger.info("no-native-model:scrape-fallback", { query: request.query });
|
|
194
|
-
return runScrapeSearch(request, config);
|
|
276
|
+
return runScrapeSearch(request, config, signal);
|
|
195
277
|
}
|
|
196
|
-
throw
|
|
197
|
-
"
|
|
198
|
-
|
|
199
|
-
|
|
278
|
+
throw ConfigurationError.resourceNotFound(
|
|
279
|
+
"Web-search-capable serving endpoint",
|
|
280
|
+
"No GPT/Gemini endpoint is deployed in this workspace and the scrape fallback is " +
|
|
281
|
+
`disabled. Deploy a supported endpoint, set model or ${MODEL_ENV}, or enable the ` +
|
|
282
|
+
"fallback (WEB_SEARCH_SCRAPE_FALLBACK=1).",
|
|
200
283
|
);
|
|
201
284
|
}
|
|
202
285
|
|
|
203
286
|
const provider = detectWebSearchProvider(modelId)!; // guaranteed by resolve step
|
|
204
287
|
const spec = webSearchToolSpec(provider, config.webSearchTools);
|
|
205
288
|
|
|
206
|
-
const
|
|
207
|
-
spec.api === "responses" ?
|
|
289
|
+
const url =
|
|
290
|
+
spec.api === "responses" ? invoke.responsesUrl(ctx.host) : invoke.chatCompletionsUrl(ctx.host);
|
|
208
291
|
const body =
|
|
209
292
|
spec.api === "responses"
|
|
210
293
|
? {
|
|
@@ -218,13 +301,14 @@ export async function runWebSearch(
|
|
|
218
301
|
tools: [spec.tool],
|
|
219
302
|
};
|
|
220
303
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
304
|
+
const payload = await postServing(
|
|
305
|
+
ctx,
|
|
306
|
+
url,
|
|
307
|
+
body,
|
|
308
|
+
config,
|
|
309
|
+
["web-search", "serving", spec.api, modelId, request.query],
|
|
310
|
+
signal,
|
|
311
|
+
);
|
|
228
312
|
|
|
229
313
|
const { answer, citations } =
|
|
230
314
|
spec.api === "responses" ? fromResponsesPayload(payload) : fromChatPayload(payload);
|
package/src/tool.ts
CHANGED
|
@@ -15,12 +15,21 @@
|
|
|
15
15
|
* before the call) treats a pattern gate as "always gate". `approval` falls
|
|
16
16
|
* back to the plugin's `approval` config when a tool omits its own.
|
|
17
17
|
*
|
|
18
|
+
* The same two tools are exposed to AppKit's own agents through the plugin's
|
|
19
|
+
* `ToolProvider` (see `plugin.ts`); this module is the Mastra half.
|
|
20
|
+
*
|
|
18
21
|
* @module
|
|
19
22
|
*/
|
|
20
23
|
|
|
21
|
-
import {
|
|
24
|
+
import { ValidationError } from "@databricks/appkit";
|
|
22
25
|
import { createTool } from "@mastra/core/tools";
|
|
23
|
-
import {
|
|
26
|
+
import type { z } from "zod";
|
|
27
|
+
import {
|
|
28
|
+
approvalMatches,
|
|
29
|
+
toApprovalPolicy,
|
|
30
|
+
type ApprovalGate,
|
|
31
|
+
type ApprovalPolicy,
|
|
32
|
+
} from "./config";
|
|
24
33
|
import { runWebFetch } from "./fetch";
|
|
25
34
|
import { getWebSearchRuntime } from "./runtime";
|
|
26
35
|
import {
|
|
@@ -28,11 +37,25 @@ import {
|
|
|
28
37
|
webFetchResultSchema,
|
|
29
38
|
webSearchRequestSchema,
|
|
30
39
|
webSearchResultSchema,
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
WEB_FETCH_TOOL_DESCRIPTION,
|
|
41
|
+
WEB_SEARCH_TOOL_DESCRIPTION,
|
|
33
42
|
} from "./schema";
|
|
34
43
|
import { resolveWebSearchContext, runWebSearch } from "./search";
|
|
35
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Validate a tool call's arguments at the runtime boundary. Mastra checks the
|
|
47
|
+
* input schema before dispatch, but the argument still arrives typed as
|
|
48
|
+
* `unknown` and the model is the one filling it in. The rejected value is not
|
|
49
|
+
* echoed back.
|
|
50
|
+
*/
|
|
51
|
+
function parseToolInput<S extends z.ZodType>(schema: S, input: unknown): z.infer<S> {
|
|
52
|
+
const parsed = schema.safeParse(input);
|
|
53
|
+
if (!parsed.success) {
|
|
54
|
+
throw ValidationError.invalidValue("input", input, "arguments matching the tool's schema");
|
|
55
|
+
}
|
|
56
|
+
return parsed.data;
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
/** Options shared by both web tools. */
|
|
37
60
|
export interface WebSearchToolOptions {
|
|
38
61
|
/** Override the tool id. */
|
|
@@ -42,12 +65,14 @@ export interface WebSearchToolOptions {
|
|
|
42
65
|
* gates every call; a URL-pattern (or list) gates only matching calls;
|
|
43
66
|
* omit / `false` for no approval. See {@link ApprovalGate}.
|
|
44
67
|
*/
|
|
45
|
-
approval?: ApprovalGate;
|
|
68
|
+
approval?: ApprovalGate | ApprovalPolicy;
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
/** Resolve the effective gate: explicit tool option, else the plugin default. */
|
|
49
|
-
function effectiveGate(opts: WebSearchToolOptions):
|
|
50
|
-
return opts.approval
|
|
72
|
+
function effectiveGate(opts: WebSearchToolOptions): ApprovalPolicy {
|
|
73
|
+
return opts.approval === undefined
|
|
74
|
+
? getWebSearchRuntime().config.approval
|
|
75
|
+
: toApprovalPolicy(opts.approval);
|
|
51
76
|
}
|
|
52
77
|
|
|
53
78
|
/**
|
|
@@ -69,24 +94,16 @@ export function webSearchTool(opts: WebSearchToolOptions = {}) {
|
|
|
69
94
|
const gate = effectiveGate(opts);
|
|
70
95
|
return createTool({
|
|
71
96
|
id: opts.id ?? "web_search",
|
|
72
|
-
description:
|
|
73
|
-
Search the web for current information and get an answer synthesized from
|
|
74
|
-
live results, with the sources it used. Pass a natural-language query;
|
|
75
|
-
the search runs inside a web-search-capable model (chosen independently
|
|
76
|
-
of your own model). Optionally pass a model name to use a specific
|
|
77
|
-
web-search model. Use it whenever a question needs up-to-date or external
|
|
78
|
-
information you don't already have.
|
|
79
|
-
`),
|
|
97
|
+
description: WEB_SEARCH_TOOL_DESCRIPTION,
|
|
80
98
|
inputSchema: webSearchRequestSchema,
|
|
81
99
|
outputSchema: webSearchResultSchema,
|
|
82
100
|
// A search's result URLs aren't known before the call, so a pattern gate
|
|
83
|
-
// is treated as "always gate"
|
|
84
|
-
...(gate ===
|
|
85
|
-
? {}
|
|
86
|
-
: { requireApproval: () => (typeof gate === "boolean" ? gate : true) }),
|
|
101
|
+
// is treated as "always gate".
|
|
102
|
+
...(gate.mode === "none" ? {} : { requireApproval: () => true }),
|
|
87
103
|
execute: async (input) => {
|
|
88
104
|
const { config } = getWebSearchRuntime();
|
|
89
|
-
|
|
105
|
+
const request = parseToolInput(webSearchRequestSchema, input);
|
|
106
|
+
return runWebSearch(request, config, await resolveWebSearchContext());
|
|
90
107
|
},
|
|
91
108
|
});
|
|
92
109
|
}
|
|
@@ -106,26 +123,21 @@ export function webFetchTool(opts: WebSearchToolOptions = {}) {
|
|
|
106
123
|
const gate = effectiveGate(opts);
|
|
107
124
|
return createTool({
|
|
108
125
|
id: opts.id ?? "web_fetch",
|
|
109
|
-
description:
|
|
110
|
-
Fetch a single web page and return its readable contents. Pass an
|
|
111
|
-
absolute URL (including https://); set format to "html" for raw markup
|
|
112
|
-
instead of extracted text. Use it to read a page returned by web_search
|
|
113
|
-
or provided by the user. Content is length-capped; fetching a URL outside
|
|
114
|
-
the configured allow-list is refused.
|
|
115
|
-
`),
|
|
126
|
+
description: WEB_FETCH_TOOL_DESCRIPTION,
|
|
116
127
|
inputSchema: webFetchRequestSchema,
|
|
117
128
|
outputSchema: webFetchResultSchema,
|
|
118
129
|
// A fetch knows its single target URL, so a pattern gate is evaluated
|
|
119
|
-
// precisely against it
|
|
120
|
-
...(gate ===
|
|
130
|
+
// precisely against it.
|
|
131
|
+
...(gate.mode === "none"
|
|
121
132
|
? {}
|
|
122
133
|
: {
|
|
123
134
|
requireApproval: (input: unknown) =>
|
|
124
|
-
|
|
135
|
+
gate.mode === "always" ||
|
|
136
|
+
approvalMatches(gate, [parseToolInput(webFetchRequestSchema, input).url]),
|
|
125
137
|
}),
|
|
126
138
|
execute: async (input) => {
|
|
127
139
|
const { config } = getWebSearchRuntime();
|
|
128
|
-
return runWebFetch(input
|
|
140
|
+
return runWebFetch(parseToolInput(webFetchRequestSchema, input), config);
|
|
129
141
|
},
|
|
130
142
|
});
|
|
131
143
|
}
|
package/test/allowlist.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
parseAllowedUrls,
|
|
7
7
|
toUrlAllowList,
|
|
8
8
|
} from "../src/allowlist";
|
|
9
|
-
import { approvalMatches, resolveWebSearchConfig } from "../src/config";
|
|
9
|
+
import { approvalMatches, resolveWebSearchConfig, toApprovalPolicy } from "../src/config";
|
|
10
10
|
import { htmlToText } from "../src/html-text";
|
|
11
11
|
import {
|
|
12
12
|
detectWebSearchProvider,
|
|
@@ -54,7 +54,7 @@ describe("web-search allow-list", () => {
|
|
|
54
54
|
|
|
55
55
|
it("assertUrlAllowed throws for a disallowed URL, passes an allowed one", () => {
|
|
56
56
|
const list = toUrlAllowList(parseAllowedUrls(["databricks.com"]));
|
|
57
|
-
assert.throws(() => assertUrlAllowed("https://evil.example.com/", list), /
|
|
57
|
+
assert.throws(() => assertUrlAllowed("https://evil.example.com/", list), /allow-list/);
|
|
58
58
|
assert.doesNotThrow(() => assertUrlAllowed("https://databricks.com/", list));
|
|
59
59
|
});
|
|
60
60
|
});
|
|
@@ -97,8 +97,9 @@ describe("web-search provider detection", () => {
|
|
|
97
97
|
describe("web-search config", () => {
|
|
98
98
|
it("defaults model fallbacks to Gemini-then-GPT and approval to none", () => {
|
|
99
99
|
const c = resolveWebSearchConfig();
|
|
100
|
-
assert.
|
|
100
|
+
assert.deepEqual(c.approval, { mode: "none" });
|
|
101
101
|
assert.equal(c.model, undefined);
|
|
102
|
+
assert.equal(c.modelSource, "none");
|
|
102
103
|
assert.match(c.modelFallbacks[0]!, /gemini/);
|
|
103
104
|
assert.ok(c.modelFallbacks.some((m) => m.includes("gpt")));
|
|
104
105
|
});
|
|
@@ -112,6 +113,71 @@ describe("web-search config", () => {
|
|
|
112
113
|
assert.equal(resolveWebSearchConfig().scrapeFallback, true);
|
|
113
114
|
assert.equal(resolveWebSearchConfig({ scrapeFallback: false }).scrapeFallback, false);
|
|
114
115
|
});
|
|
116
|
+
|
|
117
|
+
it("records where a pinned endpoint came from", () => {
|
|
118
|
+
assert.equal(resolveWebSearchConfig({ model: "gemini" }).modelSource, "config");
|
|
119
|
+
process.env.DATABRICKS_SERVING_ENDPOINT_NAME = "databricks-claude-sonnet-4-6";
|
|
120
|
+
try {
|
|
121
|
+
const c = resolveWebSearchConfig();
|
|
122
|
+
assert.equal(c.model, "databricks-claude-sonnet-4-6");
|
|
123
|
+
assert.equal(c.modelSource, "DATABRICKS_SERVING_ENDPOINT_NAME");
|
|
124
|
+
// The dedicated override wins over the shared resource binding.
|
|
125
|
+
assert.equal(resolveWebSearchConfig({ model: "gemini" }).modelSource, "config");
|
|
126
|
+
} finally {
|
|
127
|
+
delete process.env.DATABRICKS_SERVING_ENDPOINT_NAME;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("names the URL policy, defaulting from whether an allow-list was given", () => {
|
|
132
|
+
assert.equal(resolveWebSearchConfig().urlPolicy, "unrestricted");
|
|
133
|
+
assert.equal(resolveWebSearchConfig().allowList.restricted, false);
|
|
134
|
+
const restricted = resolveWebSearchConfig({ allowedUrls: ["databricks.com"] });
|
|
135
|
+
assert.equal(restricted.urlPolicy, "allowlist");
|
|
136
|
+
assert.equal(restricted.allowList.restricted, true);
|
|
137
|
+
assert.equal(
|
|
138
|
+
resolveWebSearchConfig({ urlPolicy: "allowlist", allowedUrls: ["databricks.com"] }).urlPolicy,
|
|
139
|
+
"allowlist",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("fails loudly on a URL policy that contradicts its allow-list", () => {
|
|
144
|
+
assert.throws(() => resolveWebSearchConfig({ urlPolicy: "allowlist" }), /allowedUrls/);
|
|
145
|
+
assert.throws(
|
|
146
|
+
() => resolveWebSearchConfig({ urlPolicy: "unrestricted", allowedUrls: ["databricks.com"] }),
|
|
147
|
+
/allowedUrls/,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("fails loudly on a WEB_SEARCH_TOOLS value that is not a JSON object", () => {
|
|
152
|
+
process.env.WEB_SEARCH_TOOLS = "not-json";
|
|
153
|
+
try {
|
|
154
|
+
assert.throws(() => resolveWebSearchConfig(), /WEB_SEARCH_TOOLS/);
|
|
155
|
+
} finally {
|
|
156
|
+
delete process.env.WEB_SEARCH_TOOLS;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rejects a webSearchTools override that is not a tool spec", () => {
|
|
161
|
+
assert.throws(() => webSearchToolSpec("gemini", { gemini: "nope" }), /webSearchTools.gemini/);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("web-search approval policy", () => {
|
|
166
|
+
it("normalizes every accepted spelling", () => {
|
|
167
|
+
assert.deepEqual(toApprovalPolicy(undefined), { mode: "none" });
|
|
168
|
+
assert.deepEqual(toApprovalPolicy(false), { mode: "none" });
|
|
169
|
+
assert.deepEqual(toApprovalPolicy(true), { mode: "always" });
|
|
170
|
+
assert.deepEqual(toApprovalPolicy("*.internal.example.com"), {
|
|
171
|
+
mode: "urls",
|
|
172
|
+
patterns: ["*.internal.example.com"],
|
|
173
|
+
});
|
|
174
|
+
assert.deepEqual(toApprovalPolicy({ mode: "always" }), { mode: "always" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("collapses an empty pattern list to no approval", () => {
|
|
178
|
+
assert.deepEqual(toApprovalPolicy(" "), { mode: "none" });
|
|
179
|
+
assert.deepEqual(toApprovalPolicy({ mode: "urls", patterns: [] }), { mode: "none" });
|
|
180
|
+
});
|
|
115
181
|
});
|
|
116
182
|
|
|
117
183
|
describe("web-search approval gate", () => {
|