@dbx-tools/appkit-web-search 0.9.69 → 0.9.71

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/search.ts CHANGED
@@ -26,14 +26,14 @@ import {
26
26
  ConnectionError,
27
27
  ExecutionError,
28
28
  getExecutionContext,
29
- ValidationError,
30
29
  } from "@databricks/appkit";
31
- import { invoke, resolve, modelCatalog } from "@dbx-tools/model";
30
+ import type { AgentToolExecutionContext } from "@dbx-tools/appkit/tool-provider";
31
+ import { invoke, policy, resolve, modelCatalog } from "@dbx-tools/model";
32
32
  import { log, object, stringUtils } from "@dbx-tools/shared-core";
33
- import { openaiChat } from "@dbx-tools/shared-model";
34
- import { MODEL_ENV, SERVING_ENDPOINT_ENV, type ResolvedWebSearchConfig } from "./config.ts";
33
+ import { openaiChat, type ServingEndpointSummary } from "@dbx-tools/shared-model";
34
+ import { MODEL_ENV, type ResolvedWebSearchConfig } from "./config.ts";
35
35
  import { toCallSettings, webSearchExecuteDefaults } from "./defaults.ts";
36
- import { detectWebSearchProvider, supportsWebSearch, webSearchToolSpec } from "./provider.ts";
36
+ import { supportsWebSearch, webSearchProviderForFamily, webSearchToolSpec } from "./provider.ts";
37
37
  import {
38
38
  executeRead,
39
39
  toWebSearchRuntime,
@@ -44,8 +44,9 @@ import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./sch
44
44
  import { runScrapeSearch } from "./scrape.ts";
45
45
 
46
46
  type WorkspaceClientLike = modelCatalog.WorkspaceClientLike & invoke.AuthenticatingClientLike;
47
+ type ProgressWriter = NonNullable<AgentToolExecutionContext["writeProgress"]>;
47
48
  const logger = log.logger("web-search/search");
48
- const { resolveModel } = resolve;
49
+ const { lookupModels } = resolve;
49
50
  const { listServingEndpoints } = modelCatalog;
50
51
 
51
52
  /**
@@ -55,6 +56,8 @@ const { listServingEndpoints } = modelCatalog;
55
56
  * into a full traversal of the response.
56
57
  */
57
58
  const MAX_GROUNDING_WALK_DEPTH = 6;
59
+ const GEMINI_CROSS_REGION_COOLDOWN_MS = 24 * 60 * 60 * 1000;
60
+ const GEMINI_CROSS_REGION_DISABLED = /cross-region processing is disabled/i;
58
61
 
59
62
  /** Lowest HTTP status treated as a server-side (retryable) serving failure. */
60
63
  const SERVER_ERROR_STATUS = 500;
@@ -70,6 +73,12 @@ export interface WebSearchContext {
70
73
  cacheIdentity?: string;
71
74
  }
72
75
 
76
+ /** Model-selection fields used to rank one web-search endpoint. */
77
+ export type WebSearchModelSelectionOptions = Pick<
78
+ ResolvedWebSearchConfig,
79
+ "model" | "modelFallbacks" | "fuzzy" | "fuzzyThreshold"
80
+ >;
81
+
73
82
  /**
74
83
  * Resolve the OBO workspace client + host from the active AppKit execution
75
84
  * context. Inside `agent.stream`'s `asUser(req)` scope this hits the serving
@@ -87,77 +96,96 @@ export async function resolveWebSearchContext(): Promise<WebSearchContext> {
87
96
  * Resolve a web-search-capable model against the LIVE workspace catalogue - so
88
97
  * we never return an endpoint id that isn't actually deployed (the "endpoint
89
98
  * does not exist" failure a hardcoded fallback id would cause). Reuses
90
- * `@dbx-tools/model`'s existing catalogue + resolver rather than a custom
99
+ * `@dbx-tools/model`'s existing catalogue + ranker rather than a custom
91
100
  * lookup: {@link listServingEndpoints} lists the endpoints (cached), and we
92
- * restrict the candidate set to the {@link supportsWebSearch} ones before
93
- * {@link resolveModel} fuzzy-picks within it.
101
+ * restrict the candidate set through model-owned capability metadata before
102
+ * {@link lookupModels} ranks within it.
94
103
  *
95
- * Returns the chosen endpoint id, or `null` when the workspace has no
96
- * web-search-capable model deployed (the caller then uses the scrape
97
- * fallback). A pin the caller chose deliberately - the request's `model`, the
98
- * plugin's `model`, or `WEB_SEARCH_MODEL` - throws when it resolves to an
99
- * unsupported / absent endpoint, so a bad pin surfaces rather than silently
100
- * degrading. A pin inherited from the shared `DATABRICKS_SERVING_ENDPOINT_NAME`
101
- * binding is only a preference: that endpoint is the app's serving endpoint,
102
- * not necessarily a web-search-capable one, so an unusable value falls through
103
- * to the fallback order.
104
+ * The highest-ranked Gemini model wins. Without Gemini, a GPT request matching
105
+ * the calling/configured model wins, followed by the highest-ranked GPT model
106
+ * and then any additional configured searches.
104
107
  */
105
108
  async function resolveWebSearchModel(
106
109
  ctx: WebSearchContext,
107
- config: ResolvedWebSearchConfig,
110
+ runtime: WebSearchRuntime,
108
111
  requested: string | undefined,
109
- ): Promise<string | null> {
112
+ ): Promise<ServingEndpointSummary | null> {
113
+ const { config } = runtime;
110
114
  const endpoints = await listServingEndpoints(ctx.client, ctx.host, {
111
115
  ...(ctx.cacheIdentity ? { cacheIdentity: ctx.cacheIdentity } : {}),
112
116
  });
117
+ return selectWebSearchEndpoint(endpoints, requested, config, activeFamilyCooldowns(runtime));
118
+ }
119
+
120
+ /** Select one deployed web-search endpoint using family and capability policy. */
121
+ export function selectWebSearchEndpoint(
122
+ endpoints: readonly ServingEndpointSummary[],
123
+ requested: string | undefined,
124
+ config: WebSearchModelSelectionOptions,
125
+ suppressedFamilies: ReadonlySet<policy.ModelFamily> = new Set(),
126
+ ): ServingEndpointSummary | null {
113
127
  // Only deployed, web-search-capable endpoints are candidates.
114
- const capable = endpoints.filter((e) => supportsWebSearch(e.name));
115
- const pinned = requested ?? config.model;
116
- const pinIsDeliberate =
117
- requested !== undefined || config.modelSource === "config" || config.modelSource === MODEL_ENV;
128
+ const capable = endpoints.filter((endpoint) => {
129
+ const family = endpointFamily(endpoint);
130
+ return supportsWebSearch(endpoint) && (!family || !suppressedFamilies.has(family));
131
+ });
132
+ const gemini = lookupModels(capable, {
133
+ search: policy.ModelFamily.Gemini,
134
+ limit: 1,
135
+ threshold: config.fuzzyThreshold,
136
+ })[0]?.endpoint;
137
+ if (gemini) return gemini;
138
+ const gpt = lookupModels(capable, {
139
+ search: policy.ModelFamily.Gpt,
140
+ limit: 50,
141
+ threshold: config.fuzzyThreshold,
142
+ }).map(({ endpoint }) => endpoint);
118
143
 
119
- if (pinned) {
120
- // Resolve the explicit ask within the capable set only.
121
- const { modelId } = resolveModel(capable, {
122
- explicit: pinned,
123
- fuzzy: config.fuzzy,
124
- threshold: config.fuzzyThreshold,
125
- });
126
- // resolveModel returns the input verbatim on no match; require it to be a
127
- // real capable endpoint so a bad pin is a clear error, not a phantom call.
128
- const usable = capable.some((e) => e.name === modelId) && supportsWebSearch(modelId);
129
- if (usable) return modelId;
130
- const deployed = capable.map((e) => e.name).join(", ") || "none";
131
- if (!pinIsDeliberate) {
132
- logger.info("shared-endpoint-not-web-capable", {
133
- envVar: SERVING_ENDPOINT_ENV,
134
- deployed,
135
- });
136
- } else if (requested !== undefined) {
137
- throw ValidationError.invalidValue(
138
- "model",
139
- pinned,
140
- `a deployed web-search-capable endpoint (deployed: ${deployed})`,
141
- );
142
- } else {
143
- throw ConfigurationError.resourceNotFound(
144
- "Web-search-capable serving endpoint",
145
- `Deployed web-search-capable endpoints: ${deployed}. Set model or ${MODEL_ENV} to one of them.`,
146
- );
144
+ const preferred = requested ?? config.model;
145
+ if (preferred && policy.modelFamily(preferred) === policy.ModelFamily.Gpt) {
146
+ const matched = config.fuzzy
147
+ ? lookupModels(gpt, {
148
+ search: preferred,
149
+ limit: 1,
150
+ threshold: config.fuzzyThreshold,
151
+ })[0]?.endpoint
152
+ : gpt.find((endpoint) => endpoint.name === preferred);
153
+ if (matched) return matched;
154
+ }
155
+ if (gpt[0]) return gpt[0];
156
+
157
+ for (const search of config.modelFallbacks) {
158
+ if (
159
+ search.toLowerCase() === policy.ModelFamily.Gemini ||
160
+ search.toLowerCase() === policy.ModelFamily.Gpt
161
+ ) {
162
+ continue;
147
163
  }
164
+ const matched = lookupModels(capable, {
165
+ search,
166
+ limit: 1,
167
+ threshold: config.fuzzyThreshold,
168
+ })[0]?.endpoint;
169
+ if (matched) return matched;
148
170
  }
149
171
 
150
- if (capable.length === 0) return null;
172
+ return null;
173
+ }
151
174
 
152
- // Nothing usable pinned: prefer the configured fallback order (Gemini, then
153
- // GPT) when those ids are actually deployed; else take the best capable
154
- // endpoint.
155
- const { modelId } = resolveModel(capable, {
156
- fallbacks: [...config.modelFallbacks],
157
- fuzzy: config.fuzzy,
158
- threshold: config.fuzzyThreshold,
159
- });
160
- return capable.some((e) => e.name === modelId) ? modelId : (capable[0]?.name ?? null);
175
+ /** Return one endpoint's model-owned family identity. */
176
+ function endpointFamily(endpoint: ServingEndpointSummary): policy.ModelFamily | undefined {
177
+ return policy.modelFamily(endpoint.modelServiceName ?? endpoint.name);
178
+ }
179
+
180
+ /** Drop expired family cooldowns and return the currently suppressed set. */
181
+ function activeFamilyCooldowns(
182
+ runtime: WebSearchRuntime,
183
+ now = Date.now(),
184
+ ): ReadonlySet<policy.ModelFamily> {
185
+ for (const [family, expiresAt] of runtime.familyCooldowns) {
186
+ if (expiresAt <= now) runtime.familyCooldowns.delete(family);
187
+ }
188
+ return new Set(runtime.familyCooldowns.keys());
161
189
  }
162
190
 
163
191
  /**
@@ -181,6 +209,7 @@ async function postServing(
181
209
  runtime: WebSearchRuntime,
182
210
  cacheKey: readonly (string | number)[],
183
211
  signal?: AbortSignal,
212
+ writeProgress?: ProgressWriter,
184
213
  ): Promise<Record<string, unknown>> {
185
214
  const { config } = runtime;
186
215
  const payload = await executeRead(
@@ -199,6 +228,30 @@ async function postServing(
199
228
  ...(executeSignal ? { signal: executeSignal } : {}),
200
229
  });
201
230
  if (!response.ok) {
231
+ const responseBody = await response.text();
232
+ const model =
233
+ object.isRecord(body) && typeof body.model === "string" ? body.model : undefined;
234
+ logger.error("serving-request-rejected", {
235
+ model,
236
+ api: url.endsWith("/responses") ? "responses" : "chat",
237
+ url,
238
+ status: response.status,
239
+ responseBody,
240
+ });
241
+ if (
242
+ response.status === 400 &&
243
+ model &&
244
+ policy.modelFamily(model) === policy.ModelFamily.Gemini &&
245
+ GEMINI_CROSS_REGION_DISABLED.test(responseBody)
246
+ ) {
247
+ const expiresAt = Date.now() + GEMINI_CROSS_REGION_COOLDOWN_MS;
248
+ runtime.familyCooldowns.set(policy.ModelFamily.Gemini, expiresAt);
249
+ logger.warn("provider-family-suppressed", {
250
+ family: policy.ModelFamily.Gemini,
251
+ reason: "cross-region-disabled",
252
+ expiresAt: new Date(expiresAt).toISOString(),
253
+ });
254
+ }
202
255
  // Load-shedding and server faults are worth another attempt; a 4xx is
203
256
  // this request's own problem, so it must not be retried.
204
257
  const retryable =
@@ -208,13 +261,97 @@ async function postServing(
208
261
  ? new ConnectionError(message, { context: { status: response.status } })
209
262
  : new ExecutionError(message, { context: { status: response.status } });
210
263
  }
211
- return response.json();
264
+ return writeProgress &&
265
+ response.body &&
266
+ response.headers.get("content-type")?.includes("text/event-stream")
267
+ ? readStreamingResponse(response, writeProgress)
268
+ : response.json();
212
269
  },
213
270
  signal,
214
271
  );
215
272
  return object.isRecord(payload) ? payload : {};
216
273
  }
217
274
 
275
+ /** Consume an OpenAI Responses SSE stream and retain its completed response. */
276
+ async function readStreamingResponse(
277
+ response: Response,
278
+ writeProgress: ProgressWriter,
279
+ ): Promise<Record<string, unknown>> {
280
+ const reader = response.body!.getReader();
281
+ const decoder = new TextDecoder();
282
+ let buffer = "";
283
+ let completed: Record<string, unknown> | undefined;
284
+ const consume = async (frame: string) => {
285
+ const data = frame
286
+ .split("\n")
287
+ .filter((line) => line.startsWith("data:"))
288
+ .map((line) => line.slice(5).trimStart())
289
+ .join("\n");
290
+ if (!data || data === "[DONE]") return;
291
+ const event = JSON.parse(data) as unknown;
292
+ if (!object.isRecord(event) || typeof event.type !== "string") return;
293
+ await emitWebSearchProgress(event, writeProgress);
294
+ if (event.type === "response.completed" && object.isRecord(event.response)) {
295
+ completed = event.response;
296
+ }
297
+ if (event.type === "response.failed") {
298
+ throw new ExecutionError("web-search: streaming response failed", {
299
+ context: { response: event.response },
300
+ });
301
+ }
302
+ };
303
+
304
+ for (;;) {
305
+ const { done, value } = await reader.read();
306
+ buffer += decoder.decode(value, { stream: !done });
307
+ buffer = buffer.replaceAll("\r\n", "\n");
308
+ let boundary = buffer.indexOf("\n\n");
309
+ while (boundary >= 0) {
310
+ await consume(buffer.slice(0, boundary));
311
+ buffer = buffer.slice(boundary + 2);
312
+ boundary = buffer.indexOf("\n\n");
313
+ }
314
+ if (done) break;
315
+ }
316
+ if (buffer.trim()) await consume(buffer);
317
+ if (!completed) throw new ExecutionError("web-search: streaming response did not complete");
318
+ return completed;
319
+ }
320
+
321
+ /** Publish native web-search lifecycle events as common tool progress. */
322
+ async function emitWebSearchProgress(
323
+ event: Record<string, unknown>,
324
+ writeProgress: ProgressWriter,
325
+ ): Promise<void> {
326
+ if (event.type !== "response.output_item.done" || !object.isRecord(event.item)) return;
327
+ const action = object.isRecord(event.item.action) ? event.item.action : undefined;
328
+ if (!action || action.type !== "search") return;
329
+ const query = typeof action.query === "string" ? action.query : undefined;
330
+ const queries = Array.isArray(action.queries)
331
+ ? action.queries.filter((value): value is string => typeof value === "string")
332
+ : [];
333
+ const groupId =
334
+ typeof event.item.id === "string"
335
+ ? event.item.id
336
+ : `search-${String(event.sequence_number ?? query ?? "unknown")}`;
337
+ const resultCount = queries.length > 0 ? queries.length : query ? 1 : 0;
338
+ await writeProgress({
339
+ type: "tool_status",
340
+ status: "search",
341
+ message: query ?? "the web",
342
+ groupId,
343
+ });
344
+ await writeProgress({
345
+ type: "tool_status",
346
+ status: "result",
347
+ message: "Result",
348
+ groupId,
349
+ ...(resultCount > 0
350
+ ? { detail: `${resultCount} ${resultCount === 1 ? "result" : "results"}` }
351
+ : {}),
352
+ });
353
+ }
354
+
218
355
  /* --------------------------- response extraction --------------------------- */
219
356
 
220
357
  /**
@@ -299,12 +436,13 @@ export async function runWebSearch(
299
436
  runtimeOrConfig: WebSearchRuntimeInput,
300
437
  ctx: WebSearchContext,
301
438
  signal?: AbortSignal,
439
+ writeProgress?: ProgressWriter,
302
440
  ): Promise<WebSearchResult> {
303
441
  const runtime = toWebSearchRuntime(runtimeOrConfig);
304
442
  const { config } = runtime;
305
- const modelId = await resolveWebSearchModel(ctx, config, request.model);
443
+ const endpoint = await resolveWebSearchModel(ctx, runtime, request.model);
306
444
 
307
- if (modelId === null) {
445
+ if (endpoint === null) {
308
446
  // No native web-search model deployed in this workspace.
309
447
  if (config.scrapeFallback) {
310
448
  logger.info("no-native-model:scrape-fallback", { query: request.query });
@@ -318,7 +456,51 @@ export async function runWebSearch(
318
456
  );
319
457
  }
320
458
 
321
- const provider = detectWebSearchProvider(modelId)!; // guaranteed by resolve step
459
+ try {
460
+ return await runNativeWebSearch(request, runtime, ctx, endpoint, signal, writeProgress);
461
+ } catch (error) {
462
+ if (
463
+ endpointFamily(endpoint) !== policy.ModelFamily.Gemini ||
464
+ !activeFamilyCooldowns(runtime).has(policy.ModelFamily.Gemini)
465
+ ) {
466
+ throw error;
467
+ }
468
+ const fallback = await resolveWebSearchModel(ctx, runtime, request.model);
469
+ if (!fallback) {
470
+ if (config.scrapeFallback) {
471
+ logger.info("gemini-unavailable:scrape-fallback", { query: request.query });
472
+ return runScrapeSearch(request, runtime, signal);
473
+ }
474
+ throw error;
475
+ }
476
+ logger.info("gemini-unavailable:model-fallback", {
477
+ failedModel: endpoint.name,
478
+ fallbackModel: fallback.name,
479
+ });
480
+ return runNativeWebSearch(request, runtime, ctx, fallback, signal, writeProgress);
481
+ }
482
+ }
483
+
484
+ /** Execute one native provider request against an already-selected endpoint. */
485
+ async function runNativeWebSearch(
486
+ request: WebSearchRequest,
487
+ runtime: WebSearchRuntime,
488
+ ctx: WebSearchContext,
489
+ endpoint: ServingEndpointSummary,
490
+ signal?: AbortSignal,
491
+ writeProgress?: ProgressWriter,
492
+ ): Promise<WebSearchResult> {
493
+ const { config } = runtime;
494
+ const modelId = endpoint.name;
495
+ const provider = webSearchProviderForFamily(
496
+ policy.modelFamily(endpoint.modelServiceName ?? endpoint.name),
497
+ );
498
+ if (!provider) {
499
+ throw ConfigurationError.resourceNotFound(
500
+ "Native web-search provider",
501
+ `Model ${modelId} has no supported provider family.`,
502
+ );
503
+ }
322
504
  const spec = webSearchToolSpec(provider, config.webSearchTools);
323
505
 
324
506
  const url =
@@ -326,14 +508,15 @@ export async function runWebSearch(
326
508
  const body =
327
509
  spec.api === "responses"
328
510
  ? {
511
+ ...spec.request,
329
512
  model: modelId,
330
513
  input: [{ role: "user", content: request.query }],
331
- tools: [spec.tool],
514
+ ...(writeProgress ? { stream: true } : {}),
332
515
  }
333
516
  : {
517
+ ...spec.request,
334
518
  model: modelId,
335
519
  messages: [{ role: "user", content: request.query }],
336
- tools: [spec.tool],
337
520
  };
338
521
 
339
522
  const payload = await postServing(
@@ -343,6 +526,7 @@ export async function runWebSearch(
343
526
  runtime,
344
527
  ["web-search", "serving", spec.api, modelId, request.query],
345
528
  signal,
529
+ writeProgress,
346
530
  );
347
531
 
348
532
  const { answer, citations } =
package/src/tool.ts CHANGED
@@ -37,7 +37,7 @@ import { createWebSearchRuntime, type WebSearchRuntime } from "./runtime.ts";
37
37
  import {
38
38
  webFetchRequestSchema,
39
39
  webFetchResultSchema,
40
- webSearchRequestSchema,
40
+ webSearchToolRequestSchema,
41
41
  webSearchResultSchema,
42
42
  WEB_FETCH_TOOL_DESCRIPTION,
43
43
  WEB_SEARCH_TOOL_DESCRIPTION,
@@ -108,13 +108,13 @@ export function webSearchTool(opts: WebSearchToolOptions = {}) {
108
108
  return createTool({
109
109
  id: opts.id ?? "web_search",
110
110
  description: WEB_SEARCH_TOOL_DESCRIPTION,
111
- inputSchema: webSearchRequestSchema,
111
+ inputSchema: webSearchToolRequestSchema,
112
112
  outputSchema: webSearchResultSchema,
113
113
  // A search's result URLs aren't known before the call, so a pattern gate
114
114
  // is treated as "always gate".
115
115
  ...(gate.mode === "none" ? {} : { requireApproval: () => true }),
116
116
  execute: async (input) => {
117
- const request = parseToolInput(webSearchRequestSchema, input);
117
+ const request = parseToolInput(webSearchToolRequestSchema, input);
118
118
  return runWebSearch(request, runtime, await resolveWebSearchContext());
119
119
  },
120
120
  });