@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/README.md +128 -30
- package/index.ts +5 -2
- package/package.json +6 -6
- package/src/allowlist.ts +7 -12
- package/src/config.ts +226 -56
- package/src/defaults.ts +127 -0
- package/src/fetch.ts +49 -54
- package/src/html-text.ts +72 -0
- package/src/plugin.ts +219 -33
- package/src/provider.ts +28 -5
- package/src/runtime.ts +87 -3
- package/src/schema.ts +22 -0
- package/src/scrape.ts +35 -27
- package/src/search.ts +147 -56
- package/src/tool.ts +44 -45
- package/test/allowlist.test.ts +70 -4
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(/&/g, "&")
|
|
36
|
-
.replace(/</g, "<")
|
|
37
|
-
.replace(/>/g, ">")
|
|
38
|
-
.replace(/"/g, '"')
|
|
39
|
-
.replace(/'|'/g, "'")
|
|
40
|
-
.replace(/ /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(
|
|
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 =
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
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
|
|
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 {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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).
|
|
55
|
-
* model
|
|
56
|
-
*
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
89
|
-
// 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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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) ?
|
|
145
|
-
const
|
|
146
|
-
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 : {};
|
|
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 >
|
|
152
|
-
const
|
|
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
|
-
|
|
243
|
+
const title = string.trimToEmpty(v.title);
|
|
244
|
+
citations.push({ url, ...(title ? { title } : {}) });
|
|
157
245
|
}
|
|
158
|
-
for (const val of Object.values(
|
|
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
|
|
188
|
-
"
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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);
|