@stackfactor/agent-utils 1.2.16 → 1.2.18

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 CHANGED
@@ -40,7 +40,7 @@ When `expectsJsonResponse` is `true` (the default), JSON escape instructions are
40
40
  | Parameter | Type | Default | Description |
41
41
  | --------------------- | -------------------- | --------------- | -------------------------------------------------------------------------------------------------------- |
42
42
  | `modelName` | `string` | — | Model identifier: `"gpt-4o"`, `"claude-3-5-sonnet"`, `"gemini-1.5-pro"`, `"deepseek-chat"`, `"kimi-k2-0905-preview"`, `"glm-4.6"`, etc. |
43
- | `config` | `object` | — | API keys (`openAIAPIKey`, `anthropicAPIKey`, `googleAPIKey`, `deepSeekAPIKey`, `kimiAPIKey`, `glmAPIKey`), `temperature`, `agentic`, `recursionLimit` |
43
+ | `config` | `object` | — | API keys (`openAIAPIKey`, `anthropicAPIKey`, `googleAPIKey`, `deepSeekAPIKey`, `kimiAPIKey`, `glmAPIKey`), `temperature`, `agentic`, `recursionLimit`, `webSearch` |
44
44
  | `prompt` | `string \| object[]` | — | Plain string or array of `{ role, content }` message objects |
45
45
  | `onProgressReport` | `function \| null` | `null` | Async callback receiving `{ message, progress }` updates |
46
46
  | `minPercent` | `number` | `0` | Lower bound for progress percentage |
@@ -254,6 +254,71 @@ Shared constants used across the package.
254
254
 
255
255
  ---
256
256
 
257
+ ## Web Search
258
+
259
+ Set `config.webSearch` to enable each provider's own web search tool. The provider runs the search server-side inside the same request — there is no client-side agent loop and no extra round trip — so every calling convention (streaming, non-streaming, agentic, with or without a Zod schema) is unchanged.
260
+
261
+ | Provider | Native tool | Context control | Billing |
262
+ | --------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------- | --------------------------- |
263
+ | Anthropic | [`web_search` server tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool) | Dynamic filtering (**on by default**) | $10 per 1,000 searches |
264
+ | OpenAI | [`web_search` hosted tool](https://platform.openai.com/docs/guides/tools-web-search) (Responses API) | `search_context_size` | Per search, model-dependent |
265
+ | Google | [`googleSearch` grounding](https://ai.google.dev/gemini-api/docs/google-search) | None offered by the API | Per grounded request |
266
+
267
+ ```typescript
268
+ const usageTracker = {};
269
+ const result = await langChain.runPromptWithModel(
270
+ "claude-opus-5",
271
+ {
272
+ anthropicAPIKey: "sk-ant-...",
273
+ "claude-opus-5-web-search-costs": 10, // USD per 1,000 searches
274
+ webSearch: { maxUses: 5, allowedDomains: ["reuters.com", "bbc.com"] },
275
+ },
276
+ "What happened in the markets today?",
277
+ null, 0, 100, false, null, "StackFactor", [], usageTracker,
278
+ );
279
+ // usageTracker.tokens["claude-opus-5_webSearches"] === 2
280
+ // usageTracker.cost includes 2 / 1000 * 10
281
+ // usageTracker.webSearchSources === [{ url: "https://reuters.com/...", title: "..." }, ...]
282
+ ```
283
+
284
+ ### `WebSearchConfig`
285
+
286
+ `webSearch: true` uses provider defaults. Pass an object to configure it; each field is only sent to the providers that accept it, and the rest are ignored.
287
+
288
+ | Field | Applies to | Default | Description |
289
+ | ------------------- | ----------------- | ------------ | ----------------------------------------------------------------------------------------------- |
290
+ | `maxUses` | Anthropic | unlimited | Hard cap on searches per request |
291
+ | `allowedDomains` | Anthropic, OpenAI | — | Restrict results to these domains (bare domains, no scheme) |
292
+ | `blockedDomains` | Anthropic | — | Exclude these domains; cannot be combined with `allowedDomains` |
293
+ | `userLocation` | Anthropic, OpenAI | — | `{ city, region, country, timezone }` — ISO 3166-1 alpha-2 country, IANA timezone |
294
+ | `searchContextSize` | OpenAI | `"medium"` | `"low" \| "medium" \| "high"` — how much context window results may consume |
295
+ | `timeRange` | Google | — | `{ startTime, endTime }` RFC 3339 window (`excludeDomains` is Vertex AI only and is not sent) |
296
+ | `dynamicFiltering` | Anthropic | `true` | Filter results with code before they enter the context window; `false` forces `allowed_callers: ["direct"]` |
297
+ | `responseInclusion` | Anthropic | `"excluded"` | Whether filtered-away result blocks are echoed back in the response |
298
+ | `toolVersion` | Anthropic | per model | Pin the dated tool version instead of selecting it per model |
299
+
300
+ ### Context management
301
+
302
+ Search results are the largest thing web search puts into a request, so each provider's context controls are applied up front rather than left at their defaults.
303
+
304
+ **Anthropic — dynamic filtering, on by default.** Basic search (`web_search_20250305`) loads *every* raw search result into the context window. From `web_search_20260209` Claude instead writes and runs code that filters the results first, so only relevant content is ingested. `buildWebSearchTool` picks the tool version per model: Claude 4.6 and later get `web_search_20260318` with filtering enabled and `response_inclusion: "excluded"` (which also keeps the consumed result blocks out of the response, cutting billed output tokens); earlier models, which lack programmatic tool calling, fall back to `web_search_20250305`. Pinning a filtering version on an older model via `toolVersion` automatically adds `allowed_callers: ["direct"]` so the request cannot 400. Anthropic provisions the code execution that filtering needs automatically and does not charge extra for it beyond token costs.
305
+
306
+ **OpenAI — `search_context_size`.** This is OpenAI's only context knob and it is a genuine quality/cost tradeoff, so it is left at OpenAI's own balanced default of `"medium"` rather than silently downgraded. Set `searchContextSize: "low"` to minimize ingested context where answer depth matters less.
307
+
308
+ **Google — no equivalent exists.** The Gemini API's `GoogleSearch` tool has no result-filtering or context-size field; grounded results are injected as-is. The only related lever, `dynamicRetrievalConfig`, belongs to the legacy Gemini 1.5 `googleSearchRetrieval` tool and gates *whether* a search happens, not how much of it is ingested. `searchTypes` is deliberately left unset so grounding stays on text-only web results instead of also returning image bytes.
309
+
310
+ **Downstream.** Non-text blocks (`server_tool_use`, `web_search_tool_result`, code execution results) are dropped by `extractTextContent` and never re-sent: the library issues one self-contained request per call, so encrypted search results are not carried into follow-up turns. In agentic mode the agent loop does resend history, which is where dynamic filtering and `response_inclusion: "excluded"` matter most.
311
+
312
+ **Notes**
313
+
314
+ - Models without a native web search tool (DeepSeek, Kimi, GLM) log a warning and run the prompt without search, so one config can be pointed at any model.
315
+ - Searches performed are added to `usageTracker` as `<model>_webSearches`, costed from `<model>-web-search-costs`. Sources are collected on `usageTracker.webSearchSources`, deduplicated by URL — Anthropic and Google both require citing the original sources when their output is shown to end users.
316
+ - Combining web search with a Zod `schema` requires Gemini 3 or later on Google; Gemini 1.5/2.x reject `responseSchema` alongside `googleSearch` with a 400.
317
+ - With `response_inclusion: "excluded"`, `usageTracker.webSearchSources` is populated from the citations attached to the answer rather than from every raw result — the sources actually used, which is what has to be displayed.
318
+ - Enabling web search on a `gpt-*` model routes the request through OpenAI's Responses API, where the structured-output schema is sent as `text.format` instead of `response_format`. This is handled automatically.
319
+
320
+ ---
321
+
257
322
  ## Supported Models
258
323
 
259
324
  ### Text / Chat
@@ -294,6 +359,9 @@ The `config` object accepted by LangChain methods supports the following keys:
294
359
  | `maxTokens` | `number` | Maximum output tokens (default: `16384` for Claude, `200000` for other providers). For Claude, values above `21333` automatically enable LangChain streaming to bypass the Anthropic SDK's 10-minute non-streaming guard. |
295
360
  | `agentic` | `boolean` | Enable agentic mode in `runPromptWithModel` |
296
361
  | `recursionLimit` | `number` | Max agent steps (default: `25`) |
362
+ | `webSearch` | `boolean \| WebSearchConfig` | Enable the provider's native web search tool (see [Web Search](#web-search)) |
363
+
364
+ Per-model cost constants are read from the same object using flat keys: `<model>-input-token-costs`, `<model>-output-token-costs`, `<model>-image-input-token-costs`, `<model>-image-output-token-costs`, `<model>-character-costs` (all USD per million), and `<model>-web-search-costs` (USD per **1,000** searches).
297
365
 
298
366
  ---
299
367
 
@@ -9,7 +9,7 @@ export { constants };
9
9
  export { errorHandling, AppError };
10
10
  export type { ParsedError } from "./errorHandling.js";
11
11
  export { langChain };
12
- export type { UsageTracker } from "./langChain.js";
12
+ export type { UsageTracker, WebSearchConfig } from "./langChain.js";
13
13
  export { logger };
14
14
  export { serve };
15
15
  export { callAgent };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,YAAY,CAAC;AACnC,OAAO,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,SAAS,MAAM,gBAAgB,CAAC;AACvC,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,cAAc,MAAM,qBAAqB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC;AAEnC,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,YAAY,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,OAAO,EAAE,MAAM,EAAE,CAAC;AAElB,OAAO,EAAE,KAAK,EAAE,CAAC;AAEjB,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,UAAU,EAAE,CAAC;AAEtB,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,YAAY,CAAC;AACnC,OAAO,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,SAAS,MAAM,gBAAgB,CAAC;AACvC,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,cAAc,MAAM,qBAAqB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC;AAEnC,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,YAAY,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAAE,MAAM,EAAE,CAAC;AAElB,OAAO,EAAE,KAAK,EAAE,CAAC;AAEjB,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,UAAU,EAAE,CAAC;AAEtB,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,CAAC"}
@@ -9,6 +9,73 @@ export type UsageTracker = {
9
9
  tokens: {
10
10
  [tokenKey: string]: number;
11
11
  };
12
+ /**
13
+ * Sources returned by native web search, deduplicated by URL across every
14
+ * call in the run. Only present once a search has actually run. Anthropic and
15
+ * Google both require the original sources to be cited when their output is
16
+ * shown to end users, so they are surfaced here rather than discarded with
17
+ * the rest of the non-text content blocks.
18
+ */
19
+ webSearchSources?: {
20
+ url: string;
21
+ title?: string;
22
+ }[];
23
+ };
24
+ /**
25
+ * Caller-facing options for the providers' native web search tools. Enable web
26
+ * search by setting `config.webSearch` to `true` (provider defaults) or to one
27
+ * of these objects. Every field is optional and is only forwarded to the
28
+ * providers that accept it — see `buildWebSearchTool` for the mapping.
29
+ */
30
+ export type WebSearchConfig = {
31
+ /** Anthropic only: hard cap on searches per request (`max_uses`). */
32
+ maxUses?: number;
33
+ /** Anthropic (`allowed_domains`) and OpenAI (`filters.allowed_domains`). */
34
+ allowedDomains?: string[];
35
+ /** Anthropic only (`blocked_domains`); cannot be combined with `allowedDomains`. */
36
+ blockedDomains?: string[];
37
+ /** Anthropic and OpenAI: approximate location used to localize results. */
38
+ userLocation?: {
39
+ city?: string;
40
+ region?: string;
41
+ /** Two-letter ISO 3166-1 alpha-2 code, e.g. `"US"`. */
42
+ country?: string;
43
+ /** IANA timezone ID, e.g. `"America/Los_Angeles"`. */
44
+ timezone?: string;
45
+ };
46
+ /**
47
+ * OpenAI only: how much of the context window search results may consume.
48
+ * OpenAI's default is `"medium"`; `"low"` minimizes context at some cost to
49
+ * answer quality, `"high"` is the expensive end.
50
+ */
51
+ searchContextSize?: "low" | "medium" | "high";
52
+ /** Gemini only: RFC 3339 window the search is restricted to. */
53
+ timeRange?: {
54
+ startTime: string;
55
+ endTime: string;
56
+ };
57
+ /**
58
+ * Anthropic only: opt out of dynamic filtering by forcing the search to be
59
+ * called directly (`allowed_callers: ["direct"]`). Defaults to `true` on
60
+ * models that support it — see `supportsAnthropicDynamicFiltering`. Turning
61
+ * this off means every raw search result lands in the context window.
62
+ */
63
+ dynamicFiltering?: boolean;
64
+ /**
65
+ * Anthropic only: whether search result blocks consumed by dynamic filtering
66
+ * are echoed back in the response. Defaults to `"excluded"`, which drops them
67
+ * and cuts the output tokens billed for content nothing downstream reads.
68
+ */
69
+ responseInclusion?: "full" | "excluded";
70
+ /**
71
+ * Anthropic only: pin the dated tool version instead of letting
72
+ * `buildWebSearchTool` pick per model. `web_search_20250305` is basic search,
73
+ * `web_search_20260209` adds dynamic filtering, `web_search_20260318` adds
74
+ * response-inclusion control. Pinning a filtering version on a model that
75
+ * cannot do programmatic tool calling returns a 400 unless
76
+ * `dynamicFiltering: false` is also set.
77
+ */
78
+ toolVersion?: string;
12
79
  };
13
80
  declare const _default: {
14
81
  checkIfAIProviderConfigured: (config: any) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AAuGA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACxC,CAAC;;0CAE2C,GAAG,KAAG,IAAI;wBAktB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBA0BG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCA4XF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;sDA2tBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;0CA//B8B,GAAG,KAAG,MAAM;+CAprB9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,UACd,GAAG,KACV,IAAI;mCAosBU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AAsiCT,wBASE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AAuGA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACvC;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACtD,CAAC;AAskBF;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,qEAAqE;IACrE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,oFAAoF;IACpF,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,2EAA2E;IAC3E,YAAY,CAAC,EAAE;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,uDAAuD;QACvD,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,sDAAsD;QACtD,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,CAAC;IACF;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC9C,gEAAgE;IAChE,SAAS,CAAC,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IACxC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;;0CAxnB2C,GAAG,KAAG,IAAI;wBAujC/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAiCG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCA0YF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;sDAuuBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;0CAjhC8B,GAAG,KAAG,MAAM;+CAviC9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,UACd,GAAG,KACV,IAAI;mCAujCU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AAwjCT,wBASE"}
@@ -608,6 +608,234 @@ const resolveTemperatureSetting = (modelName, config) => {
608
608
  }
609
609
  return { temperature: clamped };
610
610
  };
611
+ /**
612
+ * Whether a Claude model can run web search through dynamic filtering, where
613
+ * Claude writes and runs code that filters the search results before they reach
614
+ * the context window instead of loading every result into it. Requires Claude
615
+ * 4.6 or later (the models with programmatic tool calling); on anything earlier
616
+ * the filtering tool versions return a 400 unless search is pinned to
617
+ * `allowed_callers: ["direct"]`.
618
+ * @param modelName - The Claude model identifier being routed
619
+ * @returns `true` when the model supports dynamic filtering
620
+ */
621
+ const supportsAnthropicDynamicFiltering = (modelName) => /^claude-(opus|sonnet|haiku)-4-(?:[6-9]|\d\d)\b/.test(modelName) ||
622
+ /^claude-(opus|sonnet|haiku|fable|mythos)-(?:[5-9]|\d\d)\b/.test(modelName);
623
+ /**
624
+ * Normalizes `config.webSearch` into an options object, returning `null` when
625
+ * web search is off so callers can use it as the single enablement gate.
626
+ */
627
+ const getWebSearchOptions = (config) => {
628
+ const webSearch = config?.webSearch;
629
+ if (!webSearch)
630
+ return null;
631
+ return webSearch === true ? {} : webSearch;
632
+ };
633
+ /**
634
+ * Builds the provider-native web search tool definition for a model:
635
+ * - `claude-` → Anthropic's `web_search` server tool, executed by the Messages
636
+ * API within a single request and answered with citations. Defaults to the
637
+ * dynamic-filtering tool version on models that support it, so search results
638
+ * are filtered by code before they reach the context window.
639
+ * - `gpt-` → OpenAI's hosted `web_search` tool (Responses API). Context spend
640
+ * is governed by `search_context_size` (OpenAI defaults to `medium`).
641
+ * - `gemini-` → Google's `googleSearch` grounding tool. Google exposes no
642
+ * result-filtering or context-size control; leaving `searchTypes` unset keeps
643
+ * grounding on text-only web results rather than image bytes.
644
+ * Returns `null` for providers with no native web search (DeepSeek, Kimi, GLM),
645
+ * warning instead of throwing so one config can be pointed at any model.
646
+ * @param modelName - The model identifier being routed
647
+ * @param options - Normalized options from `getWebSearchOptions`
648
+ * @returns The provider's tool definition, or `null` when unsupported
649
+ */
650
+ const buildWebSearchTool = (modelName, options) => {
651
+ const { allowedDomains, blockedDomains, userLocation } = options;
652
+ if (modelName.startsWith("claude-")) {
653
+ // The API returns a 400 when both filters are present, so fail locally
654
+ // rather than paying for the round trip.
655
+ if (allowedDomains && blockedDomains) {
656
+ throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, "Anthropic web search accepts allowedDomains or blockedDomains, not both.");
657
+ }
658
+ // Prefer the newest tool version the model can actually run. Basic search
659
+ // loads every result into the context window; from `web_search_20260209`
660
+ // Claude filters them with code first, and `web_search_20260318` can also
661
+ // keep the consumed results out of the response.
662
+ const canFilter = supportsAnthropicDynamicFiltering(modelName);
663
+ const type = options.toolVersion ||
664
+ (canFilter ? "web_search_20260318" : "web_search_20250305");
665
+ const version = Number(type.slice(-8));
666
+ // Filtering versions default to running search from inside code execution.
667
+ // Say so explicitly when it is not wanted (or not possible), which is what
668
+ // the API requires from models without programmatic tool calling.
669
+ const directOnly = version >= 20260209 && (options.dynamicFiltering === false || !canFilter);
670
+ return {
671
+ type,
672
+ name: "web_search",
673
+ ...(options.maxUses ? { max_uses: options.maxUses } : {}),
674
+ ...(allowedDomains ? { allowed_domains: allowedDomains } : {}),
675
+ ...(blockedDomains ? { blocked_domains: blockedDomains } : {}),
676
+ ...(userLocation
677
+ ? { user_location: { type: "approximate", ...userLocation } }
678
+ : {}),
679
+ ...(directOnly ? { allowed_callers: ["direct"] } : {}),
680
+ ...(version >= 20260318
681
+ ? { response_inclusion: options.responseInclusion || "excluded" }
682
+ : {}),
683
+ };
684
+ }
685
+ if (modelName.startsWith("gpt-")) {
686
+ return {
687
+ type: "web_search",
688
+ ...(allowedDomains
689
+ ? { filters: { allowed_domains: allowedDomains } }
690
+ : {}),
691
+ ...(userLocation
692
+ ? { user_location: { type: "approximate", ...userLocation } }
693
+ : {}),
694
+ ...(options.searchContextSize
695
+ ? { search_context_size: options.searchContextSize }
696
+ : {}),
697
+ };
698
+ }
699
+ if (modelName.startsWith("gemini-")) {
700
+ // `timeRangeFilter` is the only filter the Gemini API exposes;
701
+ // `excludeDomains` is a Vertex AI field and is rejected here, so domain
702
+ // filters are deliberately not mapped for Google.
703
+ return {
704
+ googleSearch: options.timeRange
705
+ ? { timeRangeFilter: options.timeRange }
706
+ : {},
707
+ };
708
+ }
709
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Model "${modelName}" has no native web search tool; ignoring the configured webSearch options.`);
710
+ return null;
711
+ };
712
+ /**
713
+ * Flattens LangChain message content into plain text. Content is a string for
714
+ * ordinary completions, but every provider switches to an array of blocks once
715
+ * a server-side tool runs — Anthropic interleaves `server_tool_use` and
716
+ * `web_search_tool_result` blocks with the answer text, and OpenAI's Responses
717
+ * API returns annotated text blocks — so without this the JSON parse pipeline
718
+ * would receive a non-string and every web-search call would fail.
719
+ * @param content - A message's `content` field, or a raw string
720
+ * @returns The concatenated text of all text blocks
721
+ */
722
+ const extractTextContent = (content) => {
723
+ if (typeof content === "string")
724
+ return content;
725
+ if (!Array.isArray(content))
726
+ return "";
727
+ let text = "";
728
+ for (const block of content) {
729
+ if (typeof block === "string")
730
+ text += block;
731
+ else if (block?.type === "text" && typeof block.text === "string") {
732
+ text += block.text;
733
+ }
734
+ }
735
+ return text;
736
+ };
737
+ const createWebSearchUsage = () => ({
738
+ reportedRequests: 0,
739
+ callIds: new Set(),
740
+ grounded: false,
741
+ sources: new Map(),
742
+ });
743
+ /**
744
+ * Folds one message — or one streaming chunk — into a `WebSearchUsage`. Safe to
745
+ * call on every chunk of a stream and on messages that involved no search.
746
+ */
747
+ const collectWebSearchUsage = (payload, usage) => {
748
+ if (!payload)
749
+ return;
750
+ const addSource = (url, title) => {
751
+ if (typeof url === "string" && url && !usage.sources.has(url)) {
752
+ usage.sources.set(url, {
753
+ url,
754
+ ...(typeof title === "string" ? { title } : {}),
755
+ });
756
+ }
757
+ };
758
+ if (Array.isArray(payload.content)) {
759
+ for (const block of payload.content) {
760
+ if (!block || typeof block !== "object")
761
+ continue;
762
+ // Anthropic: results of a search the API executed server-side.
763
+ if (block.type === "web_search_tool_result" &&
764
+ Array.isArray(block.content)) {
765
+ for (const result of block.content) {
766
+ addSource(result?.url, result?.title);
767
+ }
768
+ }
769
+ // Anthropic: citations attached to the answer's text blocks.
770
+ if (Array.isArray(block.citations)) {
771
+ for (const citation of block.citations) {
772
+ addSource(citation?.url, citation?.title);
773
+ }
774
+ }
775
+ // OpenAI Responses API: one block per executed search, plus url citations.
776
+ if (block.type === "web_search_call" && block.id) {
777
+ usage.callIds.add(block.id);
778
+ }
779
+ if (Array.isArray(block.annotations)) {
780
+ for (const annotation of block.annotations) {
781
+ if (annotation?.type === "url_citation") {
782
+ addSource(annotation.url, annotation.title);
783
+ }
784
+ }
785
+ }
786
+ }
787
+ }
788
+ const metadata = payload.response_metadata;
789
+ if (!metadata)
790
+ return;
791
+ const requests = metadata.usage?.server_tool_use?.web_search_requests;
792
+ if (typeof requests === "number" && requests > usage.reportedRequests) {
793
+ usage.reportedRequests = requests;
794
+ }
795
+ const grounding = metadata.groundingMetadata;
796
+ if (grounding) {
797
+ usage.grounded = true;
798
+ for (const chunk of grounding.groundingChunks || []) {
799
+ addSource(chunk?.web?.uri, chunk?.web?.title);
800
+ }
801
+ }
802
+ };
803
+ /**
804
+ * Adds a call's web-search usage to the caller-supplied tracker. Searches are
805
+ * billed per request rather than per token (Anthropic charges $10 per 1,000
806
+ * searches; Google charges per grounded request), so the rate is read from the
807
+ * `<model>-web-search-costs` constant expressed in USD per 1,000 searches and
808
+ * accumulated under `<model>_webSearches`. Sources are appended to
809
+ * `tracker.webSearchSources`, deduplicated by URL across the whole run.
810
+ */
811
+ const updateWebSearchUsageTracker = (tracker, modelName, usage, config) => {
812
+ if (!tracker || !modelName)
813
+ return;
814
+ // The three signals describe the same searches from different providers, so
815
+ // the largest one is the count rather than their sum.
816
+ const searches = Math.max(usage.reportedRequests, usage.callIds.size, usage.grounded ? 1 : 0);
817
+ if (!searches && usage.sources.size === 0)
818
+ return;
819
+ if (typeof tracker.cost !== "number")
820
+ tracker.cost = 0;
821
+ if (!tracker.tokens || typeof tracker.tokens !== "object")
822
+ tracker.tokens = {};
823
+ if (searches > 0) {
824
+ const addedCost = (searches / 1_000) * getModelRate(modelName, config, "web-search");
825
+ if (Number.isFinite(addedCost) && addedCost > 0)
826
+ tracker.cost += addedCost;
827
+ const key = `${modelName}_webSearches`;
828
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + searches;
829
+ }
830
+ if (usage.sources.size > 0) {
831
+ const sources = tracker.webSearchSources || (tracker.webSearchSources = []);
832
+ for (const source of usage.sources.values()) {
833
+ if (!sources.some((existing) => existing.url === source.url)) {
834
+ sources.push(source);
835
+ }
836
+ }
837
+ }
838
+ };
611
839
  /**
612
840
  * Instantiates and returns the appropriate LangChain chat model based on the model
613
841
  * name prefix. `claude-` maps to `ChatAnthropic`, `gemini-` maps to
@@ -634,6 +862,13 @@ const getLLMModel = (modelName, config, schema = null) => {
634
862
  // Resolve `temperature` with presence/support/range handling (see
635
863
  // resolveTemperatureSetting). Applied uniformly to every provider below.
636
864
  const modelSettings = resolveTemperatureSetting(modelName, config);
865
+ // Native web search (see buildWebSearchTool). The tool is bound to the model
866
+ // so both `.invoke()` and `.stream()` pick it up; the provider runs the search
867
+ // server-side within the same request, so no client-side agent loop is needed.
868
+ const webSearchOptions = getWebSearchOptions(config);
869
+ const webSearchTool = webSearchOptions
870
+ ? buildWebSearchTool(modelName, webSearchOptions)
871
+ : null;
637
872
  // Claude models (Anthropic)
638
873
  if (modelName.startsWith("claude-")) {
639
874
  // Anthropic's SDK rejects non-streamed requests when max_tokens is large
@@ -658,7 +893,7 @@ const getLLMModel = (modelName, config, schema = null) => {
658
893
  },
659
894
  }
660
895
  : {};
661
- return new anthropic_1.ChatAnthropic({
896
+ const model = new anthropic_1.ChatAnthropic({
662
897
  apiKey: config.anthropicAPIKey,
663
898
  maxTokens,
664
899
  modelName: modelName,
@@ -667,6 +902,7 @@ const getLLMModel = (modelName, config, schema = null) => {
667
902
  ...outputConfig,
668
903
  ...modelSettings,
669
904
  });
905
+ return webSearchTool ? model.bindTools([webSearchTool]) : model;
670
906
  }
671
907
  // Gemini models (Google)
672
908
  else if (modelName.startsWith("gemini-")) {
@@ -679,6 +915,11 @@ const getLLMModel = (modelName, config, schema = null) => {
679
915
  ...(schema ? { json: true } : {}),
680
916
  ...modelSettings,
681
917
  });
918
+ // Combining grounding with structured output requires Gemini 3 or later;
919
+ // Gemini 1.5/2.x reject `responseSchema` alongside `googleSearch` with a 400.
920
+ const bound = webSearchTool
921
+ ? model.bindTools([webSearchTool])
922
+ : model;
682
923
  // `responseSchema` additionally constrains the output shape. It is a
683
924
  // call-time option (not a constructor field), so it is bound onto the model
684
925
  // via `withConfig`. Gemini's schema is an OpenAPI 3.0 subset, so
@@ -686,9 +927,9 @@ const getLLMModel = (modelName, config, schema = null) => {
686
927
  // parse/validate pipeline is unchanged.
687
928
  if (schema) {
688
929
  const jsonSchema = sanitizeGeminiSchema(buildJsonSchema(schema));
689
- return model.withConfig({ responseSchema: jsonSchema });
930
+ return bound.withConfig({ responseSchema: jsonSchema });
690
931
  }
691
- return model;
932
+ return bound;
692
933
  }
693
934
  // GPT models (OpenAI)
694
935
  else if (modelName.startsWith("gpt-")) {
@@ -696,23 +937,41 @@ const getLLMModel = (modelName, config, schema = null) => {
696
937
  apiKey: config.openAIAPIKey,
697
938
  max_tokens: config.maxTokens || 200000,
698
939
  modelName: modelName,
940
+ // `web_search` is a hosted Responses API tool, so the request has to go to
941
+ // `/v1/responses` rather than `/v1/chat/completions`.
942
+ ...(webSearchTool ? { useResponsesApi: true } : {}),
699
943
  ...modelSettings,
700
944
  };
701
- // Use native response_format with JSON schema for structured output
945
+ // Use native structured output with a JSON schema. The two endpoints spell
946
+ // the same thing differently — Chat Completions takes `response_format`,
947
+ // the Responses API takes `text.format` with the schema flattened one level
948
+ // — and `modelKwargs` is spread verbatim into whichever request is built.
702
949
  if (schema) {
703
950
  const jsonSchema = strictifyJsonSchema(buildJsonSchema(schema));
704
- openAISettings.modelKwargs = {
705
- response_format: {
706
- type: "json_schema",
707
- json_schema: {
708
- name: "response_schema",
709
- strict: true,
710
- schema: jsonSchema,
951
+ openAISettings.modelKwargs = webSearchTool
952
+ ? {
953
+ text: {
954
+ format: {
955
+ type: "json_schema",
956
+ name: "response_schema",
957
+ strict: true,
958
+ schema: jsonSchema,
959
+ },
711
960
  },
712
- },
713
- };
961
+ }
962
+ : {
963
+ response_format: {
964
+ type: "json_schema",
965
+ json_schema: {
966
+ name: "response_schema",
967
+ strict: true,
968
+ schema: jsonSchema,
969
+ },
970
+ },
971
+ };
714
972
  }
715
- return new openai_1.ChatOpenAI(openAISettings);
973
+ const model = new openai_1.ChatOpenAI(openAISettings);
974
+ return webSearchTool ? model.bindTools([webSearchTool]) : model;
716
975
  }
717
976
  // OpenAI-compatible providers: DeepSeek, Kimi (Moonshot), GLM (Zhipu)
718
977
  const openAICompatible = getOpenAICompatibleProvider(modelName, config);
@@ -741,11 +1000,18 @@ const getLLMModel = (modelName, config, schema = null) => {
741
1000
  * @returns A configured LangChain agent instance ready to be run with `runAgent`
742
1001
  */
743
1002
  const createAgent = (name, modelName, systemPrompt, tools = [], responseFormat, config) => {
1003
+ // Native web search joins the agent's tool list instead of being bound inside
1004
+ // `getLLMModel`: the agent binds its own tools to the model, which would drop
1005
+ // anything already bound there.
1006
+ const webSearchOptions = getWebSearchOptions(config);
1007
+ const webSearchTool = webSearchOptions
1008
+ ? buildWebSearchTool(modelName, webSearchOptions)
1009
+ : null;
744
1010
  const agent = (0, langchain_1.createAgent)({
745
1011
  name: name,
746
- model: getLLMModel(modelName, config),
1012
+ model: getLLMModel(modelName, { ...config, webSearch: null }),
747
1013
  systemPrompt: systemPrompt.trim(),
748
- tools,
1014
+ tools: webSearchTool ? [...tools, webSearchTool] : tools,
749
1015
  ...(responseFormat ? { responseFormat: responseFormat } : {}),
750
1016
  });
751
1017
  return agent;
@@ -832,6 +1098,13 @@ const runAgent = async (agent, prompt, config, onProgress = null, usageTracker =
832
1098
  }
833
1099
  }
834
1100
  updateUsageTracker(usageTracker, modelName, sumAgentResponseUsage(response), config);
1101
+ // Web-search activity is spread across the agent's messages — one search may
1102
+ // be reported by the message that ran it and cited by a later one.
1103
+ const webSearchUsage = createWebSearchUsage();
1104
+ for (const message of response?.messages || []) {
1105
+ collectWebSearchUsage(message, webSearchUsage);
1106
+ }
1107
+ updateWebSearchUsageTracker(usageTracker, modelName, webSearchUsage, config);
835
1108
  const endTime = Date.now();
836
1109
  const duration = endTime - startTime;
837
1110
  logger_js_1.default.log(null, logger_js_1.default.levels.info, `Agent "${agent.options?.name}" completed in ${Math.round(duration / 1000)} seconds.`);
@@ -1068,10 +1341,16 @@ const buildValidationRetryMessages = (priorMessages, rawContent, validationError
1068
1341
  *
1069
1342
  * When `expectsJsonResponse` is `true`, JSON escape instructions are prepended to the
1070
1343
  * system prompt and the parsed result is optionally validated against `schema`.
1344
+ *
1345
+ * Setting `config.webSearch` enables the provider's native web search tool in every
1346
+ * mode (see `buildWebSearchTool`). The provider runs the search server-side inside the
1347
+ * same request, so the return contract is unchanged; the searches performed and the
1348
+ * sources cited are recorded on `usageTracker`.
1071
1349
  * @param modelName - The model identifier, e.g. `"gpt-4o"`, `"claude-3-5-sonnet"`,
1072
1350
  * `"gemini-1.5-pro"`
1073
1351
  * @param config - Configuration object with API keys, `temperature`, optional `agentic`
1074
- * flag, and optional `recursionLimit`
1352
+ * flag, optional `recursionLimit`, and optional `webSearch` (`true` or a
1353
+ * `WebSearchConfig`)
1075
1354
  * @param prompt - The prompt to send; either a plain string (user message only) or an
1076
1355
  * array of `{ role, content }` message objects
1077
1356
  * @param onProgressReport - Optional async callback invoked with `{ message, progress }`
@@ -1133,12 +1412,9 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1133
1412
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "Agent returned no messages");
1134
1413
  }
1135
1414
  const lastMessage = messages[messages.length - 1];
1136
- let rawContent = lastMessage?.content || "";
1137
- // Handle array content blocks (e.g., from Gemini/Claude agent responses)
1138
- if (Array.isArray(rawContent)) {
1139
- const textBlock = rawContent.find((block) => typeof block === "object" && block.type === "text");
1140
- rawContent = textBlock?.text || "";
1141
- }
1415
+ // Flattens the array content blocks that Gemini/Claude agent responses and
1416
+ // any server-tool turn (e.g. web search) return.
1417
+ const rawContent = extractTextContent(lastMessage?.content);
1142
1418
  // If not expecting JSON, return raw content directly
1143
1419
  if (!expectsJsonResponse) {
1144
1420
  return rawContent;
@@ -1260,6 +1536,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1260
1536
  output_tokens: 0,
1261
1537
  total_tokens: 0,
1262
1538
  };
1539
+ let webSearchUsage = createWebSearchUsage();
1263
1540
  // Inner loop: wait + retry on 429 around stream setup and consumption.
1264
1541
  // Usage is only recorded on a successful stream — partial streams that
1265
1542
  // error out with a rate limit are not counted. A 429 fired mid-stream
@@ -1269,6 +1546,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1269
1546
  rawContent = "";
1270
1547
  chunkCount = 0;
1271
1548
  streamUsage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
1549
+ webSearchUsage = createWebSearchUsage();
1272
1550
  try {
1273
1551
  // Honour caller cancellation: passing the signal tears down the
1274
1552
  // upstream HTTP request so a cancelled call stops billing tokens.
@@ -1281,16 +1559,16 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1281
1559
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, "Request cancelled by caller");
1282
1560
  }
1283
1561
  accumulateChunkUsage(streamUsage, chunk);
1284
- const content = chunk?.content || chunk;
1285
- if (typeof content === "string") {
1286
- rawContent += content;
1287
- chunkCount++;
1288
- if (chunkCount % progressReportInterval === 0) {
1289
- await onProgressReport({
1290
- message: "Generating content...",
1291
- progress: Math.min(calcCurrentProgress(), maxPercent - 5),
1292
- });
1293
- }
1562
+ collectWebSearchUsage(chunk, webSearchUsage);
1563
+ // Counting every chunk (not just the ones carrying text) keeps
1564
+ // progress ticking through the pause while a search runs.
1565
+ chunkCount++;
1566
+ rawContent += extractTextContent(chunk?.content ?? chunk);
1567
+ if (chunkCount % progressReportInterval === 0) {
1568
+ await onProgressReport({
1569
+ message: "Generating content...",
1570
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
1571
+ });
1294
1572
  }
1295
1573
  }
1296
1574
  break; // stream completed without 429
@@ -1313,6 +1591,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1313
1591
  }
1314
1592
  }
1315
1593
  updateUsageTracker(usageTracker, modelName, streamUsage, config);
1594
+ updateWebSearchUsageTracker(usageTracker, modelName, webSearchUsage, config);
1316
1595
  if (!rawContent) {
1317
1596
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
1318
1597
  }
@@ -1417,7 +1696,11 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1417
1696
  }
1418
1697
  }
1419
1698
  updateUsageTracker(usageTracker, modelName, extractUsageFromInvoke(response), config);
1420
- const rawContent = response?.content || response;
1699
+ const webSearchUsage = createWebSearchUsage();
1700
+ collectWebSearchUsage(response, webSearchUsage);
1701
+ updateWebSearchUsageTracker(usageTracker, modelName, webSearchUsage, config);
1702
+ // Flattened because a server-tool turn returns content blocks, not a string.
1703
+ const rawContent = extractTextContent(response?.content ?? response);
1421
1704
  // If not expecting JSON, return raw content directly
1422
1705
  if (!expectsJsonResponse) {
1423
1706
  return rawContent;
@@ -9,7 +9,7 @@ export { constants };
9
9
  export { errorHandling, AppError };
10
10
  export type { ParsedError } from "./errorHandling.js";
11
11
  export { langChain };
12
- export type { UsageTracker } from "./langChain.js";
12
+ export type { UsageTracker, WebSearchConfig } from "./langChain.js";
13
13
  export { logger };
14
14
  export { serve };
15
15
  export { callAgent };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,YAAY,CAAC;AACnC,OAAO,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,SAAS,MAAM,gBAAgB,CAAC;AACvC,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,cAAc,MAAM,qBAAqB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC;AAEnC,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,YAAY,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,OAAO,EAAE,MAAM,EAAE,CAAC;AAElB,OAAO,EAAE,KAAK,EAAE,CAAC;AAEjB,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,UAAU,EAAE,CAAC;AAEtB,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,YAAY,CAAC;AACnC,OAAO,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,SAAS,MAAM,gBAAgB,CAAC;AACvC,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,cAAc,MAAM,qBAAqB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC;AAEnC,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,YAAY,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAAE,MAAM,EAAE,CAAC;AAElB,OAAO,EAAE,KAAK,EAAE,CAAC;AAEjB,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,UAAU,EAAE,CAAC;AAEtB,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,CAAC"}
@@ -9,6 +9,73 @@ export type UsageTracker = {
9
9
  tokens: {
10
10
  [tokenKey: string]: number;
11
11
  };
12
+ /**
13
+ * Sources returned by native web search, deduplicated by URL across every
14
+ * call in the run. Only present once a search has actually run. Anthropic and
15
+ * Google both require the original sources to be cited when their output is
16
+ * shown to end users, so they are surfaced here rather than discarded with
17
+ * the rest of the non-text content blocks.
18
+ */
19
+ webSearchSources?: {
20
+ url: string;
21
+ title?: string;
22
+ }[];
23
+ };
24
+ /**
25
+ * Caller-facing options for the providers' native web search tools. Enable web
26
+ * search by setting `config.webSearch` to `true` (provider defaults) or to one
27
+ * of these objects. Every field is optional and is only forwarded to the
28
+ * providers that accept it — see `buildWebSearchTool` for the mapping.
29
+ */
30
+ export type WebSearchConfig = {
31
+ /** Anthropic only: hard cap on searches per request (`max_uses`). */
32
+ maxUses?: number;
33
+ /** Anthropic (`allowed_domains`) and OpenAI (`filters.allowed_domains`). */
34
+ allowedDomains?: string[];
35
+ /** Anthropic only (`blocked_domains`); cannot be combined with `allowedDomains`. */
36
+ blockedDomains?: string[];
37
+ /** Anthropic and OpenAI: approximate location used to localize results. */
38
+ userLocation?: {
39
+ city?: string;
40
+ region?: string;
41
+ /** Two-letter ISO 3166-1 alpha-2 code, e.g. `"US"`. */
42
+ country?: string;
43
+ /** IANA timezone ID, e.g. `"America/Los_Angeles"`. */
44
+ timezone?: string;
45
+ };
46
+ /**
47
+ * OpenAI only: how much of the context window search results may consume.
48
+ * OpenAI's default is `"medium"`; `"low"` minimizes context at some cost to
49
+ * answer quality, `"high"` is the expensive end.
50
+ */
51
+ searchContextSize?: "low" | "medium" | "high";
52
+ /** Gemini only: RFC 3339 window the search is restricted to. */
53
+ timeRange?: {
54
+ startTime: string;
55
+ endTime: string;
56
+ };
57
+ /**
58
+ * Anthropic only: opt out of dynamic filtering by forcing the search to be
59
+ * called directly (`allowed_callers: ["direct"]`). Defaults to `true` on
60
+ * models that support it — see `supportsAnthropicDynamicFiltering`. Turning
61
+ * this off means every raw search result lands in the context window.
62
+ */
63
+ dynamicFiltering?: boolean;
64
+ /**
65
+ * Anthropic only: whether search result blocks consumed by dynamic filtering
66
+ * are echoed back in the response. Defaults to `"excluded"`, which drops them
67
+ * and cuts the output tokens billed for content nothing downstream reads.
68
+ */
69
+ responseInclusion?: "full" | "excluded";
70
+ /**
71
+ * Anthropic only: pin the dated tool version instead of letting
72
+ * `buildWebSearchTool` pick per model. `web_search_20250305` is basic search,
73
+ * `web_search_20260209` adds dynamic filtering, `web_search_20260318` adds
74
+ * response-inclusion control. Pinning a filtering version on a model that
75
+ * cannot do programmatic tool calling returns a 400 unless
76
+ * `dynamicFiltering: false` is also set.
77
+ */
78
+ toolVersion?: string;
12
79
  };
13
80
  declare const _default: {
14
81
  checkIfAIProviderConfigured: (config: any) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AAuGA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACxC,CAAC;;0CAE2C,GAAG,KAAG,IAAI;wBAktB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBA0BG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCA4XF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;sDA2tBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;0CA//B8B,GAAG,KAAG,MAAM;+CAprB9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,UACd,GAAG,KACV,IAAI;mCAosBU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AAsiCT,wBASE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AAuGA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACvC;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACtD,CAAC;AAskBF;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,qEAAqE;IACrE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,oFAAoF;IACpF,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,2EAA2E;IAC3E,YAAY,CAAC,EAAE;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,uDAAuD;QACvD,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,sDAAsD;QACtD,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,CAAC;IACF;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC9C,gEAAgE;IAChE,SAAS,CAAC,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IACxC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;;0CAxnB2C,GAAG,KAAG,IAAI;wBAujC/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAiCG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCA0YF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;sDAuuBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;0CAjhC8B,GAAG,KAAG,MAAM;+CAviC9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,UACd,GAAG,KACV,IAAI;mCAujCU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AAwjCT,wBASE"}
@@ -603,6 +603,234 @@ const resolveTemperatureSetting = (modelName, config) => {
603
603
  }
604
604
  return { temperature: clamped };
605
605
  };
606
+ /**
607
+ * Whether a Claude model can run web search through dynamic filtering, where
608
+ * Claude writes and runs code that filters the search results before they reach
609
+ * the context window instead of loading every result into it. Requires Claude
610
+ * 4.6 or later (the models with programmatic tool calling); on anything earlier
611
+ * the filtering tool versions return a 400 unless search is pinned to
612
+ * `allowed_callers: ["direct"]`.
613
+ * @param modelName - The Claude model identifier being routed
614
+ * @returns `true` when the model supports dynamic filtering
615
+ */
616
+ const supportsAnthropicDynamicFiltering = (modelName) => /^claude-(opus|sonnet|haiku)-4-(?:[6-9]|\d\d)\b/.test(modelName) ||
617
+ /^claude-(opus|sonnet|haiku|fable|mythos)-(?:[5-9]|\d\d)\b/.test(modelName);
618
+ /**
619
+ * Normalizes `config.webSearch` into an options object, returning `null` when
620
+ * web search is off so callers can use it as the single enablement gate.
621
+ */
622
+ const getWebSearchOptions = (config) => {
623
+ const webSearch = config?.webSearch;
624
+ if (!webSearch)
625
+ return null;
626
+ return webSearch === true ? {} : webSearch;
627
+ };
628
+ /**
629
+ * Builds the provider-native web search tool definition for a model:
630
+ * - `claude-` → Anthropic's `web_search` server tool, executed by the Messages
631
+ * API within a single request and answered with citations. Defaults to the
632
+ * dynamic-filtering tool version on models that support it, so search results
633
+ * are filtered by code before they reach the context window.
634
+ * - `gpt-` → OpenAI's hosted `web_search` tool (Responses API). Context spend
635
+ * is governed by `search_context_size` (OpenAI defaults to `medium`).
636
+ * - `gemini-` → Google's `googleSearch` grounding tool. Google exposes no
637
+ * result-filtering or context-size control; leaving `searchTypes` unset keeps
638
+ * grounding on text-only web results rather than image bytes.
639
+ * Returns `null` for providers with no native web search (DeepSeek, Kimi, GLM),
640
+ * warning instead of throwing so one config can be pointed at any model.
641
+ * @param modelName - The model identifier being routed
642
+ * @param options - Normalized options from `getWebSearchOptions`
643
+ * @returns The provider's tool definition, or `null` when unsupported
644
+ */
645
+ const buildWebSearchTool = (modelName, options) => {
646
+ const { allowedDomains, blockedDomains, userLocation } = options;
647
+ if (modelName.startsWith("claude-")) {
648
+ // The API returns a 400 when both filters are present, so fail locally
649
+ // rather than paying for the round trip.
650
+ if (allowedDomains && blockedDomains) {
651
+ throw errorHandlingHelper.create(constants.HTTP_CODES.BAD_REQUEST, "Anthropic web search accepts allowedDomains or blockedDomains, not both.");
652
+ }
653
+ // Prefer the newest tool version the model can actually run. Basic search
654
+ // loads every result into the context window; from `web_search_20260209`
655
+ // Claude filters them with code first, and `web_search_20260318` can also
656
+ // keep the consumed results out of the response.
657
+ const canFilter = supportsAnthropicDynamicFiltering(modelName);
658
+ const type = options.toolVersion ||
659
+ (canFilter ? "web_search_20260318" : "web_search_20250305");
660
+ const version = Number(type.slice(-8));
661
+ // Filtering versions default to running search from inside code execution.
662
+ // Say so explicitly when it is not wanted (or not possible), which is what
663
+ // the API requires from models without programmatic tool calling.
664
+ const directOnly = version >= 20260209 && (options.dynamicFiltering === false || !canFilter);
665
+ return {
666
+ type,
667
+ name: "web_search",
668
+ ...(options.maxUses ? { max_uses: options.maxUses } : {}),
669
+ ...(allowedDomains ? { allowed_domains: allowedDomains } : {}),
670
+ ...(blockedDomains ? { blocked_domains: blockedDomains } : {}),
671
+ ...(userLocation
672
+ ? { user_location: { type: "approximate", ...userLocation } }
673
+ : {}),
674
+ ...(directOnly ? { allowed_callers: ["direct"] } : {}),
675
+ ...(version >= 20260318
676
+ ? { response_inclusion: options.responseInclusion || "excluded" }
677
+ : {}),
678
+ };
679
+ }
680
+ if (modelName.startsWith("gpt-")) {
681
+ return {
682
+ type: "web_search",
683
+ ...(allowedDomains
684
+ ? { filters: { allowed_domains: allowedDomains } }
685
+ : {}),
686
+ ...(userLocation
687
+ ? { user_location: { type: "approximate", ...userLocation } }
688
+ : {}),
689
+ ...(options.searchContextSize
690
+ ? { search_context_size: options.searchContextSize }
691
+ : {}),
692
+ };
693
+ }
694
+ if (modelName.startsWith("gemini-")) {
695
+ // `timeRangeFilter` is the only filter the Gemini API exposes;
696
+ // `excludeDomains` is a Vertex AI field and is rejected here, so domain
697
+ // filters are deliberately not mapped for Google.
698
+ return {
699
+ googleSearch: options.timeRange
700
+ ? { timeRangeFilter: options.timeRange }
701
+ : {},
702
+ };
703
+ }
704
+ logger.log(null, logger.levels.warn, `Model "${modelName}" has no native web search tool; ignoring the configured webSearch options.`);
705
+ return null;
706
+ };
707
+ /**
708
+ * Flattens LangChain message content into plain text. Content is a string for
709
+ * ordinary completions, but every provider switches to an array of blocks once
710
+ * a server-side tool runs — Anthropic interleaves `server_tool_use` and
711
+ * `web_search_tool_result` blocks with the answer text, and OpenAI's Responses
712
+ * API returns annotated text blocks — so without this the JSON parse pipeline
713
+ * would receive a non-string and every web-search call would fail.
714
+ * @param content - A message's `content` field, or a raw string
715
+ * @returns The concatenated text of all text blocks
716
+ */
717
+ const extractTextContent = (content) => {
718
+ if (typeof content === "string")
719
+ return content;
720
+ if (!Array.isArray(content))
721
+ return "";
722
+ let text = "";
723
+ for (const block of content) {
724
+ if (typeof block === "string")
725
+ text += block;
726
+ else if (block?.type === "text" && typeof block.text === "string") {
727
+ text += block.text;
728
+ }
729
+ }
730
+ return text;
731
+ };
732
+ const createWebSearchUsage = () => ({
733
+ reportedRequests: 0,
734
+ callIds: new Set(),
735
+ grounded: false,
736
+ sources: new Map(),
737
+ });
738
+ /**
739
+ * Folds one message — or one streaming chunk — into a `WebSearchUsage`. Safe to
740
+ * call on every chunk of a stream and on messages that involved no search.
741
+ */
742
+ const collectWebSearchUsage = (payload, usage) => {
743
+ if (!payload)
744
+ return;
745
+ const addSource = (url, title) => {
746
+ if (typeof url === "string" && url && !usage.sources.has(url)) {
747
+ usage.sources.set(url, {
748
+ url,
749
+ ...(typeof title === "string" ? { title } : {}),
750
+ });
751
+ }
752
+ };
753
+ if (Array.isArray(payload.content)) {
754
+ for (const block of payload.content) {
755
+ if (!block || typeof block !== "object")
756
+ continue;
757
+ // Anthropic: results of a search the API executed server-side.
758
+ if (block.type === "web_search_tool_result" &&
759
+ Array.isArray(block.content)) {
760
+ for (const result of block.content) {
761
+ addSource(result?.url, result?.title);
762
+ }
763
+ }
764
+ // Anthropic: citations attached to the answer's text blocks.
765
+ if (Array.isArray(block.citations)) {
766
+ for (const citation of block.citations) {
767
+ addSource(citation?.url, citation?.title);
768
+ }
769
+ }
770
+ // OpenAI Responses API: one block per executed search, plus url citations.
771
+ if (block.type === "web_search_call" && block.id) {
772
+ usage.callIds.add(block.id);
773
+ }
774
+ if (Array.isArray(block.annotations)) {
775
+ for (const annotation of block.annotations) {
776
+ if (annotation?.type === "url_citation") {
777
+ addSource(annotation.url, annotation.title);
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+ const metadata = payload.response_metadata;
784
+ if (!metadata)
785
+ return;
786
+ const requests = metadata.usage?.server_tool_use?.web_search_requests;
787
+ if (typeof requests === "number" && requests > usage.reportedRequests) {
788
+ usage.reportedRequests = requests;
789
+ }
790
+ const grounding = metadata.groundingMetadata;
791
+ if (grounding) {
792
+ usage.grounded = true;
793
+ for (const chunk of grounding.groundingChunks || []) {
794
+ addSource(chunk?.web?.uri, chunk?.web?.title);
795
+ }
796
+ }
797
+ };
798
+ /**
799
+ * Adds a call's web-search usage to the caller-supplied tracker. Searches are
800
+ * billed per request rather than per token (Anthropic charges $10 per 1,000
801
+ * searches; Google charges per grounded request), so the rate is read from the
802
+ * `<model>-web-search-costs` constant expressed in USD per 1,000 searches and
803
+ * accumulated under `<model>_webSearches`. Sources are appended to
804
+ * `tracker.webSearchSources`, deduplicated by URL across the whole run.
805
+ */
806
+ const updateWebSearchUsageTracker = (tracker, modelName, usage, config) => {
807
+ if (!tracker || !modelName)
808
+ return;
809
+ // The three signals describe the same searches from different providers, so
810
+ // the largest one is the count rather than their sum.
811
+ const searches = Math.max(usage.reportedRequests, usage.callIds.size, usage.grounded ? 1 : 0);
812
+ if (!searches && usage.sources.size === 0)
813
+ return;
814
+ if (typeof tracker.cost !== "number")
815
+ tracker.cost = 0;
816
+ if (!tracker.tokens || typeof tracker.tokens !== "object")
817
+ tracker.tokens = {};
818
+ if (searches > 0) {
819
+ const addedCost = (searches / 1_000) * getModelRate(modelName, config, "web-search");
820
+ if (Number.isFinite(addedCost) && addedCost > 0)
821
+ tracker.cost += addedCost;
822
+ const key = `${modelName}_webSearches`;
823
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + searches;
824
+ }
825
+ if (usage.sources.size > 0) {
826
+ const sources = tracker.webSearchSources || (tracker.webSearchSources = []);
827
+ for (const source of usage.sources.values()) {
828
+ if (!sources.some((existing) => existing.url === source.url)) {
829
+ sources.push(source);
830
+ }
831
+ }
832
+ }
833
+ };
606
834
  /**
607
835
  * Instantiates and returns the appropriate LangChain chat model based on the model
608
836
  * name prefix. `claude-` maps to `ChatAnthropic`, `gemini-` maps to
@@ -629,6 +857,13 @@ const getLLMModel = (modelName, config, schema = null) => {
629
857
  // Resolve `temperature` with presence/support/range handling (see
630
858
  // resolveTemperatureSetting). Applied uniformly to every provider below.
631
859
  const modelSettings = resolveTemperatureSetting(modelName, config);
860
+ // Native web search (see buildWebSearchTool). The tool is bound to the model
861
+ // so both `.invoke()` and `.stream()` pick it up; the provider runs the search
862
+ // server-side within the same request, so no client-side agent loop is needed.
863
+ const webSearchOptions = getWebSearchOptions(config);
864
+ const webSearchTool = webSearchOptions
865
+ ? buildWebSearchTool(modelName, webSearchOptions)
866
+ : null;
632
867
  // Claude models (Anthropic)
633
868
  if (modelName.startsWith("claude-")) {
634
869
  // Anthropic's SDK rejects non-streamed requests when max_tokens is large
@@ -653,7 +888,7 @@ const getLLMModel = (modelName, config, schema = null) => {
653
888
  },
654
889
  }
655
890
  : {};
656
- return new ChatAnthropic({
891
+ const model = new ChatAnthropic({
657
892
  apiKey: config.anthropicAPIKey,
658
893
  maxTokens,
659
894
  modelName: modelName,
@@ -662,6 +897,7 @@ const getLLMModel = (modelName, config, schema = null) => {
662
897
  ...outputConfig,
663
898
  ...modelSettings,
664
899
  });
900
+ return webSearchTool ? model.bindTools([webSearchTool]) : model;
665
901
  }
666
902
  // Gemini models (Google)
667
903
  else if (modelName.startsWith("gemini-")) {
@@ -674,6 +910,11 @@ const getLLMModel = (modelName, config, schema = null) => {
674
910
  ...(schema ? { json: true } : {}),
675
911
  ...modelSettings,
676
912
  });
913
+ // Combining grounding with structured output requires Gemini 3 or later;
914
+ // Gemini 1.5/2.x reject `responseSchema` alongside `googleSearch` with a 400.
915
+ const bound = webSearchTool
916
+ ? model.bindTools([webSearchTool])
917
+ : model;
677
918
  // `responseSchema` additionally constrains the output shape. It is a
678
919
  // call-time option (not a constructor field), so it is bound onto the model
679
920
  // via `withConfig`. Gemini's schema is an OpenAPI 3.0 subset, so
@@ -681,9 +922,9 @@ const getLLMModel = (modelName, config, schema = null) => {
681
922
  // parse/validate pipeline is unchanged.
682
923
  if (schema) {
683
924
  const jsonSchema = sanitizeGeminiSchema(buildJsonSchema(schema));
684
- return model.withConfig({ responseSchema: jsonSchema });
925
+ return bound.withConfig({ responseSchema: jsonSchema });
685
926
  }
686
- return model;
927
+ return bound;
687
928
  }
688
929
  // GPT models (OpenAI)
689
930
  else if (modelName.startsWith("gpt-")) {
@@ -691,23 +932,41 @@ const getLLMModel = (modelName, config, schema = null) => {
691
932
  apiKey: config.openAIAPIKey,
692
933
  max_tokens: config.maxTokens || 200000,
693
934
  modelName: modelName,
935
+ // `web_search` is a hosted Responses API tool, so the request has to go to
936
+ // `/v1/responses` rather than `/v1/chat/completions`.
937
+ ...(webSearchTool ? { useResponsesApi: true } : {}),
694
938
  ...modelSettings,
695
939
  };
696
- // Use native response_format with JSON schema for structured output
940
+ // Use native structured output with a JSON schema. The two endpoints spell
941
+ // the same thing differently — Chat Completions takes `response_format`,
942
+ // the Responses API takes `text.format` with the schema flattened one level
943
+ // — and `modelKwargs` is spread verbatim into whichever request is built.
697
944
  if (schema) {
698
945
  const jsonSchema = strictifyJsonSchema(buildJsonSchema(schema));
699
- openAISettings.modelKwargs = {
700
- response_format: {
701
- type: "json_schema",
702
- json_schema: {
703
- name: "response_schema",
704
- strict: true,
705
- schema: jsonSchema,
946
+ openAISettings.modelKwargs = webSearchTool
947
+ ? {
948
+ text: {
949
+ format: {
950
+ type: "json_schema",
951
+ name: "response_schema",
952
+ strict: true,
953
+ schema: jsonSchema,
954
+ },
706
955
  },
707
- },
708
- };
956
+ }
957
+ : {
958
+ response_format: {
959
+ type: "json_schema",
960
+ json_schema: {
961
+ name: "response_schema",
962
+ strict: true,
963
+ schema: jsonSchema,
964
+ },
965
+ },
966
+ };
709
967
  }
710
- return new ChatOpenAI(openAISettings);
968
+ const model = new ChatOpenAI(openAISettings);
969
+ return webSearchTool ? model.bindTools([webSearchTool]) : model;
711
970
  }
712
971
  // OpenAI-compatible providers: DeepSeek, Kimi (Moonshot), GLM (Zhipu)
713
972
  const openAICompatible = getOpenAICompatibleProvider(modelName, config);
@@ -736,11 +995,18 @@ const getLLMModel = (modelName, config, schema = null) => {
736
995
  * @returns A configured LangChain agent instance ready to be run with `runAgent`
737
996
  */
738
997
  const createAgent = (name, modelName, systemPrompt, tools = [], responseFormat, config) => {
998
+ // Native web search joins the agent's tool list instead of being bound inside
999
+ // `getLLMModel`: the agent binds its own tools to the model, which would drop
1000
+ // anything already bound there.
1001
+ const webSearchOptions = getWebSearchOptions(config);
1002
+ const webSearchTool = webSearchOptions
1003
+ ? buildWebSearchTool(modelName, webSearchOptions)
1004
+ : null;
739
1005
  const agent = createLangChainAgent({
740
1006
  name: name,
741
- model: getLLMModel(modelName, config),
1007
+ model: getLLMModel(modelName, { ...config, webSearch: null }),
742
1008
  systemPrompt: systemPrompt.trim(),
743
- tools,
1009
+ tools: webSearchTool ? [...tools, webSearchTool] : tools,
744
1010
  ...(responseFormat ? { responseFormat: responseFormat } : {}),
745
1011
  });
746
1012
  return agent;
@@ -827,6 +1093,13 @@ const runAgent = async (agent, prompt, config, onProgress = null, usageTracker =
827
1093
  }
828
1094
  }
829
1095
  updateUsageTracker(usageTracker, modelName, sumAgentResponseUsage(response), config);
1096
+ // Web-search activity is spread across the agent's messages — one search may
1097
+ // be reported by the message that ran it and cited by a later one.
1098
+ const webSearchUsage = createWebSearchUsage();
1099
+ for (const message of response?.messages || []) {
1100
+ collectWebSearchUsage(message, webSearchUsage);
1101
+ }
1102
+ updateWebSearchUsageTracker(usageTracker, modelName, webSearchUsage, config);
830
1103
  const endTime = Date.now();
831
1104
  const duration = endTime - startTime;
832
1105
  logger.log(null, logger.levels.info, `Agent "${agent.options?.name}" completed in ${Math.round(duration / 1000)} seconds.`);
@@ -1063,10 +1336,16 @@ const buildValidationRetryMessages = (priorMessages, rawContent, validationError
1063
1336
  *
1064
1337
  * When `expectsJsonResponse` is `true`, JSON escape instructions are prepended to the
1065
1338
  * system prompt and the parsed result is optionally validated against `schema`.
1339
+ *
1340
+ * Setting `config.webSearch` enables the provider's native web search tool in every
1341
+ * mode (see `buildWebSearchTool`). The provider runs the search server-side inside the
1342
+ * same request, so the return contract is unchanged; the searches performed and the
1343
+ * sources cited are recorded on `usageTracker`.
1066
1344
  * @param modelName - The model identifier, e.g. `"gpt-4o"`, `"claude-3-5-sonnet"`,
1067
1345
  * `"gemini-1.5-pro"`
1068
1346
  * @param config - Configuration object with API keys, `temperature`, optional `agentic`
1069
- * flag, and optional `recursionLimit`
1347
+ * flag, optional `recursionLimit`, and optional `webSearch` (`true` or a
1348
+ * `WebSearchConfig`)
1070
1349
  * @param prompt - The prompt to send; either a plain string (user message only) or an
1071
1350
  * array of `{ role, content }` message objects
1072
1351
  * @param onProgressReport - Optional async callback invoked with `{ message, progress }`
@@ -1128,12 +1407,9 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1128
1407
  throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "Agent returned no messages");
1129
1408
  }
1130
1409
  const lastMessage = messages[messages.length - 1];
1131
- let rawContent = lastMessage?.content || "";
1132
- // Handle array content blocks (e.g., from Gemini/Claude agent responses)
1133
- if (Array.isArray(rawContent)) {
1134
- const textBlock = rawContent.find((block) => typeof block === "object" && block.type === "text");
1135
- rawContent = textBlock?.text || "";
1136
- }
1410
+ // Flattens the array content blocks that Gemini/Claude agent responses and
1411
+ // any server-tool turn (e.g. web search) return.
1412
+ const rawContent = extractTextContent(lastMessage?.content);
1137
1413
  // If not expecting JSON, return raw content directly
1138
1414
  if (!expectsJsonResponse) {
1139
1415
  return rawContent;
@@ -1255,6 +1531,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1255
1531
  output_tokens: 0,
1256
1532
  total_tokens: 0,
1257
1533
  };
1534
+ let webSearchUsage = createWebSearchUsage();
1258
1535
  // Inner loop: wait + retry on 429 around stream setup and consumption.
1259
1536
  // Usage is only recorded on a successful stream — partial streams that
1260
1537
  // error out with a rate limit are not counted. A 429 fired mid-stream
@@ -1264,6 +1541,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1264
1541
  rawContent = "";
1265
1542
  chunkCount = 0;
1266
1543
  streamUsage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
1544
+ webSearchUsage = createWebSearchUsage();
1267
1545
  try {
1268
1546
  // Honour caller cancellation: passing the signal tears down the
1269
1547
  // upstream HTTP request so a cancelled call stops billing tokens.
@@ -1276,16 +1554,16 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1276
1554
  throw errorHandlingHelper.create(constants.HTTP_CODES.BAD_REQUEST, "Request cancelled by caller");
1277
1555
  }
1278
1556
  accumulateChunkUsage(streamUsage, chunk);
1279
- const content = chunk?.content || chunk;
1280
- if (typeof content === "string") {
1281
- rawContent += content;
1282
- chunkCount++;
1283
- if (chunkCount % progressReportInterval === 0) {
1284
- await onProgressReport({
1285
- message: "Generating content...",
1286
- progress: Math.min(calcCurrentProgress(), maxPercent - 5),
1287
- });
1288
- }
1557
+ collectWebSearchUsage(chunk, webSearchUsage);
1558
+ // Counting every chunk (not just the ones carrying text) keeps
1559
+ // progress ticking through the pause while a search runs.
1560
+ chunkCount++;
1561
+ rawContent += extractTextContent(chunk?.content ?? chunk);
1562
+ if (chunkCount % progressReportInterval === 0) {
1563
+ await onProgressReport({
1564
+ message: "Generating content...",
1565
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
1566
+ });
1289
1567
  }
1290
1568
  }
1291
1569
  break; // stream completed without 429
@@ -1308,6 +1586,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1308
1586
  }
1309
1587
  }
1310
1588
  updateUsageTracker(usageTracker, modelName, streamUsage, config);
1589
+ updateWebSearchUsageTracker(usageTracker, modelName, webSearchUsage, config);
1311
1590
  if (!rawContent) {
1312
1591
  throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
1313
1592
  }
@@ -1412,7 +1691,11 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1412
1691
  }
1413
1692
  }
1414
1693
  updateUsageTracker(usageTracker, modelName, extractUsageFromInvoke(response), config);
1415
- const rawContent = response?.content || response;
1694
+ const webSearchUsage = createWebSearchUsage();
1695
+ collectWebSearchUsage(response, webSearchUsage);
1696
+ updateWebSearchUsageTracker(usageTracker, modelName, webSearchUsage, config);
1697
+ // Flattened because a server-tool turn returns content blocks, not a string.
1698
+ const rawContent = extractTextContent(response?.content ?? response);
1416
1699
  // If not expecting JSON, return raw content directly
1417
1700
  if (!expectsJsonResponse) {
1418
1701
  return rawContent;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.16",
6
+ "version": "1.2.18",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",
@@ -19,7 +19,7 @@
19
19
  "work:release": "bash scripts/release.sh",
20
20
  "release": "bash scripts/release.sh",
21
21
  "release:fanout": "bash scripts/release-fanout.sh",
22
- "ar-auth": "npm config set @stackfactor:registry https://us-central1-npm.pkg.dev/virtual-development-team/sf-devenv-npm-private/; TOKEN=$(gcloud auth print-access-token 2>/dev/null || curl -sfH 'Metadata-Flavor: Google' http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token | sed -n 's/.*\"access_token\":\"\\([^\"]*\\)\".*/\\1/p'); test -n \"$TOKEN\" || { echo 'ar-auth: no credential — run: gcloud auth login' >&2; exit 1; }; npm config set //us-central1-npm.pkg.dev/virtual-development-team/:_authToken \"$TOKEN\"; echo 'ar-auth: Artifact Registry scope + token configured'",
22
+ "ar-auth": "npm config set @stackfactor:registry https://us-central1-npm.pkg.dev/virtual-development-team/sf-devenv-npm-private/; TOKEN=$(gcloud auth print-access-token 2>/dev/null || curl -sfH 'Metadata-Flavor: Google' http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token | sed -n 's/.*\"access_token\":\"\\([^\"]*\\)\".*/\\1/p'); test -n \"$TOKEN\" || { echo 'ar-auth: no credential — run: gcloud auth login' >&2; exit 1; }; npm config set //us-central1-npm.pkg.dev/virtual-development-team/sf-devenv-npm-private/:_authToken \"$TOKEN\"; npm config set //us-central1-npm.pkg.dev/virtual-development-team/:_authToken \"$TOKEN\"; echo 'ar-auth: Artifact Registry scope + token configured'",
23
23
  "reinstall": "npm run ar-auth && npm ci"
24
24
  },
25
25
  "repository": {