@dbx-tools/appkit-web-search 0.3.21 → 0.3.23

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/package.json CHANGED
@@ -17,17 +17,17 @@
17
17
  "@mastra/core": "^1.47.0",
18
18
  "got-scraping": "^4.2.1",
19
19
  "zod": "^4.3.6",
20
- "@dbx-tools/shared-core": "0.3.21",
21
- "@dbx-tools/model": "0.3.21",
22
- "@dbx-tools/path": "0.3.21",
23
- "@dbx-tools/shared-model": "0.3.21"
20
+ "@dbx-tools/path": "0.3.23",
21
+ "@dbx-tools/model": "0.3.23",
22
+ "@dbx-tools/shared-core": "0.3.23",
23
+ "@dbx-tools/shared-model": "0.3.23"
24
24
  },
25
25
  "main": "index.ts",
26
26
  "license": "UNLICENSED",
27
27
  "publishConfig": {
28
28
  "access": "public"
29
29
  },
30
- "version": "0.3.21",
30
+ "version": "0.3.23",
31
31
  "types": "index.ts",
32
32
  "type": "module",
33
33
  "exports": {
package/src/allowlist.ts CHANGED
@@ -29,8 +29,8 @@
29
29
  * @module
30
30
  */
31
31
 
32
- import { object } from "@dbx-tools/shared-core";
33
32
  import { match, type PathMatcher } from "@dbx-tools/path";
33
+ import { object } from "@dbx-tools/shared-core";
34
34
 
35
35
  /** A compiled URL allow-list. Build one with {@link toUrlAllowList}. */
36
36
  export interface UrlAllowList {
package/src/config.ts CHANGED
@@ -222,7 +222,7 @@ function resolvePositiveInt(value: number | undefined, envKey: string, fallback:
222
222
 
223
223
  /** Parse the `WEB_SEARCH_TOOLS` env var (JSON), else `{}`. Bad JSON is ignored. */
224
224
  function parseToolsEnv(): Record<string, unknown> {
225
- const raw = process.env["WEB_SEARCH_TOOLS"];
225
+ const raw = process.env.WEB_SEARCH_TOOLS;
226
226
  if (!raw) return {};
227
227
  try {
228
228
  const parsed = JSON.parse(raw) as unknown;
@@ -241,18 +241,23 @@ function parseToolsEnv(): Record<string, unknown> {
241
241
  export function resolveWebSearchConfig(
242
242
  config: WebSearchPluginConfig = {},
243
243
  ): ResolvedWebSearchConfig {
244
- const patterns = parseAllowedUrls(config.allowedUrls ?? process.env["WEB_SEARCH_ALLOWED_URLS"]);
245
- const model = config.model ?? process.env["WEB_SEARCH_MODEL"];
246
- const fallbacks = toStringList(config.modelFallbacks ?? process.env["WEB_SEARCH_MODEL_FALLBACKS"]);
247
- const fuzzyThresholdRaw = config.modelFuzzyThreshold ?? Number(process.env["WEB_SEARCH_FUZZY_THRESHOLD"]);
244
+ const patterns = parseAllowedUrls(config.allowedUrls ?? process.env.WEB_SEARCH_ALLOWED_URLS);
245
+ const model = config.model ?? process.env.WEB_SEARCH_MODEL;
246
+ const fallbacks = toStringList(config.modelFallbacks ?? process.env.WEB_SEARCH_MODEL_FALLBACKS);
247
+ const fuzzyThresholdRaw =
248
+ config.modelFuzzyThreshold ?? Number(process.env.WEB_SEARCH_FUZZY_THRESHOLD);
248
249
  return {
249
250
  ...(model ? { model } : {}),
250
251
  modelFallbacks: fallbacks.length > 0 ? fallbacks : DEFAULT_MODEL_FALLBACKS,
251
252
  webSearchTools: { ...parseToolsEnv(), ...(config.webSearchTools ?? {}) },
252
- fuzzy:
253
- config.modelFuzzyMatch ?? object.toBoolean(process.env["WEB_SEARCH_FUZZY"]) ?? true,
254
- fuzzyThreshold: Number.isFinite(fuzzyThresholdRaw) && fuzzyThresholdRaw ? Number(fuzzyThresholdRaw) : 0.4,
255
- maxCitations: resolvePositiveInt(config.maxCitations, "WEB_SEARCH_MAX_CITATIONS", DEFAULT_MAX_CITATIONS),
253
+ fuzzy: config.modelFuzzyMatch ?? object.toBoolean(process.env.WEB_SEARCH_FUZZY) ?? true,
254
+ fuzzyThreshold:
255
+ Number.isFinite(fuzzyThresholdRaw) && fuzzyThresholdRaw ? Number(fuzzyThresholdRaw) : 0.4,
256
+ maxCitations: resolvePositiveInt(
257
+ config.maxCitations,
258
+ "WEB_SEARCH_MAX_CITATIONS",
259
+ DEFAULT_MAX_CITATIONS,
260
+ ),
256
261
  fetchMaxLength: resolvePositiveInt(
257
262
  config.fetchMaxLength,
258
263
  "WEB_SEARCH_FETCH_MAX_LENGTH",
@@ -260,7 +265,7 @@ export function resolveWebSearchConfig(
260
265
  ),
261
266
  timeoutMs: resolvePositiveInt(config.timeoutMs, "WEB_SEARCH_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
262
267
  scrapeFallback:
263
- config.scrapeFallback ?? object.toBoolean(process.env["WEB_SEARCH_SCRAPE_FALLBACK"]) ?? true,
268
+ config.scrapeFallback ?? object.toBoolean(process.env.WEB_SEARCH_SCRAPE_FALLBACK) ?? true,
264
269
  allowList: toUrlAllowList(patterns),
265
270
  approval: config.approval ?? false,
266
271
  };
package/src/fetch.ts CHANGED
@@ -94,8 +94,7 @@ export async function runWebFetch(
94
94
  const body = typeof response.body === "string" ? response.body : String(response.body ?? "");
95
95
  const contentType = response.headers["content-type"];
96
96
  const isHtml = !contentType || /html|xml/i.test(contentType);
97
- const rawContent =
98
- request.format === "html" || !isHtml ? body : htmlToText(body);
97
+ const rawContent = request.format === "html" || !isHtml ? body : htmlToText(body);
99
98
  const { content, truncated } = truncate(rawContent, cap);
100
99
  const title = isHtml ? extractTitle(body) : undefined;
101
100
 
package/src/runtime.ts CHANGED
@@ -11,7 +11,11 @@
11
11
  * @module
12
12
  */
13
13
 
14
- import { resolveWebSearchConfig, type ResolvedWebSearchConfig, type WebSearchPluginConfig } from "./config";
14
+ import {
15
+ resolveWebSearchConfig,
16
+ type ResolvedWebSearchConfig,
17
+ type WebSearchPluginConfig,
18
+ } from "./config";
15
19
 
16
20
  /** The shared resolved config. */
17
21
  export interface WebSearchRuntime {
package/src/schema.ts CHANGED
@@ -58,9 +58,7 @@ export type WebSearchCitation = z.infer<typeof webSearchCitationSchema>;
58
58
  /** Schema for the `web_search` tool output. */
59
59
  export const webSearchResultSchema = z.object({
60
60
  query: z.string().describe("Echo of the query that was searched."),
61
- answer: z
62
- .string()
63
- .describe("The model's answer, synthesized from live web results."),
61
+ answer: z.string().describe("The model's answer, synthesized from live web results."),
64
62
  citations: z
65
63
  .array(webSearchCitationSchema)
66
64
  .describe(
package/src/scrape.ts CHANGED
@@ -104,7 +104,9 @@ export async function runScrapeSearch(
104
104
  const answer =
105
105
  citations.length > 0
106
106
  ? `Web results for "${request.query}" (no Databricks web-search model is deployed in this workspace, so these are unsynthesized search results - read the citations):\n\n` +
107
- citations.map((c, n) => `${n + 1}. ${c.title}${c.snippet ? ` - ${c.snippet}` : ""} (${c.url})`).join("\n")
107
+ citations
108
+ .map((c, n) => `${n + 1}. ${c.title}${c.snippet ? ` - ${c.snippet}` : ""} (${c.url})`)
109
+ .join("\n")
108
110
  : `No web results found for "${request.query}".`;
109
111
  logger.debug("scraped", {
110
112
  query: request.query,
package/src/search.ts CHANGED
@@ -21,8 +21,9 @@
21
21
  * @module
22
22
  */
23
23
 
24
- import { error, log } from "@dbx-tools/shared-core";
25
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";
26
27
  import type { ResolvedWebSearchConfig } from "./config";
27
28
  import { detectWebSearchProvider, supportsWebSearch, webSearchToolSpec } from "./provider";
28
29
  import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
@@ -118,36 +119,16 @@ function str(v: unknown): string {
118
119
  }
119
120
 
120
121
  /**
121
- * Extract answer text + citations from an OpenAI Responses API payload. The
122
- * Responses API returns an `output` array of items; message items carry
123
- * `content` parts with `text` and optional `annotations` (url_citation).
124
- * `output_text` is the convenience aggregate when present.
122
+ * Extract answer text + citations from an OpenAI Responses API payload, via the
123
+ * shared reader in `@dbx-tools/shared-model` (the same module the model-proxy
124
+ * uses to translate the Responses wire format in the other direction).
125
125
  */
126
126
  function fromResponsesPayload(payload: Record<string, unknown>): {
127
127
  answer: string;
128
128
  citations: WebSearchCitation[];
129
129
  } {
130
- const citations: WebSearchCitation[] = [];
131
- const texts: string[] = [];
132
- const output = Array.isArray(payload["output"]) ? (payload["output"] as unknown[]) : [];
133
- for (const item of output) {
134
- const content = (item as { content?: unknown }).content;
135
- if (!Array.isArray(content)) continue;
136
- for (const part of content) {
137
- const p = part as { text?: unknown; annotations?: unknown };
138
- const text = str(p.text);
139
- if (text) texts.push(text);
140
- if (Array.isArray(p.annotations)) {
141
- for (const ann of p.annotations) {
142
- const a = ann as { url?: unknown; title?: unknown };
143
- const url = str(a.url);
144
- if (url) citations.push({ url, ...(str(a.title) ? { title: str(a.title) } : {}) });
145
- }
146
- }
147
- }
148
- }
149
- const answer = str(payload["output_text"]) || texts.join("\n").trim();
150
- return { answer, citations };
130
+ const { text, citations } = openaiResponses.readResponsesOutput(payload);
131
+ return { answer: text, citations };
151
132
  }
152
133
 
153
134
  /**
@@ -160,26 +141,26 @@ function fromChatPayload(payload: Record<string, unknown>): {
160
141
  answer: string;
161
142
  citations: WebSearchCitation[];
162
143
  } {
163
- const choices = Array.isArray(payload["choices"]) ? (payload["choices"] as unknown[]) : [];
144
+ const choices = Array.isArray(payload.choices) ? (payload.choices as unknown[]) : [];
164
145
  const message = (choices[0] as { message?: Record<string, unknown> })?.message ?? {};
165
- const answer = str(message["content"]);
146
+ const answer = str(message.content);
166
147
  const citations: WebSearchCitation[] = [];
167
148
  // Best-effort grounding extraction: walk any nested object for {uri|url,title}.
168
149
  const seen = new Set<string>();
169
150
  const visit = (v: unknown, depth: number): void => {
170
151
  if (depth > 6 || v === null || typeof v !== "object") return;
171
152
  const o = v as Record<string, unknown>;
172
- const url = str(o["url"]) || str(o["uri"]);
153
+ const url = str(o.url) || str(o.uri);
173
154
  if (url && !seen.has(url)) {
174
155
  seen.add(url);
175
- citations.push({ url, ...(str(o["title"]) ? { title: str(o["title"]) } : {}) });
156
+ citations.push({ url, ...(str(o.title) ? { title: str(o.title) } : {}) });
176
157
  }
177
158
  for (const val of Object.values(o)) {
178
159
  if (Array.isArray(val)) val.forEach((x) => visit(x, depth + 1));
179
160
  else if (val && typeof val === "object") visit(val, depth + 1);
180
161
  }
181
162
  };
182
- visit(message["grounding_metadata"] ?? message["groundingMetadata"], 0);
163
+ visit(message.grounding_metadata ?? message.groundingMetadata, 0);
183
164
  return { answer, citations };
184
165
  }
185
166
 
@@ -0,0 +1,150 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ assertUrlAllowed,
5
+ normalizeUrlPattern,
6
+ parseAllowedUrls,
7
+ toUrlAllowList,
8
+ } from "../src/allowlist";
9
+ import { approvalMatches, resolveWebSearchConfig } from "../src/config";
10
+ import { htmlToText } from "../src/fetch";
11
+ import {
12
+ detectWebSearchProvider,
13
+ supportsWebSearch,
14
+ webSearchToolSpec,
15
+ WEB_SEARCH_PROVIDERS,
16
+ } from "../src/provider";
17
+
18
+ describe("web-search allow-list", () => {
19
+ it("strips the scheme from entries, leaving host / path verbatim", () => {
20
+ assert.equal(normalizeUrlPattern("https://*.databricks.com"), "*.databricks.com");
21
+ assert.equal(normalizeUrlPattern("docs.example.com/api/**"), "docs.example.com/api/**");
22
+ assert.equal(normalizeUrlPattern("databricks.com"), "databricks.com");
23
+ });
24
+
25
+ it("parses CSV and array forms, trimming + de-duping", () => {
26
+ assert.deepEqual(parseAllowedUrls("https://databricks.com, docs.example.com"), [
27
+ "databricks.com",
28
+ "docs.example.com",
29
+ ]);
30
+ assert.deepEqual(parseAllowedUrls(["databricks.com", "databricks.com"]), ["databricks.com"]);
31
+ });
32
+
33
+ it("permits everything when unconfigured", () => {
34
+ const list = toUrlAllowList(parseAllowedUrls(undefined));
35
+ assert.equal(list.restricted, false);
36
+ assert.equal(list.allows("https://anything.example.com/x"), true);
37
+ });
38
+
39
+ it("a bare host permits the host and its subdomains, blocks others", () => {
40
+ const list = toUrlAllowList(parseAllowedUrls(["databricks.com", "*.trusted.io"]));
41
+ assert.equal(list.restricted, true);
42
+ assert.equal(list.allows("https://databricks.com/"), true);
43
+ assert.equal(list.allows("https://docs.databricks.com/aws/en/index.html"), true);
44
+ assert.equal(list.allows("https://api.trusted.io/v1"), true);
45
+ assert.equal(list.allows("https://evil.example.com/"), false);
46
+ assert.equal(list.allows("https://notdatabricks.com/"), false);
47
+ });
48
+
49
+ it("a path entry constrains the pathname", () => {
50
+ const list = toUrlAllowList(parseAllowedUrls(["docs.example.com/api/**"]));
51
+ assert.equal(list.allows("https://docs.example.com/api/v1/x"), true);
52
+ assert.equal(list.allows("https://docs.example.com/blog/x"), false);
53
+ });
54
+
55
+ it("assertUrlAllowed throws for a disallowed URL, passes an allowed one", () => {
56
+ const list = toUrlAllowList(parseAllowedUrls(["databricks.com"]));
57
+ assert.throws(() => assertUrlAllowed("https://evil.example.com/", list), /not permitted/);
58
+ assert.doesNotThrow(() => assertUrlAllowed("https://databricks.com/", list));
59
+ });
60
+ });
61
+
62
+ describe("web-search provider detection", () => {
63
+ it("maps GPT ids to openai and Gemini ids to gemini", () => {
64
+ assert.equal(detectWebSearchProvider("databricks-gpt-5"), "openai");
65
+ assert.equal(detectWebSearchProvider("databricks-gpt-5-mini"), "openai");
66
+ assert.equal(detectWebSearchProvider("databricks-gemini-3-pro"), "gemini");
67
+ assert.equal(detectWebSearchProvider("databricks-gemini-2-5-flash"), "gemini");
68
+ });
69
+
70
+ it("treats gpt-oss and non-web families as unsupported", () => {
71
+ assert.equal(detectWebSearchProvider("databricks-gpt-oss-120b"), null);
72
+ assert.equal(detectWebSearchProvider("databricks-claude-sonnet-4-6"), null);
73
+ assert.equal(detectWebSearchProvider("databricks-llama-3-70b"), null);
74
+ assert.equal(supportsWebSearch("databricks-claude-sonnet-4-6"), false);
75
+ assert.equal(supportsWebSearch("databricks-gpt-5"), true);
76
+ });
77
+
78
+ it("uses the built-in tool spec per provider", () => {
79
+ assert.deepEqual(webSearchToolSpec("openai").tool, { type: "web_search" });
80
+ assert.equal(webSearchToolSpec("openai").api, "responses");
81
+ assert.deepEqual(webSearchToolSpec("gemini").tool, { google_search: {} });
82
+ assert.equal(webSearchToolSpec("gemini").api, "chat");
83
+ assert.deepEqual(WEB_SEARCH_PROVIDERS.openai.tool, { type: "web_search" });
84
+ });
85
+
86
+ it("merges an operator override over the built-in map", () => {
87
+ const overrides = { gemini: { tool: { google_search_retrieval: {} } } };
88
+ const spec = webSearchToolSpec("gemini", overrides);
89
+ assert.deepEqual(spec.tool, { google_search_retrieval: {} });
90
+ // api falls through to the built-in when not overridden
91
+ assert.equal(spec.api, "chat");
92
+ // other providers keep their defaults
93
+ assert.deepEqual(webSearchToolSpec("openai", overrides).tool, { type: "web_search" });
94
+ });
95
+ });
96
+
97
+ describe("web-search config", () => {
98
+ it("defaults model fallbacks to Gemini-then-GPT and approval to none", () => {
99
+ const c = resolveWebSearchConfig();
100
+ assert.equal(c.approval, false);
101
+ assert.equal(c.model, undefined);
102
+ assert.match(c.modelFallbacks[0]!, /gemini/);
103
+ assert.ok(c.modelFallbacks.some((m) => m.includes("gpt")));
104
+ });
105
+
106
+ it("merges webSearchTools override into resolved config", () => {
107
+ const c = resolveWebSearchConfig({ webSearchTools: { gemini: { tool: { x: {} } } } });
108
+ assert.deepEqual((c.webSearchTools as { gemini: unknown }).gemini, { tool: { x: {} } });
109
+ });
110
+
111
+ it("enables the scrape fallback by default, honors an explicit off", () => {
112
+ assert.equal(resolveWebSearchConfig().scrapeFallback, true);
113
+ assert.equal(resolveWebSearchConfig({ scrapeFallback: false }).scrapeFallback, false);
114
+ });
115
+ });
116
+
117
+ describe("web-search approval gate", () => {
118
+ it("boolean gates pass through", () => {
119
+ assert.equal(approvalMatches(true, ["https://x.com"]), true);
120
+ assert.equal(approvalMatches(false, ["https://x.com"]), false);
121
+ });
122
+
123
+ it("pattern gate matches only candidate URLs it covers", () => {
124
+ assert.equal(
125
+ approvalMatches("*.internal.example.com", ["https://api.internal.example.com/x"]),
126
+ true,
127
+ );
128
+ assert.equal(
129
+ approvalMatches("*.internal.example.com", ["https://public.example.com/x"]),
130
+ false,
131
+ );
132
+ assert.equal(approvalMatches(["a.com", "b.com"], ["https://b.com/y"]), true);
133
+ });
134
+ });
135
+
136
+ describe("web-search html-to-text", () => {
137
+ it("strips script/style and unwraps tags", () => {
138
+ const html =
139
+ "<html><head><title>Hi</title><style>.x{}</style></head><body><p>One</p><script>bad()</script><p>Two</p></body></html>";
140
+ const text = htmlToText(html);
141
+ assert.match(text, /One/);
142
+ assert.match(text, /Two/);
143
+ assert.doesNotMatch(text, /bad\(\)/);
144
+ assert.doesNotMatch(text, /\.x\{\}/);
145
+ });
146
+
147
+ it("decodes common entities", () => {
148
+ assert.equal(htmlToText("<p>a &amp; b &lt;c&gt;</p>"), "a & b <c>");
149
+ });
150
+ });
@@ -0,0 +1,14 @@
1
+ // ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
2
+ {
3
+ "extends": "../tsconfig.json",
4
+ "compilerOptions": {
5
+ "noEmit": true,
6
+ "rootDir": ".."
7
+ },
8
+ "include": [
9
+ "**/*.ts"
10
+ ],
11
+ "exclude": [
12
+ "node_modules"
13
+ ]
14
+ }