@open-mercato/web-research-model 0.7.1-develop.7194.1.ab4fc81f82

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.
@@ -0,0 +1,2 @@
1
+ [build:web-research-model] found 4 entry points
2
+ [build:web-research-model] built successfully
package/build.mjs ADDED
@@ -0,0 +1,10 @@
1
+ import { dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { buildPackage } from '../../scripts/build-package.mjs'
4
+
5
+ const packageDir = dirname(fileURLToPath(import.meta.url))
6
+
7
+ await buildPackage(packageDir, {
8
+ name: 'web-research-model',
9
+ entryPoints: 'src/**/*.ts',
10
+ })
@@ -0,0 +1,173 @@
1
+ import {
2
+ CONTRACT_VERSION,
3
+ READY,
4
+ defineAdapterModule,
5
+ notReady,
6
+ searchOk
7
+ } from "@open-mercato/web-research";
8
+ import { z } from "zod";
9
+ import { loadNativeSearchTool } from "./nativeTools.js";
10
+ import { parseModelSummaries, stripSummaryBlock } from "./parse.js";
11
+ const modelOptionsSchema = z.object({
12
+ maxUses: z.number().int().min(1).max(10).optional(),
13
+ resolveModel: z.custom((value) => typeof value === "function").optional(),
14
+ generate: z.custom((value) => typeof value === "function").optional(),
15
+ loadTools: z.custom((value) => typeof value === "function").optional()
16
+ });
17
+ const DEFAULT_MAX_USES = 3;
18
+ const NOT_CONFIGURED_MARKERS = [
19
+ "exceeded your current quota",
20
+ "insufficient_quota",
21
+ "billing",
22
+ "payment required",
23
+ "invalid api key",
24
+ "incorrect api key",
25
+ "invalid_api_key",
26
+ "authentication",
27
+ "unauthorized",
28
+ "account is not active"
29
+ ];
30
+ const PROVIDER_CAVEATS = {
31
+ azure: "searches run via Grounding with Bing, billed separately and outside your geo boundary"
32
+ };
33
+ function isConfigurationFault(message) {
34
+ const haystack = message.toLowerCase();
35
+ return NOT_CONFIGURED_MARKERS.some((marker) => haystack.includes(marker));
36
+ }
37
+ function buildPrompt(request) {
38
+ const constraints = [];
39
+ if (request.site) constraints.push(`Restrict results to the site ${request.site}.`);
40
+ if (request.freshness) constraints.push(`Prefer sources published within the last ${request.freshness}.`);
41
+ if (request.locale) constraints.push(`Prefer sources written for the ${request.locale} locale.`);
42
+ return [
43
+ `Search the public web for: ${request.query}`,
44
+ "Use the web_search tool, then answer in two parts.",
45
+ "",
46
+ "Part 1: a short prose answer citing the most relevant, authoritative sources.",
47
+ "",
48
+ `Part 2: a JSON array in a \`\`\`json code fence with up to ${request.limit} entries, each`,
49
+ '{"url": "...", "title": "...", "summary": "one or two sentences on what this page says about the query"}.',
50
+ "Only include URLs you actually retrieved. Write real summaries, not the page title again.",
51
+ ...constraints
52
+ ].join("\n");
53
+ }
54
+ async function defaultGenerate(input) {
55
+ const { generateText } = await import("ai");
56
+ return await generateText({
57
+ model: input.model,
58
+ tools: input.tools,
59
+ prompt: input.prompt,
60
+ ...input.abortSignal ? { abortSignal: input.abortSignal } : {}
61
+ });
62
+ }
63
+ function createModelAdapter(options) {
64
+ const maxUses = options.maxUses ?? DEFAULT_MAX_USES;
65
+ const resolveModel = options.resolveModel;
66
+ const generate = options.generate ?? defaultGenerate;
67
+ const loadTools = options.loadTools ?? loadNativeSearchTool;
68
+ return {
69
+ id: "model-native",
70
+ kind: "model",
71
+ capabilities: {
72
+ search: true,
73
+ fetch: false,
74
+ snippets: true,
75
+ content: false,
76
+ freshness: true,
77
+ siteFilter: true,
78
+ cost: "llm-tokens"
79
+ },
80
+ /** `free` — resolves the configured model and its tool descriptors; no completion is issued. */
81
+ probeCost: "free",
82
+ readiness: () => resolveModel ? READY : notReady("no model resolver was provided by the host application"),
83
+ async search(request, context) {
84
+ if (!resolveModel) {
85
+ return { status: "unavailable", reason: "no model resolver was provided by the host application" };
86
+ }
87
+ let resolution;
88
+ try {
89
+ resolution = await resolveModel();
90
+ } catch (error) {
91
+ return {
92
+ status: "unavailable",
93
+ reason: `could not resolve a model: ${error instanceof Error ? error.message : String(error)}`
94
+ };
95
+ }
96
+ const tools = await loadTools(resolution.providerId, maxUses);
97
+ if (!tools) {
98
+ return {
99
+ status: "unavailable",
100
+ reason: `provider "${resolution.providerId}" has no native web search available`
101
+ };
102
+ }
103
+ let result;
104
+ try {
105
+ result = await generate({
106
+ model: resolution.model,
107
+ tools,
108
+ prompt: buildPrompt(request),
109
+ abortSignal: context.signal
110
+ });
111
+ } catch (error) {
112
+ if (context.signal.aborted) return { status: "timeout", reason: "model search was aborted" };
113
+ const detail = error instanceof Error ? error.message : String(error);
114
+ if (isConfigurationFault(detail)) {
115
+ return { status: "unavailable", reason: `model search is not usable: ${detail}` };
116
+ }
117
+ return { status: "error", reason: `model search failed: ${detail}`, retriable: true };
118
+ }
119
+ const text = result.text ?? "";
120
+ const summaries = new Map(parseModelSummaries(text).map((entry) => [entry.url, entry]));
121
+ const results = [];
122
+ const seen = /* @__PURE__ */ new Set();
123
+ for (const source of result.sources ?? []) {
124
+ if (source.sourceType && source.sourceType !== "url") continue;
125
+ const url = source.url;
126
+ if (!url || seen.has(url)) continue;
127
+ seen.add(url);
128
+ const enrichment = summaries.get(url);
129
+ results.push({
130
+ url,
131
+ title: enrichment?.title ?? source.title ?? null,
132
+ snippet: enrichment?.summary ?? null
133
+ });
134
+ if (results.length >= request.limit) break;
135
+ }
136
+ const answer = stripSummaryBlock(text);
137
+ context.report("ok", `model returned ${results.length} cited source(s)`, {
138
+ resultCount: results.length
139
+ });
140
+ return searchOk(results, answer);
141
+ },
142
+ async healthCheck() {
143
+ if (!resolveModel) return { ok: false, detail: "no model resolver was provided by the host application" };
144
+ try {
145
+ const resolution = await resolveModel();
146
+ const tools = await loadTools(resolution.providerId, maxUses);
147
+ if (!tools) {
148
+ return { ok: false, detail: `provider "${resolution.providerId}" has no native web search` };
149
+ }
150
+ const caveat = PROVIDER_CAVEATS[resolution.providerId];
151
+ return {
152
+ ok: true,
153
+ detail: `${resolution.providerId}/${resolution.modelId}${caveat ? ` - ${caveat}` : ""}`
154
+ };
155
+ } catch (error) {
156
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
157
+ }
158
+ }
159
+ };
160
+ }
161
+ const modelAdapterModule = defineAdapterModule({
162
+ id: "model-native",
163
+ kind: "model",
164
+ contractVersion: CONTRACT_VERSION,
165
+ optionsSchema: modelOptionsSchema,
166
+ createAdapter: createModelAdapter
167
+ });
168
+ export {
169
+ createModelAdapter,
170
+ modelAdapterModule,
171
+ modelOptionsSchema
172
+ };
173
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/adapter.ts"],
4
+ "sourcesContent": ["import {\n CONTRACT_VERSION,\n READY,\n defineAdapterModule,\n notReady,\n searchOk,\n type RawResult,\n type SearchAdapter,\n type SearchOutcome,\n type SearchRequest,\n} from '@open-mercato/web-research'\nimport { z } from 'zod'\nimport { loadNativeSearchTool } from './nativeTools'\nimport { parseModelSummaries, stripSummaryBlock } from './parse'\n\nexport type ModelResolution = {\n readonly model: unknown\n readonly providerId: string\n readonly modelId: string\n}\n\nexport type ModelResolver = () => Promise<ModelResolution> | ModelResolution\n\nexport type GenerateSource = { sourceType?: string; url?: string; title?: string }\n\nexport type GenerateResult = {\n readonly text?: string\n readonly sources?: readonly GenerateSource[]\n}\n\nexport type GenerateFn = (input: {\n model: unknown\n tools: Record<string, unknown>\n prompt: string\n abortSignal?: AbortSignal\n}) => Promise<GenerateResult>\n\nexport type LoadToolsFn = (providerId: string, maxUses: number) => Promise<Record<string, unknown> | null>\n\n/**\n * `resolveModel`, `generate` and `loadTools` are host-supplied capabilities rather\n * than stored configuration, so they are validated structurally and merged in by\n * the registry at instantiation time. Keeping them in the same schema means the\n * adapter has one source of truth for what it needs, and `readiness()` can say so\n * plainly instead of failing deep inside a search.\n */\nexport const modelOptionsSchema = z.object({\n maxUses: z.number().int().min(1).max(10).optional(),\n resolveModel: z.custom<ModelResolver>((value) => typeof value === 'function').optional(),\n generate: z.custom<GenerateFn>((value) => typeof value === 'function').optional(),\n loadTools: z.custom<LoadToolsFn>((value) => typeof value === 'function').optional(),\n})\n\nexport type ModelOptions = z.infer<typeof modelOptionsSchema>\n\nconst DEFAULT_MAX_USES = 3\n\n/**\n * A spent quota, a bad key or a suspended account is a configuration problem, not\n * a transient one. Reporting it as `error` tells the scheduler to treat it as\n * bad luck and an operator to wait for it to pass, when the fix is billing or a\n * credential \u2014 so it maps to `unavailable`, which is what \"this source cannot\n * currently work\" means everywhere else in the contract.\n */\nconst NOT_CONFIGURED_MARKERS = [\n 'exceeded your current quota',\n 'insufficient_quota',\n 'billing',\n 'payment required',\n 'invalid api key',\n 'incorrect api key',\n 'invalid_api_key',\n 'authentication',\n 'unauthorized',\n 'account is not active',\n]\n\n/** Shown on the adapter's health row, so it must stay one short clause. */\nconst PROVIDER_CAVEATS: Readonly<Record<string, string>> = {\n azure: 'searches run via Grounding with Bing, billed separately and outside your geo boundary',\n}\n\nfunction isConfigurationFault(message: string): boolean {\n const haystack = message.toLowerCase()\n return NOT_CONFIGURED_MARKERS.some((marker) => haystack.includes(marker))\n}\n\nfunction buildPrompt(request: SearchRequest): string {\n const constraints: string[] = []\n if (request.site) constraints.push(`Restrict results to the site ${request.site}.`)\n if (request.freshness) constraints.push(`Prefer sources published within the last ${request.freshness}.`)\n if (request.locale) constraints.push(`Prefer sources written for the ${request.locale} locale.`)\n\n return [\n `Search the public web for: ${request.query}`,\n 'Use the web_search tool, then answer in two parts.',\n '',\n 'Part 1: a short prose answer citing the most relevant, authoritative sources.',\n '',\n `Part 2: a JSON array in a \\`\\`\\`json code fence with up to ${request.limit} entries, each`,\n '{\"url\": \"...\", \"title\": \"...\", \"summary\": \"one or two sentences on what this page says about the query\"}.',\n 'Only include URLs you actually retrieved. Write real summaries, not the page title again.',\n ...constraints,\n ].join('\\n')\n}\n\nasync function defaultGenerate(input: Parameters<GenerateFn>[0]): Promise<GenerateResult> {\n const { generateText } = await import('ai')\n type GenerateTextInput = Parameters<typeof generateText>[0]\n return (await generateText({\n model: input.model as GenerateTextInput['model'],\n tools: input.tools as GenerateTextInput['tools'],\n prompt: input.prompt,\n ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),\n })) as GenerateResult\n}\n\nexport function createModelAdapter(options: ModelOptions): SearchAdapter {\n const maxUses = options.maxUses ?? DEFAULT_MAX_USES\n const resolveModel = options.resolveModel\n const generate = options.generate ?? defaultGenerate\n const loadTools = options.loadTools ?? loadNativeSearchTool\n\n return {\n id: 'model-native',\n kind: 'model',\n capabilities: {\n search: true,\n fetch: false,\n snippets: true,\n content: false,\n freshness: true,\n siteFilter: true,\n cost: 'llm-tokens',\n },\n /** `free` \u2014 resolves the configured model and its tool descriptors; no completion is issued. */\n probeCost: 'free' as const,\n readiness: () =>\n resolveModel ? READY : notReady('no model resolver was provided by the host application'),\n\n async search(request, context): Promise<SearchOutcome> {\n if (!resolveModel) {\n return { status: 'unavailable', reason: 'no model resolver was provided by the host application' }\n }\n\n let resolution: ModelResolution\n try {\n resolution = await resolveModel()\n } catch (error) {\n return {\n status: 'unavailable',\n reason: `could not resolve a model: ${error instanceof Error ? error.message : String(error)}`,\n }\n }\n\n const tools = await loadTools(resolution.providerId, maxUses)\n if (!tools) {\n return {\n status: 'unavailable',\n reason: `provider \"${resolution.providerId}\" has no native web search available`,\n }\n }\n\n let result: GenerateResult\n try {\n result = await generate({\n model: resolution.model,\n tools,\n prompt: buildPrompt(request),\n abortSignal: context.signal,\n })\n } catch (error) {\n if (context.signal.aborted) return { status: 'timeout', reason: 'model search was aborted' }\n const detail = error instanceof Error ? error.message : String(error)\n if (isConfigurationFault(detail)) {\n return { status: 'unavailable', reason: `model search is not usable: ${detail}` }\n }\n return { status: 'error', reason: `model search failed: ${detail}`, retriable: true }\n }\n\n const text = result.text ?? ''\n const summaries = new Map(parseModelSummaries(text).map((entry) => [entry.url, entry]))\n\n // Only URLs the search tool actually returned are trusted. The model's own\n // JSON is used solely to enrich those, never to introduce a link, because a\n // fabricated URL that reaches an agent is indistinguishable from a real one.\n const results: RawResult[] = []\n const seen = new Set<string>()\n for (const source of result.sources ?? []) {\n if (source.sourceType && source.sourceType !== 'url') continue\n const url = source.url\n if (!url || seen.has(url)) continue\n seen.add(url)\n const enrichment = summaries.get(url)\n results.push({\n url,\n title: enrichment?.title ?? source.title ?? null,\n snippet: enrichment?.summary ?? null,\n })\n if (results.length >= request.limit) break\n }\n\n const answer = stripSummaryBlock(text)\n context.report('ok', `model returned ${results.length} cited source(s)`, {\n resultCount: results.length,\n })\n\n return searchOk(results, answer)\n },\n\n async healthCheck() {\n if (!resolveModel) return { ok: false, detail: 'no model resolver was provided by the host application' }\n try {\n const resolution = await resolveModel()\n const tools = await loadTools(resolution.providerId, maxUses)\n if (!tools) {\n return { ok: false, detail: `provider \"${resolution.providerId}\" has no native web search` }\n }\n // Azure's native search is Grounding with Bing underneath: it is billed\n // separately and Microsoft states the Data Protection Addendum does not\n // cover it, so queries leave the tenant's compliance and geo boundary.\n // Anyone on Azure for data residency needs to see that, not discover it.\n const caveat = PROVIDER_CAVEATS[resolution.providerId]\n return {\n ok: true,\n detail: `${resolution.providerId}/${resolution.modelId}${caveat ? ` - ${caveat}` : ''}`,\n }\n } catch (error) {\n return { ok: false, detail: error instanceof Error ? error.message : String(error) }\n }\n },\n }\n}\n\nexport const modelAdapterModule = defineAdapterModule({\n id: 'model-native',\n kind: 'model',\n contractVersion: CONTRACT_VERSION,\n optionsSchema: modelOptionsSchema,\n createAdapter: createModelAdapter,\n})\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ import {
2
+ createModelAdapter,
3
+ modelAdapterModule,
4
+ modelOptionsSchema
5
+ } from "./adapter.js";
6
+ import { parseModelSummaries, stripSummaryBlock } from "./parse.js";
7
+ import { loadNativeSearchTool } from "./nativeTools.js";
8
+ import { modelAdapterModule as modelAdapterModule2 } from "./adapter.js";
9
+ export {
10
+ createModelAdapter,
11
+ modelAdapterModule2 as default,
12
+ loadNativeSearchTool,
13
+ modelAdapterModule,
14
+ modelOptionsSchema,
15
+ parseModelSummaries,
16
+ stripSummaryBlock
17
+ };
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
4
+ "sourcesContent": ["export {\n createModelAdapter,\n modelAdapterModule,\n modelOptionsSchema,\n type GenerateFn,\n type GenerateResult,\n type GenerateSource,\n type ModelOptions,\n type ModelResolution,\n type ModelResolver,\n} from './adapter'\nexport { parseModelSummaries, stripSummaryBlock, type ModelSummary } from './parse'\nexport { loadNativeSearchTool } from './nativeTools'\n\nexport { modelAdapterModule as default } from './adapter'\n"],
5
+ "mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAOK;AACP,SAAS,qBAAqB,yBAA4C;AAC1E,SAAS,4BAA4B;AAErC,SAA+B,sBAAtBA,2BAAqC;",
6
+ "names": ["modelAdapterModule"]
7
+ }
@@ -0,0 +1,31 @@
1
+ async function loadNativeSearchTool(providerId, maxUses) {
2
+ if (providerId === "anthropic") {
3
+ try {
4
+ const { anthropic } = await import("@ai-sdk/anthropic");
5
+ return { web_search: anthropic.tools.webSearch_20250305({ maxUses }) };
6
+ } catch {
7
+ return null;
8
+ }
9
+ }
10
+ if (providerId === "openai") {
11
+ try {
12
+ const { openai } = await import("@ai-sdk/openai");
13
+ return { web_search: openai.tools.webSearch() };
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+ if (providerId === "azure") {
19
+ try {
20
+ const { azure } = await import("@ai-sdk/azure");
21
+ return { web_search: azure.tools.webSearch() };
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+ export {
29
+ loadNativeSearchTool
30
+ };
31
+ //# sourceMappingURL=nativeTools.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/nativeTools.ts"],
4
+ "sourcesContent": ["export type NativeToolSet = Record<string, unknown>\n\n/**\n * Loads the provider's own web-search tool lazily.\n *\n * The import is dynamic on purpose: this package is statically imported by the\n * generated adapter registry, and a top-level import of a provider SDK the host\n * did not install would fail the whole registry rather than this one adapter.\n * Both provider packages are optional peers for the same reason.\n */\nexport async function loadNativeSearchTool(\n providerId: string,\n maxUses: number,\n): Promise<NativeToolSet | null> {\n if (providerId === 'anthropic') {\n try {\n const { anthropic } = await import('@ai-sdk/anthropic')\n return { web_search: anthropic.tools.webSearch_20250305({ maxUses }) }\n } catch {\n return null\n }\n }\n if (providerId === 'openai') {\n try {\n const { openai } = await import('@ai-sdk/openai')\n return { web_search: openai.tools.webSearch() }\n } catch {\n return null\n }\n }\n if (providerId === 'azure') {\n try {\n // Azure's web search is a Responses API built-in backed by Grounding with\n // Bing. `@ai-sdk/azure` calls the Responses API by default, which is what\n // makes the tool reachable at all \u2014 the OpenAI-compatible Chat Completions\n // surface Azure used to be wired through cannot carry it.\n const { azure } = await import('@ai-sdk/azure')\n return { web_search: azure.tools.webSearch() }\n } catch {\n return null\n }\n }\n return null\n}\n"],
5
+ "mappings": "AAUA,eAAsB,qBACpB,YACA,SAC+B;AAC/B,MAAI,eAAe,aAAa;AAC9B,QAAI;AACF,YAAM,EAAE,UAAU,IAAI,MAAM,OAAO,mBAAmB;AACtD,aAAO,EAAE,YAAY,UAAU,MAAM,mBAAmB,EAAE,QAAQ,CAAC,EAAE;AAAA,IACvE,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,MAAI,eAAe,UAAU;AAC3B,QAAI;AACF,YAAM,EAAE,OAAO,IAAI,MAAM,OAAO,gBAAgB;AAChD,aAAO,EAAE,YAAY,OAAO,MAAM,UAAU,EAAE;AAAA,IAChD,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,MAAI,eAAe,SAAS;AAC1B,QAAI;AAKF,YAAM,EAAE,MAAM,IAAI,MAAM,OAAO,eAAe;AAC9C,aAAO,EAAE,YAAY,MAAM,MAAM,UAAU,EAAE;AAAA,IAC/C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;",
6
+ "names": []
7
+ }
package/dist/parse.js ADDED
@@ -0,0 +1,47 @@
1
+ const FENCE = /```(?:json)?([\s\S]*?)```/;
2
+ function coerceSummaries(value) {
3
+ if (!Array.isArray(value)) return [];
4
+ const summaries = [];
5
+ for (const item of value) {
6
+ if (typeof item !== "object" || item === null) continue;
7
+ const record = item;
8
+ const url = typeof record.url === "string" ? record.url.trim() : "";
9
+ if (url.length === 0) continue;
10
+ summaries.push({
11
+ url,
12
+ ...typeof record.title === "string" && record.title.trim().length > 0 ? { title: record.title.trim() } : {},
13
+ ...typeof record.summary === "string" && record.summary.trim().length > 0 ? { summary: record.summary.trim() } : {}
14
+ });
15
+ }
16
+ return summaries;
17
+ }
18
+ function parseModelSummaries(text) {
19
+ if (text.trim().length === 0) return [];
20
+ const fenced = FENCE.exec(text);
21
+ if (fenced) {
22
+ try {
23
+ return coerceSummaries(JSON.parse(fenced[1]));
24
+ } catch {
25
+ }
26
+ }
27
+ const start = text.indexOf("[");
28
+ const end = text.lastIndexOf("]");
29
+ if (start === -1 || end <= start) return [];
30
+ try {
31
+ return coerceSummaries(JSON.parse(text.slice(start, end + 1)));
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+ function stripSummaryBlock(text) {
37
+ const withoutFence = text.replace(FENCE, "").trim();
38
+ if (withoutFence.length > 0) return withoutFence;
39
+ const start = text.indexOf("[");
40
+ if (start <= 0) return "";
41
+ return text.slice(0, start).trim();
42
+ }
43
+ export {
44
+ parseModelSummaries,
45
+ stripSummaryBlock
46
+ };
47
+ //# sourceMappingURL=parse.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/parse.ts"],
4
+ "sourcesContent": ["export type ModelSummary = {\n readonly url: string\n readonly title?: string\n readonly summary?: string\n}\n\n/**\n * The leading whitespace stays inside the capture group rather than being eaten\n * by a `\\s*` prefix: `\\s*` and `[\\s\\S]*?` can both match the same whitespace,\n * which makes an unterminated fence backtrack quadratically (CodeQL polynomial\n * ReDoS). The matched span is identical either way, and `JSON.parse` ignores\n * leading whitespace.\n */\nconst FENCE = /```(?:json)?([\\s\\S]*?)```/\n\nfunction coerceSummaries(value: unknown): ModelSummary[] {\n if (!Array.isArray(value)) return []\n const summaries: ModelSummary[] = []\n for (const item of value) {\n if (typeof item !== 'object' || item === null) continue\n const record = item as Record<string, unknown>\n const url = typeof record.url === 'string' ? record.url.trim() : ''\n if (url.length === 0) continue\n summaries.push({\n url,\n ...(typeof record.title === 'string' && record.title.trim().length > 0\n ? { title: record.title.trim() }\n : {}),\n ...(typeof record.summary === 'string' && record.summary.trim().length > 0\n ? { summary: record.summary.trim() }\n : {}),\n })\n }\n return summaries\n}\n\n/**\n * Pulls the per-source summaries out of the model's reply. Best-effort by design:\n * the summaries only ever enrich URLs that the search tool actually returned, so\n * a malformed or absent block costs snippets, never correctness.\n */\nexport function parseModelSummaries(text: string): ModelSummary[] {\n if (text.trim().length === 0) return []\n\n const fenced = FENCE.exec(text)\n if (fenced) {\n try {\n return coerceSummaries(JSON.parse(fenced[1]))\n } catch {\n // fall through to the bare-array scan\n }\n }\n\n const start = text.indexOf('[')\n const end = text.lastIndexOf(']')\n if (start === -1 || end <= start) return []\n try {\n return coerceSummaries(JSON.parse(text.slice(start, end + 1)))\n } catch {\n return []\n }\n}\n\n/** Removes the machine-readable block so the prose answer reads naturally. */\nexport function stripSummaryBlock(text: string): string {\n const withoutFence = text.replace(FENCE, '').trim()\n if (withoutFence.length > 0) return withoutFence\n const start = text.indexOf('[')\n if (start <= 0) return ''\n return text.slice(0, start).trim()\n}\n"],
5
+ "mappings": "AAaA,MAAM,QAAQ;AAEd,SAAS,gBAAgB,OAAgC;AACvD,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO,CAAC;AACnC,QAAM,YAA4B,CAAC;AACnC,aAAW,QAAQ,OAAO;AACxB,QAAI,OAAO,SAAS,YAAY,SAAS,KAAM;AAC/C,UAAM,SAAS;AACf,UAAM,MAAM,OAAO,OAAO,QAAQ,WAAW,OAAO,IAAI,KAAK,IAAI;AACjE,QAAI,IAAI,WAAW,EAAG;AACtB,cAAU,KAAK;AAAA,MACb;AAAA,MACA,GAAI,OAAO,OAAO,UAAU,YAAY,OAAO,MAAM,KAAK,EAAE,SAAS,IACjE,EAAE,OAAO,OAAO,MAAM,KAAK,EAAE,IAC7B,CAAC;AAAA,MACL,GAAI,OAAO,OAAO,YAAY,YAAY,OAAO,QAAQ,KAAK,EAAE,SAAS,IACrE,EAAE,SAAS,OAAO,QAAQ,KAAK,EAAE,IACjC,CAAC;AAAA,IACP,CAAC;AAAA,EACH;AACA,SAAO;AACT;AAOO,SAAS,oBAAoB,MAA8B;AAChE,MAAI,KAAK,KAAK,EAAE,WAAW,EAAG,QAAO,CAAC;AAEtC,QAAM,SAAS,MAAM,KAAK,IAAI;AAC9B,MAAI,QAAQ;AACV,QAAI;AACF,aAAO,gBAAgB,KAAK,MAAM,OAAO,CAAC,CAAC,CAAC;AAAA,IAC9C,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,QAAM,MAAM,KAAK,YAAY,GAAG;AAChC,MAAI,UAAU,MAAM,OAAO,MAAO,QAAO,CAAC;AAC1C,MAAI;AACF,WAAO,gBAAgB,KAAK,MAAM,KAAK,MAAM,OAAO,MAAM,CAAC,CAAC,CAAC;AAAA,EAC/D,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAGO,SAAS,kBAAkB,MAAsB;AACtD,QAAM,eAAe,KAAK,QAAQ,OAAO,EAAE,EAAE,KAAK;AAClD,MAAI,aAAa,SAAS,EAAG,QAAO;AACpC,QAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,MAAI,SAAS,EAAG,QAAO;AACvB,SAAO,KAAK,MAAM,GAAG,KAAK,EAAE,KAAK;AACnC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,28 @@
1
+ /** @type {import('jest').Config} */
2
+ const base = require('../../jest.config.base.cjs')
3
+
4
+ module.exports = {
5
+ ...base,
6
+ preset: 'ts-jest',
7
+ testEnvironment: 'node',
8
+ watchman: false,
9
+ rootDir: '.',
10
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
11
+ moduleNameMapper: {
12
+ '^@open-mercato/web-research/(.*)$': '<rootDir>/../web-research/src/$1',
13
+ '^@open-mercato/web-research$': '<rootDir>/../web-research/src/index.ts',
14
+ },
15
+ transform: {
16
+ '^.+\\.(t|j)sx?$': [
17
+ 'ts-jest',
18
+ {
19
+ tsconfig: {
20
+ rootDir: '.',
21
+ ignoreDeprecations: '6.0',
22
+ },
23
+ },
24
+ ],
25
+ },
26
+ testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
27
+ passWithNoTests: true,
28
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@open-mercato/web-research-model",
3
+ "version": "0.7.1-develop.7194.1.ab4fc81f82",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "openMercato": {
8
+ "webResearchAdapter": {
9
+ "id": "model-native"
10
+ }
11
+ },
12
+ "scripts": {
13
+ "build": "node build.mjs",
14
+ "watch": "node watch.mjs",
15
+ "test": "jest --config jest.config.cjs",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./*.ts": {
24
+ "types": "./src/*.ts",
25
+ "default": "./dist/*.js"
26
+ },
27
+ "./*": {
28
+ "types": [
29
+ "./src/*.ts"
30
+ ],
31
+ "default": "./dist/*.js"
32
+ }
33
+ },
34
+ "peerDependencies": {
35
+ "@ai-sdk/anthropic": "^4.0.1",
36
+ "@ai-sdk/azure": "^4.0.0",
37
+ "@ai-sdk/openai": "^4.0.2",
38
+ "@open-mercato/web-research": "^0.6.0",
39
+ "ai": "^7.0.4"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "@ai-sdk/anthropic": {
43
+ "optional": true
44
+ },
45
+ "@ai-sdk/azure": {
46
+ "optional": true
47
+ },
48
+ "@ai-sdk/openai": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "dependencies": {
53
+ "zod": "^4.4.3"
54
+ },
55
+ "devDependencies": {
56
+ "@ai-sdk/anthropic": "^4.0.1",
57
+ "@ai-sdk/azure": "^4.0.0",
58
+ "@ai-sdk/openai": "^4.0.2",
59
+ "@open-mercato/web-research": "0.7.1-develop.7194.1.ab4fc81f82",
60
+ "@types/jest": "^30.0.0",
61
+ "@types/node": "^26.0.1",
62
+ "ai": "^7.0.4",
63
+ "esbuild": "^0.28.1",
64
+ "glob": "^13.0.6",
65
+ "jest": "^30.4.2",
66
+ "ts-jest": "^29.4.11",
67
+ "typescript": "^6.0.3"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public"
71
+ },
72
+ "repository": {
73
+ "type": "git",
74
+ "url": "https://github.com/open-mercato/open-mercato",
75
+ "directory": "packages/web-research-model"
76
+ },
77
+ "stableVersion": "0.7.0"
78
+ }
@@ -0,0 +1,248 @@
1
+ import { createTestContext, describeAdapterContract } from '@open-mercato/web-research/testing'
2
+ import { createModelAdapter, modelAdapterModule, type GenerateFn, type ModelResolver } from '../adapter'
3
+ import { parseModelSummaries, stripSummaryBlock } from '../parse'
4
+
5
+ const anthropicResolver: ModelResolver = () => ({
6
+ model: { id: 'fake' },
7
+ providerId: 'anthropic',
8
+ modelId: 'claude-sonnet-5',
9
+ })
10
+
11
+ const REPLY = `Open Mercato is a modular commerce platform.
12
+
13
+ \`\`\`json
14
+ [
15
+ {"url": "https://a.com/1", "title": "Alpha docs", "summary": "Explains the module system in detail."},
16
+ {"url": "https://b.com/2", "title": "Beta post", "summary": "Covers pricing."}
17
+ ]
18
+ \`\`\``
19
+
20
+ const generate =
21
+ (result: Awaited<ReturnType<GenerateFn>>): GenerateFn =>
22
+ async () =>
23
+ result
24
+
25
+ /** Stands in for the provider SDK so these tests never load one. */
26
+ const loadTools = async () => ({ web_search: {} })
27
+
28
+ describe('parseModelSummaries', () => {
29
+ it('reads a fenced JSON block', () => {
30
+ expect(parseModelSummaries(REPLY)).toEqual([
31
+ { url: 'https://a.com/1', title: 'Alpha docs', summary: 'Explains the module system in detail.' },
32
+ { url: 'https://b.com/2', title: 'Beta post', summary: 'Covers pricing.' },
33
+ ])
34
+ })
35
+
36
+ it('reads a bare array when the model skipped the fence', () => {
37
+ expect(parseModelSummaries('Here you go: [{"url":"https://x/1","summary":"s"}]')).toEqual([
38
+ { url: 'https://x/1', summary: 's' },
39
+ ])
40
+ })
41
+
42
+ it('returns nothing for malformed or absent JSON', () => {
43
+ expect(parseModelSummaries('no json here')).toEqual([])
44
+ expect(parseModelSummaries('```json\n{ broken ]\n```')).toEqual([])
45
+ })
46
+
47
+ it('drops entries without a url', () => {
48
+ expect(parseModelSummaries('[{"summary":"orphan"},{"url":"https://x/1"}]')).toEqual([
49
+ { url: 'https://x/1' },
50
+ ])
51
+ })
52
+ })
53
+
54
+ describe('stripSummaryBlock', () => {
55
+ it('keeps the prose and drops the machine-readable block', () => {
56
+ expect(stripSummaryBlock(REPLY)).toBe('Open Mercato is a modular commerce platform.')
57
+ })
58
+ })
59
+
60
+ describe('model adapter', () => {
61
+ it('is unavailable when the host provided no resolver', async () => {
62
+ const adapter = createModelAdapter({})
63
+ expect(adapter.readiness().ready).toBe(false)
64
+ await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
65
+ status: 'unavailable',
66
+ })
67
+ })
68
+
69
+ it('attaches model summaries to the URLs the search tool returned', async () => {
70
+ const adapter = createModelAdapter({
71
+ resolveModel: anthropicResolver,
72
+ loadTools,
73
+ generate: generate({
74
+ text: REPLY,
75
+ sources: [
76
+ { sourceType: 'url', url: 'https://a.com/1', title: 'Raw title' },
77
+ { sourceType: 'url', url: 'https://b.com/2' },
78
+ ],
79
+ }),
80
+ })
81
+
82
+ const outcome = await adapter.search({ query: 'open mercato', limit: 5 }, createTestContext())
83
+
84
+ expect(outcome.status).toBe('ok')
85
+ if (outcome.status !== 'ok') return
86
+ // The old implementation returned snippet: '' here, which is the whole reason
87
+ // the agent had to spend a web_fetch per link to learn anything.
88
+ expect(outcome.results).toEqual([
89
+ { url: 'https://a.com/1', title: 'Alpha docs', snippet: 'Explains the module system in detail.' },
90
+ { url: 'https://b.com/2', title: 'Beta post', snippet: 'Covers pricing.' },
91
+ ])
92
+ expect(outcome.answer).toBe('Open Mercato is a modular commerce platform.')
93
+ })
94
+
95
+ it('never introduces a URL the search tool did not return', async () => {
96
+ const adapter = createModelAdapter({
97
+ resolveModel: anthropicResolver,
98
+ loadTools,
99
+ generate: generate({
100
+ text: '[{"url":"https://hallucinated.example/x","summary":"invented"}]',
101
+ sources: [{ sourceType: 'url', url: 'https://real.com/1' }],
102
+ }),
103
+ })
104
+
105
+ const outcome = await adapter.search({ query: 'x', limit: 5 }, createTestContext())
106
+
107
+ expect(outcome.status).toBe('ok')
108
+ if (outcome.status !== 'ok') return
109
+ expect(outcome.results.map((result) => result.url)).toEqual(['https://real.com/1'])
110
+ })
111
+
112
+ it('keeps a prose answer that cited nothing', async () => {
113
+ const adapter = createModelAdapter({
114
+ resolveModel: anthropicResolver,
115
+ loadTools,
116
+ generate: generate({ text: 'A plain answer with no citations.', sources: [] }),
117
+ })
118
+
119
+ const outcome = await adapter.search({ query: 'x', limit: 5 }, createTestContext())
120
+
121
+ expect(outcome).toMatchObject({ status: 'ok', results: [], answer: 'A plain answer with no citations.' })
122
+ })
123
+
124
+ it('is empty when the model produced neither links nor prose', async () => {
125
+ const adapter = createModelAdapter({
126
+ resolveModel: anthropicResolver,
127
+ loadTools,
128
+ generate: generate({ text: '', sources: [] }),
129
+ })
130
+
131
+ await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
132
+ status: 'empty',
133
+ })
134
+ })
135
+
136
+ it('reports a provider without native search as unavailable, not an error', async () => {
137
+ const adapter = createModelAdapter({
138
+ resolveModel: () => ({ model: {}, providerId: 'ollama', modelId: 'llama' }),
139
+ loadTools: async () => null,
140
+ generate: generate({ text: 'x', sources: [] }),
141
+ })
142
+
143
+ await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
144
+ status: 'unavailable',
145
+ })
146
+ })
147
+
148
+ it('surfaces a resolver failure as unavailable', async () => {
149
+ const adapter = createModelAdapter({
150
+ resolveModel: () => {
151
+ throw new Error('no API key in this process')
152
+ },
153
+ })
154
+
155
+ await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
156
+ status: 'unavailable',
157
+ reason: expect.stringContaining('no API key in this process'),
158
+ })
159
+ })
160
+
161
+ it('reports a spent quota as unavailable rather than a retriable error', async () => {
162
+ // Seen live against OpenAI: reported as `error` with retriable: true, which
163
+ // reads as bad luck when the fix is billing.
164
+ const adapter = createModelAdapter({
165
+ resolveModel: () => ({ model: {}, providerId: 'openai', modelId: 'gpt' }),
166
+ loadTools: async () => ({ web_search: {} }),
167
+ generate: async () => {
168
+ throw new Error(
169
+ 'Failed after 3 attempts. Last error: AI_APICallError: You exceeded your current quota, please check your plan and billing details.',
170
+ )
171
+ },
172
+ })
173
+
174
+ await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
175
+ status: 'unavailable',
176
+ })
177
+ })
178
+
179
+ it('still reports a genuine transient failure as a retriable error', async () => {
180
+ const adapter = createModelAdapter({
181
+ resolveModel: () => ({ model: {}, providerId: 'openai', modelId: 'gpt' }),
182
+ loadTools: async () => ({ web_search: {} }),
183
+ generate: async () => {
184
+ throw new Error('socket hang up')
185
+ },
186
+ })
187
+
188
+ await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
189
+ status: 'error',
190
+ retriable: true,
191
+ })
192
+ })
193
+
194
+ it('passes the abort signal down and reports a timeout when aborted', async () => {
195
+ const controller = new AbortController()
196
+ let received: AbortSignal | undefined
197
+ const adapter = createModelAdapter({
198
+ resolveModel: anthropicResolver,
199
+ loadTools,
200
+ generate: async (input) => {
201
+ received = input.abortSignal
202
+ controller.abort()
203
+ throw new Error('aborted by test')
204
+ },
205
+ })
206
+
207
+ const outcome = await adapter.search(
208
+ { query: 'x', limit: 5 },
209
+ createTestContext({ signal: controller.signal }),
210
+ )
211
+
212
+ expect(received).toBe(controller.signal)
213
+ expect(outcome.status).toBe('timeout')
214
+ })
215
+
216
+ it('caps results at the requested limit', async () => {
217
+ const adapter = createModelAdapter({
218
+ resolveModel: anthropicResolver,
219
+ loadTools,
220
+ generate: generate({
221
+ text: '',
222
+ sources: Array.from({ length: 10 }, (_, index) => ({
223
+ sourceType: 'url',
224
+ url: `https://e.com/${index}`,
225
+ })),
226
+ }),
227
+ })
228
+
229
+ const outcome = await adapter.search({ query: 'x', limit: 3 }, createTestContext())
230
+
231
+ expect(outcome.status).toBe('ok')
232
+ if (outcome.status !== 'ok') return
233
+ expect(outcome.results).toHaveLength(3)
234
+ })
235
+ })
236
+
237
+ describeAdapterContract(
238
+ modelAdapterModule,
239
+ {
240
+ unconfiguredOptions: {},
241
+ configuredOptions: {
242
+ resolveModel: anthropicResolver,
243
+ loadTools,
244
+ generate: generate({ text: '', sources: [{ sourceType: 'url', url: 'https://e.com/1' }] }),
245
+ },
246
+ },
247
+ { describe, it, expect: expect as never },
248
+ )
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Covers the provider→tool mapping only.
3
+ *
4
+ * The SDK packages are ESM-only and this suite runs through CJS ts-jest, so a
5
+ * real `await import()` degrades to `require()` and lands in the catch — which
6
+ * is exactly why every provider silently looked like it had no native search
7
+ * here. The modules are mocked so the branch logic is asserted; that the real
8
+ * import resolves is proved at runtime, not by this file.
9
+ */
10
+
11
+ jest.mock(
12
+ '@ai-sdk/azure',
13
+ () => ({ azure: { tools: { webSearch: () => ({ kind: 'azure-web-search' }) } } }),
14
+ { virtual: true },
15
+ )
16
+ jest.mock(
17
+ '@ai-sdk/openai',
18
+ () => ({ openai: { tools: { webSearch: () => ({ kind: 'openai-web-search' }) } } }),
19
+ { virtual: true },
20
+ )
21
+ jest.mock(
22
+ '@ai-sdk/anthropic',
23
+ () => ({
24
+ anthropic: { tools: { webSearch_20250305: () => ({ kind: 'anthropic-web-search' }) } },
25
+ }),
26
+ { virtual: true },
27
+ )
28
+
29
+ import { loadNativeSearchTool } from '../nativeTools'
30
+
31
+ describe('loadNativeSearchTool', () => {
32
+ it('offers native search on Azure', async () => {
33
+ // Azure has had a Responses API `web_search` built-in for a while, but this
34
+ // returned null for it, so an agent on Foundry was told its provider had no
35
+ // web search at all no matter how capable the deployment was.
36
+ const tools = await loadNativeSearchTool('azure', 3)
37
+ expect(tools).not.toBeNull()
38
+ expect(tools?.web_search).toEqual({ kind: 'azure-web-search' })
39
+ })
40
+
41
+ it('offers native search on OpenAI and Anthropic', async () => {
42
+ expect((await loadNativeSearchTool('openai', 3))?.web_search).toEqual({ kind: 'openai-web-search' })
43
+ expect((await loadNativeSearchTool('anthropic', 3))?.web_search).toEqual({
44
+ kind: 'anthropic-web-search',
45
+ })
46
+ })
47
+
48
+ it('returns null for a provider that has none', async () => {
49
+ expect(await loadNativeSearchTool('ollama', 3)).toBeNull()
50
+ expect(await loadNativeSearchTool('lm-studio', 3)).toBeNull()
51
+ })
52
+ })
package/src/adapter.ts ADDED
@@ -0,0 +1,241 @@
1
+ import {
2
+ CONTRACT_VERSION,
3
+ READY,
4
+ defineAdapterModule,
5
+ notReady,
6
+ searchOk,
7
+ type RawResult,
8
+ type SearchAdapter,
9
+ type SearchOutcome,
10
+ type SearchRequest,
11
+ } from '@open-mercato/web-research'
12
+ import { z } from 'zod'
13
+ import { loadNativeSearchTool } from './nativeTools'
14
+ import { parseModelSummaries, stripSummaryBlock } from './parse'
15
+
16
+ export type ModelResolution = {
17
+ readonly model: unknown
18
+ readonly providerId: string
19
+ readonly modelId: string
20
+ }
21
+
22
+ export type ModelResolver = () => Promise<ModelResolution> | ModelResolution
23
+
24
+ export type GenerateSource = { sourceType?: string; url?: string; title?: string }
25
+
26
+ export type GenerateResult = {
27
+ readonly text?: string
28
+ readonly sources?: readonly GenerateSource[]
29
+ }
30
+
31
+ export type GenerateFn = (input: {
32
+ model: unknown
33
+ tools: Record<string, unknown>
34
+ prompt: string
35
+ abortSignal?: AbortSignal
36
+ }) => Promise<GenerateResult>
37
+
38
+ export type LoadToolsFn = (providerId: string, maxUses: number) => Promise<Record<string, unknown> | null>
39
+
40
+ /**
41
+ * `resolveModel`, `generate` and `loadTools` are host-supplied capabilities rather
42
+ * than stored configuration, so they are validated structurally and merged in by
43
+ * the registry at instantiation time. Keeping them in the same schema means the
44
+ * adapter has one source of truth for what it needs, and `readiness()` can say so
45
+ * plainly instead of failing deep inside a search.
46
+ */
47
+ export const modelOptionsSchema = z.object({
48
+ maxUses: z.number().int().min(1).max(10).optional(),
49
+ resolveModel: z.custom<ModelResolver>((value) => typeof value === 'function').optional(),
50
+ generate: z.custom<GenerateFn>((value) => typeof value === 'function').optional(),
51
+ loadTools: z.custom<LoadToolsFn>((value) => typeof value === 'function').optional(),
52
+ })
53
+
54
+ export type ModelOptions = z.infer<typeof modelOptionsSchema>
55
+
56
+ const DEFAULT_MAX_USES = 3
57
+
58
+ /**
59
+ * A spent quota, a bad key or a suspended account is a configuration problem, not
60
+ * a transient one. Reporting it as `error` tells the scheduler to treat it as
61
+ * bad luck and an operator to wait for it to pass, when the fix is billing or a
62
+ * credential — so it maps to `unavailable`, which is what "this source cannot
63
+ * currently work" means everywhere else in the contract.
64
+ */
65
+ const NOT_CONFIGURED_MARKERS = [
66
+ 'exceeded your current quota',
67
+ 'insufficient_quota',
68
+ 'billing',
69
+ 'payment required',
70
+ 'invalid api key',
71
+ 'incorrect api key',
72
+ 'invalid_api_key',
73
+ 'authentication',
74
+ 'unauthorized',
75
+ 'account is not active',
76
+ ]
77
+
78
+ /** Shown on the adapter's health row, so it must stay one short clause. */
79
+ const PROVIDER_CAVEATS: Readonly<Record<string, string>> = {
80
+ azure: 'searches run via Grounding with Bing, billed separately and outside your geo boundary',
81
+ }
82
+
83
+ function isConfigurationFault(message: string): boolean {
84
+ const haystack = message.toLowerCase()
85
+ return NOT_CONFIGURED_MARKERS.some((marker) => haystack.includes(marker))
86
+ }
87
+
88
+ function buildPrompt(request: SearchRequest): string {
89
+ const constraints: string[] = []
90
+ if (request.site) constraints.push(`Restrict results to the site ${request.site}.`)
91
+ if (request.freshness) constraints.push(`Prefer sources published within the last ${request.freshness}.`)
92
+ if (request.locale) constraints.push(`Prefer sources written for the ${request.locale} locale.`)
93
+
94
+ return [
95
+ `Search the public web for: ${request.query}`,
96
+ 'Use the web_search tool, then answer in two parts.',
97
+ '',
98
+ 'Part 1: a short prose answer citing the most relevant, authoritative sources.',
99
+ '',
100
+ `Part 2: a JSON array in a \`\`\`json code fence with up to ${request.limit} entries, each`,
101
+ '{"url": "...", "title": "...", "summary": "one or two sentences on what this page says about the query"}.',
102
+ 'Only include URLs you actually retrieved. Write real summaries, not the page title again.',
103
+ ...constraints,
104
+ ].join('\n')
105
+ }
106
+
107
+ async function defaultGenerate(input: Parameters<GenerateFn>[0]): Promise<GenerateResult> {
108
+ const { generateText } = await import('ai')
109
+ type GenerateTextInput = Parameters<typeof generateText>[0]
110
+ return (await generateText({
111
+ model: input.model as GenerateTextInput['model'],
112
+ tools: input.tools as GenerateTextInput['tools'],
113
+ prompt: input.prompt,
114
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
115
+ })) as GenerateResult
116
+ }
117
+
118
+ export function createModelAdapter(options: ModelOptions): SearchAdapter {
119
+ const maxUses = options.maxUses ?? DEFAULT_MAX_USES
120
+ const resolveModel = options.resolveModel
121
+ const generate = options.generate ?? defaultGenerate
122
+ const loadTools = options.loadTools ?? loadNativeSearchTool
123
+
124
+ return {
125
+ id: 'model-native',
126
+ kind: 'model',
127
+ capabilities: {
128
+ search: true,
129
+ fetch: false,
130
+ snippets: true,
131
+ content: false,
132
+ freshness: true,
133
+ siteFilter: true,
134
+ cost: 'llm-tokens',
135
+ },
136
+ /** `free` — resolves the configured model and its tool descriptors; no completion is issued. */
137
+ probeCost: 'free' as const,
138
+ readiness: () =>
139
+ resolveModel ? READY : notReady('no model resolver was provided by the host application'),
140
+
141
+ async search(request, context): Promise<SearchOutcome> {
142
+ if (!resolveModel) {
143
+ return { status: 'unavailable', reason: 'no model resolver was provided by the host application' }
144
+ }
145
+
146
+ let resolution: ModelResolution
147
+ try {
148
+ resolution = await resolveModel()
149
+ } catch (error) {
150
+ return {
151
+ status: 'unavailable',
152
+ reason: `could not resolve a model: ${error instanceof Error ? error.message : String(error)}`,
153
+ }
154
+ }
155
+
156
+ const tools = await loadTools(resolution.providerId, maxUses)
157
+ if (!tools) {
158
+ return {
159
+ status: 'unavailable',
160
+ reason: `provider "${resolution.providerId}" has no native web search available`,
161
+ }
162
+ }
163
+
164
+ let result: GenerateResult
165
+ try {
166
+ result = await generate({
167
+ model: resolution.model,
168
+ tools,
169
+ prompt: buildPrompt(request),
170
+ abortSignal: context.signal,
171
+ })
172
+ } catch (error) {
173
+ if (context.signal.aborted) return { status: 'timeout', reason: 'model search was aborted' }
174
+ const detail = error instanceof Error ? error.message : String(error)
175
+ if (isConfigurationFault(detail)) {
176
+ return { status: 'unavailable', reason: `model search is not usable: ${detail}` }
177
+ }
178
+ return { status: 'error', reason: `model search failed: ${detail}`, retriable: true }
179
+ }
180
+
181
+ const text = result.text ?? ''
182
+ const summaries = new Map(parseModelSummaries(text).map((entry) => [entry.url, entry]))
183
+
184
+ // Only URLs the search tool actually returned are trusted. The model's own
185
+ // JSON is used solely to enrich those, never to introduce a link, because a
186
+ // fabricated URL that reaches an agent is indistinguishable from a real one.
187
+ const results: RawResult[] = []
188
+ const seen = new Set<string>()
189
+ for (const source of result.sources ?? []) {
190
+ if (source.sourceType && source.sourceType !== 'url') continue
191
+ const url = source.url
192
+ if (!url || seen.has(url)) continue
193
+ seen.add(url)
194
+ const enrichment = summaries.get(url)
195
+ results.push({
196
+ url,
197
+ title: enrichment?.title ?? source.title ?? null,
198
+ snippet: enrichment?.summary ?? null,
199
+ })
200
+ if (results.length >= request.limit) break
201
+ }
202
+
203
+ const answer = stripSummaryBlock(text)
204
+ context.report('ok', `model returned ${results.length} cited source(s)`, {
205
+ resultCount: results.length,
206
+ })
207
+
208
+ return searchOk(results, answer)
209
+ },
210
+
211
+ async healthCheck() {
212
+ if (!resolveModel) return { ok: false, detail: 'no model resolver was provided by the host application' }
213
+ try {
214
+ const resolution = await resolveModel()
215
+ const tools = await loadTools(resolution.providerId, maxUses)
216
+ if (!tools) {
217
+ return { ok: false, detail: `provider "${resolution.providerId}" has no native web search` }
218
+ }
219
+ // Azure's native search is Grounding with Bing underneath: it is billed
220
+ // separately and Microsoft states the Data Protection Addendum does not
221
+ // cover it, so queries leave the tenant's compliance and geo boundary.
222
+ // Anyone on Azure for data residency needs to see that, not discover it.
223
+ const caveat = PROVIDER_CAVEATS[resolution.providerId]
224
+ return {
225
+ ok: true,
226
+ detail: `${resolution.providerId}/${resolution.modelId}${caveat ? ` - ${caveat}` : ''}`,
227
+ }
228
+ } catch (error) {
229
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) }
230
+ }
231
+ },
232
+ }
233
+ }
234
+
235
+ export const modelAdapterModule = defineAdapterModule({
236
+ id: 'model-native',
237
+ kind: 'model',
238
+ contractVersion: CONTRACT_VERSION,
239
+ optionsSchema: modelOptionsSchema,
240
+ createAdapter: createModelAdapter,
241
+ })
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export {
2
+ createModelAdapter,
3
+ modelAdapterModule,
4
+ modelOptionsSchema,
5
+ type GenerateFn,
6
+ type GenerateResult,
7
+ type GenerateSource,
8
+ type ModelOptions,
9
+ type ModelResolution,
10
+ type ModelResolver,
11
+ } from './adapter'
12
+ export { parseModelSummaries, stripSummaryBlock, type ModelSummary } from './parse'
13
+ export { loadNativeSearchTool } from './nativeTools'
14
+
15
+ export { modelAdapterModule as default } from './adapter'
@@ -0,0 +1,44 @@
1
+ export type NativeToolSet = Record<string, unknown>
2
+
3
+ /**
4
+ * Loads the provider's own web-search tool lazily.
5
+ *
6
+ * The import is dynamic on purpose: this package is statically imported by the
7
+ * generated adapter registry, and a top-level import of a provider SDK the host
8
+ * did not install would fail the whole registry rather than this one adapter.
9
+ * Both provider packages are optional peers for the same reason.
10
+ */
11
+ export async function loadNativeSearchTool(
12
+ providerId: string,
13
+ maxUses: number,
14
+ ): Promise<NativeToolSet | null> {
15
+ if (providerId === 'anthropic') {
16
+ try {
17
+ const { anthropic } = await import('@ai-sdk/anthropic')
18
+ return { web_search: anthropic.tools.webSearch_20250305({ maxUses }) }
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+ if (providerId === 'openai') {
24
+ try {
25
+ const { openai } = await import('@ai-sdk/openai')
26
+ return { web_search: openai.tools.webSearch() }
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+ if (providerId === 'azure') {
32
+ try {
33
+ // Azure's web search is a Responses API built-in backed by Grounding with
34
+ // Bing. `@ai-sdk/azure` calls the Responses API by default, which is what
35
+ // makes the tool reachable at all — the OpenAI-compatible Chat Completions
36
+ // surface Azure used to be wired through cannot carry it.
37
+ const { azure } = await import('@ai-sdk/azure')
38
+ return { web_search: azure.tools.webSearch() }
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+ return null
44
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,71 @@
1
+ export type ModelSummary = {
2
+ readonly url: string
3
+ readonly title?: string
4
+ readonly summary?: string
5
+ }
6
+
7
+ /**
8
+ * The leading whitespace stays inside the capture group rather than being eaten
9
+ * by a `\s*` prefix: `\s*` and `[\s\S]*?` can both match the same whitespace,
10
+ * which makes an unterminated fence backtrack quadratically (CodeQL polynomial
11
+ * ReDoS). The matched span is identical either way, and `JSON.parse` ignores
12
+ * leading whitespace.
13
+ */
14
+ const FENCE = /```(?:json)?([\s\S]*?)```/
15
+
16
+ function coerceSummaries(value: unknown): ModelSummary[] {
17
+ if (!Array.isArray(value)) return []
18
+ const summaries: ModelSummary[] = []
19
+ for (const item of value) {
20
+ if (typeof item !== 'object' || item === null) continue
21
+ const record = item as Record<string, unknown>
22
+ const url = typeof record.url === 'string' ? record.url.trim() : ''
23
+ if (url.length === 0) continue
24
+ summaries.push({
25
+ url,
26
+ ...(typeof record.title === 'string' && record.title.trim().length > 0
27
+ ? { title: record.title.trim() }
28
+ : {}),
29
+ ...(typeof record.summary === 'string' && record.summary.trim().length > 0
30
+ ? { summary: record.summary.trim() }
31
+ : {}),
32
+ })
33
+ }
34
+ return summaries
35
+ }
36
+
37
+ /**
38
+ * Pulls the per-source summaries out of the model's reply. Best-effort by design:
39
+ * the summaries only ever enrich URLs that the search tool actually returned, so
40
+ * a malformed or absent block costs snippets, never correctness.
41
+ */
42
+ export function parseModelSummaries(text: string): ModelSummary[] {
43
+ if (text.trim().length === 0) return []
44
+
45
+ const fenced = FENCE.exec(text)
46
+ if (fenced) {
47
+ try {
48
+ return coerceSummaries(JSON.parse(fenced[1]))
49
+ } catch {
50
+ // fall through to the bare-array scan
51
+ }
52
+ }
53
+
54
+ const start = text.indexOf('[')
55
+ const end = text.lastIndexOf(']')
56
+ if (start === -1 || end <= start) return []
57
+ try {
58
+ return coerceSummaries(JSON.parse(text.slice(start, end + 1)))
59
+ } catch {
60
+ return []
61
+ }
62
+ }
63
+
64
+ /** Removes the machine-readable block so the prose answer reads naturally. */
65
+ export function stripSummaryBlock(text: string): string {
66
+ const withoutFence = text.replace(FENCE, '').trim()
67
+ if (withoutFence.length > 0) return withoutFence
68
+ const start = text.indexOf('[')
69
+ if (start <= 0) return ''
70
+ return text.slice(0, start).trim()
71
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "noEmit": true,
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
10
+ }
package/watch.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import { dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { watch } from '../../scripts/watch.mjs'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+
7
+ watch(__dirname)