@dbx-tools/appkit-web-search 0.3.28 → 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/src/runtime.ts CHANGED
@@ -5,23 +5,64 @@
5
5
  * plugin at setup) primes it from the plugin's config; later callers (the
6
6
  * tools' `execute`) reuse it.
7
7
  *
8
+ * The runtime also carries the {@link WebSearchExecutor} every outbound call
9
+ * runs through. The plugin installs its own `execute()` there at setup, which
10
+ * is how the tools - plain functions with no plugin instance in scope - still
11
+ * get AppKit's cache / retry / timeout / telemetry chain. Without a
12
+ * registered plugin (a direct call from a script or a test) the calls still
13
+ * run, just without interceptors.
14
+ *
8
15
  * Unlike the email runtime there is no connection to pool - the backend is
9
- * stateless HTTP per call - so the runtime holds only the resolved config.
16
+ * stateless HTTP per call - so the runtime holds only the resolved config and
17
+ * that executor.
10
18
  *
11
19
  * @module
12
20
  */
13
21
 
22
+ import { AppKitError, ExecutionError, type ExecutionResult } from "@databricks/appkit";
23
+ import { async, error, log } from "@dbx-tools/shared-core";
14
24
  import {
15
25
  resolveWebSearchConfig,
16
26
  type ResolvedWebSearchConfig,
17
27
  type WebSearchPluginConfig,
18
28
  } from "./config";
29
+ import type { WebSearchExecutionSettings } from "./defaults";
30
+
31
+ const logger = log.logger("web-search/runtime");
32
+
33
+ /**
34
+ * Runs one outbound call through AppKit's interceptor chain. Matches
35
+ * `Plugin.execute()`, which never throws: a failure comes back as
36
+ * `{ ok: false }`.
37
+ */
38
+ export type WebSearchExecutor = <T>(
39
+ fn: (signal?: AbortSignal) => Promise<T>,
40
+ settings: WebSearchExecutionSettings,
41
+ ) => Promise<ExecutionResult<T>>;
19
42
 
20
- /** The shared resolved config. */
43
+ /** The shared resolved config plus the executor outbound calls run through. */
21
44
  export interface WebSearchRuntime {
22
45
  config: ResolvedWebSearchConfig;
46
+ execute: WebSearchExecutor;
23
47
  }
24
48
 
49
+ /**
50
+ * Executor used until (or unless) the plugin installs its own: run the call
51
+ * directly, mapping a throw onto the same {@link ExecutionResult} shape so
52
+ * call sites branch on `ok` either way.
53
+ */
54
+ const directExecute: WebSearchExecutor = async (fn) => {
55
+ try {
56
+ return { ok: true, data: await fn() };
57
+ } catch (err) {
58
+ return {
59
+ ok: false,
60
+ status: err instanceof AppKitError ? err.statusCode : 500,
61
+ message: error.errorMessage(err),
62
+ };
63
+ }
64
+ };
65
+
25
66
  let runtime: WebSearchRuntime | undefined;
26
67
 
27
68
  /**
@@ -33,12 +74,55 @@ let runtime: WebSearchRuntime | undefined;
33
74
  */
34
75
  export function getWebSearchRuntime(overrides?: WebSearchPluginConfig): WebSearchRuntime {
35
76
  if (!runtime) {
36
- runtime = { config: resolveWebSearchConfig(overrides) };
77
+ runtime = { config: resolveWebSearchConfig(overrides), execute: directExecute };
37
78
  }
38
79
  return runtime;
39
80
  }
40
81
 
82
+ /**
83
+ * Install the executor outbound calls run through. The plugin calls this at
84
+ * setup with its own `execute()`; a second call replaces the previous one, so
85
+ * a re-registered plugin does not leave the tools bound to a dead instance.
86
+ */
87
+ export function setWebSearchExecutor(execute: WebSearchExecutor): void {
88
+ getWebSearchRuntime().execute = execute;
89
+ }
90
+
41
91
  /** Drop the memoized runtime so the next {@link getWebSearchRuntime} rebuilds it. */
42
92
  export function resetWebSearchRuntime(): void {
43
93
  runtime = undefined;
44
94
  }
95
+
96
+ /**
97
+ * Run one idempotent read through the shared executor and unwrap it.
98
+ *
99
+ * `execute()` never throws, so a failed call arrives as `{ ok: false }` with
100
+ * a status the interceptors already sanitized; it is logged here and re-raised
101
+ * as a stable {@link ExecutionError} so an upstream message never becomes the
102
+ * caller's error text. `signal` is the caller's own cancellation (an agent
103
+ * run, a request teardown); it is merged with the signal the timeout
104
+ * interceptor supplies so either one unwinds the I/O.
105
+ */
106
+ export async function executeRead<T>(
107
+ operation: string,
108
+ settings: WebSearchExecutionSettings,
109
+ fn: (signal?: AbortSignal) => Promise<T>,
110
+ signal?: AbortSignal,
111
+ ): Promise<T> {
112
+ const { execute } = getWebSearchRuntime();
113
+ const result = await execute(
114
+ (executeSignal) => fn(async.combineAbortSignals(executeSignal, signal)),
115
+ settings,
116
+ );
117
+ if (result.ok) return result.data;
118
+ // A caller that cancelled is not a failure worth reporting as one.
119
+ if (signal?.aborted) throw ExecutionError.canceled();
120
+ logger.warn("execution-failed", {
121
+ operation,
122
+ status: result.status,
123
+ error: result.message,
124
+ });
125
+ throw new ExecutionError(`web-search: ${operation} failed`, {
126
+ context: { operation, status: result.status },
127
+ });
128
+ }
package/src/schema.ts CHANGED
@@ -20,6 +20,28 @@
20
20
  import { string } from "@dbx-tools/shared-core";
21
21
  import { z } from "zod";
22
22
 
23
+ /**
24
+ * Description the model reads for `web_search`. Shared by the Mastra tool and
25
+ * the AppKit tool provider so both hosts describe the tool identically.
26
+ */
27
+ export const WEB_SEARCH_TOOL_DESCRIPTION = string.toDescription(`
28
+ Search the web for current information and get an answer synthesized from
29
+ live results, with the sources it used. Pass a natural-language query;
30
+ the search runs inside a web-search-capable model (chosen independently
31
+ of your own model). Optionally pass a model name to use a specific
32
+ web-search model. Use it whenever a question needs up-to-date or external
33
+ information you don't already have.
34
+ `);
35
+
36
+ /** Description the model reads for `web_fetch`. Shared like {@link WEB_SEARCH_TOOL_DESCRIPTION}. */
37
+ export const WEB_FETCH_TOOL_DESCRIPTION = string.toDescription(`
38
+ Fetch a single web page and return its readable contents. Pass an
39
+ absolute URL (including https://); set format to "html" for raw markup
40
+ instead of extracted text. Use it to read a page returned by web_search
41
+ or provided by the user. Content is length-capped; fetching a URL outside
42
+ the configured allow-list is refused.
43
+ `);
44
+
23
45
  /** Schema for the `web_search` tool input. */
24
46
  export const webSearchRequestSchema = z.object({
25
47
  query: z
package/src/scrape.ts CHANGED
@@ -21,6 +21,9 @@
21
21
  import { log } from "@dbx-tools/shared-core";
22
22
  import { gotScraping } from "got-scraping";
23
23
  import type { ResolvedWebSearchConfig } from "./config";
24
+ import { scrapeSearchExecuteDefaults, toCallSettings } from "./defaults";
25
+ import { htmlFragmentToText } from "./html-text";
26
+ import { executeRead } from "./runtime";
24
27
  import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
25
28
 
26
29
  const logger = log.logger("web-search/scrape");
@@ -28,20 +31,6 @@ const logger = log.logger("web-search/scrape");
28
31
  /** DuckDuckGo's no-JS HTML results endpoint (queried via GET). */
29
32
  const DDG_HTML_URL = "https://html.duckduckgo.com/html/";
30
33
 
31
- /** Strip HTML tags and decode the few entities DDG emits in titles/snippets. */
32
- function stripTags(html: string): string {
33
- return html
34
- .replace(/<[^>]+>/g, "")
35
- .replace(/&amp;/g, "&")
36
- .replace(/&lt;/g, "<")
37
- .replace(/&gt;/g, ">")
38
- .replace(/&quot;/g, '"')
39
- .replace(/&#x27;|&#39;/g, "'")
40
- .replace(/&nbsp;/g, " ")
41
- .replace(/\s+/g, " ")
42
- .trim();
43
- }
44
-
45
34
  /**
46
35
  * DDG wraps result URLs in a redirect (`//duckduckgo.com/l/?uddg=<encoded>`).
47
36
  * Unwrap to the real destination; leave already-absolute URLs untouched.
@@ -66,12 +55,12 @@ function parseDdgHtml(html: string): WebSearchCitation[] {
66
55
  const snippetRe = /<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/g;
67
56
  const snippets: string[] = [];
68
57
  let sm: RegExpExecArray | null;
69
- while ((sm = snippetRe.exec(html)) !== null) snippets.push(stripTags(sm[1] ?? ""));
58
+ while ((sm = snippetRe.exec(html)) !== null) snippets.push(htmlFragmentToText(sm[1] ?? ""));
70
59
  let am: RegExpExecArray | null;
71
60
  let i = 0;
72
61
  while ((am = anchorRe.exec(html)) !== null) {
73
62
  const url = unwrapDdgUrl(am[1] ?? "");
74
- const title = stripTags(am[2] ?? "");
63
+ const title = htmlFragmentToText(am[2] ?? "");
75
64
  if (!url || !title) continue;
76
65
  const snippet = snippets[i] ?? "";
77
66
  citations.push({ url, title, ...(snippet ? { snippet } : {}) });
@@ -84,21 +73,40 @@ function parseDdgHtml(html: string): WebSearchCitation[] {
84
73
  * Run a scraping search over DuckDuckGo. Returns the same
85
74
  * {@link WebSearchResult} shape as the native path, with `model` set to
86
75
  * `"scrape:duckduckgo"` so callers can tell how the result was produced.
87
- * Citations are filtered through the configured URL allow-list.
76
+ * Citations are filtered through the configured URL allow-list. `signal`
77
+ * cancels the in-flight request.
88
78
  */
89
79
  export async function runScrapeSearch(
90
80
  request: WebSearchRequest,
91
81
  config: ResolvedWebSearchConfig,
82
+ signal?: AbortSignal,
92
83
  ): Promise<WebSearchResult> {
93
- const response = await gotScraping({
94
- url: `${DDG_HTML_URL}?q=${encodeURIComponent(request.query)}`,
95
- method: "GET",
96
- timeout: { request: config.timeoutMs },
97
- throwHttpErrors: false,
98
- followRedirect: true,
99
- });
100
- const body = typeof response.body === "string" ? response.body : String(response.body ?? "");
101
- const all = parseDdgHtml(body);
84
+ const page = await executeRead(
85
+ "scrape-search",
86
+ toCallSettings(scrapeSearchExecuteDefaults, config.timeoutMs, [
87
+ "web-search",
88
+ "scrape",
89
+ request.query,
90
+ ]),
91
+ async (executeSignal): Promise<{ body: string; statusCode: number }> => {
92
+ const response = await gotScraping({
93
+ url: `${DDG_HTML_URL}?q=${encodeURIComponent(request.query)}`,
94
+ method: "GET",
95
+ // got's own timeout aborts the socket and reports which phase timed
96
+ // out; the interceptor timeout bounds the whole attempt around it.
97
+ timeout: { request: config.timeoutMs },
98
+ throwHttpErrors: false,
99
+ followRedirect: true,
100
+ ...(executeSignal ? { signal: executeSignal } : {}),
101
+ });
102
+ return {
103
+ body: typeof response.body === "string" ? response.body : String(response.body ?? ""),
104
+ statusCode: response.statusCode,
105
+ };
106
+ },
107
+ signal,
108
+ );
109
+ const all = parseDdgHtml(page.body);
102
110
  const permitted = all.filter((c) => config.allowList.allows(c.url));
103
111
  const citations = permitted.slice(0, config.maxCitations);
104
112
  const answer =
@@ -112,7 +120,7 @@ export async function runScrapeSearch(
112
120
  query: request.query,
113
121
  found: all.length,
114
122
  returned: citations.length,
115
- status: response.statusCode,
123
+ status: page.statusCode,
116
124
  });
117
125
  return { query: request.query, answer, citations, model: "scrape:duckduckgo" };
118
126
  }
package/src/search.ts CHANGED
@@ -10,22 +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 in the workspace. An explicit
14
- * but unsupported model is a hard error, not a silent fallback.
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, using the OBO-scoped workspace client.
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 { resolve, serving } from "@dbx-tools/model";
25
- import { error, log } from "@dbx-tools/shared-core";
26
- import { openaiResponses } from "@dbx-tools/shared-model";
27
- import type { ResolvedWebSearchConfig } from "./config";
24
+ import {
25
+ ConfigurationError,
26
+ ConnectionError,
27
+ ExecutionError,
28
+ getExecutionContext,
29
+ ValidationError,
30
+ } from "@databricks/appkit";
31
+ import { invoke, resolve, serving } from "@dbx-tools/model";
32
+ import { log, object, string } from "@dbx-tools/shared-core";
33
+ import { openaiChat, openaiResponses } from "@dbx-tools/shared-model";
34
+ import { MODEL_ENV, SERVING_ENDPOINT_ENV, type ResolvedWebSearchConfig } from "./config";
35
+ import { toCallSettings, webSearchExecuteDefaults } from "./defaults";
28
36
  import { detectWebSearchProvider, supportsWebSearch, webSearchToolSpec } from "./provider";
37
+ import { executeRead } from "./runtime";
29
38
  import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
30
39
  import { runScrapeSearch } from "./scrape";
31
40
 
@@ -34,12 +43,38 @@ const logger = log.logger("web-search/search");
34
43
  const { resolveModel } = resolve;
35
44
  const { listServingEndpoints } = serving;
36
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
+
37
60
  /** Context a search needs from the caller: the OBO client + workspace host. */
38
61
  export interface WebSearchContext {
39
62
  client: WorkspaceClientLike;
40
63
  host: string;
41
64
  }
42
65
 
66
+ /**
67
+ * Resolve the OBO workspace client + host from the active AppKit execution
68
+ * context. Inside `agent.stream`'s `asUser(req)` scope this hits the serving
69
+ * endpoint as the requesting user; outside a user context AppKit falls back to
70
+ * the service principal.
71
+ */
72
+ export async function resolveWebSearchContext(): Promise<WebSearchContext> {
73
+ const ctx = getExecutionContext();
74
+ const host = (await ctx.client.config.getHost()).toString();
75
+ return { client: ctx.client, host };
76
+ }
77
+
43
78
  /**
44
79
  * Resolve a web-search-capable model against the LIVE workspace catalogue - so
45
80
  * we never return an endpoint id that isn't actually deployed (the "endpoint
@@ -51,9 +86,13 @@ export interface WebSearchContext {
51
86
  *
52
87
  * Returns the chosen endpoint id, or `null` when the workspace has no
53
88
  * web-search-capable model deployed (the caller then uses the scrape
54
- * fallback). An explicit request that resolves to an unsupported / absent
55
- * model throws, so a deliberate bad pin surfaces rather than silently
56
- * degrading.
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.
57
96
  */
58
97
  async function resolveWebSearchModel(
59
98
  ctx: WebSearchContext,
@@ -64,6 +103,8 @@ async function resolveWebSearchModel(
64
103
  // Only deployed, web-search-capable endpoints are candidates.
65
104
  const capable = endpoints.filter((e) => supportsWebSearch(e.name));
66
105
  const pinned = requested ?? config.model;
106
+ const pinIsDeliberate =
107
+ requested !== undefined || config.modelSource === "config" || config.modelSource === MODEL_ENV;
67
108
 
68
109
  if (pinned) {
69
110
  // Resolve the explicit ask within the capable set only.
@@ -74,19 +115,33 @@ async function resolveWebSearchModel(
74
115
  });
75
116
  // resolveModel returns the input verbatim on no match; require it to be a
76
117
  // real capable endpoint so a bad pin is a clear error, not a phantom call.
77
- if (!capable.some((e) => e.name === modelId) || !supportsWebSearch(modelId)) {
78
- throw new Error(
79
- `web-search: requested model "${pinned}" is not a deployed web-search-capable endpoint. ` +
80
- `Deployed GPT/Gemini endpoints: [${capable.map((e) => e.name).join(", ") || "none"}].`,
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.`,
81
136
  );
82
137
  }
83
- return modelId;
84
138
  }
85
139
 
86
140
  if (capable.length === 0) return null;
87
141
 
88
- // Nothing pinned: prefer the configured fallback order (Gemini, then GPT)
89
- // when those ids are actually deployed; else take the best capable endpoint.
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.
90
145
  const { modelId } = resolveModel(capable, {
91
146
  fallbacks: config.modelFallbacks,
92
147
  fuzzy: config.fuzzy,
@@ -95,29 +150,61 @@ async function resolveWebSearchModel(
95
150
  return capable.some((e) => e.name === modelId) ? modelId : (capable[0]?.name ?? null);
96
151
  }
97
152
 
98
- /** POST a serving request through the OBO client and return the parsed JSON. */
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
+ */
99
167
  async function postServing(
100
168
  ctx: WebSearchContext,
101
- path: string,
169
+ url: string,
102
170
  body: unknown,
171
+ config: ResolvedWebSearchConfig,
172
+ cacheKey: readonly (string | number)[],
173
+ signal?: AbortSignal,
103
174
  ): Promise<Record<string, unknown>> {
104
- const res = await ctx.client.apiClient.request({
105
- path,
106
- method: "POST",
107
- headers: new Headers({ Accept: "application/json", "Content-Type": "application/json" }),
108
- raw: false,
109
- payload: body,
110
- });
111
- return (res ?? {}) as Record<string, unknown>;
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 : {};
112
204
  }
113
205
 
114
206
  /* --------------------------- response extraction --------------------------- */
115
207
 
116
- /** Coerce an unknown to a trimmed string, or "" . */
117
- function str(v: unknown): string {
118
- return typeof v === "string" ? v.trim() : "";
119
- }
120
-
121
208
  /**
122
209
  * Extract answer text + citations from an OpenAI Responses API payload, via the
123
210
  * shared reader in `@dbx-tools/shared-model` (the same module the model-proxy
@@ -141,21 +228,22 @@ function fromChatPayload(payload: Record<string, unknown>): {
141
228
  answer: string;
142
229
  citations: WebSearchCitation[];
143
230
  } {
144
- const choices = Array.isArray(payload.choices) ? (payload.choices as unknown[]) : [];
145
- const message = (choices[0] as { message?: Record<string, unknown> })?.message ?? {};
146
- const answer = str(message.content);
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 : {};
234
+ const answer = openaiChat.chatContentToText(message.content);
147
235
  const citations: WebSearchCitation[] = [];
148
236
  // Best-effort grounding extraction: walk any nested object for {uri|url,title}.
149
237
  const seen = new Set<string>();
150
238
  const visit = (v: unknown, depth: number): void => {
151
- if (depth > 6 || v === null || typeof v !== "object") return;
152
- const o = v as Record<string, unknown>;
153
- const url = str(o.url) || str(o.uri);
239
+ if (depth > MAX_GROUNDING_WALK_DEPTH || !object.isRecord(v)) return;
240
+ const url = string.trimToEmpty(v.url) || string.trimToEmpty(v.uri);
154
241
  if (url && !seen.has(url)) {
155
242
  seen.add(url);
156
- citations.push({ url, ...(str(o.title) ? { title: str(o.title) } : {}) });
243
+ const title = string.trimToEmpty(v.title);
244
+ citations.push({ url, ...(title ? { title } : {}) });
157
245
  }
158
- for (const val of Object.values(o)) {
246
+ for (const val of Object.values(v)) {
159
247
  if (Array.isArray(val)) val.forEach((x) => visit(x, depth + 1));
160
248
  else if (val && typeof val === "object") visit(val, depth + 1);
161
249
  }
@@ -170,11 +258,14 @@ function fromChatPayload(payload: Record<string, unknown>): {
170
258
  * workspace has no such endpoint AND the scrape fallback is enabled, falls
171
259
  * back to a DuckDuckGo scrape so the tool still returns results instead of
172
260
  * erroring. Citations are filtered through the configured URL allow-list.
261
+ *
262
+ * `signal` cancels the whole call, including the in-flight serving request.
173
263
  */
174
264
  export async function runWebSearch(
175
265
  request: WebSearchRequest,
176
266
  config: ResolvedWebSearchConfig,
177
267
  ctx: WebSearchContext,
268
+ signal?: AbortSignal,
178
269
  ): Promise<WebSearchResult> {
179
270
  const modelId = await resolveWebSearchModel(ctx, config, request.model);
180
271
 
@@ -182,22 +273,21 @@ export async function runWebSearch(
182
273
  // No native web-search model deployed in this workspace.
183
274
  if (config.scrapeFallback) {
184
275
  logger.info("no-native-model:scrape-fallback", { query: request.query });
185
- return runScrapeSearch(request, config);
276
+ return runScrapeSearch(request, config, signal);
186
277
  }
187
- throw new Error(
188
- "web-search: no web-search-capable model (GPT/Gemini) is deployed in this workspace, " +
189
- "and the scrape fallback is disabled. Deploy a supported endpoint, set `model` / " +
190
- "WEB_SEARCH_MODEL, or enable the fallback (WEB_SEARCH_SCRAPE_FALLBACK=1).",
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).",
191
283
  );
192
284
  }
193
285
 
194
286
  const provider = detectWebSearchProvider(modelId)!; // guaranteed by resolve step
195
287
  const spec = webSearchToolSpec(provider, config.webSearchTools);
196
288
 
197
- const path =
198
- spec.api === "responses"
199
- ? "/serving-endpoints/responses"
200
- : "/serving-endpoints/chat/completions";
289
+ const url =
290
+ spec.api === "responses" ? invoke.responsesUrl(ctx.host) : invoke.chatCompletionsUrl(ctx.host);
201
291
  const body =
202
292
  spec.api === "responses"
203
293
  ? {
@@ -211,13 +301,14 @@ export async function runWebSearch(
211
301
  tools: [spec.tool],
212
302
  };
213
303
 
214
- let payload: Record<string, unknown>;
215
- try {
216
- payload = await postServing(ctx, path, body);
217
- } catch (err) {
218
- logger.warn("serving-error", { model: modelId, provider, error: error.errorMessage(err) });
219
- throw err;
220
- }
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
+ );
221
312
 
222
313
  const { answer, citations } =
223
314
  spec.api === "responses" ? fromResponsesPayload(payload) : fromChatPayload(payload);